mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-08-01 02:06:17 -04:00
feat(cdp): implement Browser.setDownloadBehavior file downloads
Browser.setDownloadBehavior was a noop, so Lightpanda had no file-download path. A response with Content-Disposition: attachment is now streamed to disk under downloadPath, and Page.downloadWillBegin / Browser.downloadProgress are emitted when eventsEnabled. Fixes #2701
This commit is contained in:
committed by
Pierre Tachoire
parent
de046cec36
commit
8ba54850db
@@ -96,6 +96,8 @@ const EventListeners = struct {
|
||||
model_context_tool_added: List = .{},
|
||||
model_context_tool_removed: List = .{},
|
||||
cookie_changed: List = .{},
|
||||
download_will_begin: List = .{},
|
||||
download_progress: List = .{},
|
||||
};
|
||||
|
||||
const Events = union(enum) {
|
||||
@@ -123,6 +125,8 @@ const Events = union(enum) {
|
||||
model_context_tool_added: *const ModelContextToolEvent,
|
||||
model_context_tool_removed: *const ModelContextToolEvent,
|
||||
cookie_changed: *const CookieChanged,
|
||||
download_will_begin: *const DownloadWillBegin,
|
||||
download_progress: *const DownloadProgress,
|
||||
};
|
||||
const EventType = std.meta.FieldEnum(Events);
|
||||
|
||||
@@ -271,6 +275,34 @@ pub const CookieChanged = struct {
|
||||
pub const Kind = enum { changed, deleted };
|
||||
};
|
||||
|
||||
// Emitted by Frame when a navigation response is treated as a file download
|
||||
// (Content-Disposition: attachment) under an `allow`/`allowAndName`
|
||||
// `Browser.setDownloadBehavior`. The CDP page domain turns this into a
|
||||
// `Page.downloadWillBegin` event. All fields point at Frame-owned memory that
|
||||
// outlives the synchronous dispatch.
|
||||
pub const DownloadWillBegin = struct {
|
||||
frame_id: u32,
|
||||
guid: []const u8,
|
||||
url: []const u8,
|
||||
suggested_filename: []const u8,
|
||||
};
|
||||
|
||||
// Emitted by Frame as a download is written to disk: once when it starts
|
||||
// (`.in_progress`) and once when the body is fully written (`.completed`).
|
||||
// The CDP browser domain turns this into a `Browser.downloadProgress` event.
|
||||
pub const DownloadProgress = struct {
|
||||
guid: []const u8,
|
||||
total_bytes: u64,
|
||||
received_bytes: u64,
|
||||
state: State,
|
||||
|
||||
pub const State = enum {
|
||||
in_progress,
|
||||
completed,
|
||||
canceled,
|
||||
};
|
||||
};
|
||||
|
||||
pub const DialogResponse = struct {
|
||||
accept: bool = false,
|
||||
// Set when the CDP client sent a `promptText` with `accept: true`. Memory
|
||||
|
||||
@@ -1198,9 +1198,167 @@ fn frameHeaderDoneCallback(response: HttpClient.Response) !HttpClient.HeaderResu
|
||||
});
|
||||
}
|
||||
|
||||
// If the response is a file download, stream its body to disk instead of
|
||||
// parsing it as a page. This sets _parse_state to .download, which the
|
||||
// data/done callbacks below special-case.
|
||||
_ = try self.maybeStartDownload(response);
|
||||
|
||||
return .proceed;
|
||||
}
|
||||
|
||||
// Returns true when the response was set up as a file download. A response is
|
||||
// treated as a download when Browser.setDownloadBehavior opted in
|
||||
// (allow/allowAndName) and the response carries Content-Disposition: attachment.
|
||||
// See issue #2701.
|
||||
fn maybeStartDownload(self: *Frame, response: HttpClient.Response) !bool {
|
||||
const session = self._session;
|
||||
switch (session.download_behavior) {
|
||||
.allow, .allow_and_name => {},
|
||||
.default, .deny => return false,
|
||||
}
|
||||
|
||||
const disposition = blk: {
|
||||
var it = response.headerIterator();
|
||||
while (it.next()) |hdr| {
|
||||
if (std.ascii.eqlIgnoreCase(hdr.name, "content-disposition")) {
|
||||
break :blk hdr.value;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if (isAttachment(disposition) == false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const download_path = session.download_path orelse {
|
||||
log.warn(.frame, "download without downloadPath", .{ .url = self.url });
|
||||
return false;
|
||||
};
|
||||
|
||||
var guid_buf: [36]u8 = undefined;
|
||||
@import("../id.zig").uuidv4(&guid_buf);
|
||||
const guid = try self.arena.dupe(u8, &guid_buf);
|
||||
|
||||
const suggested = dispositionFilename(disposition) orelse urlBasename(self.url) orelse guid;
|
||||
const suggested_filename = try self.arena.dupe(u8, suggested);
|
||||
|
||||
// allowAndName stores the file under its guid; allow uses the suggested name.
|
||||
const on_disk_name = switch (session.download_behavior) {
|
||||
.allow_and_name => guid,
|
||||
else => suggested_filename,
|
||||
};
|
||||
|
||||
std.fs.cwd().makePath(download_path) catch |err| {
|
||||
log.err(.frame, "download makePath", .{ .err = err, .path = download_path });
|
||||
return false;
|
||||
};
|
||||
var dir = std.fs.cwd().openDir(download_path, .{}) catch |err| {
|
||||
log.err(.frame, "download openDir", .{ .err = err, .path = download_path });
|
||||
return false;
|
||||
};
|
||||
defer dir.close();
|
||||
const file = dir.createFile(on_disk_name, .{ .truncate = true }) catch |err| {
|
||||
log.err(.frame, "download createFile", .{ .err = err, .name = on_disk_name });
|
||||
return false;
|
||||
};
|
||||
|
||||
const total: ?u64 = if (response.contentLength()) |cl| cl else null;
|
||||
|
||||
self._parse_state = .{ .download = .{
|
||||
.guid = guid,
|
||||
.file = file,
|
||||
.filename = suggested_filename,
|
||||
.received = 0,
|
||||
.total = total,
|
||||
} };
|
||||
|
||||
if (session.download_events_enabled) {
|
||||
session.notification.dispatch(.download_will_begin, &.{
|
||||
.frame_id = self._frame_id,
|
||||
.guid = guid,
|
||||
.url = self.url,
|
||||
.suggested_filename = suggested_filename,
|
||||
});
|
||||
session.notification.dispatch(.download_progress, &.{
|
||||
.guid = guid,
|
||||
.total_bytes = total orelse 0,
|
||||
.received_bytes = 0,
|
||||
.state = .in_progress,
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// True when a Content-Disposition value's type token is "attachment".
|
||||
fn isAttachment(disposition: []const u8) bool {
|
||||
const semi = std.mem.indexOfScalar(u8, disposition, ';') orelse disposition.len;
|
||||
const token = std.mem.trim(u8, disposition[0..semi], " \t");
|
||||
return std.ascii.eqlIgnoreCase(token, "attachment");
|
||||
}
|
||||
|
||||
// Extracts the filename from a Content-Disposition value, handling the quoted,
|
||||
// unquoted, and RFC 5987 (filename*=charset''value) forms. Path components are
|
||||
// stripped so the result is always a bare basename.
|
||||
fn dispositionFilename(disposition: []const u8) ?[]const u8 {
|
||||
// Prefer the extended filename*= form when present, per RFC 6266.
|
||||
if (paramValue(disposition, "filename*")) |ext| {
|
||||
// charset'lang'value — take everything after the second quote.
|
||||
if (std.mem.indexOfScalar(u8, ext, '\'')) |first| {
|
||||
if (std.mem.indexOfScalarPos(u8, ext, first + 1, '\'')) |second| {
|
||||
return basename(ext[second + 1 ..]);
|
||||
}
|
||||
}
|
||||
return basename(ext);
|
||||
}
|
||||
if (paramValue(disposition, "filename")) |name| {
|
||||
return basename(name);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Returns the (optionally quoted) value of `key=` within a parameter list.
|
||||
fn paramValue(disposition: []const u8, key: []const u8) ?[]const u8 {
|
||||
var rest = disposition;
|
||||
while (std.mem.indexOfScalar(u8, rest, ';')) |semi| {
|
||||
const param = std.mem.trim(u8, rest[semi + 1 ..][0 .. (std.mem.indexOfScalarPos(u8, rest, semi + 1, ';') orelse rest.len) - semi - 1], " \t");
|
||||
if (std.mem.indexOfScalar(u8, param, '=')) |eq| {
|
||||
const name = std.mem.trim(u8, param[0..eq], " \t");
|
||||
if (std.ascii.eqlIgnoreCase(name, key)) {
|
||||
var value = std.mem.trim(u8, param[eq + 1 ..], " \t");
|
||||
if (value.len >= 2 and value[0] == '"' and value[value.len - 1] == '"') {
|
||||
value = value[1 .. value.len - 1];
|
||||
}
|
||||
if (value.len > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
rest = rest[semi + 1 ..];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Strips any directory components, guarding against path traversal.
|
||||
fn basename(name: []const u8) ?[]const u8 {
|
||||
var out = name;
|
||||
if (std.mem.lastIndexOfAny(u8, out, "/\\")) |i| {
|
||||
out = out[i + 1 ..];
|
||||
}
|
||||
if (out.len == 0 or std.mem.eql(u8, out, ".") or std.mem.eql(u8, out, "..")) {
|
||||
return null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Derives a filename from a URL's last path segment, ignoring query/fragment.
|
||||
fn urlBasename(url: []const u8) ?[]const u8 {
|
||||
var path = url;
|
||||
if (std.mem.indexOfScalar(u8, path, '?')) |i| path = path[0..i];
|
||||
if (std.mem.indexOfScalar(u8, path, '#')) |i| path = path[0..i];
|
||||
return basename(path);
|
||||
}
|
||||
|
||||
fn frameDataCallback(response: HttpClient.Response, data: []const u8) !void {
|
||||
var self: *Frame = @ptrCast(@alignCast(response.ctx));
|
||||
|
||||
@@ -1279,6 +1437,13 @@ fn frameDataCallback(response: HttpClient.Response, data: []const u8) !void {
|
||||
}
|
||||
},
|
||||
.raw, .image => |*buf| try buf.appendSlice(self.arena, data),
|
||||
.download => |*download| {
|
||||
download.file.writeAll(data) catch |err| {
|
||||
log.err(.frame, "download write", .{ .err = err, .guid = download.guid });
|
||||
return;
|
||||
};
|
||||
download.received += data.len;
|
||||
},
|
||||
.pre => unreachable,
|
||||
.complete => unreachable,
|
||||
.err => unreachable,
|
||||
@@ -1373,6 +1538,30 @@ fn frameDoneCallback(ctx: *anyopaque) !void {
|
||||
self._parse_state = .complete;
|
||||
self.documentIsComplete();
|
||||
},
|
||||
.download => |*download| {
|
||||
download.file.close();
|
||||
|
||||
// Capture before invalidating the union below.
|
||||
const guid = download.guid;
|
||||
const received = download.received;
|
||||
const total = download.total orelse download.received;
|
||||
self._parse_state = .complete;
|
||||
|
||||
const session = self._session;
|
||||
if (session.download_events_enabled) {
|
||||
session.notification.dispatch(.download_progress, &.{
|
||||
.guid = guid,
|
||||
.total_bytes = total,
|
||||
.received_bytes = received,
|
||||
.state = .completed,
|
||||
});
|
||||
}
|
||||
|
||||
// The body went to disk; commit an empty document so the frame
|
||||
// navigation still completes cleanly (mirrors the .raw path).
|
||||
parser.parse("<html><head><meta charset=\"utf-8\"></head><body></body></html>");
|
||||
self.documentIsComplete();
|
||||
},
|
||||
else => unreachable,
|
||||
}
|
||||
}
|
||||
@@ -3793,15 +3982,34 @@ const ParseState = union(enum) {
|
||||
image: std.ArrayList(u8),
|
||||
raw: std.ArrayList(u8),
|
||||
raw_done: []const u8,
|
||||
download: Download,
|
||||
|
||||
fn deinit(self: *ParseState, frame: *Frame) void {
|
||||
switch (self.*) {
|
||||
.html => |html| frame.releaseArena(html.arena),
|
||||
// Only reached when a frame is torn down mid-download (the normal
|
||||
// completion path in frameDoneCallback already closes the file and
|
||||
// transitions to .complete).
|
||||
.download => |*download| download.file.close(),
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// An in-flight file download (Content-Disposition: attachment under an
|
||||
// allow/allowAndName Browser.setDownloadBehavior). The response body is
|
||||
// streamed straight to `file` rather than parsed as a page. See issue #2701.
|
||||
const Download = struct {
|
||||
// uuidv4, arena-owned. Matches the guid reported in the CDP events.
|
||||
guid: []const u8,
|
||||
file: std.fs.File,
|
||||
// suggested filename surfaced to the client, arena-owned.
|
||||
filename: []const u8,
|
||||
received: u64,
|
||||
// from Content-Length, when the response advertised one.
|
||||
total: ?u64,
|
||||
};
|
||||
|
||||
const LoadState = enum {
|
||||
// waiting for the main HTML
|
||||
waiting,
|
||||
@@ -4644,6 +4852,35 @@ fn asUint(comptime string: anytype) std.meta.Int(
|
||||
}
|
||||
|
||||
const testing = @import("../testing.zig");
|
||||
|
||||
test "Frame: isAttachment" {
|
||||
try testing.expectEqual(true, isAttachment("attachment"));
|
||||
try testing.expectEqual(true, isAttachment("attachment; filename=\"a.csv\""));
|
||||
try testing.expectEqual(true, isAttachment(" ATTACHMENT ; filename=a.csv"));
|
||||
try testing.expectEqual(false, isAttachment("inline"));
|
||||
try testing.expectEqual(false, isAttachment("inline; filename=a.csv"));
|
||||
try testing.expectEqual(false, isAttachment(""));
|
||||
}
|
||||
|
||||
test "Frame: dispositionFilename" {
|
||||
try testing.expectEqualSlices(u8, "report.csv", dispositionFilename("attachment; filename=\"report.csv\"").?);
|
||||
try testing.expectEqualSlices(u8, "report.csv", dispositionFilename("attachment; filename=report.csv").?);
|
||||
try testing.expectEqualSlices(u8, "r e.csv", dispositionFilename("attachment; filename=\"r e.csv\"").?);
|
||||
// RFC 5987 extended form is preferred over the plain filename when present
|
||||
// (the value is taken verbatim after the charset'lang' prefix).
|
||||
try testing.expectEqualSlices(u8, "extended.txt", dispositionFilename("attachment; filename=\"fallback.txt\"; filename*=UTF-8''extended.txt").?);
|
||||
try testing.expect(dispositionFilename("attachment") == null);
|
||||
// Path components are stripped to guard against traversal.
|
||||
try testing.expectEqualSlices(u8, "evil.sh", dispositionFilename("attachment; filename=\"../../evil.sh\"").?);
|
||||
try testing.expect(dispositionFilename("attachment; filename=\"..\"") == null);
|
||||
}
|
||||
|
||||
test "Frame: urlBasename" {
|
||||
try testing.expectEqualSlices(u8, "report.csv", urlBasename("http://x.com/a/b/report.csv").?);
|
||||
try testing.expectEqualSlices(u8, "report.csv", urlBasename("http://x.com/report.csv?v=1#x").?);
|
||||
try testing.expect(urlBasename("http://x.com/") == null);
|
||||
}
|
||||
|
||||
test "WebApi: Frame" {
|
||||
const filter: testing.LogFilter = .init(&.{.http});
|
||||
defer filter.deinit();
|
||||
|
||||
@@ -164,7 +164,7 @@ fn _tick(self: *Runner, comptime is_cdp: bool, opts: TickOpts) !TickResult {
|
||||
const http_client = self.http_client;
|
||||
|
||||
switch (frame._parse_state) {
|
||||
.pre, .raw, .text, .image => {
|
||||
.pre, .raw, .text, .image, .download => {
|
||||
// The main frame hasn't started/finished navigating.
|
||||
// There's no JS to run, and no reason to run the scheduler
|
||||
// — unless we're the CDP worker, in which case we want
|
||||
|
||||
@@ -112,6 +112,24 @@ load_external_stylesheets: bool = false,
|
||||
/// timeout.
|
||||
cancel_hook: ?CancelHook = null,
|
||||
|
||||
// Download handling configured via the `Browser.setDownloadBehavior` CDP
|
||||
// method (see issue #2701). When `download_behavior` is `.allow` or
|
||||
// `.allow_and_name`, a navigation whose response carries
|
||||
// `Content-Disposition: attachment` is written to `download_path` instead
|
||||
// of being parsed as a page, and (when `download_events_enabled` is set)
|
||||
// `Page.downloadWillBegin` / `Browser.downloadProgress` events are emitted.
|
||||
// `download_path` is duped into the Session arena.
|
||||
download_behavior: DownloadBehavior = .default,
|
||||
download_path: ?[]const u8 = null,
|
||||
download_events_enabled: bool = false,
|
||||
|
||||
pub const DownloadBehavior = enum {
|
||||
default,
|
||||
allow,
|
||||
allow_and_name,
|
||||
deny,
|
||||
};
|
||||
|
||||
pub const CancelHook = struct {
|
||||
context: *anyopaque,
|
||||
check: *const fn (*anyopaque) bool,
|
||||
|
||||
@@ -526,6 +526,10 @@ pub const BrowserContext = struct {
|
||||
http_proxy_changed: bool = false,
|
||||
user_agent_changed: bool = false,
|
||||
|
||||
// True once we've registered for the download notifications, so repeated
|
||||
// Browser.setDownloadBehavior calls don't add duplicate listeners.
|
||||
download_events_registered: bool = false,
|
||||
|
||||
// Extra headers to add to all requests.
|
||||
extra_headers: std.ArrayList([*c]const u8) = .empty,
|
||||
|
||||
@@ -797,6 +801,36 @@ pub const BrowserContext = struct {
|
||||
self.notification.unregister(.runtime_console_message, self);
|
||||
}
|
||||
|
||||
// Registers for the download notifications dispatched by Frame when a
|
||||
// navigation is treated as a file download. Idempotent. See issue #2701.
|
||||
pub fn downloadEventsEnable(self: *BrowserContext) !void {
|
||||
if (self.download_events_registered) {
|
||||
return;
|
||||
}
|
||||
try self.notification.register(.download_will_begin, self, onDownloadWillBegin);
|
||||
try self.notification.register(.download_progress, self, onDownloadProgress);
|
||||
self.download_events_registered = true;
|
||||
}
|
||||
|
||||
pub fn downloadEventsDisable(self: *BrowserContext) void {
|
||||
if (self.download_events_registered == false) {
|
||||
return;
|
||||
}
|
||||
self.notification.unregister(.download_will_begin, self);
|
||||
self.notification.unregister(.download_progress, self);
|
||||
self.download_events_registered = false;
|
||||
}
|
||||
|
||||
pub fn onDownloadWillBegin(ctx: *anyopaque, msg: *const Notification.DownloadWillBegin) !void {
|
||||
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
|
||||
return @import("domains/page.zig").downloadWillBegin(self, msg);
|
||||
}
|
||||
|
||||
pub fn onDownloadProgress(ctx: *anyopaque, msg: *const Notification.DownloadProgress) !void {
|
||||
const self: *BrowserContext = @ptrCast(@alignCast(ctx));
|
||||
return @import("domains/browser.zig").downloadProgress(self, msg);
|
||||
}
|
||||
|
||||
pub fn webmcpEnable(self: *BrowserContext) !void {
|
||||
try self.notification.register(.model_context_tool_added, self, onModelContextToolAdded);
|
||||
try self.notification.register(.model_context_tool_removed, self, onModelContextToolRemoved);
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
const std = @import("std");
|
||||
const lp = @import("lightpanda");
|
||||
const CDP = @import("../CDP.zig");
|
||||
const Session = @import("../../browser/Session.zig");
|
||||
const Notification = @import("../../Notification.zig");
|
||||
|
||||
const log = lp.log;
|
||||
const PermissionState = @import("../../browser/webapi/Permissions.zig").State;
|
||||
@@ -73,18 +75,61 @@ fn getVersion(cmd: *CDP.Command) !void {
|
||||
}, .{ .include_session_id = false });
|
||||
}
|
||||
|
||||
// TODO: noop method
|
||||
// https://chromedevtools.github.io/devtools-protocol/tot/Browser/#method-setDownloadBehavior
|
||||
fn setDownloadBehavior(cmd: *CDP.Command) !void {
|
||||
// const params = (try cmd.params(struct {
|
||||
// behavior: []const u8,
|
||||
// browserContextId: ?[]const u8 = null,
|
||||
// downloadPath: ?[]const u8 = null,
|
||||
// eventsEnabled: ?bool = null,
|
||||
// })) orelse return error.InvalidParams;
|
||||
const params = (try cmd.params(struct {
|
||||
behavior: []const u8,
|
||||
browserContextId: ?[]const u8 = null,
|
||||
downloadPath: ?[]const u8 = null,
|
||||
eventsEnabled: ?bool = null,
|
||||
})) orelse return error.InvalidParams;
|
||||
|
||||
const behavior: Session.DownloadBehavior = if (std.mem.eql(u8, params.behavior, "allow"))
|
||||
.allow
|
||||
else if (std.mem.eql(u8, params.behavior, "allowAndName"))
|
||||
.allow_and_name
|
||||
else if (std.mem.eql(u8, params.behavior, "deny"))
|
||||
.deny
|
||||
else if (std.mem.eql(u8, params.behavior, "default"))
|
||||
.default
|
||||
else
|
||||
return error.InvalidParams;
|
||||
|
||||
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
||||
const session = bc.session;
|
||||
|
||||
session.download_behavior = behavior;
|
||||
// downloadPath comes from the (transient) command arena; persist it on the
|
||||
// session arena since Frame reads it on later navigations.
|
||||
session.download_path = if (params.downloadPath) |p| try session.arena.dupe(u8, p) else null;
|
||||
session.download_events_enabled = params.eventsEnabled orelse false;
|
||||
|
||||
if (session.download_events_enabled) {
|
||||
try bc.downloadEventsEnable();
|
||||
} else {
|
||||
bc.downloadEventsDisable();
|
||||
}
|
||||
|
||||
return cmd.sendResult(null, .{ .include_session_id = false });
|
||||
}
|
||||
|
||||
// https://chromedevtools.github.io/devtools-protocol/tot/Browser/#event-downloadProgress
|
||||
// Dispatched by Frame as a download is written to disk (see issue #2701).
|
||||
pub fn downloadProgress(bc: *CDP.BrowserContext, msg: *const Notification.DownloadProgress) !void {
|
||||
const session_id = bc.session_id orelse return;
|
||||
|
||||
return bc.cdp.sendEvent("Browser.downloadProgress", .{
|
||||
.guid = msg.guid,
|
||||
.totalBytes = msg.total_bytes,
|
||||
.receivedBytes = msg.received_bytes,
|
||||
.state = switch (msg.state) {
|
||||
.in_progress => "inProgress",
|
||||
.completed => "completed",
|
||||
.canceled => "canceled",
|
||||
},
|
||||
}, .{ .session_id = session_id });
|
||||
}
|
||||
|
||||
fn getWindowForTarget(cmd: *CDP.Command) !void {
|
||||
// const params = (try cmd.params(struct {
|
||||
// targetId: ?[]const u8 = null,
|
||||
@@ -230,3 +275,97 @@ test "cdp.browser: grant/set/reset permissions reach navigator.permissions" {
|
||||
try ctx.expectSentResult(null, .{ .id = 42, .session_id = null });
|
||||
try testing.expectEqual(@as(usize, 0), browser.permissions.count());
|
||||
}
|
||||
|
||||
test "cdp.browser: setDownloadBehavior stores config on the session" {
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
const bc = try ctx.loadBrowserContext(.{ .session_id = "SID-CFG" });
|
||||
|
||||
try ctx.processMessage(.{
|
||||
.id = 34,
|
||||
.method = "Browser.setDownloadBehavior",
|
||||
.params = .{
|
||||
.behavior = "allowAndName",
|
||||
.downloadPath = "/tmp/lp-downloads",
|
||||
.eventsEnabled = true,
|
||||
},
|
||||
});
|
||||
try ctx.expectSentResult(null, .{ .id = 34, .session_id = null });
|
||||
|
||||
try testing.expectEqual(.allow_and_name, bc.session.download_behavior);
|
||||
try testing.expectEqualSlices(u8, "/tmp/lp-downloads", bc.session.download_path.?);
|
||||
try testing.expect(bc.session.download_events_enabled);
|
||||
try testing.expect(bc.download_events_registered);
|
||||
|
||||
// Flipping back to default tears the registration down again.
|
||||
try ctx.processMessage(.{
|
||||
.id = 35,
|
||||
.method = "Browser.setDownloadBehavior",
|
||||
.params = .{ .behavior = "default" },
|
||||
});
|
||||
try testing.expectEqual(.default, bc.session.download_behavior);
|
||||
try testing.expect(bc.session.download_path == null);
|
||||
try testing.expect(bc.session.download_events_enabled == false);
|
||||
try testing.expect(bc.download_events_registered == false);
|
||||
}
|
||||
|
||||
test "cdp.browser: setDownloadBehavior rejects an unknown behavior" {
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
_ = try ctx.loadBrowserContext(.{ .session_id = "SID-BAD" });
|
||||
|
||||
try ctx.processMessage(.{
|
||||
.id = 36,
|
||||
.method = "Browser.setDownloadBehavior",
|
||||
.params = .{ .behavior = "sometimes" },
|
||||
});
|
||||
try ctx.expectSentError(-31998, "InvalidParams", .{ .id = 36 });
|
||||
}
|
||||
|
||||
test "cdp.browser: setDownloadBehavior writes an attachment to disk and emits events" {
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
const download_path = try tmp.dir.realpathAlloc(testing.allocator, ".");
|
||||
defer testing.allocator.free(download_path);
|
||||
|
||||
const bc = try ctx.loadBrowserContext(.{
|
||||
.session_id = "SID-DL",
|
||||
.target_id = "TID-00000000DL".*,
|
||||
});
|
||||
|
||||
try ctx.processMessage(.{
|
||||
.id = 37,
|
||||
.method = "Browser.setDownloadBehavior",
|
||||
.params = .{
|
||||
.behavior = "allow",
|
||||
.downloadPath = download_path,
|
||||
.eventsEnabled = true,
|
||||
},
|
||||
});
|
||||
try ctx.expectSentResult(null, .{ .id = 37, .session_id = null });
|
||||
|
||||
const frame = try bc.session.createPage();
|
||||
try frame.navigate("http://127.0.0.1:9582/download/report.csv", .{});
|
||||
var runner = try bc.session.runner(.{});
|
||||
try runner.wait(.{ .ms = 2000 });
|
||||
|
||||
// The guid is random, so it's omitted from these subset matches.
|
||||
try ctx.expectSentEvent("Page.downloadWillBegin", .{
|
||||
.url = "http://127.0.0.1:9582/download/report.csv",
|
||||
.suggestedFilename = "report.csv",
|
||||
}, .{ .session_id = "SID-DL" });
|
||||
try ctx.expectSentEvent("Browser.downloadProgress", .{
|
||||
.state = "completed",
|
||||
.totalBytes = 30,
|
||||
.receivedBytes = 30,
|
||||
}, .{ .session_id = "SID-DL" });
|
||||
|
||||
const written = try tmp.dir.readFileAlloc(testing.allocator, "report.csv", 1024);
|
||||
defer testing.allocator.free(written);
|
||||
try testing.expectEqualSlices(u8, "col1,col2\nhello,world\n42,1337\n", written);
|
||||
}
|
||||
|
||||
@@ -806,6 +806,20 @@ pub fn frameNetworkAlmostIdle(bc: *CDP.BrowserContext, event: *const Notificatio
|
||||
return sendPageLifecycle(bc, "networkAlmostIdle", event.timestamp, &id.toFrameId(event.frame_id), &id.toLoaderId(event.loader_id));
|
||||
}
|
||||
|
||||
// https://chromedevtools.github.io/devtools-protocol/tot/Page/#event-downloadWillBegin
|
||||
// Dispatched by Frame when a navigation response is treated as a file download
|
||||
// (see issue #2701).
|
||||
pub fn downloadWillBegin(bc: *CDP.BrowserContext, event: *const Notification.DownloadWillBegin) !void {
|
||||
const session_id = bc.session_id orelse return;
|
||||
|
||||
return bc.cdp.sendEvent("Page.downloadWillBegin", .{
|
||||
.frameId = &id.toFrameId(event.frame_id),
|
||||
.guid = event.guid,
|
||||
.url = event.url,
|
||||
.suggestedFilename = event.suggested_filename,
|
||||
}, .{ .session_id = session_id });
|
||||
}
|
||||
|
||||
fn sendPageLifecycle(bc: *CDP.BrowserContext, name: []const u8, timestamp: u64, frame_id: []const u8, loader_id: []const u8) !void {
|
||||
// detachTarget could be called, in which case, we still have a frame doing
|
||||
// things, but no session.
|
||||
|
||||
@@ -821,6 +821,17 @@ fn testHTTPHandler(req: *std.http.Server.Request) !void {
|
||||
});
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, path, "/download/report.csv")) {
|
||||
// A file download: Content-Disposition: attachment drives the
|
||||
// Browser.setDownloadBehavior path (issue #2701).
|
||||
return req.respond("col1,col2\nhello,world\n42,1337\n", .{
|
||||
.extra_headers = &.{
|
||||
.{ .name = "Content-Type", .value = "text/csv" },
|
||||
.{ .name = "Content-Disposition", .value = "attachment; filename=\"report.csv\"" },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (std.mem.startsWith(u8, path, "/src/browser/tests/")) {
|
||||
// strip off leading / so that it's relative to CWD
|
||||
return TestHTTPServer.sendFile(req, path[1..]);
|
||||
|
||||
Reference in New Issue
Block a user