diff --git a/src/browser/frame/resource_load.zig b/src/browser/frame/resource_load.zig index e05c2b17b..04af69ab5 100644 --- a/src/browser/frame/resource_load.zig +++ b/src/browser/frame/resource_load.zig @@ -26,6 +26,7 @@ const URL = @import("../URL.zig"); const Frame = @import("../Frame.zig"); const Factory = @import("../Factory.zig"); const Element = @import("../webapi/Element.zig"); +const image_dimensions = @import("../image_dimensions.zig"); const HttpClient = @import("../../network/HttpClient.zig"); const log = lp.log; @@ -83,7 +84,7 @@ pub fn image(frame: *Frame, img: *Element.Html.Image, src: []const u8) !void { .request_mode = .no_cors, .credentials_mode = .include, .resource_type = .image, - .headers_only = true, + .partial = 16 * 1024, .header_callback = ImageLoad.headerCallback, .data_callback = ImageLoad.dataCallback, .done_callback = ImageLoad.doneCallback, @@ -116,6 +117,7 @@ const ImageLoad = struct { image: *Element.Html.Image, generation: u32, status: u16 = 0, + dimensions: ?image_dimensions.Dimensions = null, fn headerCallback(transfer: *HttpClient.Transfer) !HttpClient.Transfer.HeaderResult { const self: *ImageLoad = @ptrCast(@alignCast(transfer.req.ctx)); @@ -123,8 +125,9 @@ const ImageLoad = struct { return .proceed; } - fn dataCallback(_: *HttpClient.Transfer, _: []const u8) !void { - // headers_only tears the transfer down at the first body byte. + fn dataCallback(transfer: *HttpClient.Transfer, prefix: []const u8) !void { + const self: *ImageLoad = @ptrCast(@alignCast(transfer.req.ctx)); + self.dimensions = image_dimensions.parse(prefix); } fn doneCallback(ctx: *anyopaque) !void { @@ -173,6 +176,12 @@ const ImageLoad = struct { const current = self.generation == img._generation; if (current) { img._complete = true; + if (kind == .load) { + if (self.dimensions) |dimensions| { + img._natural_width = dimensions.width; + img._natural_height = dimensions.height; + } + } } // Released before anything below, because everything below can run JS diff --git a/src/browser/image_dimensions.zig b/src/browser/image_dimensions.zig new file mode 100644 index 000000000..d8ec40c7c --- /dev/null +++ b/src/browser/image_dimensions.zig @@ -0,0 +1,186 @@ +// Copyright (C) 2026 Lightpanda (Selecy SAS) +// +// 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"); + +pub const Dimensions = struct { width: u32, height: u32 }; + +/// Read only the image header. The caller supplies a bounded prefix; an +/// unsupported format or a JPEG with its SOF beyond that prefix has no known +/// dimensions. No bitmap data is decoded. +pub fn parse(bytes: []const u8) ?Dimensions { + if (bytes.len >= 24 and std.mem.eql(u8, bytes[0..8], "\x89PNG\r\n\x1a\n") and + std.mem.eql(u8, bytes[8..12], "\x00\x00\x00\x0d") and std.mem.eql(u8, bytes[12..16], "IHDR")) + { + return valid(std.mem.readInt(u32, bytes[16..20], .big), std.mem.readInt(u32, bytes[20..24], .big)); + } + + if (bytes.len >= 10 and (std.mem.eql(u8, bytes[0..6], "GIF87a") or std.mem.eql(u8, bytes[0..6], "GIF89a"))) { + return valid(std.mem.readInt(u16, bytes[6..8], .little), std.mem.readInt(u16, bytes[8..10], .little)); + } + + if (bytes.len >= 4 and bytes[0] == 0xff and bytes[1] == 0xd8) { + return jpeg(bytes); + } + + if (bytes.len >= 25 and std.mem.eql(u8, bytes[0..4], "RIFF") and std.mem.eql(u8, bytes[8..12], "WEBP")) { + if (bytes.len >= 30 and std.mem.eql(u8, bytes[12..16], "VP8X")) { + const width = @as(u32, bytes[24]) | @as(u32, bytes[25]) << 8 | @as(u32, bytes[26]) << 16; + const height = @as(u32, bytes[27]) | @as(u32, bytes[28]) << 8 | @as(u32, bytes[29]) << 16; + return valid(width + 1, height + 1); + } + if (bytes.len >= 30 and std.mem.eql(u8, bytes[12..16], "VP8 ") and std.mem.eql(u8, bytes[23..26], "\x9d\x01\x2a")) { + return valid(std.mem.readInt(u16, bytes[26..28], .little) & 0x3fff, std.mem.readInt(u16, bytes[28..30], .little) & 0x3fff); + } + if (std.mem.eql(u8, bytes[12..16], "VP8L") and bytes[20] == 0x2f) { + const width = @as(u32, bytes[21]) | (@as(u32, bytes[22]) & 0x3f) << 8; + const height = @as(u32, bytes[22]) >> 6 | @as(u32, bytes[23]) << 2 | (@as(u32, bytes[24]) & 0x0f) << 10; + return valid(width + 1, height + 1); + } + } + return null; +} + +fn jpeg(bytes: []const u8) ?Dimensions { + var orientation: u16 = 1; + var pos: usize = 2; + while (pos < bytes.len) { + if (bytes[pos] != 0xff) { + return null; + } + while (pos < bytes.len and bytes[pos] == 0xff) : (pos += 1) {} + if (pos >= bytes.len) { + return null; + } + + const marker = bytes[pos]; + pos += 1; + if (marker == 0xd9 or marker == 0xda) { + return null; // EOI or scan data + } + if (marker == 0x01 or (marker >= 0xd0 and marker <= 0xd7)) { + continue; + } + + if (bytes.len - pos < 2) { + return null; + } + const len = std.mem.readInt(u16, bytes[pos..][0..2], .big); + if (len < 2 or len > bytes.len - pos) { + return null; + } + + const segment = bytes[pos + 2 .. pos + len]; + if (marker == 0xe1 and orientation == 1) { + orientation = exifOrientation(segment); + } + // SOF markers (except the non-frame DHT, JPG and DAC markers). + if ((marker >= 0xc0 and marker <= 0xcf) and marker != 0xc4 and marker != 0xc8 and marker != 0xcc) { + if (segment.len < 5) { + return null; + } + const height = std.mem.readInt(u16, segment[1..3], .big); + const width = std.mem.readInt(u16, segment[3..5], .big); + // Browsers apply EXIF orientation by default (image-orientation: + // from-image); 5-8 are the orientations that rotate by 90°. + if (orientation >= 5 and orientation <= 8) { + return valid(height, width); + } + return valid(width, height); + } + pos += len; + } + return null; +} + +// The Orientation tag (0x0112) from IFD0 of an APP1 Exif segment, or 1 +// (upright) when there isn't one. +fn exifOrientation(segment: []const u8) u16 { + if (segment.len < 14 or std.mem.eql(u8, segment[0..6], "Exif\x00\x00") == false) { + return 1; + } + + const tiff = segment[6..]; + const endian: std.builtin.Endian = if (std.mem.eql(u8, tiff[0..2], "II")) + .little + else if (std.mem.eql(u8, tiff[0..2], "MM")) + .big + else + return 1; + + if (std.mem.readInt(u16, tiff[2..4], endian) != 42) { + return 1; + } + + const ifd = std.mem.readInt(u32, tiff[4..8], endian); + if (ifd > tiff.len - 2) { + return 1; + } + const count = std.mem.readInt(u16, tiff[ifd..][0..2], endian); + var entry: usize = ifd + 2; + for (0..count) |_| { + if (tiff.len - entry < 12) { + return 1; + } + + if (std.mem.readInt(u16, tiff[entry..][0..2], endian) == 0x0112) { + // SHORT: the value sits in the first two bytes of the value field. + if (std.mem.readInt(u16, tiff[entry + 2 ..][0..2], endian) != 3) { + return 1; + } + return std.mem.readInt(u16, tiff[entry + 8 ..][0..2], endian); + } + entry += 12; + } + return 1; +} + +fn valid(width: u32, height: u32) ?Dimensions { + if (width == 0 or height == 0) return null; + return .{ .width = width, .height = height }; +} + +const testing = std.testing; +test "image dimensions: PNG, GIF, JPEG, WebP" { + try testing.expectEqual(Dimensions{ .width = 1000, .height = 750 }, parse("\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x03\xe8\x00\x00\x02\xee").?); + try testing.expectEqual(Dimensions{ .width = 320, .height = 240 }, parse("GIF89a\x40\x01\xf0\x00").?); + try testing.expectEqual(Dimensions{ .width = 1000, .height = 750 }, parse("\xff\xd8\xff\xe1\x00\x04\x00\x00\xff\xc0\x00\x0b\x08\x02\xee\x03\xe8\x01\x01\x11\x00").?); + try testing.expectEqual(Dimensions{ .width = 1000, .height = 750 }, parse("RIFF\x00\x00\x00\x00WEBPVP8X\x0a\x00\x00\x00\x00\x00\x00\x00\xe7\x03\x00\xed\x02\x00").?); + try testing.expectEqual(Dimensions{ .width = 1, .height = 1 }, parse("RIFF\x00\x00\x00\x00WEBPVP8L\x05\x00\x00\x00\x2f\x00\x00\x00\x00\x00").?); +} + +test "image dimensions: JPEG EXIF orientation" { + // APP1 Exif, big-endian, one IFD0 entry: Orientation (SHORT) = 6. + const rotated = "\xff\xd8\xff\xe1\x00\x22Exif\x00\x00MM\x00\x2a\x00\x00\x00\x08\x00\x01\x01\x12\x00\x03\x00\x00\x00\x01\x00\x06\x00\x00\x00\x00\x00\x00" ++ + "\xff\xc0\x00\x0b\x08\x02\xee\x03\xe8\x01\x01\x11\x00"; + try testing.expectEqual(Dimensions{ .width = 750, .height = 1000 }, parse(rotated).?); + + // Same, little-endian, Orientation = 3 (180°): no swap. + const flipped = "\xff\xd8\xff\xe1\x00\x22Exif\x00\x00II\x2a\x00\x08\x00\x00\x00\x01\x00\x12\x01\x03\x00\x01\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00" ++ + "\xff\xc0\x00\x0b\x08\x02\xee\x03\xe8\x01\x01\x11\x00"; + try testing.expectEqual(Dimensions{ .width = 1000, .height = 750 }, parse(flipped).?); + + // An IFD offset past the segment is ignored, not trusted. + const bogus = "\xff\xd8\xff\xe1\x00\x10Exif\x00\x00MM\x00\x2a\xff\xff\xff\xff" ++ + "\xff\xc0\x00\x0b\x08\x02\xee\x03\xe8\x01\x01\x11\x00"; + try testing.expectEqual(Dimensions{ .width = 1000, .height = 750 }, parse(bogus).?); +} + +test "image dimensions: truncated or invalid headers" { + try testing.expectEqual(null, parse("\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00")); + try testing.expectEqual(null, parse("\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x00\x00\x00\x00\x01")); + try testing.expectEqual(null, parse("\xff\xd8\xff\xe1\x00\xff\x00")); + try testing.expectEqual(null, parse("")); +} diff --git a/src/browser/tests/element/html/image.html b/src/browser/tests/element/html/image.html index a778285cb..368de087b 100644 --- a/src/browser/tests/element/html/image.html +++ b/src/browser/tests/element/html/image.html @@ -135,6 +135,7 @@ testing.expectEqual(false, e.composed); testing.expectEqual(true, e.isTrusted); testing.expectEqual(img, e.target); + testing.expectEqual(0, img.naturalWidth); // Images are not fetched by default. }); diff --git a/src/browser/tests/element/html/image_fetch.html b/src/browser/tests/element/html/image_fetch.html index 995a987bc..74fc63050 100644 --- a/src/browser/tests/element/html/image_fetch.html +++ b/src/browser/tests/element/html/image_fetch.html @@ -9,7 +9,7 @@ complete successfully — that's what makes these a test of the fetch path and not just of the event plumbing. - /images/ok.png is over the headers-only drain threshold (abort branch), + /images/ok.png is over the partial drain threshold (abort branch), /images/small.png is under it (drain branch). --> @@ -26,6 +26,9 @@ state.resolve(); await state.done(() => { testing.expectEqual("load", evt); + testing.expectEqual(1000, img.naturalWidth); + testing.expectEqual(750, img.naturalHeight); + testing.expectEqual(0, img.width); // Natural size does not set HTML attributes. }); @@ -44,6 +47,7 @@ state.resolve(); await state.done(() => { testing.expectEqual("error", evt); + testing.expectEqual(0, img.naturalWidth); }); @@ -63,8 +67,9 @@ state.resolve(); await state.done(() => { testing.expectEqual("load", evt); - // Drained, not decoded: still no dimensions. - testing.expectEqual(0, img.naturalWidth); + // The body is not decoded, but its header gives the natural size. + testing.expectEqual(320, img.naturalWidth); + testing.expectEqual(240, img.naturalHeight); }); @@ -103,6 +108,7 @@ state.resolve(); await state.done(() => { testing.expectEqual("load", evt); + testing.expectEqual(0, img.naturalWidth); // SVG is not parsed as a bitmap header. }); @@ -137,12 +143,51 @@ state.resolve(); await state.done(() => { testing.expectEqual("load", evt); + testing.expectEqual(1000, img.naturalWidth); + testing.expectEqual(750, img.naturalHeight); + }); + + + + + @@ -219,6 +264,7 @@ // The superseded 404 is dropped; only the winning fetch reports. testing.expectEqual(1, events.length); testing.expectEqual("load", events[0]); + testing.expectEqual(1000, img.naturalWidth); }); diff --git a/src/browser/webapi/element/html/Image.zig b/src/browser/webapi/element/html/Image.zig index 37dcbd21e..b1b05d24a 100644 --- a/src/browser/webapi/element/html/Image.zig +++ b/src/browser/webapi/element/html/Image.zig @@ -40,6 +40,8 @@ pub const Proto = HtmlElement; _generation: u32 = 0, // Per spec, false only while a fetch is in flight. _complete: bool = true, +_natural_width: u32 = 0, +_natural_height: u32 = 0, // Hash of the URL the last update selected, so a mutation that // leaves the selection unchanged doesn't restart the load. _selected_hash: u64 = 0, @@ -100,16 +102,12 @@ fn setLoading(self: *Image, value: []const u8, frame: *Frame) !void { try self.asElement().setAttributeSafe(comptime .wrap("loading"), .wrap(value), frame); } -fn getNaturalWidth(_: *const Image) u32 { - // this is a valid response under a number of normal conditions, but could - // be used to detect the nature of Browser. - return 0; +fn getNaturalWidth(self: *const Image) u32 { + return self._natural_width; } -fn getNaturalHeight(_: *const Image) u32 { - // this is a valid response under a number of normal conditions, but could - // be used to detect the nature of Browser. - return 0; +fn getNaturalHeight(self: *const Image) u32 { + return self._natural_height; } fn getComplete(self: *const Image) bool { @@ -138,6 +136,8 @@ fn imageAddedCallback(self: *Image, frame: *Frame) !void { self._generation +%= 1; self._complete = true; + self._natural_width = 0; + self._natural_height = 0; const src = self.selectSource(frame); self._selected_hash = std.hash.Wyhash.hash(0, src); diff --git a/src/network/HttpClient.zig b/src/network/HttpClient.zig index abbb09cb6..2665925e1 100644 --- a/src/network/HttpClient.zig +++ b/src/network/HttpClient.zig @@ -1296,8 +1296,8 @@ fn cacheLookup(self: *Client, transfer: *Transfer) !bool { // Redirects rewrite req.url; the entry must be stored/renewed under the // URL this lookup ran against, not the final hop. req.url is arena-owned, // so the captured slice outlives any redirect rewrite. - const key: [:0]const u8 = if (req.headers_only) - try std.fmt.allocPrintSentinel(arena.allocator(), "headers-only:{s}", .{req.url}, 0) + const key: [:0]const u8 = if (req.partial != null) + try std.fmt.allocPrintSentinel(arena.allocator(), "partial:{s}", .{req.url}, 0) else req.url; transfer._cache_key = key; @@ -1782,9 +1782,9 @@ fn processOneMessage(self: *Client, msg: http.Handles.MultiMessage, transfer: *T // we match that behavior: when CURLE_WRITE_ERROR arrives but our callback // never errored and bytes were received, treat it as success. const effective_err: ?anyerror = if (msg.err) |err| blk: { - // Our own headers_only abort, not a failure: fall through so the - // response is materialized and delivered with an empty body. - if (err == error.WriteError and transfer.res.headers_only_abort) { + // Our own partial abort, not a failure: fall through so the + // response is materialized and delivered with the kept prefix. + if (err == error.WriteError and transfer.res.partial_abort) { break :blk null; } if (err == error.WriteError and transfer.res.callback_error == null and transfer.res.bytes_received > 0) { @@ -2017,11 +2017,11 @@ pub const Request = struct { // internal requests transparently following redirects. const RedirectMode = enum { follow, manual, @"error" }; - // How much of a headers_only body we'll read rather than abort. Draining + // How much of a partial body we'll read rather than abort. Draining // costs bandwidth but keeps the connection poolable; aborting saves // bandwidth but forces a reconnect. 16 KiB is the rough break-even: about // ten segments, versus a TCP handshake plus a TLS one. - const HEADERS_ONLY_DRAIN_MAX: usize = 16 * 1024; + const PARTIAL_DRAIN_MAX: usize = 16 * 1024; pub const CredentialsMode = enum { // Never send credentials, even same-origin. @@ -2051,14 +2051,15 @@ pub const Request = struct { timeout_ms: u32 = 0, skip_cache: bool = false, - // The caller wants the status and the response headers, not the body. - // Unlike a HEAD, the request itself is byte-for-byte a normal GET, so - // origins and CDNs see (and answer) exactly what a real browser sends; - // the body is then discarded, and torn off the wire if it doesn't fit in - // HEADERS_ONLY_DRAIN_MAX. The consumer still gets the usual - // start/header/done sequence, with an empty body; `data_callback` never - // fires. - headers_only: bool = false, + // The caller wants the status, the response headers and at most this + // many leading body bytes (0 for none), not the whole body. Unlike a + // HEAD, the request itself is byte-for-byte a normal GET, so origins and + // CDNs see (and answer) exactly what a real browser sends; the rest of + // the body is discarded, and torn off the wire if it doesn't fit in + // PARTIAL_DRAIN_MAX, which also caps the prefix. The consumer gets the + // usual start/header/data/done sequence with the prefix as the body. CDP + // never sees the prefix: it isn't the response body. + partial: ?u32 = null, // Should only be set when they need to differ from the owner's. frame_id: u32 = 0, @@ -3213,12 +3214,12 @@ pub const Transfer = struct { } // A cached body is stored decoded; its wire size is long gone. A - // headers_only fetch (images) tears the body off the wire: the + // partial fetch (images) tears the body off the wire: the // Content-Length, else whatever arrived before the abort, is the // best size we have for both. var decoded_body_size = t.decoded_body_size; var encoded_body_size = if (t.cache == .none) t.encoded_body_size else decoded_body_size; - if (self.req.headers_only) { + if (self.req.partial != null) { const known = if (self._content_length > 0) self._content_length else t.encoded_body_size; decoded_body_size = known; encoded_body_size = known; @@ -3412,11 +3413,11 @@ pub const Transfer = struct { } } - // headers_only is exempt: the cap exists to bound how much body we - // buffer, and this transfer buffers none of it. Failing a 4 MB image + // A partial fetch is exempt: the cap exists to bound how much body we + // buffer, and this transfer buffers at most PARTIAL_DRAIN_MAX of it. Failing a 4 MB image // we were never going to read would turn the size limit into a // spurious `error` event on a perfectly good response. - if (opts.check_content_length and !self.req.headers_only) { + if (opts.check_content_length and self.req.partial == null) { if (self.getContentLength()) |cl| { if (cl > self.client.max_response_size) { return error.ResponseTooLarge; @@ -3952,7 +3953,7 @@ pub const Transfer = struct { return @intCast(chunk_len); } - if (transfer.req.headers_only == false) { + if (transfer.req.partial == null) { if (transfer.getContentLength()) |cl| { if (cl > transfer.client.max_response_size) { res.callback_error = error.ResponseTooLarge; @@ -3976,18 +3977,26 @@ pub const Transfer = struct { res.bytes_received += chunk_len; - if (transfer.req.headers_only) { + if (transfer.req.partial) |partial| { + const limit = @min(partial, Request.PARTIAL_DRAIN_MAX); + if (res.buffer.items.len < limit) { + const count = @min(chunk_len, limit - res.buffer.items.len); + res.buffer.appendSlice(transfer.arena.allocator(), buffer[0..count]) catch |err| { + res.callback_error = err; + return http.writefunc_error; + }; + } // Plenty of images have no Content-Length to decide this up front, so // decide it as the body arrives. - if (res.bytes_received <= Request.HEADERS_ONLY_DRAIN_MAX) { + if (res.bytes_received <= Request.PARTIAL_DRAIN_MAX) { return @intCast(chunk_len); } // Returning writefunc_error is the only way to end a transfer // early from a write callback; processOneMessage recognises the // flag and treats the resulting CURLE_WRITE_ERROR as a completed - // response with an empty body. - res.headers_only_abort = true; + // response with the kept prefix as its body. + res.partial_abort = true; return http.writefunc_error; } @@ -4179,7 +4188,8 @@ pub const Transfer = struct { } }, .data => |chunk| { - if (transfer._notify_cdp) { + // A partial body is a prefix, not the response body. + if (transfer._notify_cdp and req.partial == null) { transfer.notify(.http_response_data, &.{ .data = chunk, .transfer = transfer, @@ -4328,10 +4338,10 @@ const Response = struct { first_data_received: bool = false, // Set when dataCallback deliberately killed the transfer to satisfy - // `Request.headers_only`. processOneMessage uses it to tell our own - // abort apart from a real CURLE_WRITE_ERROR and deliver the response - // (headers, status, empty body) as a success. - headers_only_abort: bool = false, + // `Request.partial`. processOneMessage uses it to tell our own abort + // apart from a real CURLE_WRITE_ERROR and deliver the response (headers, + // status, kept prefix) as a success. + partial_abort: bool = false, // Response body. Filled by dataCallback, consumed in processMessages. // See Stream.spare to see how this works in streaming mode diff --git a/src/server/cdp/domains/network.zig b/src/server/cdp/domains/network.zig index 204bea014..d1de19f9d 100644 --- a/src/server/cdp/domains/network.zig +++ b/src/server/cdp/domains/network.zig @@ -1294,7 +1294,7 @@ const EchoDriver = struct { self.err = err; } - fn run(bc: *CDP.BrowserContext, frame_id: u32, body: []const u8) ![14]u8 { + fn run(bc: *CDP.BrowserContext, frame_id: u32, body: []const u8, partial: ?u32) ![14]u8 { const client = &bc.cdp.browser.http_client; var request_id: [14]u8 = undefined; _ = std.fmt.bufPrint(&request_id, "REQ-{d:0>10}", .{client.next_request_id +% 1}) catch unreachable; @@ -1306,6 +1306,7 @@ const EchoDriver = struct { .method = .POST, .url = "http://127.0.0.1:9582/echo_body", .body = body, + .partial = partial, .origin = bc.security_origin, .request_mode = .no_cors, .credentials_mode = .same_origin, @@ -1341,8 +1342,8 @@ test "cdp.Network: enable maxResourceBufferSize evicts oversized bodies" { }); try ctx.expectSentResult(null, .{ .id = 1 }); - const big = try EchoDriver.run(bc, page.frame_id, "12345678"); - const small = try EchoDriver.run(bc, page.frame_id, "123"); + const big = try EchoDriver.run(bc, page.frame_id, "12345678", null); + const small = try EchoDriver.run(bc, page.frame_id, "123", null); try ctx.processMessage(.{ .id = 2, @@ -1375,6 +1376,26 @@ test "cdp.Network: enable maxResourceBufferSize evicts oversized bodies" { try testing.expectEqual(0, bc.captured_responses_size); } +test "cdp.Network: getResponseBody omits a partial body" { + var ctx = try testing.context(); + defer ctx.deinit(); + + const bc = try ctx.loadBrowserContext(.{ .id = "BID-PRT", .session_id = "SID-PRT" }); + const page = try bc.session.createPage(); + + try ctx.processMessage(.{ .id = 1, .method = "Network.enable" }); + try ctx.expectSentResult(null, .{ .id = 1 }); + + const request_id = try EchoDriver.run(bc, page.frame_id, "12345678", 4); + try ctx.processMessage(.{ + .id = 2, + .method = "Network.getResponseBody", + .params = .{ .requestId = &request_id }, + }); + try ctx.expectSentResult(.{ .body = "", .base64Encoded = false }, .{ .id = 2 }); + try testing.expectEqual(0, bc.captured_responses_size); +} + test "cdp.Network: enable maxTotalBufferSize evicts oldest bodies first" { var ctx = try testing.context(); defer ctx.deinit(); @@ -1389,8 +1410,8 @@ test "cdp.Network: enable maxTotalBufferSize evicts oldest bodies first" { }); try ctx.expectSentResult(null, .{ .id = 1 }); - const first = try EchoDriver.run(bc, page.frame_id, "aaaaaa"); - const second = try EchoDriver.run(bc, page.frame_id, "bbbbbb"); + const first = try EchoDriver.run(bc, page.frame_id, "aaaaaa", null); + const second = try EchoDriver.run(bc, page.frame_id, "bbbbbb", null); try ctx.processMessage(.{ .id = 2, @@ -1435,7 +1456,7 @@ test "cdp.Network: enable maxPostDataSize omits inline postData" { try ctx.expectSentResult(null, .{ .id = 1 }); const body = "{\"source\":\"xhr\",\"pageSize\":100}"; - const request_id = try EchoDriver.run(bc, page.frame_id, body); + const request_id = try EchoDriver.run(bc, page.frame_id, body, null); try ctx.expectSentEvent("Network.requestWillBeSent", .{ .requestId = &request_id, diff --git a/src/testing.zig b/src/testing.zig index 03b149f8c..949a42e2a 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -1105,13 +1105,15 @@ fn testHTTPHandler(req: *std.http.Server.Request) !void { } // Bodies are non-empty so that libcurl always reaches the write callback, - // which is where a headers_only request decides whether to drain or abort. + // which is where a partial request decides whether to drain or abort. // ok.png takes the abort branch, small.png the drain branch; both must // behave identically as far as the DOM is concerned. if (std.mem.eql(u8, path, "/images/ok.png")) { - // > HttpClient.Request.HEADERS_ONLY_DRAIN_MAX + // > HttpClient.Request.PARTIAL_DRAIN_MAX. The synthetic PNG + // header advertises 1000 x 750 pixels; no bitmap is decoded. const body = try arena_allocator.alloc(u8, 16 * 1024 + 1); @memset(body, 'x'); + @memcpy(body[0..24], "\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x03\xe8\x00\x00\x02\xee"); return req.respond(body, .{ .extra_headers = &.{ .{ .name = "Content-Type", .value = "image/png" }, @@ -1124,6 +1126,7 @@ fn testHTTPHandler(req: *std.http.Server.Request) !void { if (std.mem.startsWith(u8, path, "/images/small.png")) { const body = try arena_allocator.alloc(u8, 1024); @memset(body, 'x'); + @memcpy(body[0..24], "\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x01\x40\x00\x00\x00\xf0"); return req.respond(body, .{ .extra_headers = &.{ .{ .name = "Content-Type", .value = "image/png" }, @@ -1131,6 +1134,15 @@ fn testHTTPHandler(req: *std.http.Server.Request) !void { }); } + if (std.mem.eql(u8, path, "/images/photo.jpg")) { + const body = "\xff\xd8\xff\xe1\x00\x04\x00\x00\xff\xc0\x00\x0b\x08\x02\xee\x03\xe8\x01\x01\x11\x00"; + return req.respond(body, .{ + .extra_headers = &.{ + .{ .name = "Content-Type", .value = "image/jpeg" }, + }, + }); + } + // No Content-Length: whether the body is small enough to drain can only // be decided as it arrives. if (std.mem.eql(u8, path, "/images/chunked.png")) {