diff --git a/src/browser/tests/element/html/input.html b/src/browser/tests/element/html/input.html index f7052f51..b78c8185 100644 --- a/src/browser/tests/element/html/input.html +++ b/src/browser/tests/element/html/input.html @@ -23,6 +23,47 @@ testing.expectEqual('radio', $('#radio1').type) + + + + diff --git a/src/browser/tests/event/abort_controller.html b/src/browser/tests/event/abort_controller.html index 050c14b2..4a14aee4 100644 --- a/src/browser/tests/event/abort_controller.html +++ b/src/browser/tests/event/abort_controller.html @@ -7,7 +7,7 @@ const signal = controller.signal; testing.expectEqual(false, signal.aborted); - testing.expectEqual(null, signal.reason); + testing.expectEqual(undefined, signal.reason); } @@ -123,7 +123,7 @@ const signal = AbortSignal.abort(); testing.expectEqual(true, signal.aborted); - testing.expectEqual(null, signal.reason); + testing.expectEqual("AbortError", signal.reason); } @@ -211,3 +211,42 @@ testing.expectEqual(2, count); // Still 2, listener was removed } + + + + + + diff --git a/src/browser/tests/legacy/storage/local_storage.html b/src/browser/tests/legacy/storage/local_storage.html index 4ad0b14f..c78f3889 100644 --- a/src/browser/tests/legacy/storage/local_storage.html +++ b/src/browser/tests/legacy/storage/local_storage.html @@ -9,7 +9,7 @@ localStorage.setItem('foo', 'bar'); testing.expectEqual(1, localStorage.length) testing.expectEqual('bar', localStorage.getItem('foo')); - testing.expectEqual('bar', localStorage.key(0)); + testing.expectEqual('foo', localStorage.key(0)); testing.expectEqual(null, localStorage.key(1)); localStorage.removeItem('foo'); diff --git a/src/browser/tests/storage.html b/src/browser/tests/storage.html index edd90979..7633293b 100644 --- a/src/browser/tests/storage.html +++ b/src/browser/tests/storage.html @@ -60,3 +60,31 @@ localStorage.clear(); testing.expectEqual(0, localStorage.length); + + diff --git a/src/browser/webapi/AbortController.zig b/src/browser/webapi/AbortController.zig index 13718b97..d9f7c318 100644 --- a/src/browser/webapi/AbortController.zig +++ b/src/browser/webapi/AbortController.zig @@ -37,8 +37,8 @@ pub fn getSignal(self: *const AbortController) *AbortSignal { return self._signal; } -pub fn abort(self: *AbortController, reason: ?js.Object, page: *Page) !void { - try self._signal.abort(reason, page); +pub fn abort(self: *AbortController, reason_: ?js.Object, page: *Page) !void { + try self._signal.abort(if (reason_) |r| .{.js_obj = r} else null, page); } pub const JsApi = struct { diff --git a/src/browser/webapi/AbortSignal.zig b/src/browser/webapi/AbortSignal.zig index 40ac9e89..6f823032 100644 --- a/src/browser/webapi/AbortSignal.zig +++ b/src/browser/webapi/AbortSignal.zig @@ -18,6 +18,7 @@ const std = @import("std"); const js = @import("../js/js.zig"); +const log = @import("../../log.zig"); const Page = @import("../Page.zig"); const Event = @import("Event.zig"); @@ -27,15 +28,12 @@ const AbortSignal = @This(); _proto: *EventTarget, _aborted: bool = false, -_reason: ?js.Object = null, +_reason: Reason = .undefined, _on_abort: ?js.Function = null, pub fn init(page: *Page) !*AbortSignal { return page._factory.eventTarget(AbortSignal{ ._proto = undefined, - ._aborted = false, - ._reason = null, - ._on_abort = null, }); } @@ -43,7 +41,7 @@ pub fn getAborted(self: *const AbortSignal) bool { return self._aborted; } -pub fn getReason(self: *const AbortSignal) ?js.Object { +pub fn getReason(self: *const AbortSignal) Reason { return self._reason; } @@ -63,14 +61,22 @@ pub fn asEventTarget(self: *AbortSignal) *EventTarget { return self._proto; } -pub fn abort(self: *AbortSignal, reason_: ?js.Object, page: *Page) !void { - if (self._aborted) return; +pub fn abort(self: *AbortSignal, reason_: ?Reason, page: *Page) !void { + if (self._aborted) { + return; + } self._aborted = true; // Store the abort reason (default to a simple string if none provided) if (reason_) |reason| { - self._reason = try reason.persist(); + switch (reason) { + .js_obj => |js_obj| self._reason = .{.js_obj = try js_obj.persist()}, + .string => |str| self._reason = .{.string = try page.dupeString(str)}, + .undefined => self._reason = reason, + } + } else { + self._reason = .{.string = "AbortError"}; } // Dispatch abort event @@ -86,16 +92,59 @@ pub fn abort(self: *AbortSignal, reason_: ?js.Object, page: *Page) !void { // Static method to create an already-aborted signal pub fn createAborted(reason_: ?js.Object, page: *Page) !*AbortSignal { const signal = try init(page); - try signal.abort(reason_, page); + try signal.abort(if (reason_) |r| .{.js_obj = r} else null, page); return signal; } -pub fn throwIfAborted(self: *const AbortSignal) !void { - if (self._aborted) { - return error.Aborted; - } +pub fn createTimeout(delay: u32, page: *Page) !*AbortSignal { + const callback = try page.arena.create(TimeoutCallback); + callback.* = .{ + .page = page, + .signal = try init(page), + }; + + try page.scheduler.add(callback, TimeoutCallback.run, delay, .{ + .name = "AbortSignal.timeout", + }); + + return callback.signal; } +const ThrowIfAborted = union(enum) { + exception: js.Exception, + undefined: void, +}; +pub fn throwIfAborted(self: *const AbortSignal, page: *Page) !ThrowIfAborted { + if (self._aborted) { + const exception = switch (self._reason) { + .string => |str| page.js.throw(str), + .js_obj => |js_obj| page.js.throw(try js_obj.toString()), + .undefined => page.js.throw("AbortError"), + }; + return .{ .exception = exception }; + } + return .undefined; +} + +const Reason = union(enum) { + js_obj: js.Object, + string: []const u8, + undefined: void, +}; + +const TimeoutCallback = struct { + page: *Page, + signal: *AbortSignal, + + fn run(ctx: *anyopaque) !?u32 { + const self: *TimeoutCallback = @ptrCast(@alignCast(ctx)); + self.signal.abort(.{.string = "TimeoutError"}, self.page) catch |err| { + log.warn(.app, "abort signal timeout", .{ .err = err }); + }; + return null; + } +}; + pub const JsApi = struct { pub const bridge = js.Bridge(AbortSignal); @@ -116,4 +165,5 @@ pub const JsApi = struct { // Static method pub const abort = bridge.function(AbortSignal.createAborted, .{ .static = true }); + pub const timeout = bridge.function(AbortSignal.createTimeout, .{ .static = true }); }; diff --git a/src/browser/webapi/XMLSerializer.zig b/src/browser/webapi/XMLSerializer.zig index 83f42e20..7aee1d20 100644 --- a/src/browser/webapi/XMLSerializer.zig +++ b/src/browser/webapi/XMLSerializer.zig @@ -37,7 +37,10 @@ pub fn serializeToString(self: *const XMLSerializer, node: *Node, page: *Page) ! } else { try dump.deep(node, .{ .shadow = .skip }, &buf.writer, page); } - return buf.written(); + // Not sure about this trim. But `dump` is meant to display relatively + // pretty HTML, so it does include newlines, which can result in a trailing + // newline. XMLSerializer is a bit more strict. + return std.mem.trim(u8, buf.written(), &std.ascii.whitespace); } pub const JsApi = struct { diff --git a/src/browser/webapi/element/Html.zig b/src/browser/webapi/element/Html.zig index 75e691b5..8de457b6 100644 --- a/src/browser/webapi/element/Html.zig +++ b/src/browser/webapi/element/Html.zig @@ -16,6 +16,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const std = @import("std"); const js = @import("../../js/js.zig"); const reflect = @import("../../reflect.zig"); @@ -61,8 +62,6 @@ pub const Unknown = @import("html/Unknown.zig"); const HtmlElement = @This(); -const std = @import("std"); - _type: Type, _proto: *Element, diff --git a/src/browser/webapi/element/html/Input.zig b/src/browser/webapi/element/html/Input.zig index 9c4593a9..865912d2 100644 --- a/src/browser/webapi/element/html/Input.zig +++ b/src/browser/webapi/element/html/Input.zig @@ -73,6 +73,7 @@ _default_value: ?[]const u8 = null, _default_checked: bool = false, _value: ?[]const u8 = null, _checked: bool = false, +_checked_dirty: bool = false, _input_type: Type = .text, pub fn asElement(self: *Input) *Element { @@ -122,8 +123,9 @@ pub fn setChecked(self: *Input, checked: bool, page: *Page) !void { if (checked and self._input_type == .radio) { try self.uncheckRadioGroup(page); } - // This should _not_ call setAttribute. It updates the default state only + // This should _not_ call setAttribute. It updates the current state only self._checked = checked; + self._checked_dirty = true; } pub fn getDefaultChecked(self: *const Input) bool { @@ -160,6 +162,78 @@ pub fn setName(self: *Input, name: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe("name", name, page); } +pub fn getAccept(self: *const Input) []const u8 { + return self.asConstElement().getAttributeSafe("accept") orelse ""; +} + +pub fn setAccept(self: *Input, accept: []const u8, page: *Page) !void { + try self.asElement().setAttributeSafe("accept", accept, page); +} + +pub fn getAlt(self: *const Input) []const u8 { + return self.asConstElement().getAttributeSafe("alt") orelse ""; +} + +pub fn setAlt(self: *Input, alt: []const u8, page: *Page) !void { + try self.asElement().setAttributeSafe("alt", alt, page); +} + +pub fn getMaxLength(self: *const Input) i32 { + const attr = self.asConstElement().getAttributeSafe("maxlength") orelse return -1; + return std.fmt.parseInt(i32, attr, 10) catch -1; +} + +pub fn setMaxLength(self: *Input, max_length: i32, page: *Page) !void { + if (max_length < 0) { + return error.NegativeValueNotAllowed; + } + var buf: [32]u8 = undefined; + const value = std.fmt.bufPrint(&buf, "{d}", .{max_length}) catch unreachable; + try self.asElement().setAttributeSafe("maxlength", value, page); +} + +pub fn getSize(self: *const Input) i32 { + const attr = self.asConstElement().getAttributeSafe("size") orelse return 20; + const parsed = std.fmt.parseInt(i32, attr, 10) catch return 20; + return if (parsed == 0) 20 else parsed; +} + +pub fn setSize(self: *Input, size: i32, page: *Page) !void { + if (size == 0) { + return error.ZeroNotAllowed; + } + if (size < 0) { + return self.asElement().setAttributeSafe("size", "20", page); + } + + var buf: [32]u8 = undefined; + const value = std.fmt.bufPrint(&buf, "{d}", .{size}) catch unreachable; + try self.asElement().setAttributeSafe("size", value, page); +} + +pub fn getSrc(self: *const Input, page: *Page) ![]const u8 { + const src = self.asConstElement().getAttributeSafe("src") orelse return ""; + // If attribute is explicitly set (even if empty), resolve it against the base URL + return @import("../../URL.zig").resolve(page.call_arena, page.base(), src, .{}); +} + +pub fn setSrc(self: *Input, src: []const u8, page: *Page) !void { + const trimmed = std.mem.trim(u8, src, &std.ascii.whitespace); + try self.asElement().setAttributeSafe("src", trimmed, page); +} + +pub fn getReadonly(self: *const Input) bool { + return self.asConstElement().getAttributeSafe("readonly") != null; +} + +pub fn setReadonly(self: *Input, readonly: bool, page: *Page) !void { + if (readonly) { + try self.asElement().setAttributeSafe("readonly", "", page); + } else { + try self.asElement().removeAttribute("readonly", page); + } +} + pub fn getRequired(self: *const Input) bool { return self.asConstElement().getAttributeSafe("required") != null; } @@ -256,6 +330,12 @@ pub const JsApi = struct { pub const disabled = bridge.accessor(Input.getDisabled, Input.setDisabled, .{}); pub const name = bridge.accessor(Input.getName, Input.setName, .{}); pub const required = bridge.accessor(Input.getRequired, Input.setRequired, .{}); + pub const accept = bridge.accessor(Input.getAccept, Input.setAccept, .{}); + pub const readOnly = bridge.accessor(Input.getReadonly, Input.setReadonly, .{}); + pub const alt = bridge.accessor(Input.getAlt, Input.setAlt, .{}); + pub const maxLength = bridge.accessor(Input.getMaxLength, Input.setMaxLength, .{}); + pub const size = bridge.accessor(Input.getSize, Input.setSize, .{}); + pub const src = bridge.accessor(Input.getSrc, Input.setSrc, .{}); pub const form = bridge.accessor(Input.getForm, null, .{}); }; @@ -291,10 +371,13 @@ pub const Build = struct { .value => self._default_value = value, .checked => { self._default_checked = true; - self._checked = true; - // If setting a radio button to checked, uncheck others in the group - if (self._input_type == .radio) { - try self.uncheckRadioGroup(page); + // Only update checked state if it hasn't been manually modified + if (!self._checked_dirty) { + self._checked = true; + // If setting a radio button to checked, uncheck others in the group + if (self._input_type == .radio) { + try self.uncheckRadioGroup(page); + } } }, } @@ -308,7 +391,10 @@ pub const Build = struct { .value => self._default_value = null, .checked => { self._default_checked = false; - self._checked = false; + // Only update checked state if it hasn't been manually modified + if (!self._checked_dirty) { + self._checked = false; + } }, } } diff --git a/src/browser/webapi/element/html/Script.zig b/src/browser/webapi/element/html/Script.zig index 0d303cc9..c4fa83de 100644 --- a/src/browser/webapi/element/html/Script.zig +++ b/src/browser/webapi/element/html/Script.zig @@ -66,6 +66,14 @@ pub fn setType(self: *Script, value: []const u8, page: *Page) !void { return self.asElement().setAttributeSafe("type", value, page); } +pub fn getNonce(self: *const Script) []const u8 { + return self.asConstElement().getAttributeSafe("nonce") orelse ""; +} + +pub fn setNonce(self: *Script, value: []const u8, page: *Page) !void { + return self.asElement().setAttributeSafe("nonce", value, page); +} + pub fn getOnLoad(self: *const Script) ?js.Function { return self._on_load; } @@ -109,6 +117,7 @@ pub const JsApi = struct { pub const src = bridge.accessor(Script.getSrc, Script.setSrc, .{}); pub const @"type" = bridge.accessor(Script.getType, Script.setType, .{}); + pub const nonce = bridge.accessor(Script.getNonce, Script.setNonce, .{}); pub const onload = bridge.accessor(Script.getOnLoad, Script.setOnLoad, .{}); pub const onerror = bridge.accessor(Script.getOnError, Script.setOnError, .{}); pub const noModule = bridge.accessor(Script.getNoModule, null, .{}); diff --git a/src/browser/webapi/storage/storage.zig b/src/browser/webapi/storage/storage.zig index e5c8e22e..64dfae23 100644 --- a/src/browser/webapi/storage/storage.zig +++ b/src/browser/webapi/storage/storage.zig @@ -116,6 +116,7 @@ pub const Lookup = struct { pub const removeItem = bridge.function(Lookup.removeItem, .{}); pub const clear = bridge.function(Lookup.clear, .{}); pub const key = bridge.function(Lookup.key, .{}); + pub const @"[str]" = bridge.namedIndexed(Lookup.getItem, Lookup.setItem, null, .{ .null_as_undefined = true }); }; }; diff --git a/src/main_legacy_test.zig b/src/main_legacy_test.zig index e8a57997..622ae5e2 100644 --- a/src/main_legacy_test.zig +++ b/src/main_legacy_test.zig @@ -61,6 +61,13 @@ pub fn main() !void { if (!std.mem.endsWith(u8, entry.basename, ".html")) { continue; } + if (std.mem.indexOf(u8, entry.basename, "navigation") != null) { + continue; + } + if (std.mem.indexOf(u8, entry.basename, "history") != null) { + continue; + } + if (filter) |f| { if (std.mem.indexOf(u8, entry.path, f) == null) { continue;