Various input fixes (support for more attributes) based on legacy tests

AbortSignal.timeout function

LocalStorage named getter/setter
This commit is contained in:
Karl Seguin
2025-12-24 18:36:46 +08:00
parent a9e6051867
commit 29efb467f0
12 changed files with 309 additions and 27 deletions

View File

@@ -23,6 +23,47 @@
testing.expectEqual('radio', $('#radio1').type) testing.expectEqual('radio', $('#radio1').type)
</script> </script>
<script id=attributes>
{
const input = $('#text1');
testing.expectEqual('', input.accept);
input.accept = 'anything';
testing.expectEqual('anything', input.accept);
testing.expectEqual('', input.alt);
input.alt = 'x1';
testing.expectEqual('x1', input.alt);
testing.expectEqual(false, input.readOnly);
input.readOnly = true;
testing.expectEqual(true, input.readOnly);
input.readOnly = false;
testing.expectEqual(false, input.readOnly);
testing.expectEqual(-1, input.maxLength);
input.maxLength = 5;
testing.expectEqual(5, input.maxLength);
input.maxLength = 'banana';
testing.expectEqual(0, input.maxLength);
testing.expectError('Error: NegativeValueNotAllowed', () => { input.maxLength = -45;});
testing.expectEqual(20, input.size);
input.size = 5;
testing.expectEqual(5, input.size);
input.size = -449;
testing.expectEqual(20, input.size);
testing.expectError('Error: ZeroNotAllowed', () => { input.size = 0; });
testing.expectEqual('', input.src);
input.src = 'foo'
testing.expectEqual('http://127.0.0.1:9582/src/browser/tests/element/html/foo', input.src);
input.src = '-3'
testing.expectEqual('http://127.0.0.1:9582/src/browser/tests/element/html/-3', input.src);
input.src = ''
}
</script>
<script id="type_case_insensitive"> <script id="type_case_insensitive">
{ {
const input = document.createElement('input') const input = document.createElement('input')
@@ -396,3 +437,22 @@
testing.expectEqual($('#radio3'), document.querySelector('input[type="radio"]:checked')) testing.expectEqual($('#radio3'), document.querySelector('input[type="radio"]:checked'))
testing.expectEqual($('#radio3'), document.querySelector('input[type="radio"][name="group1"]:checked')) testing.expectEqual($('#radio3'), document.querySelector('input[type="radio"][name="group1"]:checked'))
</script> </script>
<script id=related>
{
let input_checked = document.createElement('input')
testing.expectEqual(false, input_checked.defaultChecked);
testing.expectEqual(false, input_checked.checked);
input_checked.defaultChecked = true;
testing.expectEqual(true, input_checked.defaultChecked);
testing.expectEqual(true, input_checked.checked);
input_checked.checked = false;
testing.expectEqual(true, input_checked.defaultChecked);
testing.expectEqual(false, input_checked.checked);
input_checked.defaultChecked = true;
testing.expectEqual(false, input_checked.checked);
}
</script>

View File

@@ -7,7 +7,7 @@
const signal = controller.signal; const signal = controller.signal;
testing.expectEqual(false, signal.aborted); testing.expectEqual(false, signal.aborted);
testing.expectEqual(null, signal.reason); testing.expectEqual(undefined, signal.reason);
} }
</script> </script>
@@ -123,7 +123,7 @@
const signal = AbortSignal.abort(); const signal = AbortSignal.abort();
testing.expectEqual(true, signal.aborted); testing.expectEqual(true, signal.aborted);
testing.expectEqual(null, signal.reason); testing.expectEqual("AbortError", signal.reason);
} }
</script> </script>
@@ -211,3 +211,42 @@
testing.expectEqual(2, count); // Still 2, listener was removed testing.expectEqual(2, count); // Still 2, listener was removed
} }
</script> </script>
<script id=legacy1>
var a1 = new AbortController();
var s1 = a1.signal;
testing.expectEqual(undefined, s1.throwIfAborted());
testing.expectEqual(undefined, s1.reason);
let target;;
let called = 0;
s1.addEventListener('abort', (e) => {
called += 1;
target = e.target;
});
a1.abort();
testing.expectEqual(true, s1.aborted)
testing.expectEqual(s1, target)
testing.expectEqual('AbortError', s1.reason)
testing.expectEqual(1, called)
</script>
<script id=abortsignal_abort>
var s2 = AbortSignal.abort('over 9000');
testing.expectEqual(true, s2.aborted);
testing.expectEqual('over 9000', s2.reason);
testing.expectEqual('AbortError', AbortSignal.abort().reason);
</script>
<script id=abortsignal_timeout>
var s3 = AbortSignal.timeout(10);
testing.eventually(() => {
testing.expectEqual(true, s3.aborted);
testing.expectEqual('TimeoutError', s3.reason);
testing.expectError('Error: TimeoutError', () => {
s3.throwIfAborted()
});
});
</script>

View File

@@ -9,7 +9,7 @@
localStorage.setItem('foo', 'bar'); localStorage.setItem('foo', 'bar');
testing.expectEqual(1, localStorage.length) testing.expectEqual(1, localStorage.length)
testing.expectEqual('bar', localStorage.getItem('foo')); testing.expectEqual('bar', localStorage.getItem('foo'));
testing.expectEqual('bar', localStorage.key(0)); testing.expectEqual('foo', localStorage.key(0));
testing.expectEqual(null, localStorage.key(1)); testing.expectEqual(null, localStorage.key(1));
localStorage.removeItem('foo'); localStorage.removeItem('foo');

View File

@@ -60,3 +60,31 @@
localStorage.clear(); localStorage.clear();
testing.expectEqual(0, localStorage.length); testing.expectEqual(0, localStorage.length);
</script> </script>
<script id="legacy">
localStorage.clear();
testing.expectEqual(0, localStorage.length);
testing.expectEqual(null, localStorage.getItem('foo'));
testing.expectEqual(null, localStorage.key(0));
localStorage.setItem('foo', 'bar');
testing.expectEqual(1, localStorage.length)
testing.expectEqual('bar', localStorage.getItem('foo'));
testing.expectEqual('foo', localStorage.key(0));
testing.expectEqual(null, localStorage.key(1));
localStorage.removeItem('foo');
testing.expectEqual(0, localStorage.length)
testing.expectEqual(null, localStorage.getItem('foo'));
localStorage['foo'] = 'bar';
testing.expectEqual(1, localStorage.length);
testing.expectEqual('bar', localStorage['foo']);
localStorage.setItem('a', '1');
localStorage.setItem('b', '2');
localStorage.setItem('c', '3');
testing.expectEqual(4, localStorage.length)
localStorage.clear();
testing.expectEqual(0, localStorage.length)
</script>

View File

@@ -37,8 +37,8 @@ pub fn getSignal(self: *const AbortController) *AbortSignal {
return self._signal; return self._signal;
} }
pub fn abort(self: *AbortController, reason: ?js.Object, page: *Page) !void { pub fn abort(self: *AbortController, reason_: ?js.Object, page: *Page) !void {
try self._signal.abort(reason, page); try self._signal.abort(if (reason_) |r| .{.js_obj = r} else null, page);
} }
pub const JsApi = struct { pub const JsApi = struct {

View File

@@ -18,6 +18,7 @@
const std = @import("std"); const std = @import("std");
const js = @import("../js/js.zig"); const js = @import("../js/js.zig");
const log = @import("../../log.zig");
const Page = @import("../Page.zig"); const Page = @import("../Page.zig");
const Event = @import("Event.zig"); const Event = @import("Event.zig");
@@ -27,15 +28,12 @@ const AbortSignal = @This();
_proto: *EventTarget, _proto: *EventTarget,
_aborted: bool = false, _aborted: bool = false,
_reason: ?js.Object = null, _reason: Reason = .undefined,
_on_abort: ?js.Function = null, _on_abort: ?js.Function = null,
pub fn init(page: *Page) !*AbortSignal { pub fn init(page: *Page) !*AbortSignal {
return page._factory.eventTarget(AbortSignal{ return page._factory.eventTarget(AbortSignal{
._proto = undefined, ._proto = undefined,
._aborted = false,
._reason = null,
._on_abort = null,
}); });
} }
@@ -43,7 +41,7 @@ pub fn getAborted(self: *const AbortSignal) bool {
return self._aborted; return self._aborted;
} }
pub fn getReason(self: *const AbortSignal) ?js.Object { pub fn getReason(self: *const AbortSignal) Reason {
return self._reason; return self._reason;
} }
@@ -63,14 +61,22 @@ pub fn asEventTarget(self: *AbortSignal) *EventTarget {
return self._proto; return self._proto;
} }
pub fn abort(self: *AbortSignal, reason_: ?js.Object, page: *Page) !void { pub fn abort(self: *AbortSignal, reason_: ?Reason, page: *Page) !void {
if (self._aborted) return; if (self._aborted) {
return;
}
self._aborted = true; self._aborted = true;
// Store the abort reason (default to a simple string if none provided) // Store the abort reason (default to a simple string if none provided)
if (reason_) |reason| { 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 // 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 // Static method to create an already-aborted signal
pub fn createAborted(reason_: ?js.Object, page: *Page) !*AbortSignal { pub fn createAborted(reason_: ?js.Object, page: *Page) !*AbortSignal {
const signal = try init(page); const signal = try init(page);
try signal.abort(reason_, page); try signal.abort(if (reason_) |r| .{.js_obj = r} else null, page);
return signal; return signal;
} }
pub fn throwIfAborted(self: *const AbortSignal) !void { pub fn createTimeout(delay: u32, page: *Page) !*AbortSignal {
if (self._aborted) { const callback = try page.arena.create(TimeoutCallback);
return error.Aborted; 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 JsApi = struct {
pub const bridge = js.Bridge(AbortSignal); pub const bridge = js.Bridge(AbortSignal);
@@ -116,4 +165,5 @@ pub const JsApi = struct {
// Static method // Static method
pub const abort = bridge.function(AbortSignal.createAborted, .{ .static = true }); pub const abort = bridge.function(AbortSignal.createAborted, .{ .static = true });
pub const timeout = bridge.function(AbortSignal.createTimeout, .{ .static = true });
}; };

View File

@@ -37,7 +37,10 @@ pub fn serializeToString(self: *const XMLSerializer, node: *Node, page: *Page) !
} else { } else {
try dump.deep(node, .{ .shadow = .skip }, &buf.writer, page); 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 { pub const JsApi = struct {

View File

@@ -16,6 +16,7 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const js = @import("../../js/js.zig"); const js = @import("../../js/js.zig");
const reflect = @import("../../reflect.zig"); const reflect = @import("../../reflect.zig");
@@ -61,8 +62,6 @@ pub const Unknown = @import("html/Unknown.zig");
const HtmlElement = @This(); const HtmlElement = @This();
const std = @import("std");
_type: Type, _type: Type,
_proto: *Element, _proto: *Element,

View File

@@ -73,6 +73,7 @@ _default_value: ?[]const u8 = null,
_default_checked: bool = false, _default_checked: bool = false,
_value: ?[]const u8 = null, _value: ?[]const u8 = null,
_checked: bool = false, _checked: bool = false,
_checked_dirty: bool = false,
_input_type: Type = .text, _input_type: Type = .text,
pub fn asElement(self: *Input) *Element { 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) { if (checked and self._input_type == .radio) {
try self.uncheckRadioGroup(page); 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 = checked;
self._checked_dirty = true;
} }
pub fn getDefaultChecked(self: *const Input) bool { 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); 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 { pub fn getRequired(self: *const Input) bool {
return self.asConstElement().getAttributeSafe("required") != null; 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 disabled = bridge.accessor(Input.getDisabled, Input.setDisabled, .{});
pub const name = bridge.accessor(Input.getName, Input.setName, .{}); pub const name = bridge.accessor(Input.getName, Input.setName, .{});
pub const required = bridge.accessor(Input.getRequired, Input.setRequired, .{}); 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, .{}); pub const form = bridge.accessor(Input.getForm, null, .{});
}; };
@@ -291,10 +371,13 @@ pub const Build = struct {
.value => self._default_value = value, .value => self._default_value = value,
.checked => { .checked => {
self._default_checked = true; self._default_checked = true;
self._checked = true; // Only update checked state if it hasn't been manually modified
// If setting a radio button to checked, uncheck others in the group if (!self._checked_dirty) {
if (self._input_type == .radio) { self._checked = true;
try self.uncheckRadioGroup(page); // 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, .value => self._default_value = null,
.checked => { .checked => {
self._default_checked = false; 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;
}
}, },
} }
} }

View File

@@ -66,6 +66,14 @@ pub fn setType(self: *Script, value: []const u8, page: *Page) !void {
return self.asElement().setAttributeSafe("type", value, page); 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 { pub fn getOnLoad(self: *const Script) ?js.Function {
return self._on_load; return self._on_load;
} }
@@ -109,6 +117,7 @@ pub const JsApi = struct {
pub const src = bridge.accessor(Script.getSrc, Script.setSrc, .{}); pub const src = bridge.accessor(Script.getSrc, Script.setSrc, .{});
pub const @"type" = bridge.accessor(Script.getType, Script.setType, .{}); 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 onload = bridge.accessor(Script.getOnLoad, Script.setOnLoad, .{});
pub const onerror = bridge.accessor(Script.getOnError, Script.setOnError, .{}); pub const onerror = bridge.accessor(Script.getOnError, Script.setOnError, .{});
pub const noModule = bridge.accessor(Script.getNoModule, null, .{}); pub const noModule = bridge.accessor(Script.getNoModule, null, .{});

View File

@@ -116,6 +116,7 @@ pub const Lookup = struct {
pub const removeItem = bridge.function(Lookup.removeItem, .{}); pub const removeItem = bridge.function(Lookup.removeItem, .{});
pub const clear = bridge.function(Lookup.clear, .{}); pub const clear = bridge.function(Lookup.clear, .{});
pub const key = bridge.function(Lookup.key, .{}); pub const key = bridge.function(Lookup.key, .{});
pub const @"[str]" = bridge.namedIndexed(Lookup.getItem, Lookup.setItem, null, .{ .null_as_undefined = true });
}; };
}; };

View File

@@ -61,6 +61,13 @@ pub fn main() !void {
if (!std.mem.endsWith(u8, entry.basename, ".html")) { if (!std.mem.endsWith(u8, entry.basename, ".html")) {
continue; 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 (filter) |f| {
if (std.mem.indexOf(u8, entry.path, f) == null) { if (std.mem.indexOf(u8, entry.path, f) == null) {
continue; continue;