Compare commits

...

10 Commits

Author SHA1 Message Date
Pierre Tachoire
ee4f2d400d Add XMLHttpRequest.timeout with curl enforcement
Implement the XHR timeout property end-to-end: the JS-visible
getter/setter stores the value, send() passes it to the HTTP client,
and curl enforces it via CURLOPT_TIMEOUT_MS. On timeout, a `timeout`
event is dispatched instead of `error`, per the XHR spec.
2026-04-01 11:28:26 +02:00
Pierre Tachoire
ca8361f5c1 Merge pull request #2059 from lightpanda-io/integration-e2e-report
Some checks are pending
e2e-test / zig build release (push) Waiting to run
e2e-test / demo-scripts (push) Blocked by required conditions
e2e-test / wba-demo-scripts (push) Blocked by required conditions
e2e-test / wba-test (push) Blocked by required conditions
e2e-test / cdp-and-hyperfine-bench (push) Blocked by required conditions
e2e-test / perf-fmt (push) Blocked by required conditions
e2e-test / browser fetch (push) Blocked by required conditions
zig-test / zig fmt (push) Waiting to run
zig-test / zig test using v8 in debug mode (push) Waiting to run
zig-test / zig test (push) Waiting to run
zig-test / perf-fmt (push) Blocked by required conditions
ci: send e2e integration results to slack
2026-04-01 10:41:16 +02:00
Pierre Tachoire
bc4afcd82f Merge pull request #2058 from lightpanda-io/wpt-diff
Wpt diff
2026-04-01 10:31:12 +02:00
Pierre Tachoire
1c1bd81daa ci: send e2e integration results to slack 2026-04-01 10:29:56 +02:00
Pierre Tachoire
ea87fc2c50 ci: publish ci regression list into slack 2026-04-01 10:18:06 +02:00
Karl Seguin
f4802b49e8 Merge pull request #2055 from lightpanda-io/document-dir
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Add HTMLElement.dir and HTMLElement.lang properties
2026-04-01 06:46:06 +08:00
Karl Seguin
a26f544509 Merge pull request #2054 from lightpanda-io/nikneym/url-search-params-array-constructor
`URLSearchParams`: support passing arrays to constructor
2026-04-01 06:45:14 +08:00
Pierre Tachoire
68e2140bb3 Add HTMLElement.dir and HTMLElement.lang properties
Added as attribute-backed accessors on HTMLElement (inherited by all
HTML elements) and on HTMLDocument (delegates to documentElement).
2026-03-31 16:12:26 +02:00
Halil Durak
94b003804b URLSearchParams: update tests 2026-03-31 16:01:21 +03:00
Halil Durak
b41c86e104 URLSearchParams: support passing arrays to constructor 2026-03-31 16:01:08 +03:00
11 changed files with 264 additions and 4 deletions

View File

@@ -60,7 +60,20 @@ jobs:
- run: chmod a+x ./lightpanda - run: chmod a+x ./lightpanda
- name: run end to end integration tests - name: run end to end integration tests
continue-on-error: true
run: | run: |
./lightpanda serve --log-level error & echo $! > LPD.pid ./lightpanda serve --log-level error & echo $! > LPD.pid
go run integration/main.go go run integration/main.go |tee result.log
kill `cat LPD.pid` kill `cat LPD.pid`
- name: Send result to slack
uses: slackapi/slack-github-action@v3.0.1
with:
errors: true
method: files.uploadV2
token: ${{ secrets.CI_SLACK_BOT_TOKEN }}
payload: |
channel_id: ${{ vars.E2E_SLACK_CHANNEL_ID }}
initial_comment: "Last e2e integration tests"
file: "./result.log"
filename: "e2e-integration-${{ github.sha }}.txt"

View File

@@ -153,3 +153,34 @@ jobs:
- name: format and send json result - name: format and send json result
run: /perf-fmt wpt ${{ github.sha }} wpt.json run: /perf-fmt wpt ${{ github.sha }} wpt.json
wptdiff:
name: perf-fmt
needs: perf-fmt
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
repository: 'lightpanda-io/demo'
fetch-depth: 0
- run: |
cd ./wptdiff
CGO_ENABLED=0 go build
- run: |
./wptdiff/wptdiff |tee diff.log
- name: Send regression to slack
uses: slackapi/slack-github-action@v3.0.1
with:
errors: true
method: files.uploadV2
token: ${{ secrets.CI_SLACK_BOT_TOKEN }}
payload: |
channel_id: ${{ vars.WPT_SLACK_CHANNEL_ID }}
initial_comment: "Last WPT regressions"
file: "./diff.log"
filename: "wpt-regression-${{ github.sha }}.txt"

View File

@@ -928,6 +928,7 @@ pub const Request = struct {
credentials: ?[:0]const u8 = null, credentials: ?[:0]const u8 = null,
notification: *Notification, notification: *Notification,
max_response_size: ?usize = null, max_response_size: ?usize = null,
timeout_ms: u32 = 0,
// This is only relevant for intercepted requests. If a request is flagged // This is only relevant for intercepted requests. If a request is flagged
// as blocking AND is intercepted, then it'll be up to us to wait until // as blocking AND is intercepted, then it'll be up to us to wait until
@@ -1142,6 +1143,11 @@ pub const Transfer = struct {
try conn.setPrivate(self); try conn.setPrivate(self);
// Per-request timeout override (e.g. XHR timeout)
if (req.timeout_ms > 0) {
try conn.setTimeout(req.timeout_ms);
}
// add credentials // add credentials
if (req.credentials) |creds| { if (req.credentials) |creds| {
if (self._auth_challenge != null and self._auth_challenge.?.source == .proxy) { if (self._auth_challenge != null and self._auth_challenge.?.source == .proxy) {

View File

@@ -0,0 +1,41 @@
<html lang="en" dir="ltr">
<head>
<script src="../testing.js"></script>
</head>
<body>
<script id="test-document-dir-lang">
// HTMLElement.dir and HTMLElement.lang
testing.expectEqual('ltr', document.documentElement.dir);
testing.expectEqual('en', document.documentElement.lang);
// Document.dir and Document.lang delegate to documentElement
testing.expectEqual('ltr', document.dir);
testing.expectEqual('en', document.lang);
// Setting via document updates the documentElement attribute
document.dir = 'rtl';
testing.expectEqual('rtl', document.documentElement.dir);
testing.expectEqual('rtl', document.documentElement.getAttribute('dir'));
document.lang = 'fr';
testing.expectEqual('fr', document.documentElement.lang);
testing.expectEqual('fr', document.documentElement.getAttribute('lang'));
// Setting via element is reflected in document
document.documentElement.dir = 'ltr';
testing.expectEqual('ltr', document.dir);
document.documentElement.lang = 'de';
testing.expectEqual('de', document.lang);
// div elements also have dir and lang
const div = document.createElement('div');
testing.expectEqual('', div.dir);
testing.expectEqual('', div.lang);
div.dir = 'rtl';
div.lang = 'ar';
testing.expectEqual('rtl', div.dir);
testing.expectEqual('ar', div.lang);
</script>
</body>
</html>

View File

@@ -20,8 +20,8 @@
<script id=urlSearchParams> <script id=urlSearchParams>
const inputs = [ const inputs = [
// @ZIGDOM [["over", "9000!!"], ["abc", 123], ["key1", ""], ["key2", ""]], [["over", "9000!!"], ["abc", "123"], ["key1", ""], ["key2", ""]],
{over: "9000!!", abc: 123, key1: "", key2: ""}, {over: "9000!!", abc: 123, key1: "", key2: ""},
"over=9000!!&abc=123&key1&key2=", "over=9000!!&abc=123&key1&key2=",
"?over=9000!!&abc=123&key1&key2=", "?over=9000!!&abc=123&key1&key2=",
] ]
@@ -367,3 +367,49 @@
testing.expectEqual(['3'], ups.getAll('b')); testing.expectEqual(['3'], ups.getAll('b'));
} }
</script> </script>
<script id=arrayOfArrays>
{
const usp = new URLSearchParams([["a", "1"], ["b", "2"], ["a", "3"]]);
testing.expectEqual(3, usp.size);
testing.expectEqual('1', usp.get('a'));
testing.expectEqual(['1', '3'], usp.getAll('a'));
testing.expectEqual('2', usp.get('b'));
testing.expectEqual('a=1&b=2&a=3', usp.toString());
}
</script>
<script id=arrayOfArraysEmpty>
{
const usp = new URLSearchParams([]);
testing.expectEqual(0, usp.size);
testing.expectEqual('', usp.toString());
}
</script>
<script id=arrayOfArraysDuplicateKeys>
{
const usp = new URLSearchParams([["key", "first"], ["key", "second"], ["key", "third"]]);
testing.expectEqual(3, usp.size);
testing.expectEqual('first', usp.get('key'));
testing.expectEqual(['first', 'second', 'third'], usp.getAll('key'));
}
</script>
<script id=arrayOfArraysSpecialChars>
{
const usp = new URLSearchParams([["q", "hello world"], ["url", "https://example.com/?a=1&b=2"]]);
testing.expectEqual(2, usp.size);
testing.expectEqual('hello world', usp.get('q'));
testing.expectEqual('https://example.com/?a=1&b=2', usp.get('url'));
}
</script>
<script id=arrayOfArraysNumericValues>
{
const usp = new URLSearchParams([["count", 42], ["pi", 3.14]]);
testing.expectEqual(2, usp.size);
testing.expectEqual('42', usp.get('count'));
testing.expectEqual('3.14', usp.get('pi'));
}
</script>

View File

@@ -306,3 +306,27 @@
URL.revokeObjectURL(blobUrl); URL.revokeObjectURL(blobUrl);
}); });
</script> </script>
<script id=xhr_timeout>
// 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);
testing.expectEqual(5000, req.timeout);
});
</script>

View File

@@ -182,6 +182,30 @@ pub fn setLocation(self: *HTMLDocument, url: [:0]const u8, page: *Page) !void {
return page.scheduleNavigation(url, .{ .reason = .script, .kind = .{ .push = null } }, .{ .script = self._proto._page }); return page.scheduleNavigation(url, .{ .reason = .script, .kind = .{ .push = null } }, .{ .script = self._proto._page });
} }
pub fn getDir(self: *HTMLDocument) []const u8 {
const el = self._proto.getDocumentElement() orelse return "";
const html = el.is(Element.Html) orelse return "";
return html.getDir();
}
pub fn setDir(self: *HTMLDocument, value: []const u8, page: *Page) !void {
const el = self._proto.getDocumentElement() orelse return;
const html = el.is(Element.Html) orelse return;
try html.setDir(value, page);
}
pub fn getLang(self: *HTMLDocument) []const u8 {
const el = self._proto.getDocumentElement() orelse return "";
const html = el.is(Element.Html) orelse return "";
return html.getLang();
}
pub fn setLang(self: *HTMLDocument, value: []const u8, page: *Page) !void {
const el = self._proto.getDocumentElement() orelse return;
const html = el.is(Element.Html) orelse return;
try html.setLang(value, page);
}
pub fn getAll(self: *HTMLDocument, page: *Page) !*collections.HTMLAllCollection { pub fn getAll(self: *HTMLDocument, page: *Page) !*collections.HTMLAllCollection {
return page._factory.create(collections.HTMLAllCollection.init(self.asNode(), page)); return page._factory.create(collections.HTMLAllCollection.init(self.asNode(), page));
} }
@@ -250,8 +274,10 @@ pub const JsApi = struct {
}); });
} }
pub const dir = bridge.accessor(HTMLDocument.getDir, HTMLDocument.setDir, .{});
pub const head = bridge.accessor(HTMLDocument.getHead, null, .{}); pub const head = bridge.accessor(HTMLDocument.getHead, null, .{});
pub const body = bridge.accessor(HTMLDocument.getBody, null, .{}); pub const body = bridge.accessor(HTMLDocument.getBody, null, .{});
pub const lang = bridge.accessor(HTMLDocument.getLang, HTMLDocument.setLang, .{});
pub const title = bridge.accessor(HTMLDocument.getTitle, HTMLDocument.setTitle, .{}); pub const title = bridge.accessor(HTMLDocument.getTitle, HTMLDocument.setTitle, .{});
pub const images = bridge.accessor(HTMLDocument.getImages, null, .{}); pub const images = bridge.accessor(HTMLDocument.getImages, null, .{});
pub const scripts = bridge.accessor(HTMLDocument.getScripts, null, .{}); pub const scripts = bridge.accessor(HTMLDocument.getScripts, null, .{});

View File

@@ -375,6 +375,22 @@ pub fn setTabIndex(self: *HtmlElement, value: i32, page: *Page) !void {
try self.asElement().setAttributeSafe(comptime .wrap("tabindex"), .wrap(str), page); try self.asElement().setAttributeSafe(comptime .wrap("tabindex"), .wrap(str), page);
} }
pub fn getDir(self: *HtmlElement) []const u8 {
return self.asElement().getAttributeSafe(comptime .wrap("dir")) orelse "";
}
pub fn setDir(self: *HtmlElement, value: []const u8, page: *Page) !void {
try self.asElement().setAttributeSafe(comptime .wrap("dir"), .wrap(value), page);
}
pub fn getLang(self: *HtmlElement) []const u8 {
return self.asElement().getAttributeSafe(comptime .wrap("lang")) orelse "";
}
pub fn setLang(self: *HtmlElement, value: []const u8, page: *Page) !void {
try self.asElement().setAttributeSafe(comptime .wrap("lang"), .wrap(value), page);
}
pub fn getAttributeFunction( pub fn getAttributeFunction(
self: *HtmlElement, self: *HtmlElement,
listener_type: GlobalEventHandler, listener_type: GlobalEventHandler,
@@ -1211,7 +1227,9 @@ pub const JsApi = struct {
pub const insertAdjacentHTML = bridge.function(HtmlElement.insertAdjacentHTML, .{ .dom_exception = true }); pub const insertAdjacentHTML = bridge.function(HtmlElement.insertAdjacentHTML, .{ .dom_exception = true });
pub const click = bridge.function(HtmlElement.click, .{}); pub const click = bridge.function(HtmlElement.click, .{});
pub const dir = bridge.accessor(HtmlElement.getDir, HtmlElement.setDir, .{});
pub const hidden = bridge.accessor(HtmlElement.getHidden, HtmlElement.setHidden, .{}); pub const hidden = bridge.accessor(HtmlElement.getHidden, HtmlElement.setHidden, .{});
pub const lang = bridge.accessor(HtmlElement.getLang, HtmlElement.setLang, .{});
pub const tabIndex = bridge.accessor(HtmlElement.getTabIndex, HtmlElement.setTabIndex, .{}); pub const tabIndex = bridge.accessor(HtmlElement.getTabIndex, HtmlElement.setTabIndex, .{});
pub const onabort = bridge.accessor(HtmlElement.getOnAbort, HtmlElement.setOnAbort, .{}); pub const onabort = bridge.accessor(HtmlElement.getOnAbort, HtmlElement.setOnAbort, .{});

View File

@@ -46,6 +46,10 @@ pub fn init(opts_: ?InitOpts, page: *Page) !*URLSearchParams {
.query_string => |qs| break :blk try paramsFromString(arena, qs, &page.buf), .query_string => |qs| break :blk try paramsFromString(arena, qs, &page.buf),
.form_data => |fd| break :blk try KeyValueList.copy(arena, fd._list), .form_data => |fd| break :blk try KeyValueList.copy(arena, fd._list),
.value => |js_val| { .value => |js_val| {
// Order matters here; Array is also an Object.
if (js_val.isArray()) {
break :blk try paramsFromArray(arena, js_val.toArray());
}
if (js_val.isObject()) { if (js_val.isObject()) {
break :blk try KeyValueList.fromJsObject(arena, js_val.toObject(), null, page); break :blk try KeyValueList.fromJsObject(arena, js_val.toObject(), null, page);
} }
@@ -134,6 +138,37 @@ pub fn sort(self: *URLSearchParams) void {
}.cmp); }.cmp);
} }
fn paramsFromArray(allocator: Allocator, array: js.Array) !KeyValueList {
const array_len = array.len();
if (array_len == 0) {
return .empty;
}
var params = KeyValueList.init();
try params.ensureTotalCapacity(allocator, array_len);
// TODO: Release `params` on error.
var i: u32 = 0;
while (i < array_len) : (i += 1) {
const item = try array.get(i);
if (!item.isArray()) return error.InvalidArgument;
const as_array = item.toArray();
// Need 2 items for KV.
if (as_array.len() != 2) return error.InvalidArgument;
const name_val = try as_array.get(0);
const value_val = try as_array.get(1);
params._entries.appendAssumeCapacity(.{
.name = try name_val.toSSOWithAlloc(allocator),
.value = try value_val.toSSOWithAlloc(allocator),
});
}
return params;
}
fn paramsFromString(allocator: Allocator, input_: []const u8, buf: []u8) !KeyValueList { fn paramsFromString(allocator: Allocator, input_: []const u8, buf: []u8) !KeyValueList {
if (input_.len == 0) { if (input_.len == 0) {
return .empty; return .empty;

View File

@@ -63,6 +63,7 @@ _response_type: ResponseType = .text,
_ready_state: ReadyState = .unsent, _ready_state: ReadyState = .unsent,
_on_ready_state_change: ?js.Function.Temp = null, _on_ready_state_change: ?js.Function.Temp = null,
_with_credentials: bool = false, _with_credentials: bool = false,
_timeout: u32 = 0,
const ReadyState = enum(u8) { const ReadyState = enum(u8) {
unsent = 0, unsent = 0,
@@ -180,6 +181,14 @@ pub fn setWithCredentials(self: *XMLHttpRequest, value: bool) !void {
self._with_credentials = value; self._with_credentials = value;
} }
pub fn getTimeout(self: *const XMLHttpRequest) u32 {
return self._timeout;
}
pub fn setTimeout(self: *XMLHttpRequest, value: u32) void {
self._timeout = value;
}
// TODO: this takes an optional 3 more parameters // TODO: this takes an optional 3 more parameters
// TODO: url should be a union, as it can be multiple things // TODO: url should be a union, as it can be multiple things
pub fn open(self: *XMLHttpRequest, method_: []const u8, url: [:0]const u8) !void { pub fn open(self: *XMLHttpRequest, method_: []const u8, url: [:0]const u8) !void {
@@ -253,6 +262,7 @@ pub fn send(self: *XMLHttpRequest, body_: ?[]const u8) !void {
.cookie_jar = if (cookie_support) &page._session.cookie_jar else null, .cookie_jar = if (cookie_support) &page._session.cookie_jar else null,
.cookie_origin = page.url, .cookie_origin = page.url,
.resource_type = .xhr, .resource_type = .xhr,
.timeout_ms = self._timeout,
.notification = page._session.notification, .notification = page._session.notification,
.start_callback = httpStartCallback, .start_callback = httpStartCallback,
.header_callback = httpHeaderDoneCallback, .header_callback = httpHeaderDoneCallback,
@@ -539,6 +549,7 @@ fn handleError(self: *XMLHttpRequest, err: anyerror) void {
} }
fn _handleError(self: *XMLHttpRequest, err: anyerror) !void { fn _handleError(self: *XMLHttpRequest, err: anyerror) !void {
const is_abort = err == error.Abort; const is_abort = err == error.Abort;
const is_timeout = err == error.OperationTimedout;
const new_state: ReadyState = if (is_abort) .unsent else .done; const new_state: ReadyState = if (is_abort) .unsent else .done;
if (new_state != self._ready_state) { if (new_state != self._ready_state) {
@@ -547,8 +558,12 @@ fn _handleError(self: *XMLHttpRequest, err: anyerror) !void {
try self.stateChanged(new_state, page); try self.stateChanged(new_state, page);
if (is_abort) { if (is_abort) {
try self._proto.dispatch(.abort, null, page); try self._proto.dispatch(.abort, null, page);
} else if (is_timeout) {
try self._proto.dispatch(.timeout, null, page);
}
if (!is_timeout) {
try self._proto.dispatch(.err, null, page);
} }
try self._proto.dispatch(.err, null, page);
try self._proto.dispatch(.load_end, null, page); try self._proto.dispatch(.load_end, null, page);
} }
@@ -610,6 +625,7 @@ pub const JsApi = struct {
pub const DONE = bridge.property(@intFromEnum(XMLHttpRequest.ReadyState.done), .{ .template = true }); pub const DONE = bridge.property(@intFromEnum(XMLHttpRequest.ReadyState.done), .{ .template = true });
pub const onreadystatechange = bridge.accessor(XMLHttpRequest.getOnReadyStateChange, XMLHttpRequest.setOnReadyStateChange, .{}); pub const onreadystatechange = bridge.accessor(XMLHttpRequest.getOnReadyStateChange, XMLHttpRequest.setOnReadyStateChange, .{});
pub const timeout = bridge.accessor(XMLHttpRequest.getTimeout, XMLHttpRequest.setTimeout, .{});
pub const withCredentials = bridge.accessor(XMLHttpRequest.getWithCredentials, XMLHttpRequest.setWithCredentials, .{ .dom_exception = true }); pub const withCredentials = bridge.accessor(XMLHttpRequest.getWithCredentials, XMLHttpRequest.setWithCredentials, .{ .dom_exception = true });
pub const open = bridge.function(XMLHttpRequest.open, .{}); pub const open = bridge.function(XMLHttpRequest.open, .{});
pub const send = bridge.function(XMLHttpRequest.send, .{ .dom_exception = true }); pub const send = bridge.function(XMLHttpRequest.send, .{ .dom_exception = true });

View File

@@ -234,6 +234,10 @@ pub const Connection = struct {
try libcurl.curl_easy_setopt(self._easy, .url, url.ptr); try libcurl.curl_easy_setopt(self._easy, .url, url.ptr);
} }
pub fn setTimeout(self: *const Connection, timeout_ms: u32) !void {
try libcurl.curl_easy_setopt(self._easy, .timeout_ms, timeout_ms);
}
// a libcurl request has 2 methods. The first is the method that // a libcurl request has 2 methods. The first is the method that
// controls how libcurl behaves. This specifically influences how redirects // controls how libcurl behaves. This specifically influences how redirects
// are handled. For example, if you do a POST and get a 301, libcurl will // are handled. For example, if you do a POST and get a 301, libcurl will