Merge pull request #3685 from staylor/fix/image-natural-dimensions

Report natural dimensions for fetched images
This commit is contained in:
Karl Seguin authored and GitHub committed 2026-09-30 08:06:16 +08:00
commit 7d558af4f3
8 files changed
+341 -56

No files matched your search

+12 -3
View File
@@ -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
+186
View File
@@ -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 <https://www.gnu.org/licenses/>.
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("<svg width='10' height='20'></svg>"));
}
@@ -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.
});
</script>
@@ -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.
});
</script>
@@ -44,6 +47,7 @@
state.resolve();
await state.done(() => {
testing.expectEqual("error", evt);
testing.expectEqual(0, img.naturalWidth);
});
</script>
@@ -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);
});
</script>
@@ -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.
});
</script>
@@ -137,12 +143,51 @@
state.resolve();
await state.done(() => {
testing.expectEqual("load", evt);
testing.expectEqual(1000, img.naturalWidth);
testing.expectEqual(750, img.naturalHeight);
});
</script>
<script id="img-jpeg-and-src-change" type=module>
const state = await testing.async();
const img = new Image();
const loaded = new Promise(resolve => img.addEventListener('load', resolve, { once: true }));
img.src = '/images/photo.jpg';
await loaded;
testing.expectEqual(1000, img.naturalWidth);
testing.expectEqual(750, img.naturalHeight);
const failed = new Promise(resolve => img.addEventListener('error', resolve, { once: true }));
img.src = '/images/404.png';
testing.expectEqual(0, img.naturalWidth); // Old dimensions are invalid immediately.
await failed;
state.resolve();
await state.done(() => {
testing.expectEqual(0, img.naturalWidth);
testing.expectEqual(0, img.naturalHeight);
});
</script>
<script id="img-removing-loaded-src-clears-dimensions" type=module>
const state = await testing.async();
const img = new Image();
const loaded = new Promise(resolve => img.addEventListener('load', resolve, { once: true }));
img.src = '/images/small.png';
await loaded;
testing.expectEqual(320, img.naturalWidth);
img.removeAttribute('src');
state.resolve();
await state.done(() => {
testing.expectEqual(0, img.naturalWidth);
testing.expectEqual(0, img.naturalHeight);
});
</script>
<script id="img-complete-tracks-the-fetch" type=module>
// complete is false only while a request is in flight. naturalWidth stays
// 0 either way: we fetch images, we don't decode them.
// complete is false while a request is in flight. Dimensions are only
// available after the header has arrived; no bitmap is decoded.
const state = await testing.async();
const img = document.createElement("img");
@@ -160,8 +205,8 @@
await state.done(() => {
testing.expectEqual(false, during);
testing.expectEqual(true, after);
testing.expectEqual(0, img.naturalWidth);
testing.expectEqual(0, img.naturalHeight);
testing.expectEqual(1000, img.naturalWidth);
testing.expectEqual(750, img.naturalHeight);
});
</script>
@@ -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);
});
</script>
+8 -8
View File
@@ -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 <picture> 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);
+40 -30
View File
@@ -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
+27 -6
View File
@@ -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,
+14 -2
View File
@@ -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")) {