Merge branch 'main' into agent

This commit is contained in:
Adrià Arrufat
2026-04-18 08:28:03 +02:00
31 changed files with 1216 additions and 905 deletions

View File

@@ -57,6 +57,8 @@ Verify the binary before running anything:
[Linux aarch64 is also available](https://github.com/lightpanda-io/browser/releases/tag/nightly)
> **Note:** The Linux release binaries are linked against glibc. On musl-based distros (Alpine, etc.) the binary fails with `cannot execute: required file not found` because the glibc dynamic linker is missing. Use a glibc-based base image (e.g., `FROM debian:bookworm-slim` or `FROM ubuntu:24.04`) or [build from sources](#build-from-sources).
*For MacOS*
```console
curl -L -o lightpanda https://github.com/lightpanda-io/browser/releases/download/nightly/lightpanda-aarch64-macos && \

View File

@@ -57,8 +57,11 @@ pub const HeaderIterator = http.HeaderIterator;
// those other http requests.
pub const Client = @This();
// Count of active requests
active: usize = 0,
// Count of active ws requests
ws_active: usize = 0,
// Count of active http requests
http_active: usize = 0,
// Count of intercepted requests. This is to help deal with intercepted requests.
// The client doesn't track intercepted transfers. If a request is intercepted,
@@ -87,6 +90,13 @@ next_request_id: u32 = 0,
// When handles has no more available easys, requests get queued.
queue: std.DoublyLinkedList = .{},
// Queue is for Transfers that have no connection. ready_queue is for connections
// that were initiated when performing == true and thus need to wait until
// performing == false before being added. I'm hoping this is temporary and that
// we can unify the two queues. But HTTP is being changed a lot right now, and
// I'm trying to minimize the surface area.
ready_queue: std.DoublyLinkedList = .{},
// The main app allocator
allocator: Allocator,
@@ -220,6 +230,13 @@ pub fn setTlsVerify(self: *Client, verify: bool) !void {
const conn: *http.Connection = @fieldParentPtr("node", node);
try conn.setTlsVerify(verify, self.use_proxy);
}
it = self.ready_queue.first;
while (it) |node| : (it = node.next) {
const conn: *http.Connection = @fieldParentPtr("node", node);
try conn.setTlsVerify(verify, self.use_proxy);
}
self.tls_verify = verify;
}
@@ -258,26 +275,8 @@ pub fn abortFrame(self: *Client, frame_id: u32) void {
// Written this way so that both abort and abortFrame can share the same code
// but abort can avoid the frame_id check at comptime.
fn _abort(self: *Client, comptime abort_all: bool, frame_id: u32) void {
{
var n = self.in_use.first;
while (n) |node| {
n = node.next;
const conn: *http.Connection = @fieldParentPtr("node", node);
switch (conn.transport) {
.http => |transfer| {
if ((comptime abort_all) or transfer.req.frame_id == frame_id) {
transfer.kill();
}
},
.websocket => |ws| {
if ((comptime abort_all) or ws._page._frame_id == frame_id) {
ws.kill();
}
},
.none => unreachable,
}
}
}
abortConnections(self.in_use, abort_all, frame_id);
abortConnections(self.ready_queue, abort_all, frame_id);
{
var q = &self.queue;
@@ -296,6 +295,7 @@ fn _abort(self: *Client, comptime abort_all: bool, frame_id: u32) void {
if (comptime abort_all) {
self.queue = .{};
self.ready_queue = .{};
}
if (comptime IS_DEBUG and abort_all) {
@@ -312,7 +312,28 @@ fn _abort(self: *Client, comptime abort_all: bool, frame_id: u32) void {
}
leftover += 1;
}
std.debug.assert(self.active == leftover);
std.debug.assert(self.http_active == leftover);
}
}
fn abortConnections(list: std.DoublyLinkedList, comptime abort_all: bool, frame_id: u32) void {
var n = list.first;
while (n) |node| {
n = node.next;
const conn: *http.Connection = @fieldParentPtr("node", node);
switch (conn.transport) {
.http => |transfer| {
if ((comptime abort_all) or transfer.req.frame_id == frame_id) {
transfer.kill();
}
},
.websocket => |ws| {
if ((comptime abort_all) or ws._page._frame_id == frame_id) {
ws.kill();
}
},
.none => unreachable,
}
}
}
@@ -848,6 +869,11 @@ fn perform(self: *Client, timeout_ms: c_int) anyerror!PerformStatus {
self.releaseConn(conn);
}
while (self.ready_queue.popFirst()) |node| {
const conn: *http.Connection = @fieldParentPtr("node", node);
try self.trackConn(conn);
}
// We're potentially going to block for a while until we get data. Process
// whatever messages we have waiting ahead of time.
if (try self.processMessages()) {
@@ -1034,7 +1060,7 @@ fn processMessages(self: *Client) !bool {
// Conn was removed from handles during redirect reconfiguration
// but not re-added. Release it directly to avoid double-remove.
self.in_use.remove(&c.node);
self.active -= 1;
self.http_active -= 1;
self.releaseConn(c);
transfer._detached_conn = null;
}
@@ -1046,6 +1072,7 @@ fn processMessages(self: *Client) !bool {
}
},
.websocket => |ws| {
// ws_active will be decremented through the call to disconnected
if (msg.err) |err| switch (err) {
error.GotNothing => ws.disconnected(null),
else => ws.disconnected(err),
@@ -1063,26 +1090,51 @@ fn processMessages(self: *Client) !bool {
}
pub fn trackConn(self: *Client, conn: *http.Connection) !void {
if (self.performing) {
conn.in_use = false;
self.ready_queue.append(&conn.node);
return;
}
self.in_use.append(&conn.node);
conn.in_use = true;
// Set private pointer so readMessage can find the Connection.
// Must be done each time since curl_easy_reset clears it when
// connections are returned to pool.
conn.setPrivate(conn) catch |err| {
self.in_use.remove(&conn.node);
conn.in_use = false;
self.releaseConn(conn);
return err;
};
self.handles.add(conn) catch |err| {
self.in_use.remove(&conn.node);
conn.in_use = false;
self.releaseConn(conn);
return err;
};
self.active += 1;
switch (conn.transport) {
.http => self.http_active += 1,
.websocket => self.ws_active += 1,
else => unreachable,
}
}
pub fn removeConn(self: *Client, conn: *http.Connection) void {
if (conn.in_use == false) {
self.ready_queue.remove(&conn.node);
self.releaseConn(conn);
return;
}
self.in_use.remove(&conn.node);
self.active -= 1;
conn.in_use = false;
switch (conn.transport) {
.http => self.http_active -= 1,
.websocket => self.ws_active -= 1,
else => unreachable,
}
if (self.handles.remove(conn)) {
self.releaseConn(conn);
} else |_| {
@@ -1097,7 +1149,7 @@ fn releaseConn(self: *Client, conn: *http.Connection) void {
}
fn ensureNoActiveConnection(self: *const Client) !void {
if (self.active > 0) {
if (self.http_active > 0 or self.ws_active > 0) {
return error.InflightConnection;
}
}

View File

@@ -135,7 +135,7 @@ fn _tick(self: *Runner, comptime is_cdp: bool, opts: TickOpts) !CDPTickResult {
.pre, .raw, .text, .image => {
// The main page hasn't started/finished navigating.
// There's no JS to run, and no reason to run the scheduler.
if (http_client.active == 0 and (comptime is_cdp) == false) {
if (http_client.http_active == 0 and (comptime is_cdp) == false) {
// haven't started navigating, I guess.
return .done;
}
@@ -162,14 +162,14 @@ fn _tick(self: *Runner, comptime is_cdp: bool, opts: TickOpts) !CDPTickResult {
// download, or scheduled tasks to execute, or both.
// scheduler.run could trigger new http transfers, so do not
// store http_client.active BEFORE this call and then use
// store http_client.http_active BEFORE this call and then use
// it AFTER.
try browser.runMacrotasks();
// Each call to this runs scheduled load events.
try page.dispatchLoad();
const http_active = http_client.active;
const http_active = http_client.http_active;
const total_network_activity = http_active + http_client.intercepted;
if (page._notified_network_almost_idle.check(total_network_activity <= 2)) {
page.notifyNetworkAlmostIdle();
@@ -178,9 +178,22 @@ fn _tick(self: *Runner, comptime is_cdp: bool, opts: TickOpts) !CDPTickResult {
page.notifyNetworkIdle();
}
if (http_active == 0 and (comptime is_cdp == false)) {
switch (opts.until) {
.done => {},
.domcontentloaded => if (page._load_state == .load or page._load_state == .complete) {
return .done;
},
.load => if (page._load_state == .complete) {
return .done;
},
.networkidle => if (page._notified_network_idle == .done) {
return .done;
},
}
if (http_active == 0 and http_client.ws_active == 0 and (comptime is_cdp == false)) {
// we don't need to consider http_client.intercepted here
// because is_cdp is true, and that can only be
// because is_cdp is false, and that can only be
// the case when interception isn't possible.
if (comptime IS_DEBUG) {
std.debug.assert(http_client.intercepted == 0);
@@ -192,19 +205,6 @@ fn _tick(self: *Runner, comptime is_cdp: bool, opts: TickOpts) !CDPTickResult {
browser.waitForBackgroundTasks();
}
switch (opts.until) {
.done => {},
.domcontentloaded => if (page._load_state == .load or page._load_state == .complete) {
return .done;
},
.load => if (page._load_state == .complete) {
return .done;
},
.networkidle => if (page._notified_network_idle == .done) {
return .done;
},
}
// We never advertise a wait time of more than 20, there can
// always be new background tasks to run.
if (browser.msToNextMacrotask()) |ms_to_next_task| {

View File

@@ -163,6 +163,53 @@ pub fn isFloat64Array(self: Value) bool {
return v8.v8__Value__IsFloat64Array(self.handle);
}
// A few places in the code take various types, but want a string. This is a
// type-aware version of toString(). If you do:
// (new ArrayBuffer(100)).toString()
// You'll get "[object ArrayBuffer]". But this `toStringSmart()` knows about
// buffers, and Blobs, etc and will try to return the real underlying string
// value. It _does_ ultimately fallback to toString() - callers should check
// for types they _don't_ want before calling this. For example, `Response`
// checks for null or undefined before calling this to apply specific handling
// to those cases.
pub fn toStringSmart(self: Value) ![]const u8 {
if (self.isString()) |js_str| {
return try js_str.toSlice();
}
const Blob = @import("../webapi/Blob.zig");
if (self.local.jsValueToZig(*Blob, self)) |blob_obj| {
return blob_obj._slice;
} else |_| {}
var byte_offset: usize = 0;
var byte_len: usize = undefined;
var array_buffer: ?*const v8.ArrayBuffer = null;
if (self.isTypedArray() or self.isArrayBufferView()) {
const buffer_handle: *const v8.ArrayBufferView = @ptrCast(self.handle);
byte_len = v8.v8__ArrayBufferView__ByteLength(buffer_handle);
byte_offset = v8.v8__ArrayBufferView__ByteOffset(buffer_handle);
array_buffer = v8.v8__ArrayBufferView__Buffer(buffer_handle);
} else if (self.isArrayBuffer()) {
array_buffer = @ptrCast(self.handle);
byte_len = v8.v8__ArrayBuffer__ByteLength(array_buffer);
} else {
return self.toStringSlice();
}
const backing_store_ptr = v8.v8__ArrayBuffer__GetBackingStore(array_buffer orelse return "");
if (byte_len == 0) {
return &[_]u8{};
}
const backing_store_handle = v8.std__shared_ptr__v8__BackingStore__get(&backing_store_ptr) orelse return "";
const data = v8.v8__BackingStore__Data(backing_store_handle) orelse return "";
const base = @as([*]const u8, @ptrCast(data)) + byte_offset;
return base[0..byte_len];
}
pub fn isPromise(self: Value) bool {
return v8.v8__Value__IsPromise(self.handle);
}

View File

@@ -1,7 +1,9 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id=animation>
<script id=animation type=module>
const state = await testing.async();
let a1 = document.createElement('div').animate(null, null);
testing.expectEqual('idle', a1.playState);
@@ -9,13 +11,18 @@
a1.finished.then((x) => {
cb.push(a1.playState);
cb.push(x == a1);
state.resolve();
});
a1.ready.then(() => {
cb.push(a1.playState);
a1.play();
cb.push(a1.playState);
});
testing.onload(() => testing.expectEqual(['idle', 'running', 'finished', true], cb));
await state.done(() => {
testing.expectEqual(['idle', 'running', 'finished', true], cb);
});
</script>
<!-- <script id=startTime>

View File

@@ -79,6 +79,100 @@
}
</script>
<script id=parts>
// Blob as a blob part - contents are copied in, not stringified.
{
const inner = new Blob(["hello "], { type: "text/plain" });
const blob = new Blob([inner, "world"]);
testing.expectEqual(11, blob.size);
testing.async(async () => {
testing.expectEqual("hello world", await blob.text());
});
}
// Uint8Array as a blob part.
{
const bytes = new Uint8Array([104, 101, 108, 108, 111]); // "hello"
const blob = new Blob([bytes, " ", "world"]);
testing.expectEqual(11, blob.size);
testing.async(async () => {
testing.expectEqual("hello world", await blob.text());
});
}
// Non-byte TypedArrays contribute their raw backing bytes.
{
// Uint16Array of one element (0x0041 = 'A' little-endian) produces 2 bytes.
const u16 = new Uint16Array([0x0041]);
const blob = new Blob([u16]);
testing.expectEqual(2, blob.size);
}
{
// Float32Array: 4 bytes per element.
const f32 = new Float32Array([1.0, 2.0, 3.0]);
const blob = new Blob([f32]);
testing.expectEqual(12, blob.size);
}
// ArrayBuffer as a blob part.
{
const buf = new Uint8Array([1, 2, 3, 4]).buffer;
const blob = new Blob([buf]);
testing.expectEqual(4, blob.size);
testing.async(async () => {
const result = await blob.bytes();
testing.expectEqual(new Uint8Array([1, 2, 3, 4]), result);
});
}
// DataView (ArrayBufferView) as a blob part.
{
const buf = new Uint8Array([10, 20, 30, 40, 50]).buffer;
const view = new DataView(buf, 1, 3); // bytes [20, 30, 40]
const blob = new Blob([view]);
testing.expectEqual(3, blob.size);
testing.async(async () => {
const result = await blob.bytes();
testing.expectEqual(new Uint8Array([20, 30, 40]), result);
});
}
// Mixed types in a single parts array.
{
const inner = new Blob(["bb"]);
const bytes = new Uint8Array([99, 99]); // "cc"
const buf = new Uint8Array([100, 100]).buffer; // "dd"
const blob = new Blob(["aa", inner, bytes, buf, "ee"]);
testing.expectEqual(10, blob.size);
testing.async(async () => {
testing.expectEqual("aabbccddee", await blob.text());
});
}
// Number coerces to string.
{
const blob = new Blob([42]);
testing.expectEqual(2, blob.size);
testing.async(async () => {
testing.expectEqual("42", await blob.text());
});
}
// Empty parts array.
{
const blob = new Blob([]);
testing.expectEqual(0, blob.size);
testing.expectEqual("", blob.type);
}
// No arguments.
{
const blob = new Blob();
testing.expectEqual(0, blob.size);
}
</script>
<script id=stream>
{
const parts = ["may", "thy", "knife", "chip", "and", "shatter"];

View File

@@ -121,7 +121,9 @@
<div id="will_be_removed">This will be removed by document.open()</div>
<script id=test_open_close_async>
<script id=test_open_close_async type=module>
const state = await testing.async();
// Mark that we saw the element before
const sawBefore = document.getElementById('will_be_removed') !== null;
testing.expectEqual(true, sawBefore);
@@ -131,7 +133,15 @@
document.open();
}, 5);
testing.onload(() => {
// doing this after test_open_close_async used to crash, so we keep it
// to make sure it doesn't
setTimeout(() => {
document.open();
document.close();
state.resolve();
}, 20);
await state.done(() => {
// The element should be gone now
const afterOpen = document.getElementById('will_be_removed');
testing.expectEqual(null, afterOpen);
@@ -149,13 +159,4 @@
testing.expectEqual('Replaced', newContent.textContent);
})
</script>
<script>
// doing this after test_open_close_async used to crash, so we keep it
// to make sure it doesn't
setTimeout(() => {
document.open();
document.close();
}, 20);
</script>
</body>

View File

@@ -240,13 +240,20 @@
testing.expectEqual('AbortError', AbortSignal.abort().reason);
</script>
<script id=abortsignal_timeout>
var s3 = AbortSignal.timeout(10);
testing.onload(() => {
testing.expectEqual(true, s3.aborted);
testing.expectEqual('TimeoutError', s3.reason);
testing.expectError('Error: TimeoutError', () => {
s3.throwIfAborted()
<script id=abortsignal_timeout type=module>
{
const state = await testing.async();
var s3 = AbortSignal.timeout(10);
window.setTimeout(() => {
state.resolve()
}, 11);
await state.done(() => {
testing.expectEqual(true, s3.aborted);
testing.expectEqual('TimeoutError', s3.reason);
testing.expectError('Error: TimeoutError', () => {
s3.throwIfAborted()
});
});
});
}
</script>

View File

@@ -3,11 +3,13 @@
<iframe id="receiver"></iframe>
<script id="messages">
<script id="messages" type=module>
{
const state = await testing.async();
let reply = null;
window.addEventListener('message', (e) => {
reply = e.data;
state.resolve();
});
const iframe = $('#receiver');
@@ -16,7 +18,7 @@
iframe.contentWindow.postMessage('ping', '*');
});
testing.onload(() => {
await state.done(() => {
testing.expectEqual('pong', reply.data);
testing.expectEqual(testing.ORIGIN, reply.origin);
});

View File

@@ -3,9 +3,13 @@
<iframe name=f1 id=frame1></iframe>
<a id=l1 target=f1 href=support/page.html></a>
<script id=anchor>
<script id=anchor type=module>
const state = await testing.async();
$('#l1').click();
testing.onload(() => {
$('#frame1').onload = () => { state.resolve(); }
state.done(() => {
testing.expectEqual('<html><head></head><body>a-page\n</body></html>', $('#frame1').contentDocument.documentElement.outerHTML);
});
</script>

View File

@@ -1,105 +1,124 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id=fetch_basic>
testing.async(async (restore) => {
<script id=fetch_basic type=module>
{
const state = await testing.async()
const response = await fetch('http://127.0.0.1:9582/xhr');
restore();
testing.expectEqual(200, response.status);
testing.expectEqual(true, response.ok);
testing.expectEqual('basic', response.type);
testing.expectEqual('http://127.0.0.1:9582/xhr', response.url);
testing.expectEqual(false, response.redirected);
// Check headers
const headers = response.headers;
testing.expectEqual('text/html; charset=utf-8', headers.get('Content-Type'));
testing.expectEqual('100', headers.get('content-length'));
// Check text response
const text = await response.text();
testing.expectEqual(100, text.length);
});
state.resolve();
await state.done(() => {
testing.expectEqual(200, response.status);
testing.expectEqual(true, response.ok);
testing.expectEqual('basic', response.type);
testing.expectEqual('http://127.0.0.1:9582/xhr', response.url);
testing.expectEqual(false, response.redirected);
// Check headers
const headers = response.headers;
testing.expectEqual('text/html; charset=utf-8', headers.get('Content-Type'));
testing.expectEqual('100', headers.get('content-length'));
// Check text response
testing.expectEqual(100, text.length);
});
}
</script>
<script id=fetch_json>
testing.async(async (restore) => {
<script id=fetch_json type=module>
{
const state = await testing.async();
const response = await fetch('http://127.0.0.1:9582/xhr/json');
restore();
testing.expectEqual(200, response.status);
testing.expectEqual(true, response.ok);
testing.expectEqual('basic', response.type);
testing.expectEqual(false, response.redirected);
const json = await response.json();
testing.expectEqual('9000!!!', json.over);
testing.expectEqual("number", typeof json.updated_at);
testing.expectEqual(1765867200000, json.updated_at);
testing.expectEqual({over: '9000!!!',updated_at:1765867200000}, json);
});
state.resolve();
await state.done(() => {
testing.expectEqual(200, response.status);
testing.expectEqual(true, response.ok);
testing.expectEqual('basic', response.type);
testing.expectEqual(false, response.redirected);
testing.expectEqual('9000!!!', json.over);
testing.expectEqual("number", typeof json.updated_at);
testing.expectEqual(1765867200000, json.updated_at);
testing.expectEqual({over: '9000!!!',updated_at:1765867200000}, json);
});
}
</script>
<script id=fetch_post>
testing.async(async (restore) => {
<script id=fetch_post type=module>
{
const state = await testing.async();
const response = await fetch('http://127.0.0.1:9582/xhr', {
method: 'POST',
body: 'foo'
});
restore();
testing.expectEqual(200, response.status);
testing.expectEqual(true, response.ok);
const text = await response.text();
testing.expectEqual(true, text.length > 64);
});
state.resolve();
await state.done(() => {
testing.expectEqual(200, response.status);
testing.expectEqual(true, response.ok);
testing.expectEqual(true, text.length > 64);
});
}
</script>
<script id=fetch_redirect>
testing.async(async (restore) => {
<script id=fetch_redirect type=module>
{
const state = await testing.async();
const response = await fetch('http://127.0.0.1:9582/xhr/redirect');
restore();
testing.expectEqual(200, response.status);
testing.expectEqual(true, response.ok);
testing.expectEqual('http://127.0.0.1:9582/xhr', response.url);
testing.expectEqual(true, response.redirected);
const text = await response.text();
testing.expectEqual(100, text.length);
});
state.resolve();
await state.done(() => {
testing.expectEqual(200, response.status);
testing.expectEqual(true, response.ok);
testing.expectEqual('http://127.0.0.1:9582/xhr', response.url);
testing.expectEqual(true, response.redirected);
testing.expectEqual(100, text.length);
});
}
</script>
<script id=fetch_404>
testing.async(async (restore) => {
<script id=fetch_404 type=module>
{
const state = await testing.async();
const response = await fetch('http://127.0.0.1:9582/xhr/404');
restore();
testing.expectEqual(404, response.status);
testing.expectEqual(false, response.ok);
const text = await response.text();
testing.expectEqual('Not Found', text);
});
state.resolve();
await state.done(() => {
testing.expectEqual(404, response.status);
testing.expectEqual(false, response.ok);
testing.expectEqual('Not Found', text);
});
}
</script>
<script id=fetch_500>
testing.async(async (restore) => {
<script id=fetch_500 type=module>
{
const state = await testing.async();
const response = await fetch('http://127.0.0.1:9582/xhr/500');
restore();
testing.expectEqual(500, response.status);
testing.expectEqual(false, response.ok);
const text = await response.text();
testing.expectEqual('Internal Server Error', text);
});
state.resolve();
await state.done(() => {
testing.expectEqual(500, response.status);
testing.expectEqual(false, response.ok);
testing.expectEqual('Internal Server Error', text);
});
}
</script>
<script id=fetch_request_object>
testing.async(async (restore) => {
<script id=fetch_request_object type=module>
{
const state = await testing.async();
const request = new Request('http://127.0.0.1:9582/xhr', {
method: 'GET'
});
@@ -108,29 +127,33 @@
testing.expectEqual('GET', request.method);
const response = await fetch(request);
restore();
testing.expectEqual(200, response.status);
testing.expectEqual(true, response.ok);
const text = await response.text();
testing.expectEqual(100, text.length);
});
state.resolve();
await state.done(() => {
testing.expectEqual(200, response.status);
testing.expectEqual(true, response.ok);
testing.expectEqual(100, text.length);
});
}
</script>
<script id=fetch_request_with_headers>
testing.async(async (restore) => {
<script id=fetch_request_with_headers type=module>
{
const state = await testing.async();
const response = await fetch('http://127.0.0.1:9582/xhr', {
method: 'GET',
headers: {
'X-Custom-Header': 'test-value'
}
});
restore();
state.resolve();
testing.expectEqual(200, response.status);
testing.expectEqual(true, response.ok);
});
await state.done(() => {
testing.expectEqual(200, response.status);
testing.expectEqual(true, response.ok);
});
}
</script>
<script id=fetch_request_with_method_variations>
@@ -154,8 +177,8 @@
}
</script>
<script id=response_constructor>
testing.async(async (restore) => {
<script id=response_constructor type=module>
{
const response1 = new Response(null);
testing.expectEqual(200, response1.status);
testing.expectEqual(true, response1.ok);
@@ -163,79 +186,97 @@
const response2 = new Response('Hello', {
status: 201,
});
testing.expectEqual(201, response2.status);
testing.expectEqual(true, response2.ok);
const state = await testing.async();
const text = await response2.text();
restore();
state.resolve();
testing.expectEqual('Hello', text);
});
await state.done(() => {
testing.expectEqual(201, response2.status);
testing.expectEqual(true, response2.ok);
testing.expectEqual('Hello', text);
});
}
</script>
<script id=response_body_stream>
testing.async(async (restore) => {
<script id=response_body_stream type=module>
{
const state = await testing.async();
const response = await fetch('http://127.0.0.1:9582/xhr');
restore();
testing.expectEqual(true, response.body !== null);
testing.expectEqual(true, response.body instanceof ReadableStream);
state.resolve();
const buf = await response.arrayBuffer()
restore();
const uint8array = new Uint8Array(buf);
const decoder = new TextDecoder('utf-8');
testing.expectEqual('1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890', decoder.decode(uint8array));
});
await state.done(() => {
testing.expectEqual(true, response.body !== null);
testing.expectEqual(true, response.body instanceof ReadableStream);
const uint8array = new Uint8Array(buf);
const decoder = new TextDecoder('utf-8');
testing.expectEqual('1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890', decoder.decode(uint8array));
})
};
</script>
<script id=response_empty_body>
testing.async(async (restore) => {
<script id=response_empty_body type=module>
{
const state = await testing.async();
const response = new Response('');
testing.expectEqual(200, response.status);
const text = await response.text();
restore();
state.resolve();
testing.expectEqual('', text);
// Empty body should still create a valid stream
testing.expectEqual(true, response.body !== null);
});
await state.done(() => {
testing.expectEqual(200, response.status);
testing.expectEqual('', text);
// Empty body should still create a valid stream
testing.expectEqual(true, response.body !== null);
});
}
</script>
<script id=fetch_blob_url>
testing.async(async (restore) => {
<script id=fetch_blob_url type=module>
{
const state = await testing.async();
// Create a blob and get its URL
const blob = new Blob(['Hello from blob!'], { type: 'text/plain' });
const blobUrl = URL.createObjectURL(blob);
const response = await fetch(blobUrl);
restore();
testing.expectEqual(200, response.status);
testing.expectEqual(true, response.ok);
testing.expectEqual(blobUrl, response.url);
testing.expectEqual('text/plain', response.headers.get('Content-Type'));
const text = await response.text();
testing.expectEqual('Hello from blob!', text);
// Clean up
URL.revokeObjectURL(blobUrl);
});
state.resolve();
await state.done(() => {
testing.expectEqual(200, response.status);
testing.expectEqual(true, response.ok);
testing.expectEqual(blobUrl, response.url);
testing.expectEqual('text/plain', response.headers.get('Content-Type'));
testing.expectEqual('Hello from blob!', text);
// Clean up
URL.revokeObjectURL(blobUrl);
});
}
</script>
<script id=abort>
testing.async(async (restore) => {
<script id=abort type=module>
{
const state = await testing.async();
const controller = new AbortController();
controller.abort();
try {
await fetch('http://127.0.0.1:9582/xhr', { signal: controller.signal });
testain.fail('fetch should have been aborted');
state.resolve();
await state.done(() => {
testing.fail('fetch should have been aborted');
});
} catch (e) {
restore();
testing.expectEqual("AbortError", e.name);
state.resolve();
await state.done(() => {
testing.expectEqual("AbortError", e.name);
});
}
});
}
</script>

View File

@@ -22,6 +22,9 @@
const req = new Request('/path');
testing.expectEqual(true, req.url.includes('/path'));
}
const req = new Request('https://example.com/api/hello world');
testing.expectEqual('https://example.com/api/hello%20world', req.url);
</script>
<script id=headers>

View File

@@ -561,3 +561,21 @@
});
}
</script>
<script id=ws_within_callback type=module>
{
const state = await testing.async();
let nested = false;
let ws1 = new WebSocket('ws://127.0.0.1:9584/');
ws1.addEventListener('open', () => {
let ws2 = new WebSocket('ws://127.0.0.1:9584/');
ws2.addEventListener('open', state.resolve);
});
await state.done(() => {
// this case used to fail, so just reaching here is a success!
testing.expectTrue(true);
});
}
</script>

View File

@@ -1,332 +1,335 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id=xhr>
<script id=xhr type=module>
testing.expectEqual(0, XMLHttpRequest.UNSENT);
testing.expectEqual(1, XMLHttpRequest.OPENED);
testing.expectEqual(2, XMLHttpRequest.HEADERS_RECEIVED);
testing.expectEqual(3, XMLHttpRequest.LOADING);
testing.expectEqual(4, XMLHttpRequest.DONE);
testing.async(async (restore) => {
{
const state = await testing.async();
const req = new XMLHttpRequest();
const event = await new Promise((resolve) => {
function cbk(event) {
resolve(event)
}
req.onload = cbk;
testing.expectEqual(cbk, req.onload);
req.onload = cbk;
function cbk(event) {
state.resolve(event)
}
req.open('GET', 'http://127.0.0.1:9582/xhr');
testing.expectEqual(0, req.status);
testing.expectEqual('', req.statusText);
testing.expectEqual('', req.getAllResponseHeaders());
testing.expectEqual(null, req.getResponseHeader('Content-Type'));
testing.expectEqual('', req.responseText);
testing.expectEqual('', req.responseURL);
req.send();
req.onload = cbk;
testing.expectEqual(cbk, req.onload);
req.onload = cbk;
req.open('GET', 'http://127.0.0.1:9582/xhr');
testing.expectEqual(0, req.status);
testing.expectEqual('', req.statusText);
testing.expectEqual('', req.getAllResponseHeaders());
testing.expectEqual(null, req.getResponseHeader('Content-Type'));
testing.expectEqual('', req.responseText);
testing.expectEqual('', req.responseURL);
req.send();
await state.done((event) => {
testing.expectEqual('load', event.type);
testing.expectEqual(true, event.loaded > 0);
testing.expectEqual(true, event instanceof ProgressEvent);
testing.expectEqual(200, req.status);
testing.expectEqual('OK', req.statusText);
testing.expectEqual('text/html; charset=utf-8', req.getResponseHeader('Content-Type'));
testing.expectEqual('content-length: 100\r\nContent-Type: text/html; charset=utf-8\r\n', req.getAllResponseHeaders());
testing.expectEqual(100, req.responseText.length);
testing.expectEqual(req.responseText.length, req.response.length);
testing.expectEqual('http://127.0.0.1:9582/xhr', req.responseURL);
});
restore();
testing.expectEqual('load', event.type);
testing.expectEqual(true, event.loaded > 0);
testing.expectEqual(true, event instanceof ProgressEvent);
testing.expectEqual(200, req.status);
testing.expectEqual('OK', req.statusText);
testing.expectEqual('text/html; charset=utf-8', req.getResponseHeader('Content-Type'));
testing.expectEqual('content-length: 100\r\nContent-Type: text/html; charset=utf-8\r\n', req.getAllResponseHeaders());
testing.expectEqual(100, req.responseText.length);
testing.expectEqual(req.responseText.length, req.response.length);
testing.expectEqual('http://127.0.0.1:9582/xhr', req.responseURL);
});
}
</script>
<script id=xhr2>
const req2 = new XMLHttpRequest()
testing.async(async (restore) => {
await new Promise((resolve) => {
req2.onload = resolve;
req2.open('GET', 'http://127.0.0.1:9582/xhr')
req2.responseType = 'document';
req2.send()
});
<script id=xhr2 type=module type=module>
{
const state = await testing.async();
restore();
testing.expectEqual(200, req2.status);
testing.expectEqual('OK', req2.statusText);
testing.expectEqual(true, req2.response instanceof Document);
testing.expectEqual(true, req2.responseXML instanceof Document);
});
const req2 = new XMLHttpRequest()
req2.onload = () => { state.resolve() };
req2.open('GET', 'http://127.0.0.1:9582/xhr')
req2.responseType = 'document';
req2.send()
await state.done(() => {
testing.expectEqual(200, req2.status);
testing.expectEqual('OK', req2.statusText);
testing.expectEqual(true, req2.response instanceof Document);
testing.expectEqual(true, req2.responseXML instanceof Document);
});
}
</script>
<script id=xhr3>
const req3 = new XMLHttpRequest()
testing.async(async (restore) => {
await new Promise((resolve) => {
req3.onload = resolve;
req3.open('GET', 'http://127.0.0.1:9582/xhr/json')
req3.responseType = 'json';
req3.send()
});
<script id=xhr3 type=module type=module>
{
const state = await testing.async();
restore();
testing.expectEqual(200, req3.status);
testing.expectEqual('OK', req3.statusText);
testing.expectEqual('9000!!!', req3.response.over);
testing.expectEqual("number", typeof json.updated_at);
testing.expectEqual(1765867200000, json.updated_at);
testing.expectEqual({over: '9000!!!',updated_at:1765867200000}, json);
});
const req3 = new XMLHttpRequest()
req3.onload = () => { state.resolve() };
req3.open('GET', 'http://127.0.0.1:9582/xhr/json')
req3.responseType = 'json';
req3.send();
await state.done(() => {
testing.expectEqual(200, req3.status);
testing.expectEqual('OK', req3.statusText);
testing.expectEqual('9000!!!', req3.response.over);
testing.expectEqual("number", typeof json.updated_at);
testing.expectEqual(1765867200000, json.updated_at);
testing.expectEqual({over: '9000!!!',updated_at:1765867200000}, json);
});
}
</script>
<script id=xhr4>
const req4 = new XMLHttpRequest()
testing.async(async (restore) => {
await new Promise((resolve) => {
req4.onload = resolve;
req4.open('POST', 'http://127.0.0.1:9582/xhr')
req4.send('foo')
});
<script id=xhr4 type=module>
{
const state = await testing.async();
restore();
testing.expectEqual(200, req4.status);
testing.expectEqual('OK', req4.statusText);
testing.expectEqual(true, req4.responseText.length > 64);
});
const req4 = new XMLHttpRequest();
req4.onload = () => { state.resolve() };
req4.open('POST', 'http://127.0.0.1:9582/xhr')
req4.send('foo');
await state.done(() => {
testing.expectEqual(200, req4.status);
testing.expectEqual('OK', req4.statusText);
testing.expectEqual(true, req4.responseText.length > 64);
});
}
</script>
<script id=xhr5>
testing.async(async (restore) => {
let state = [];
<script id=xhr5 type=module>
{
const state = await testing.async();
let records = [];
const req5 = new XMLHttpRequest();
const result = await new Promise((resolve) => {
req5.onreadystatechange = (e) => {
state.push(req5.readyState);
if (req5.readyState === XMLHttpRequest.DONE) {
resolve({states: state, target: e.currentTarget});
}
req5.onreadystatechange = (e) => {
records.push(req5.readyState);
if (req5.readyState === XMLHttpRequest.DONE) {
state.resolve({records: records, target: e.currentTarget});
}
}
req5.open('GET', 'http://127.0.0.1:9582/xhr');
req5.send();
req5.open('GET', 'http://127.0.0.1:9582/xhr');
req5.send();
await state.done((result) => {
const {records: records, target: target} = result;
testing.expectEqual(4, records.length)
testing.expectEqual(XMLHttpRequest.OPENED, records[0]);
testing.expectEqual(XMLHttpRequest.HEADERS_RECEIVED, records[1]);
testing.expectEqual(XMLHttpRequest.LOADING, records[2]);
testing.expectEqual(XMLHttpRequest.DONE, records[3]);
testing.expectEqual(req5, target);
});
restore();
const {states: states, target: target} = result;
testing.expectEqual(4, states.length)
testing.expectEqual(XMLHttpRequest.OPENED, states[0]);
testing.expectEqual(XMLHttpRequest.HEADERS_RECEIVED, states[1]);
testing.expectEqual(XMLHttpRequest.LOADING, states[2]);
testing.expectEqual(XMLHttpRequest.DONE, states[3]);
testing.expectEqual(req5, target);
})
}
</script>
<script id=xhr6>
const req6 = new XMLHttpRequest()
testing.async(async (restore) => {
await new Promise((resolve) => {
req6.onload = resolve;
req6.open('GET', 'http://127.0.0.1:9582/xhr/binary')
req6.responseType ='arraybuffer'
req6.send()
});
<script id=xhr6 type=module>
{
const state = await testing.async();
restore();
testing.expectEqual(200, req6.status);
testing.expectEqual('OK', req6.statusText);
testing.expectEqual(7, req6.response.byteLength);
testing.expectEqual([0, 0, 1, 2, 0, 0, 9], new Int32Array(req6.response));
testing.expectEqual('', typeof req6.response);
testing.expectEqual('arraybuffer', req6.responseType);
});
const req6 = new XMLHttpRequest();
req6.onload = () => { state.resolve() };
req6.open('GET', 'http://127.0.0.1:9582/xhr/binary')
req6.responseType ='arraybuffer';
req6.send();
await state.done(() => {
testing.expectEqual(200, req6.status);
testing.expectEqual('OK', req6.statusText);
testing.expectEqual(7, req6.response.byteLength);
testing.expectEqual([0, 0, 1, 2, 0, 0, 9], new Int32Array(req6.response));
testing.expectEqual('', typeof req6.response);
testing.expectEqual('arraybuffer', req6.responseType);
});
}
</script>
<script id=xhr_redirect>
testing.async(async (restore) => {
<script id=xhr_redirect type=module>
{
const state = await testing.async();
const req = new XMLHttpRequest();
await new Promise((resolve) => {
req.onload = resolve;
req.open('GET', 'http://127.0.0.1:9582/xhr/redirect');
req.send();
});
req.onload = () => { state.resolve() };
req.open('GET', 'http://127.0.0.1:9582/xhr/redirect');
req.send();
restore();
testing.expectEqual(200, req.status);
testing.expectEqual('OK', req.statusText);
testing.expectEqual('http://127.0.0.1:9582/xhr', req.responseURL);
testing.expectEqual(100, req.responseText.length);
});
await state.done(() => {
testing.expectEqual(200, req.status);
testing.expectEqual('OK', req.statusText);
testing.expectEqual('http://127.0.0.1:9582/xhr', req.responseURL);
testing.expectEqual(100, req.responseText.length);
});
}
</script>
<script id=xhr_404>
testing.async(async (restore) => {
<script id=xhr_404 type=module>
{
const state = await testing.async();
const req = new XMLHttpRequest();
await new Promise((resolve) => {
req.onload = resolve;
req.open('GET', 'http://127.0.0.1:9582/xhr/404');
req.send();
});
req.onload = () => { state.resolve() };
req.open('GET', 'http://127.0.0.1:9582/xhr/404');
req.send();
restore();
testing.expectEqual(404, req.status);
testing.expectEqual('Not Found', req.statusText);
testing.expectEqual('Not Found', req.responseText);
});
await state.done(() => {
testing.expectEqual(404, req.status);
testing.expectEqual('Not Found', req.statusText);
testing.expectEqual('Not Found', req.responseText);
});
}
</script>
<script id=xhr_500>
testing.async(async (restore) => {
<script id=xhr_500 type=module>
{
const state = await testing.async();
const req = new XMLHttpRequest();
await new Promise((resolve) => {
req.onload = resolve;
req.open('GET', 'http://127.0.0.1:9582/xhr/500');
req.send();
});
req.onload = () => { state.resolve() };
req.open('GET', 'http://127.0.0.1:9582/xhr/500');
req.send();
restore();
testing.expectEqual(500, req.status);
testing.expectEqual('Internal Server Error', req.statusText);
testing.expectEqual('Internal Server Error', req.responseText);
});
await state.done(() => {
testing.expectEqual(500, req.status);
testing.expectEqual('Internal Server Error', req.statusText);
testing.expectEqual('Internal Server Error', req.responseText);
});
}
</script>
<script id=xhr_abort>
testing.async(async (restore) => {
<script id=xhr_abort type=module>
{
const state = await testing.async();
const req = new XMLHttpRequest();
let abortFired = false;
let errorFired = false;
let loadEndFired = false;
await new Promise((resolve) => {
req.onabort = () => { abortFired = true; };
req.onerror = () => { errorFired = true; };
req.onloadend = () => {
loadEndFired = true;
resolve();
};
req.onabort = () => { abortFired = true; };
req.onerror = () => { errorFired = true; };
req.onloadend = () => {
loadEndFired = true;
state.resolve();
};
req.open('GET', 'http://127.0.0.1:9582/xhr');
req.send();
req.open('GET', 'http://127.0.0.1:9582/xhr');
req.send();
req.abort();
await state.done(() => {
testing.expectEqual(true, abortFired);
testing.expectEqual(true, errorFired);
testing.expectEqual(true, loadEndFired);
testing.expectEqual(XMLHttpRequest.UNSENT, req.readyState);
});
}
</script>
<script id=xhr_abort_callback type=module>
{
const state = await testing.async();
const req = new XMLHttpRequest();
let abortFired = false;
let errorFired = false;
let loadEndFired = false;
req.onabort = () => { abortFired = true; };
req.onerror = () => { errorFired = true; };
req.onloadend = () => {
loadEndFired = true;
state.resolve();
};
req.open('GET', 'http://127.0.0.1:9582/xhr');
req.onreadystatechange = (e) => {
req.abort();
});
}
req.send();
restore();
testing.expectEqual(true, abortFired);
testing.expectEqual(true, errorFired);
testing.expectEqual(true, loadEndFired);
testing.expectEqual(XMLHttpRequest.UNSENT, req.readyState);
});
await state.done(() => {
testing.expectEqual(true, abortFired);
testing.expectEqual(true, errorFired);
testing.expectEqual(true, loadEndFired);
testing.expectEqual(XMLHttpRequest.UNSENT, req.readyState);
});
}
</script>
<script id=xhr_abort_callback>
testing.async(async (restore) => {
<script id=xhr_abort_callback_nobody type=module>
{
const state = await testing.async();
const req = new XMLHttpRequest();
let abortFired = false;
let errorFired = false;
let loadEndFired = false;
await new Promise((resolve) => {
req.onabort = () => { abortFired = true; };
req.onerror = () => { errorFired = true; };
req.onloadend = () => {
loadEndFired = true;
resolve();
};
req.onabort = () => { abortFired = true; };
req.onerror = () => { errorFired = true; };
req.onloadend = () => {
loadEndFired = true;
state.resolve();
};
req.open('GET', 'http://127.0.0.1:9582/xhr');
req.onreadystatechange = (e) => {
req.abort();
}
req.send();
req.open('GET', 'http://127.0.0.1:9582/xhr_empty');
req.onreadystatechange = (e) => {
req.abort();
}
req.send();
await state.done(() => {
testing.expectEqual(true, abortFired);
testing.expectEqual(true, errorFired);
testing.expectEqual(true, loadEndFired);
testing.expectEqual(XMLHttpRequest.UNSENT, req.readyState);
});
restore();
testing.expectEqual(true, abortFired);
testing.expectEqual(true, errorFired);
testing.expectEqual(true, loadEndFired);
testing.expectEqual(XMLHttpRequest.UNSENT, req.readyState);
});
}
</script>
<script id=xhr_blob_url type=module>
{
const state = await testing.async();
<script id=xhr_abort_callback_nobody>
testing.async(async (restore) => {
const req = new XMLHttpRequest();
let abortFired = false;
let errorFired = false;
let loadEndFired = false;
await new Promise((resolve) => {
req.onabort = () => { abortFired = true; };
req.onerror = () => { errorFired = true; };
req.onloadend = () => {
loadEndFired = true;
resolve();
};
req.open('GET', 'http://127.0.0.1:9582/xhr_empty');
req.onreadystatechange = (e) => {
req.abort();
}
req.send();
});
restore();
testing.expectEqual(true, abortFired);
testing.expectEqual(true, errorFired);
testing.expectEqual(true, loadEndFired);
testing.expectEqual(XMLHttpRequest.UNSENT, req.readyState);
});
</script>
<script id=xhr_blob_url>
testing.async(async (restore) => {
// Create a blob and get its URL
const blob = new Blob(['Hello from blob!'], { type: 'text/plain' });
const blobUrl = URL.createObjectURL(blob);
const req = new XMLHttpRequest();
await new Promise((resolve) => {
req.onload = resolve;
req.open('GET', blobUrl);
req.send();
req.onload = () => { state.resolve() };
req.open('GET', blobUrl);
req.send();
await state.done(() => {
testing.expectEqual(200, req.status);
testing.expectEqual('Hello from blob!', req.responseText);
testing.expectEqual(blobUrl, req.responseURL);
// Clean up
URL.revokeObjectURL(blobUrl);
});
restore();
testing.expectEqual(200, req.status);
testing.expectEqual('Hello from blob!', req.responseText);
testing.expectEqual(blobUrl, req.responseURL);
// Clean up
URL.revokeObjectURL(blobUrl);
});
}
</script>
<script id=xhr_timeout>
// timeout property: default is 0
const req = new XMLHttpRequest();
testing.expectEqual(0, req.timeout);
<script id=xhr_timeout type=module>
{
// timeout property: default is 0
const req = new XMLHttpRequest();
testing.expectEqual(0, req.timeout);
// timeout can be set and read back
req.timeout = 5000;
testing.expectEqual(5000, req.timeout);
// request with timeout set succeeds normally when server responds in time
testing.async(async (restore) => {
const event = await new Promise((resolve) => {
req.onload = resolve;
req.open('GET', 'http://127.0.0.1:9582/xhr');
req.send();
});
restore();
testing.expectEqual('load', event.type);
testing.expectEqual(200, req.status);
// timeout can be set and read back
req.timeout = 5000;
testing.expectEqual(5000, req.timeout);
});
// request with timeout set succeeds normally when server responds in time
const state = await testing.async();
req.onload = (e) => { state.resolve(e) };
req.open('GET', 'http://127.0.0.1:9582/xhr');
req.send();
await state.done((event) => {
testing.expectEqual('load', event.type);
testing.expectEqual(200, req.status);
testing.expectEqual(5000, req.timeout);
});
}
</script>

View File

@@ -2,13 +2,15 @@
<body></body>
<script src="../testing.js"></script>
<script id="gbk_encoding">
<script id="gbk_encoding" type=module>
{
const state = await testing.async();
const iframe = document.createElement('iframe');
document.body.appendChild(iframe);
iframe.src = 'encoding/gbk.html';
iframe.onload = () => { state.resolve(); }
testing.onload(() => {
await state.done(() => {
// GBK-encoded "中文" should be decoded to UTF-8
testing.expectEqual('中文', iframe.contentDocument.getElementById('test').textContent);
// document.characterSet should return canonical encoding name
@@ -19,57 +21,67 @@
}
</script>
<script id="shift_jis_encoding">
<script id="shift_jis_encoding" type=module>
{
const state = await testing.async();
const iframe = document.createElement('iframe');
document.body.appendChild(iframe);
iframe.src = 'encoding/shift_jis.html';
iframe.onload = () => { state.resolve(); }
testing.onload(() => {
await state.done(() => {
// Shift_JIS-encoded "日本語" should be decoded to UTF-8
testing.expectEqual('日本語', iframe.contentDocument.getElementById('test').textContent);
});
}
</script>
<script id="latin1_encoding">
<script id="latin1_encoding" type=module>
{
const state = await testing.async();
const iframe = document.createElement('iframe');
document.body.appendChild(iframe);
iframe.src = 'encoding/latin1.html';
iframe.onload = () => { state.resolve(); }
testing.onload(() => {
await state.done(() => {
// ISO-8859-1-encoded "Café" should be decoded to UTF-8
testing.expectEqual('Café', iframe.contentDocument.getElementById('test').textContent);
});
}
</script>
<script id="content_type_header_charset">
<script id="content_type_header_charset" type=module>
{
const state = await testing.async();
// Test charset from Content-Type HTTP header (no meta charset in file)
// TestHTTPServer returns "text/html; charset=GB2312" for *.GB2312.html files
const iframe = document.createElement('iframe');
document.body.appendChild(iframe);
iframe.src = testing.BASE_URL + 'page/encoding/content_type.GB2312.html';
iframe.onload = () => { state.resolve(); }
testing.onload(() => {
await state.done(() => {
// GB2312-encoded "中文" should be decoded to UTF-8 via Content-Type header charset
testing.expectEqual('中文', iframe.contentDocument.getElementById('test').textContent);
});
}
</script>
<script id="no_charset_fallback">
<script id="no_charset_fallback" type=module>
{
const state = await testing.async();
// Test file with non-UTF-8 bytes but NO charset declaration anywhere.
// Without charset info, the bytes are parsed as UTF-8, producing replacement characters.
// This documents the "broken" behavior for files without proper encoding declaration.
const iframe = document.createElement('iframe');
document.body.appendChild(iframe);
iframe.src = 'encoding/no_charset.html';
iframe.onload = () => { state.resolve(); }
testing.onload(() => {
await state.done(() => {
// The GBK bytes D6 D0 CE C4 are invalid UTF-8, each becomes U+FFFD
const text = iframe.contentDocument.getElementById('test').textContent;
// Should contain replacement characters (the exact count depends on how invalid bytes are handled)
@@ -78,16 +90,19 @@
}
</script>
<script id="anchor_href_encoding_with_ncr">
<script id="anchor_href_encoding_with_ncr" type=module>
{
const state = await testing.async();
// Test that anchor.href encodes unmappable characters as NCRs in non-UTF-8 documents.
// When a character can't be represented in the document's encoding, it should become &#nnnnn;
// Per WHATWG URL Standard, query strings use document encoding with NCR fallback.
const iframe = document.createElement('iframe');
document.body.appendChild(iframe);
iframe.src = 'encoding/gbk.html';
iframe.onload = () => { state.resolve(); }
testing.onload(() => {
await state.done(() => {
testing.expectEqual('GBK', iframe.contentDocument.characterSet);
// Test 1: U+3D34 (㴴) - a Han character NOT in GBK, should become NCR &#15668;

View File

@@ -4,7 +4,7 @@
let eventuallies = [];
let async_capture = null;
let current_script_id = null;
let async_pending = 0;
let async_pending = new Set();
function expectTrue(actual) {
expectEqual(true, actual);
@@ -71,26 +71,29 @@
}
async function async(cb) {
const script_id = document.currentScript.id;
if (cb == undefined) {
let resolve = null
const promise = new Promise((r) => { resolve = r});
async_pending += 1;
async_pending.add(script_id);
return {
promise: promise,
resolve: resolve,
capture: {script_id: document.currentScript.id, stack: new Error().stack},
capture: {script_id: script_id, stack: new Error().stack},
done: async function(cb) {
await this.promise;
async_pending -= 1;
const res = await this.promise;
async_pending.delete(script_id);
async_capture = this.capture;
cb();
cb(res);
async_capture = false;
}
};
}
let capture = {script_id: document.currentScript.id, stack: new Error().stack};
let capture = {script_id: script_id, stack: new Error().stack};
await cb(() => { async_capture = capture; });
async_capture = null;
}
@@ -100,7 +103,7 @@
throw new Error('Failed');
}
if (async_pending > 0) {
if (async_pending.size > 0) {
return false;
}
@@ -131,6 +134,10 @@
return true;
}
function printTimeoutState() {
console.warn('Pending count:', Array.from(async_pending));
}
const IS_TEST_RUNNER = window.navigator.userAgent.startsWith("Lightpanda/");
window.testing = {
@@ -142,6 +149,7 @@
expectEqual: expectEqual,
expectError: expectError,
withError: withError,
printTimeoutState: printTimeoutState,
onload: onload,
IS_TEST_RUNNER: IS_TEST_RUNNER,
HOST: '127.0.0.1',

View File

@@ -1,35 +1,47 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id=setInterval>
let set_interval1 = false
let timer1 = window.setInterval(function() {
set_interval1 = true;
testing.expectEqual(window, this);
}, 1);
<script id=setInterval type=module>
{
const state = await testing.async();
let set_interval2 = false
let timer2 = window.setInterval(function() {
set_interval2 = true;
}, 1)
window.clearInterval(timer2);
let set_interval1 = false
let timer1 = window.setInterval(function() {
set_interval1 = true;
testing.expectEqual(window, this);
}, 1);
testing.expectEqual(true, timer1 != timer2);
let set_interval2 = false
let timer2 = window.setInterval(function() {
set_interval2 = true;
}, 1)
window.clearInterval(timer2);
testing.expectEqual(true, timer1 != timer2);
testing.onload(() => {
testing.expectEqual(true, set_interval1);
testing.expectEqual(false, set_interval2);
});
window.setTimeout(() => {
state.resolve()
}, 5);
await state.done(() => {
testing.expectEqual(true, set_interval1);
testing.expectEqual(false, set_interval2);
});
}
</script>
<script id=setTimeout>
<script id=setTimeout type=module>
const state = await testing.async();
testing.expectEqual(1, window.setTimeout.length);
let wst2 = 1;
window.setTimeout((a, b) => {
wst2 = a + b;
state.resolve();
}, 1, 2, 3);
testing.onload(() => testing.expectEqual(5, wst2));
await state.done(() => testing.expectEqual(5, wst2));
</script>
<script id=invalid-timer-clear>

View File

@@ -11,48 +11,51 @@
}
</script>
<script id="worker_message">
testing.async(async (capture) => {
<script id="worker_message" type=module>
{
const state = await testing.async();
const worker = new Worker('./echo-worker.js');
const response = await new Promise((resolve) => {
worker.onmessage = function(event) {
resolve(event.data);
};
// Give the script time to load before posting
setTimeout(() => {
worker.postMessage({ greeting: 'hello' });
}, 100);
});
worker.onmessage = function(event) {
state.resolve(event.data);
};
capture();
testing.expectEqual('hello', response.echo.greeting);
testing.expectEqual('worker', response.from);
});
// Give the script time to load before posting
setTimeout(() => {
worker.postMessage({ greeting: 'hello' });
}, 100);
await state.done((response) => {
testing.expectEqual('hello', response.echo.greeting);
testing.expectEqual('worker', response.from);
});
}
</script>
<script id="worker_structured_clone_date">
testing.async(async (capture) => {
<script id="worker_structured_clone_date" type=module>
{
const state = await testing.async();
const worker = new Worker('./echo-worker.js');
const testDate = new Date('2024-06-15T12:30:00Z');
const response = await new Promise((resolve) => {
worker.onmessage = function(event) {
resolve(event.data);
};
setTimeout(() => {
worker.postMessage({ date: testDate });
}, 100);
});
worker.onmessage = function(event) {
state.resolve(event.data);
};
capture();
testing.expectTrue(response.echo.date instanceof Date);
testing.expectEqual(testDate.getTime(), response.echo.date.getTime());
});
setTimeout(() => {
worker.postMessage({ date: testDate });
}, 100);
await state.done((response) => {
testing.expectTrue(response.echo.date instanceof Date);
testing.expectEqual(testDate.getTime(), response.echo.date.getTime());
});
}
</script>
<script id="worker_structured_clone_arraybuffer">
testing.async(async (capture) => {
<script id="worker_structured_clone_arraybuffer" type=module>
{
const state = await testing.async();
const worker = new Worker('./echo-worker.js');
const buffer = new ArrayBuffer(8);
@@ -60,118 +63,118 @@
view[0] = 1; view[1] = 2; view[2] = 3; view[3] = 4;
view[4] = 5; view[5] = 6; view[6] = 7; view[7] = 8;
const response = await new Promise((resolve) => {
worker.onmessage = function(event) {
resolve(event.data);
};
setTimeout(() => {
worker.postMessage({ buffer: buffer });
}, 100);
});
worker.onmessage = function(event) {
state.resolve(event.data);
};
setTimeout(() => {
worker.postMessage({ buffer: buffer });
}, 100);
capture();
testing.expectTrue(response.echo.buffer instanceof ArrayBuffer);
testing.expectEqual(8, response.echo.buffer.byteLength);
const resultView = new Uint8Array(response.echo.buffer);
testing.expectEqual(1, resultView[0]);
testing.expectEqual(8, resultView[7]);
});
await state.done((response) => {
testing.expectTrue(response.echo.buffer instanceof ArrayBuffer);
testing.expectEqual(8, response.echo.buffer.byteLength);
const resultView = new Uint8Array(response.echo.buffer);
testing.expectEqual(1, resultView[0]);
testing.expectEqual(8, resultView[7]);
});
}
</script>
<script id="worker_structured_clone_typedarray">
testing.async(async (capture) => {
<script id="worker_structured_clone_typedarray" type=module>
{
const state = await testing.async();
const worker = new Worker('./echo-worker.js');
const arr = new Float64Array([1.5, 2.5, 3.5, 4.5]);
const response = await new Promise((resolve) => {
worker.onmessage = function(event) {
resolve(event.data);
};
setTimeout(() => {
worker.postMessage({ arr: arr });
}, 100);
});
worker.onmessage = function(event) {
state.resolve(event.data);
};
setTimeout(() => {
worker.postMessage({ arr: arr });
}, 100);
capture();
testing.expectTrue(response.echo.arr instanceof Float64Array);
testing.expectEqual(4, response.echo.arr.length);
testing.expectEqual(1.5, response.echo.arr[0]);
testing.expectEqual(4.5, response.echo.arr[3]);
});
await state.done((response) => {
testing.expectTrue(response.echo.arr instanceof Float64Array);
testing.expectEqual(4, response.echo.arr.length);
testing.expectEqual(1.5, response.echo.arr[0]);
testing.expectEqual(4.5, response.echo.arr[3]);
});
}
</script>
<script id="worker_structured_clone_map">
testing.async(async (capture) => {
<script id="worker_structured_clone_map" type=module>
{
const state = await testing.async();
const worker = new Worker('./echo-worker.js');
const map = new Map([['a', 1], ['b', 2], ['c', 3]]);
const response = await new Promise((resolve) => {
worker.onmessage = function(event) {
resolve(event.data);
};
setTimeout(() => {
worker.postMessage({ map: map });
}, 100);
});
worker.onmessage = function(event) {
state.resolve(event.data);
};
setTimeout(() => {
worker.postMessage({ map: map });
}, 100);
capture();
testing.expectTrue(response.echo.map instanceof Map);
testing.expectEqual(3, response.echo.map.size);
testing.expectEqual(1, response.echo.map.get('a'));
testing.expectEqual(3, response.echo.map.get('c'));
});
await state.done((response) => {
testing.expectTrue(response.echo.map instanceof Map);
testing.expectEqual(3, response.echo.map.size);
testing.expectEqual(1, response.echo.map.get('a'));
testing.expectEqual(3, response.echo.map.get('c'));
});
}
</script>
<script id="worker_structured_clone_set">
testing.async(async (capture) => {
<script id="worker_structured_clone_set" type=module>
{
const state = await testing.async();
const worker = new Worker('./echo-worker.js');
const set = new Set([1, 2, 3, 'four', 'five']);
const response = await new Promise((resolve) => {
worker.onmessage = function(event) {
resolve(event.data);
};
setTimeout(() => {
worker.postMessage({ set: set });
}, 100);
});
worker.onmessage = function(event) {
state.resolve(event.data);
};
setTimeout(() => {
worker.postMessage({ set: set });
}, 100);
capture();
testing.expectTrue(response.echo.set instanceof Set);
testing.expectEqual(5, response.echo.set.size);
testing.expectTrue(response.echo.set.has(1));
testing.expectTrue(response.echo.set.has('four'));
});
await state.done((response) => {
testing.expectTrue(response.echo.set instanceof Set);
testing.expectEqual(5, response.echo.set.size);
testing.expectTrue(response.echo.set.has(1));
testing.expectTrue(response.echo.set.has('four'));
});
}
</script>
<script id="worker_structured_clone_regexp">
testing.async(async (capture) => {
<script id="worker_structured_clone_regexp" type=module>
{
const state = await testing.async();
const worker = new Worker('./echo-worker.js');
const regex = /hello.*world/gi;
const response = await new Promise((resolve) => {
worker.onmessage = function(event) {
resolve(event.data);
};
setTimeout(() => {
worker.postMessage({ regex: regex });
}, 100);
});
worker.onmessage = function(event) {
state.resolve(event.data);
};
setTimeout(() => {
worker.postMessage({ regex: regex });
}, 100);
capture();
testing.expectTrue(response.echo.regex instanceof RegExp);
testing.expectEqual('hello.*world', response.echo.regex.source);
testing.expectTrue(response.echo.regex.global);
testing.expectTrue(response.echo.regex.ignoreCase);
});
await state.done((response) => {
testing.expectTrue(response.echo.regex instanceof RegExp);
testing.expectEqual('hello.*world', response.echo.regex.source);
testing.expectTrue(response.echo.regex.global);
testing.expectTrue(response.echo.regex.ignoreCase);
});
}
</script>
<script id="worker_structured_clone_nested">
testing.async(async (capture) => {
<script id="worker_structured_clone_nested" type=module>
{
const state = await testing.async();
const worker = new Worker('./echo-worker.js');
const complex = {
@@ -184,23 +187,22 @@
buffer: new Uint8Array([10, 20, 30]).buffer
};
const response = await new Promise((resolve) => {
worker.onmessage = function(event) {
resolve(event.data);
};
setTimeout(() => {
worker.postMessage(complex);
}, 100);
});
worker.onmessage = function(event) {
state.resolve(event.data);
};
setTimeout(() => {
worker.postMessage(complex);
}, 100);
capture();
testing.expectEqual('hello', response.echo.string);
testing.expectEqual(42, response.echo.number);
testing.expectEqual(true, response.echo.boolean);
testing.expectEqual(null, response.echo.null);
testing.expectEqual(3, response.echo.array.length);
testing.expectEqual('value', response.echo.array[2].nested);
testing.expectTrue(response.echo.date instanceof Date);
testing.expectTrue(response.echo.buffer instanceof ArrayBuffer);
});
await state.done((response) => {
testing.expectEqual('hello', response.echo.string);
testing.expectEqual(42, response.echo.number);
testing.expectEqual(true, response.echo.boolean);
testing.expectEqual(null, response.echo.null);
testing.expectEqual(3, response.echo.array.length);
testing.expectEqual('value', response.echo.array[2].nested);
testing.expectTrue(response.echo.date instanceof Date);
testing.expectTrue(response.echo.buffer instanceof ArrayBuffer);
});
}
</script>

View File

@@ -59,69 +59,23 @@ const InitOptions = struct {
endings: []const u8 = "transparent",
};
/// Creates a new Blob (JS constructor).
pub fn init(
maybe_blob_parts: ?[]const []const u8,
maybe_options: ?InitOptions,
page: *Page,
) !*Blob {
return initWithMimeValidation(maybe_blob_parts, maybe_options, false, page);
}
/// Creates a new Blob with optional MIME validation.
/// When validate_mime is true, uses full MIME parsing (for Response/Request).
/// When false, uses simple ASCII validation per FileAPI spec (for Blob constructor).
pub fn initWithMimeValidation(
maybe_blob_parts: ?[]const []const u8,
maybe_options: ?InitOptions,
validate_mime: bool,
page: *Page,
) !*Blob {
const data_len = blk: {
const parts = maybe_blob_parts orelse break :blk 0;
var size: usize = 0;
for (parts) |p| {
size += p.len;
}
break :blk size;
};
const arena = try page.getArena(256 + data_len, "Blob");
/// Creates a new Blob from JS values with optional MIME validation.
/// This is the JS Constructor
pub fn init(parts_: ?[]const js.Value, opts_: ?InitOptions, page: *Page) !*Blob {
const arena = try page.getArena(.large, "Blob");
errdefer page.releaseArena(arena);
const options: InitOptions = maybe_options orelse .{};
const mime: []const u8 = blk: {
const t = options.type;
if (t.len == 0) {
break :blk "";
}
const buf = try arena.dupe(u8, t);
if (validate_mime) {
// Full MIME parsing per MIME sniff spec (for Content-Type headers)
_ = Mime.parse(buf) catch break :blk "";
} else {
// Simple validation per FileAPI spec (for Blob constructor):
// - If any char is outside U+0020-U+007E, return empty string
// - Otherwise lowercase
for (t) |c| {
if (c < 0x20 or c > 0x7E) {
break :blk "";
}
}
_ = std.ascii.lowerString(buf, buf);
}
break :blk buf;
};
const opts: InitOptions = opts_ orelse .{};
const mime = try validateMimeType(arena, opts.type, false);
const data = blk: {
if (maybe_blob_parts) |blob_parts| {
if (parts_) |blob_parts| {
const use_native_endings = std.mem.eql(u8, opts.endings, "native");
var w: Writer.Allocating = .init(arena);
const use_native_endings = std.mem.eql(u8, options.endings, "native");
try writeBlobParts(&w.writer, blob_parts, use_native_endings);
for (blob_parts) |js_val| {
const part = try js_val.toStringSmart();
try writePartWithEndings(part, use_native_endings, &w.writer);
}
break :blk w.written();
}
@@ -139,6 +93,50 @@ pub fn initWithMimeValidation(
return self;
}
/// Creates a new Blob from raw byte slices (for internal Zig use).
pub fn initFromBytes(data: []const u8, content_type: []const u8, validate_mime: bool, page: *Page) !*Blob {
const arena = try page.getArena(.large, "Blob");
errdefer page.releaseArena(arena);
const mime = try validateMimeType(arena, content_type, validate_mime);
const self = try arena.create(Blob);
self.* = .{
._rc = .{},
._arena = arena,
._type = .generic,
._slice = try arena.dupe(u8, data),
._mime = mime,
};
return self;
}
/// Validates and normalizes MIME type according to spec.
fn validateMimeType(arena: Allocator, mime_type: []const u8, full_validation: bool) ![]const u8 {
if (mime_type.len == 0) {
return "";
}
const buf = try arena.dupe(u8, mime_type);
if (full_validation) {
// Full MIME parsing per MIME sniff spec (for Content-Type headers)
_ = Mime.parse(buf) catch return "";
} else {
// Simple validation per FileAPI spec (for Blob constructor):
// - If any char is outside U+0020-U+007E, return empty string
// - Otherwise lowercase
for (mime_type) |c| {
if (c < 0x20 or c > 0x7E) {
return "";
}
}
_ = std.ascii.lowerString(buf, buf);
}
return buf;
}
pub fn deinit(self: *Blob, session: *Session) void {
session.releaseArena(self._arena);
}
@@ -171,18 +169,11 @@ const vector_sizes = blk: {
break :blk items;
};
/// Writes blob parts to given `Writer` with desired endings.
fn writeBlobParts(
writer: *Writer,
blob_parts: []const []const u8,
use_native_endings: bool,
) !void {
// Transparent.
/// Writes a single part with optional line ending normalization.
fn writePartWithEndings(part: []const u8, use_native_endings: bool, writer: *Writer) !void {
// Transparent - no conversion needed.
if (!use_native_endings) {
for (blob_parts) |part| {
try writer.writeAll(part);
}
try writer.writeAll(part);
return;
}
@@ -204,68 +195,66 @@ fn writeBlobParts(
// ```
// "the quick\n\nbrown fox"
// ```
scan_parts: for (blob_parts) |part| {
var end: usize = 0;
var end: usize = 0;
inline for (vector_sizes) |vector_len| {
const Vec = @Vector(vector_len, u8);
inline for (vector_sizes) |vector_len| {
const Vec = @Vector(vector_len, u8);
while (end + vector_len <= part.len) : (end += vector_len) {
const cr: Vec = @splat('\r');
// Load chunk as vectors.
const data = part[end..][0..vector_len];
const chunk: Vec = data.*;
// Look for CR.
const match = chunk == cr;
while (end + vector_len <= part.len) : (end += vector_len) {
const cr: Vec = @splat('\r');
// Load chunk as vectors.
const data = part[end..][0..vector_len];
const chunk: Vec = data.*;
// Look for CR.
const match = chunk == cr;
// Create a bitset out of match vector.
const bitset = std.bit_set.IntegerBitSet(vector_len){
.mask = @bitCast(@intFromBool(match)),
};
// Create a bitset out of match vector.
const bitset = std.bit_set.IntegerBitSet(vector_len){
.mask = @bitCast(@intFromBool(match)),
};
var iter = bitset.iterator(.{});
var relative_start: usize = 0;
while (iter.next()) |index| {
_ = try writer.writeVec(&.{ data[relative_start..index], "\n" });
var iter = bitset.iterator(.{});
var relative_start: usize = 0;
while (iter.next()) |index| {
_ = try writer.writeVec(&.{ data[relative_start..index], "\n" });
if (index + 1 != data.len and data[index + 1] == '\n') {
relative_start = index + 2;
} else {
relative_start = index + 1;
}
}
_ = try writer.writeVec(&.{data[relative_start..]});
}
}
// Scalar scan fallback.
var relative_start: usize = end;
while (end < part.len) {
if (part[end] == '\r') {
_ = try writer.writeVec(&.{ part[relative_start..end], "\n" });
// Part ends with CR. We can continue to next part.
if (end + 1 == part.len) {
continue :scan_parts;
}
// If next char is LF, skip it too.
if (part[end + 1] == '\n') {
relative_start = end + 2;
if (index + 1 != data.len and data[index + 1] == '\n') {
relative_start = index + 2;
} else {
relative_start = end + 1;
relative_start = index + 1;
}
}
end += 1;
_ = try writer.writeVec(&.{data[relative_start..]});
}
}
// Scalar scan fallback.
var relative_start: usize = end;
while (end < part.len) {
if (part[end] == '\r') {
_ = try writer.writeVec(&.{ part[relative_start..end], "\n" });
// Part ends with CR. We need to remember this for next part.
if (end + 1 == part.len) {
return;
}
// If next char is LF, skip it too.
if (part[end + 1] == '\n') {
relative_start = end + 2;
} else {
relative_start = end + 1;
}
}
// Write the remaining. We get this in such situations:
// `the quick brown\rfox`
// `the quick brown\r\nfox`
try writer.writeAll(part[relative_start..end]);
end += 1;
}
// Write the remaining. We get this in such situations:
// `the quick brown\rfox`
// `the quick brown\r\nfox`
try writer.writeAll(part[relative_start..end]);
}
/// Returns a Promise that resolves with the contents of the blob
@@ -323,7 +312,7 @@ pub fn slice(
break :blk @min(data.len, @max(start, @as(u31, @intCast(requested_end))));
};
return Blob.init(&.{data[start..end]}, .{ .type = content_type_ orelse "" }, page);
return Blob.initFromBytes(data[start..end], content_type_ orelse "", false, page);
}
/// Returns the size of the Blob in bytes.

View File

@@ -165,6 +165,10 @@ pub fn getSessionStorage(self: *Window) *storage.Lookup {
return &self._storage_bucket.session;
}
pub fn getOrigin(self: *const Window) []const u8 {
return self._page.origin orelse "null";
}
pub fn getLocation(self: *const Window) *Location {
return self._location;
}
@@ -827,6 +831,7 @@ pub const JsApi = struct {
pub const performance = bridge.accessor(Window.getPerformance, null, .{});
pub const localStorage = bridge.accessor(Window.getLocalStorage, null, .{});
pub const sessionStorage = bridge.accessor(Window.getSessionStorage, null, .{});
pub const origin = bridge.accessor(Window.getOrigin, null, .{});
pub const location = bridge.accessor(Window.getLocation, Window.setLocation, .{ .deletable = false });
pub const history = bridge.accessor(Window.getHistory, null, .{});
pub const navigation = bridge.accessor(Window.getNavigation, null, .{});

View File

@@ -232,9 +232,16 @@ fn httpDoneCallback(ctx: *anyopaque) !void {
return ls.toLocal(self._resolver).resolve("fetch done", js_val);
}
fn httpErrorCallback(ctx: *anyopaque, _: anyerror) void {
fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void {
const self: *Fetch = @ptrCast(@alignCast(ctx));
log.info(.http, "request error", .{
.source = "fetch",
.url = self._url,
.status = self._response._status,
.err = err,
});
var response = self._response;
response._http_response = null;
// the response is only passed on v8 on success, if we're here, it's safe to

View File

@@ -73,7 +73,7 @@ const Cache = enum {
pub fn init(input: Input, opts_: ?InitOpts, page: *Page) !*Request {
const arena = page.arena;
const url = switch (input) {
.url => |u| try URL.resolve(arena, page.base(), u, .{ .always_dupe = true }),
.url => |u| try URL.resolve(arena, page.base(), u, .{ .always_dupe = true, .encoding = page.charset }),
.request => |r| try arena.dupeZ(u8, r._url),
};
@@ -174,12 +174,7 @@ pub fn blob(self: *Request, page: *Page) !js.Promise {
const headers = try self.getHeaders(page);
const content_type = try headers.get("content-type", page) orelse "";
const b = try Blob.initWithMimeValidation(
&.{body},
.{ .type = content_type },
true,
page,
);
const b = try Blob.initFromBytes(body, content_type, true, page);
return page.js.local.?.resolvePromise(b);
}

View File

@@ -83,29 +83,10 @@ pub fn init(body_: ?BodyInit, opts_: ?InitOpts, page: *Page) !*Response {
.bytes => |body_bytes| break :blk .{ .bytes = try arena.dupe(u8, body_bytes) },
.stream => |stream| break :blk .{ .stream = stream },
.js_val => |js_val| {
const local = page.js.local.?;
if (local.jsValueToZig(*ReadableStream, js_val)) |stream| {
break :blk .{ .stream = stream };
} else |_| {}
if (js_val.isString()) |js_str| {
break :blk .{ .bytes = try js_str.toSliceWithAlloc(arena) };
}
if (js_val.isArrayBuffer() or js_val.isTypedArray() or js_val.isArrayBufferView()) {
if (local.jsValueToZig([]u8, js_val)) |data| {
break :blk .{ .bytes = try arena.dupe(u8, data) };
} else |_| {}
}
if (local.jsValueToZig(*Blob, js_val)) |blob_obj| {
break :blk .{ .bytes = try arena.dupe(u8, blob_obj._slice) };
} else |_| {}
if (js_val.isNullOrUndefined() == false) {
break :blk .{ .bytes = try js_val.toStringSliceWithAlloc(arena) };
if (js_val.isNullOrUndefined()) {
break :blk .empty;
}
break :blk .{ .bytes = try arena.dupe(u8, try js_val.toStringSmart()) };
},
}
break :blk .empty;
@@ -335,14 +316,7 @@ pub fn blob(self: *const Response, page: *Page) !js.Promise {
.stream => return local.rejectPromise(.{ .type_error = "Cannot read blob from stream body" }),
};
const content_type = try self._headers.get("content-type", page) orelse "";
const b = try Blob.initWithMimeValidation(
&.{body},
.{ .type = content_type },
true,
page,
);
const b = try Blob.initFromBytes(body, content_type, true, page);
return local.resolvePromise(b);
}

View File

@@ -466,7 +466,7 @@ fn dispatchMessageEvent(self: *WebSocket, data: []const u8, frame_type: http.WsF
switch (self._binary_type) {
.arraybuffer => .{ .arraybuffer = .{ .values = data } },
.blob => blk: {
const blob = try Blob.init(&.{data}, .{}, page);
const blob = try Blob.initFromBytes(data, "", false, page);
blob.acquireRef();
break :blk .{ .blob = blob };
},

View File

@@ -76,6 +76,13 @@ pub fn parse(allocator: Allocator, url: [:0]const u8, str: []const u8) !Cookie {
return error.InvalidNameValue;
};
if (cookie_name.len == 0 and (std.ascii.startsWithIgnoreCase(cookie_value, "__Host-") or std.ascii.startsWithIgnoreCase(cookie_value, "__Secure-"))) {
// A nameless cookie whose value begins with __Host- or __Secure-
// (case-insensitive) would otherwise impersonate a cookie with that
// prefix. Reject per the cookie-name-prefix rules.
return error.InvalidNameValue;
}
var scrap: [8]u8 = undefined;
var path: ?[]const u8 = null;
@@ -127,6 +134,34 @@ pub fn parse(allocator: Allocator, url: [:0]const u8, str: []const u8) !Cookie {
return error.InsecureSameSite;
}
// Enforce cookie-name-prefix rules. Match is case-insensitive to
// cover impersonation attempts (e.g. "__HoSt-").
// https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#name-cookie-name-prefixes
if (std.ascii.startsWithIgnoreCase(cookie_name, "__Host-")) {
if (secure == null) {
return error.InvalidPrefixedCookie;
}
if (!std.mem.startsWith(u8, url, "https://")) {
return error.InvalidPrefixedCookie;
}
if (domain != null and domain.?.len > 0) {
return error.InvalidPrefixedCookie;
}
if (path == null or !std.mem.eql(u8, path.?, "/")) {
return error.InvalidPrefixedCookie;
}
} else if (std.ascii.startsWithIgnoreCase(cookie_name, "__Secure-")) {
if (secure == null) {
return error.InvalidPrefixedCookie;
}
if (!std.mem.startsWith(u8, url, "https://")) {
return error.InvalidPrefixedCookie;
}
}
if (cookie_value.len > max_cookie_size) {
return error.CookieSizeExceeded;
}
@@ -183,6 +218,7 @@ const ValidateCookieError = error{ Empty, InvalidByteSequence };
/// Returns an error if cookie str length is 0
/// or contains characters outside of the ascii range 32...126.
/// Tab (0x09) is also allowed, matching browser behavior and WPT.
fn validateCookieString(str: []const u8) ValidateCookieError!void {
if (str.len == 0) {
return error.Empty;
@@ -195,17 +231,16 @@ fn validateCookieString(str: []const u8) ValidateCookieError!void {
if (comptime vec_size_suggestion) |size| {
while (str.len - offset >= size) : (offset += size) {
const Vec = @Vector(size, u8);
const tab: Vec = @splat(9);
const space: Vec = @splat(32);
const tilde: Vec = @splat(126);
const chunk: Vec = str[offset..][0..size].*;
// This creates a mask where invalid characters represented
// as ones and valid characters as zeros. We then bitCast this
// into an unsigned integer. If the integer is not equal to 0,
// we know that we've invalid characters in this chunk.
// @popCount can also be used but using integers are simpler.
const mask = (@intFromBool(chunk < space) | @intFromBool(chunk > tilde));
const reduced: std.meta.Int(.unsigned, size) = @bitCast(mask);
// Invalid if (c < 32 AND c != 9) OR c > 126. Tab is the one
// sub-space byte we allow through (per browser/WPT behavior).
const below = @intFromBool(chunk < space) & @intFromBool(chunk != tab);
const above = @intFromBool(chunk > tilde);
const reduced: std.meta.Int(.unsigned, size) = @bitCast(below | above);
// Got match.
if (reduced != 0) {
@@ -221,11 +256,10 @@ fn validateCookieString(str: []const u8) ValidateCookieError!void {
}
// Either remaining slice or the original if fast path not taken.
const slice = str[offset..];
// Slow path.
const min, const max = std.mem.minMax(u8, slice);
if (min < 32 or max > 126) {
return error.InvalidByteSequence;
for (str[offset..]) |c| {
if ((c < 32 and c != 9) or c > 126) {
return error.InvalidByteSequence;
}
}
}
@@ -872,6 +906,52 @@ test "Cookie: parse key=value" {
try expectError(error.InvalidByteSequence, null, &.{ 'a', '=', 'b', 20 });
try expectError(error.InvalidByteSequence, null, &.{ 'a', '=', 'b', 128 });
// Tab (0x09) is allowed in name and value, matching browser/WPT behavior.
try expectAttribute(.{ .name = "a\tb", .value = "c" }, null, "a\tb=c");
try expectAttribute(.{ .name = "a", .value = "b\tc" }, null, "a=b\tc");
// Other control characters remain rejected.
try expectError(error.InvalidByteSequence, null, "a\nb=c");
try expectError(error.InvalidByteSequence, null, "a\rb=c");
try expectError(error.InvalidByteSequence, null, &.{ 'a', '=', 'b', 0 });
// Nameless cookies whose value begins with __Host- or __Secure-
// (case-insensitive) are rejected so they can't impersonate prefixed cookies.
try expectError(error.InvalidNameValue, null, "=__Host-abc=1");
try expectError(error.InvalidNameValue, null, "=__Secure-abc=1");
try expectError(error.InvalidNameValue, null, "=__HoSt-abc");
try expectError(error.InvalidNameValue, null, "__Secure-abc");
// __Host- cookie-name-prefix rules:
// - must be Secure
// - must be set from an https origin
// - must not have a Domain attribute
// - must have Path=/
try expectAttribute(.{ .name = "__Host-abc", .value = "1" }, "https://lightpanda.io/", "__Host-abc=1; Secure; Path=/");
try expectAttribute(.{ .name = "__HoSt-abc", .value = "1" }, "https://lightpanda.io/", "__HoSt-abc=1; Secure; Path=/");
try expectError(error.InvalidPrefixedCookie, "https://lightpanda.io/", "__Host-abc=1; Path=/");
try expectError(error.InvalidPrefixedCookie, null, "__Host-abc=1; Secure; Path=/");
try expectError(error.InvalidPrefixedCookie, "https://lightpanda.io/", "__Host-abc=1; Secure");
try expectError(error.InvalidPrefixedCookie, "https://lightpanda.io/", "__Host-abc=1; Secure; Path=/foo");
try expectError(error.InvalidPrefixedCookie, "https://lightpanda.io/", "__Host-abc=1; Secure; Path=/; Domain=lightpanda.io");
// __Secure- cookie-name-prefix rules: must be Secure and from https.
try expectAttribute(.{ .name = "__Secure-abc", .value = "1" }, "https://lightpanda.io/", "__Secure-abc=1; Secure");
try expectAttribute(.{ .name = "__SeCuRe-abc", .value = "1" }, "https://lightpanda.io/", "__SeCuRe-abc=1; Secure; Domain=lightpanda.io");
try expectError(error.InvalidPrefixedCookie, "https://lightpanda.io/", "__Secure-abc=1");
try expectError(error.InvalidPrefixedCookie, null, "__Secure-abc=1; Secure");
// Empty Domain= is treated as no Domain and accepted on __Host-.
try expectAttribute(.{ .name = "__Host-abc", .value = "1" }, "https://lightpanda.io/", "__Host-abc=1; Secure; Path=/; Domain=");
// __Host- with additional unrelated attributes remains valid.
try expectAttribute(.{ .name = "__Host-abc", .value = "1" }, "https://lightpanda.io/", "__Host-abc=1; Secure; Path=/; Max-Age=60; HttpOnly");
// Near-misses are not subject to the prefix rules.
try expectAttribute(.{ .name = "__Host", .value = "1" }, null, "__Host=1");
try expectAttribute(.{ .name = "_Host-abc", .value = "1" }, null, "_Host-abc=1");
try expectAttribute(.{ .name = "__Hos-abc", .value = "1" }, null, "__Hos-abc=1");
try expectAttribute(.{ .name = "__Secure", .value = "1" }, null, "__Secure=1");
try expectAttribute(.{ .name = "", .value = "a" }, null, "a");
try expectAttribute(.{ .name = "", .value = "a" }, null, "a;");
try expectAttribute(.{ .name = "", .value = "a b" }, null, "a b");

View File

@@ -141,7 +141,7 @@ fn setLifecycleEventsEnabled(cmd: *CDP.Command) !void {
try sendPageLifecycle(bc, "load", now, frame_id, loader_id);
const http_client = page._session.browser.http_client;
const http_active = http_client.active;
const http_active = http_client.http_active;
const total_network_activity = http_active + http_client.intercepted;
if (page._notified_network_almost_idle.check(total_network_activity <= 2)) {
try sendPageLifecycle(bc, "networkAlmostIdle", now, frame_id, loader_id);

139
src/html5ever/Cargo.lock generated
View File

@@ -39,31 +39,26 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "fastrand"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "find-msvc-tools"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ced73b1dacfc750a6db6c0a0c3a3853c8b41997e2e2c563dc90804ae6867959"
[[package]]
name = "futf"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
dependencies = [
"mac",
"new_debug_unreachable",
]
[[package]]
name = "html5ever"
version = "0.35.0"
version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
dependencies = [
"log",
"markup5ever",
"match_token",
]
[[package]]
@@ -78,7 +73,7 @@ version = "0.1.0"
dependencies = [
"encoding_rs",
"html5ever",
"string_cache 0.9.0",
"string_cache",
"tikv-jemalloc-ctl",
"tikv-jemallocator",
"typed-arena",
@@ -101,34 +96,17 @@ version = "0.4.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
[[package]]
name = "mac"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "markup5ever"
version = "0.35.0"
version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3"
checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
dependencies = [
"log",
"tendril",
"web_atoms",
]
[[package]]
name = "match_token"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
@@ -166,40 +144,32 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "phf"
version = "0.11.3"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [
"phf_shared 0.11.3",
"phf_shared",
"serde",
]
[[package]]
name = "phf_codegen"
version = "0.11.3"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
dependencies = [
"phf_generator",
"phf_shared 0.11.3",
"phf_shared",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
dependencies = [
"phf_shared 0.11.3",
"rand",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
"fastrand",
"phf_shared",
]
[[package]]
@@ -235,21 +205,6 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
[[package]]
name = "redox_syscall"
version = "0.5.12"
@@ -303,19 +258,6 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "string_cache"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared 0.11.3",
"precomputed-hash",
"serde",
]
[[package]]
name = "string_cache"
version = "0.9.0"
@@ -324,19 +266,19 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared 0.13.1",
"phf_shared",
"precomputed-hash",
"serde",
]
[[package]]
name = "string_cache_codegen"
version = "0.5.4"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
dependencies = [
"phf_generator",
"phf_shared 0.11.3",
"phf_shared",
"proc-macro2",
"quote",
]
@@ -354,20 +296,19 @@ dependencies = [
[[package]]
name = "tendril"
version = "0.4.3"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24"
dependencies = [
"futf",
"mac",
"new_debug_unreachable",
"utf-8",
]
[[package]]
name = "tikv-jemalloc-ctl"
version = "0.6.0"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f21f216790c8df74ce3ab25b534e0718da5a1916719771d3fec23315c99e468b"
checksum = "661f1f6a57b3a36dc9174a2c10f19513b4866816e13425d3e418b11cc37bc24c"
dependencies = [
"libc",
"paste",
@@ -376,9 +317,9 @@ dependencies = [
[[package]]
name = "tikv-jemalloc-sys"
version = "0.6.0+5.3.0-1-ge13ca993e8ccb9ba9847cc330696e02839f328f7"
version = "0.6.1+5.3.0-1-ge13ca993e8ccb9ba9847cc330696e02839f328f7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd3c60906412afa9c2b5b5a48ca6a5abe5736aec9eb48ad05037a677e52e4e2d"
checksum = "cd8aa5b2ab86a2cefa406d889139c162cbb230092f7d1d7cbc1716405d852a3b"
dependencies = [
"cc",
"libc",
@@ -386,9 +327,9 @@ dependencies = [
[[package]]
name = "tikv-jemallocator"
version = "0.6.0"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cec5ff18518d81584f477e9bfdf957f5bb0979b0bac3af4ca30b5b3ae2d2865"
checksum = "0359b4327f954e0567e69fb191cf1436617748813819c94b8cd4a431422d053a"
dependencies = [
"libc",
"tikv-jemalloc-sys",
@@ -414,13 +355,13 @@ checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "web_atoms"
version = "0.1.3"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414"
checksum = "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576"
dependencies = [
"phf",
"phf_codegen",
"string_cache 0.8.9",
"string_cache",
"string_cache_codegen",
]
@@ -490,9 +431,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "xml5ever"
version = "0.35.0"
version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee3f1e41afb31a75aef076563b0ad3ecc24f5bd9d12a72b132222664eb76b494"
checksum = "5ab627f34ff61b80d756180d556f9c68801d836d271b3b8c094504ceca69d221"
dependencies = [
"log",
"markup5ever",

View File

@@ -9,12 +9,12 @@ path = "lib.rs"
crate-type = ["cdylib", "staticlib"]
[dependencies]
html5ever = "0.35.0"
html5ever = "0.39.0"
string_cache = "0.9.0"
typed-arena = "2.0.2"
tikv-jemallocator = {version = "0.6.0", features = ["stats"]}
tikv-jemalloc-ctl = {version = "0.6.0", features = ["stats"]}
xml5ever = "0.35.0"
tikv-jemallocator = {version = "0.6.1", features = ["stats"]}
tikv-jemalloc-ctl = {version = "0.6.1", features = ["stats"]}
xml5ever = "0.39.0"
encoding_rs = "0.8"
[profile.release]

View File

@@ -220,7 +220,7 @@ fn dumpWPT(page: *Page, writer: *std.Io.Writer) !void {
\\ notrun: cases.filter(c => c.status === 'Not Run').length,
\\ unsupported: cases.filter(c => c.status === 'Optional Feature Unsupported').length
\\ },
\\ cases
\\ not_passed: cases.filter(c => c.status !== 'Pass')
\\ };
\\ })(), null, 2)
;

View File

@@ -262,6 +262,7 @@ fn opensocketCallback(
pub const Connection = struct {
_easy: *libcurl.Curl,
in_use: bool,
transport: Transport,
node: std.DoublyLinkedList.Node = .{},
@@ -278,7 +279,7 @@ pub const Connection = struct {
) !Connection {
const easy = libcurl.curl_easy_init() orelse return error.FailedToInitializeEasy;
var self = Connection{ ._easy = easy, .transport = .none };
var self = Connection{ ._easy = easy, .in_use = false, .transport = .none };
errdefer self.deinit();
try self.reset(config, ca_blob, ip_filter);

View File

@@ -419,7 +419,7 @@ fn runWebApiTest(test_file: [:0]const u8) !void {
}
var runner = try test_session.runner(.{});
try runner.wait(.{ .ms = 2000 });
try runner.wait(.{ .ms = 2000, .until = .load });
var wait_ms: u32 = 2000;
var timer = try std.time.Timer.start();
@@ -443,6 +443,7 @@ fn runWebApiTest(test_file: [:0]const u8) !void {
const ms_elapsed = timer.lap() / 1_000_000;
if (ms_elapsed >= wait_ms) {
ls.local.eval("testing.printTimeoutState()", "testing.printTimeoutState()") catch {};
return error.TestTimedOut;
}
wait_ms -= @intCast(ms_elapsed);