diff --git a/src/dom/document.zig b/src/dom/document.zig index 7e2ac989..23609600 100644 --- a/src/dom/document.zig +++ b/src/dom/document.zig @@ -7,6 +7,7 @@ const Case = jsruntime.test_utils.Case; const checkCases = jsruntime.test_utils.checkCases; const Node = @import("node.zig").Node; +const HTMLCollection = @import("html_collection.zig").HTMLCollection; const Element = @import("element.zig").Element; const ElementUnion = @import("element.zig").Union; @@ -34,6 +35,22 @@ pub const Document = struct { const e = parser.documentCreateElement(self, tag_name); return Element.toInterface(e); } + + // We can't simply use libdom dom_document_get_elements_by_tag_name here. + // Indeed, netsurf implemented a previous dom spec when + // getElementsByTagName returned a NodeList. + // But since + // https://github.com/whatwg/dom/commit/190700b7c12ecfd3b5ebdb359ab1d6ea9cbf7749 + // the spec changed to return an HTMLCollection instead. + // That's why we reimplemented getElementsByTagName by using an + // HTMLCollection in zig here. + pub fn _getElementsByTagName(self: *parser.Document, tag_name: []const u8) HTMLCollection { + const root = parser.documentGetDocumentElement(self); + return HTMLCollection{ + .root = parser.elementToNode(root), + .match = tag_name, + }; + } }; // Tests @@ -58,6 +75,19 @@ pub fn testExecFn( }; try checkCases(js_env, &getElementById); + var getElementsByTagName = [_]Case{ + .{ .src = "let getElementsByTagName = document.getElementsByTagName('p')", .ex = "undefined" }, + .{ .src = "getElementsByTagName.length", .ex = "2" }, + .{ .src = "getElementsByTagName.item(0).localName", .ex = "p" }, + .{ .src = "getElementsByTagName.item(1).localName", .ex = "p" }, + .{ .src = "let getElementsByTagNameAll = document.getElementsByTagName('*')", .ex = "undefined" }, + .{ .src = "getElementsByTagNameAll.length", .ex = "8" }, + .{ .src = "getElementsByTagNameAll.item(0).localName", .ex = "html" }, + .{ .src = "getElementsByTagNameAll.item(7).localName", .ex = "p" }, + .{ .src = "getElementsByTagNameAll.namedItem('para-empty-child').localName", .ex = "span" }, + }; + try checkCases(js_env, &getElementsByTagName); + const tags = comptime parser.Tag.all(); comptime var createElements: [(tags.len) * 2]Case = undefined; inline for (tags, 0..) |tag, i| { diff --git a/src/dom/html_collection.zig b/src/dom/html_collection.zig new file mode 100644 index 00000000..10cfb78f --- /dev/null +++ b/src/dom/html_collection.zig @@ -0,0 +1,210 @@ +const std = @import("std"); + +const parser = @import("../netsurf.zig"); + +const jsruntime = @import("jsruntime"); +const Case = jsruntime.test_utils.Case; +const checkCases = jsruntime.test_utils.checkCases; + +const utils = @import("utils.z"); +const Element = @import("element.zig").Element; +const Union = @import("element.zig").Union; + +// WEB IDL https://dom.spec.whatwg.org/#htmlcollection +// HTMLCollection is re implemented in zig here because libdom +// dom_html_collection expects a comparison function callback as arguement. +// But we wanted a dynamically comparison here, according to the match tagname. +pub const HTMLCollection = struct { + pub const mem_guarantied = true; + + root: *parser.Node, + // match is used to select node against their name. + // match comparison is case insensitive. + match: []const u8, + + // save a state for the collection to improve the _item speed. + cur_idx: ?u32 = undefined, + cur_node: ?*parser.Node = undefined, + + // get_next iterates over the DOM tree to return the next following node or + // null at the end. + // + // This implementation is a zig version of Netsurf code. + // http://source.netsurf-browser.org/libdom.git/tree/src/html/html_collection.c#n177 + // + // The iteration is a depth first as required by the specification. + // https://dom.spec.whatwg.org/#htmlcollection + // https://dom.spec.whatwg.org/#concept-tree-order + fn get_next(root: *parser.Node, cur: *parser.Node) ?*parser.Node { + // TODO deinit next + if (parser.nodeFirstChild(cur)) |next| { + return next; + } + + // TODO deinit next + if (parser.nodeNextSibling(cur)) |next| { + return next; + } + + // TODO deinit parent + // Back to the parent of cur. + // If cur has no parent, then the iteration is over. + var parent = parser.nodeParentNode(cur) orelse return null; + + // TODO deinit lastchild + var lastchild = parser.nodeLastChild(parent); + var prev = cur; + while (prev != root and prev == lastchild) { + prev = parent; + + // TODO deinit parent + // Back to the prev's parent. + // If prev has no parent, then the loop must stop. + parent = parser.nodeParentNode(prev) orelse break; + + // TODO deinit lastchild + lastchild = parser.nodeLastChild(parent); + } + + if (prev == root) { + return null; + } + + return parser.nodeNextSibling(prev); + } + + /// get_length computes the collection's length dynamically according to + /// the current root structure. + // TODO: nodes retrieved must be de-referenced. + pub fn get_length(self: *HTMLCollection, allocator: std.mem.Allocator) !u32 { + var len: u32 = 0; + var node: *parser.Node = self.root; + var ntype: parser.NodeType = undefined; + + const imatch = try std.ascii.allocUpperString(allocator, self.match); + defer allocator.free(imatch); + + const is_wildcard = std.mem.eql(u8, self.match, "*"); + + while (true) { + ntype = parser.nodeType(node); + if (ntype == .element) { + if (is_wildcard or std.mem.eql(u8, imatch, parser.nodeName(node))) { + len += 1; + } + } + + node = get_next(self.root, node) orelse break; + } + + return len; + } + + pub fn _item(self: *HTMLCollection, allocator: std.mem.Allocator, index: u32) !?Union { + var i: u32 = 0; + var node: *parser.Node = self.root; + var ntype: parser.NodeType = undefined; + + const is_wildcard = std.mem.eql(u8, self.match, "*"); + + // Use the current state to improve speed if possible. + if (self.cur_idx != null and index >= self.cur_idx.?) { + i = self.cur_idx.?; + node = self.cur_node.?; + } + + const imatch = try std.ascii.allocUpperString(allocator, self.match); + defer allocator.free(imatch); + + while (true) { + ntype = parser.nodeType(node); + if (ntype == .element) { + if (is_wildcard or std.mem.eql(u8, imatch, parser.nodeName(node))) { + // check if we found the searched element. + if (i == index) { + // save the current state + self.cur_node = node; + self.cur_idx = i; + + const e = @as(*parser.Element, @ptrCast(node)); + return Element.toInterface(e); + } + + i += 1; + } + } + + node = get_next(self.root, node) orelse break; + } + + return null; + } + + pub fn _namedItem(self: *HTMLCollection, allocator: std.mem.Allocator, name: []const u8) !?Union { + if (name.len == 0) { + return null; + } + + var node: *parser.Node = self.root; + var ntype: parser.NodeType = undefined; + + const is_wildcard = std.mem.eql(u8, self.match, "*"); + + const imatch = try std.ascii.allocUpperString(allocator, self.match); + defer allocator.free(imatch); + + while (true) { + ntype = parser.nodeType(node); + if (ntype == .element) { + if (is_wildcard or std.mem.eql(u8, imatch, parser.nodeName(node))) { + const elem = @as(*parser.Element, @ptrCast(node)); + + var attr = parser.elementGetAttribute(elem, "id"); + // check if the node id corresponds to the name argument. + if (attr != null and std.mem.eql(u8, name, attr.?)) { + return Element.toInterface(elem); + } + + attr = parser.elementGetAttribute(elem, "name"); + // check if the node id corresponds to the name argument. + if (attr != null and std.mem.eql(u8, name, attr.?)) { + return Element.toInterface(elem); + } + } + } + + node = get_next(self.root, node) orelse break; + } + + return null; + } +}; + +// Tests +// ----- + +pub fn testExecFn( + _: std.mem.Allocator, + js_env: *jsruntime.Env, + comptime _: []jsruntime.API, +) !void { + var getElementsByTagName = [_]Case{ + .{ .src = "let getElementsByTagName = document.getElementsByTagName('p')", .ex = "undefined" }, + .{ .src = "getElementsByTagName.length", .ex = "2" }, + .{ .src = "let getElementsByTagNameCI = document.getElementsByTagName('P')", .ex = "undefined" }, + .{ .src = "getElementsByTagNameCI.length", .ex = "2" }, + .{ .src = "getElementsByTagName.item(0).localName", .ex = "p" }, + .{ .src = "getElementsByTagName.item(1).localName", .ex = "p" }, + .{ .src = "let getElementsByTagNameAll = document.getElementsByTagName('*')", .ex = "undefined" }, + .{ .src = "getElementsByTagNameAll.length", .ex = "8" }, + .{ .src = "getElementsByTagNameAll.item(0).localName", .ex = "html" }, + .{ .src = "getElementsByTagNameAll.item(0).localName", .ex = "html" }, + .{ .src = "getElementsByTagNameAll.item(1).localName", .ex = "head" }, + .{ .src = "getElementsByTagNameAll.item(0).localName", .ex = "html" }, + .{ .src = "getElementsByTagNameAll.item(2).localName", .ex = "body" }, + .{ .src = "getElementsByTagNameAll.item(3).localName", .ex = "div" }, + .{ .src = "getElementsByTagNameAll.item(7).localName", .ex = "p" }, + .{ .src = "getElementsByTagNameAll.namedItem('para-empty-child').localName", .ex = "span" }, + }; + try checkCases(js_env, &getElementsByTagName); +} diff --git a/src/dom/node.zig b/src/dom/node.zig index 3b9005b3..a57a94a9 100644 --- a/src/dom/node.zig +++ b/src/dom/node.zig @@ -14,6 +14,7 @@ const EventTarget = @import("event_target.zig").EventTarget; const CData = @import("character_data.zig"); const Element = @import("element.zig").Element; const Document = @import("document.zig").Document; +const HTMLCollection = @import("html_collection.zig").HTMLCollection; // HTML const HTML = @import("../html/html.zig"); @@ -25,6 +26,7 @@ pub const Interfaces = generate.Tuple(.{ CData.Interfaces, Element, Document, + HTMLCollection, HTML.Interfaces, }); diff --git a/src/main_wpt.zig b/src/main_wpt.zig index 7f567ea2..96c8ad42 100644 --- a/src/main_wpt.zig +++ b/src/main_wpt.zig @@ -205,6 +205,11 @@ fn runWPT(arena: *std.heap.ArenaAllocator, comptime apis: []jsruntime.API, f: [] \\ return true; \\}; \\window.removeEventListener = function () {}; + \\ + \\console = []; + \\console.log = function () { + \\ console.push(...arguments); + \\}; ; res = try evalJS(js_env, alloc, init, "init"); if (!res.success) { @@ -247,6 +252,12 @@ fn runWPT(arena: *std.heap.ArenaAllocator, comptime apis: []jsruntime.API, f: [] return res; } + // display console logs + res = try evalJS(js_env, alloc, "console.join(', ');", "console"); + if (res.result.len > 0) { + std.debug.print("-- CONSOLE LOG\n{s}\n--\n", .{res.result}); + } + // Check the final test status. res = try evalJS(js_env, alloc, "report.status;", "teststatus"); if (!res.success) { diff --git a/src/netsurf.zig b/src/netsurf.zig index 5f246d98..24bb1771 100644 --- a/src/netsurf.zig +++ b/src/netsurf.zig @@ -597,6 +597,9 @@ pub fn textSplitText(text: *Text, offset: u32) *Text { // Comment pub const Comment = c.dom_comment; +// Attribute +pub const Attribute = c.dom_attr; + // Element pub const Element = c.dom_element; @@ -618,6 +621,11 @@ pub fn elementGetAttribute(elem: *Element, name: []const u8) ?[]const u8 { return stringToData(s.?); } +// elementToNode is an helper to convert an element to a node. +pub inline fn elementToNode(e: *Element) *Node { + return @as(*Node, @ptrCast(e)); +} + // ElementHTML pub const ElementHTML = c.dom_html_element; @@ -731,6 +739,13 @@ pub inline fn documentGetElementsByTagName(doc: *Document, tagname: []const u8) return nlist.?; } +// documentGetDocumentElement returns the root document element. +pub inline fn documentGetDocumentElement(doc: *Document) *Element { + var elem: ?*Element = undefined; + _ = documentVtable(doc).dom_document_get_document_element.?(doc, &elem); + return elem.?; +} + pub inline fn documentCreateElement(doc: *Document, tag_name: []const u8) *Element { var elem: ?*Element = undefined; _ = documentVtable(doc).dom_document_create_element.?(doc, stringFromData(tag_name), &elem); diff --git a/src/run_tests.zig b/src/run_tests.zig index 8860bc67..b5ad7159 100644 --- a/src/run_tests.zig +++ b/src/run_tests.zig @@ -11,6 +11,7 @@ const HTMLDocumentTestExecFn = @import("html/document.zig").testExecFn; const nodeTestExecFn = @import("dom/node.zig").testExecFn; const characterDataTestExecFn = @import("dom/character_data.zig").testExecFn; const textTestExecFn = @import("dom/text.zig").testExecFn; +const HTMLCollectionTestExecFn = @import("dom/html_collection.zig").testExecFn; var doc: *parser.DocumentHTML = undefined; @@ -51,6 +52,7 @@ fn testsAllExecFn( nodeTestExecFn, characterDataTestExecFn, textTestExecFn, + HTMLCollectionTestExecFn, }; inline for (testFns) |testFn| { diff --git a/tests/wpt/dom/nodes/Document-Element-getElementsByTagName.js b/tests/wpt/dom/nodes/Document-Element-getElementsByTagName.js new file mode 100644 index 00000000..dbbe667f --- /dev/null +++ b/tests/wpt/dom/nodes/Document-Element-getElementsByTagName.js @@ -0,0 +1,208 @@ +function test_getElementsByTagName(context, element) { + // TODO: getElementsByTagName("*") + test(function() { + assert_false(context.getElementsByTagName("html") instanceof NodeList, + "Should not return a NodeList") + assert_true(context.getElementsByTagName("html") instanceof HTMLCollection, + "Should return an HTMLCollection") + }, "Interfaces") + + test(function() { + var firstCollection = context.getElementsByTagName("html"), + secondCollection = context.getElementsByTagName("html") + assert_true(firstCollection !== secondCollection || + firstCollection === secondCollection) + }, "Caching is allowed") + + test(function() { + var l = context.getElementsByTagName("nosuchtag") + l[5] = "foopy" + assert_equals(l[5], undefined) + assert_equals(l.item(5), null) + }, "Shouldn't be able to set unsigned properties on a HTMLCollection (non-strict mode)") + + test(function() { + var l = context.getElementsByTagName("nosuchtag") + assert_throws_js(TypeError, function() { + "use strict"; + l[5] = "foopy" + }) + assert_equals(l[5], undefined) + assert_equals(l.item(5), null) + }, "Shouldn't be able to set unsigned properties on a HTMLCollection (strict mode)") + + test(function() { + var l = context.getElementsByTagName("nosuchtag") + var fn = l.item; + assert_equals(fn, HTMLCollection.prototype.item); + l.item = "pass" + assert_equals(l.item, "pass") + assert_equals(HTMLCollection.prototype.item, fn); + }, "Should be able to set expando shadowing a proto prop (item)") + + test(function() { + var l = context.getElementsByTagName("nosuchtag") + var fn = l.namedItem; + assert_equals(fn, HTMLCollection.prototype.namedItem); + l.namedItem = "pass" + assert_equals(l.namedItem, "pass") + assert_equals(HTMLCollection.prototype.namedItem, fn); + }, "Should be able to set expando shadowing a proto prop (namedItem)") + + test(function() { + var t1 = element.appendChild(document.createElement("pre")); + t1.id = "x"; + var t2 = element.appendChild(document.createElement("pre")); + t2.setAttribute("name", "y"); + var t3 = element.appendChild(document.createElementNS("", "pre")); + t3.setAttribute("id", "z"); + var t4 = element.appendChild(document.createElementNS("", "pre")); + t4.setAttribute("name", "w"); + this.add_cleanup(function() { + element.removeChild(t1) + element.removeChild(t2) + element.removeChild(t3) + element.removeChild(t4) + }); + + var list = context.getElementsByTagName('pre'); + var pre = list[0]; + assert_equals(pre.id, "x"); + + var exposedNames = { 'x': 0, 'y': 1, 'z': 2 }; + for (var exposedName in exposedNames) { + assert_equals(list[exposedName], list[exposedNames[exposedName]]); + assert_equals(list[exposedName], list.namedItem(exposedName)); + assert_true(exposedName in list, "'" + exposedName + "' in list"); + assert_true(list.hasOwnProperty(exposedName), + "list.hasOwnProperty('" + exposedName + "')"); + } + + var unexposedNames = ["w"]; + for (var unexposedName of unexposedNames) { + assert_false(unexposedName in list); + assert_false(list.hasOwnProperty(unexposedName)); + assert_equals(list[unexposedName], undefined); + assert_equals(list.namedItem(unexposedName), null); + } + + assert_array_equals(Object.getOwnPropertyNames(list).sort(), + ["0", "1", "2", "3", "x", "y", "z"]); + + var desc = Object.getOwnPropertyDescriptor(list, '0'); + assert_equals(typeof desc, "object", "descriptor should be an object"); + assert_true(desc.enumerable, "desc.enumerable"); + assert_true(desc.configurable, "desc.configurable"); + + desc = Object.getOwnPropertyDescriptor(list, 'x'); + assert_equals(typeof desc, "object", "descriptor should be an object"); + assert_false(desc.enumerable, "desc.enumerable"); + assert_true(desc.configurable, "desc.configurable"); + }, "hasOwnProperty, getOwnPropertyDescriptor, getOwnPropertyNames") + + test(function() { + assert_equals(document.createElementNS("http://www.w3.org/1999/xhtml", "i").localName, "i") // Sanity + var t = element.appendChild(document.createElementNS("http://www.w3.org/1999/xhtml", "I")) + this.add_cleanup(function() {element.removeChild(t)}) + assert_equals(t.localName, "I") + assert_equals(t.tagName, "I") + assert_equals(context.getElementsByTagName("I").length, 0) + assert_equals(context.getElementsByTagName("i").length, 0) + }, "HTML element with uppercase tagName never matches in HTML Documents") + + test(function() { + var t = element.appendChild(document.createElementNS("test", "st")) + this.add_cleanup(function() {element.removeChild(t)}) + assert_array_equals(context.getElementsByTagName("st"), [t]) + assert_array_equals(context.getElementsByTagName("ST"), []) + }, "Element in non-HTML namespace, no prefix, lowercase name") + + test(function() { + var t = element.appendChild(document.createElementNS("test", "ST")) + this.add_cleanup(function() {element.removeChild(t)}) + assert_array_equals(context.getElementsByTagName("ST"), [t]) + assert_array_equals(context.getElementsByTagName("st"), []) + }, "Element in non-HTML namespace, no prefix, uppercase name") + + test(function() { + var t = element.appendChild(document.createElementNS("test", "te:st")) + this.add_cleanup(function() {element.removeChild(t)}) + assert_array_equals(context.getElementsByTagName("st"), []) + assert_array_equals(context.getElementsByTagName("ST"), []) + assert_array_equals(context.getElementsByTagName("te:st"), [t]) + assert_array_equals(context.getElementsByTagName("te:ST"), []) + }, "Element in non-HTML namespace, prefix, lowercase name") + + test(function() { + var t = element.appendChild(document.createElementNS("test", "te:ST")) + this.add_cleanup(function() {element.removeChild(t)}) + assert_array_equals(context.getElementsByTagName("st"), []) + assert_array_equals(context.getElementsByTagName("ST"), []) + assert_array_equals(context.getElementsByTagName("te:st"), []) + assert_array_equals(context.getElementsByTagName("te:ST"), [t]) + }, "Element in non-HTML namespace, prefix, uppercase name") + + test(function() { + var t = element.appendChild(document.createElement("aÇ")) + this.add_cleanup(function() {element.removeChild(t)}) + assert_equals(t.localName, "aÇ") + assert_array_equals(context.getElementsByTagName("AÇ"), [t], "All uppercase input") + assert_array_equals(context.getElementsByTagName("aÇ"), [t], "Ascii lowercase input") + assert_array_equals(context.getElementsByTagName("aç"), [], "All lowercase input") + }, "Element in HTML namespace, no prefix, non-ascii characters in name") + + test(function() { + var t = element.appendChild(document.createElementNS("test", "AÇ")) + this.add_cleanup(function() {element.removeChild(t)}) + assert_array_equals(context.getElementsByTagName("AÇ"), [t]) + assert_array_equals(context.getElementsByTagName("aÇ"), []) + assert_array_equals(context.getElementsByTagName("aç"), []) + }, "Element in non-HTML namespace, non-ascii characters in name") + + test(function() { + var t = element.appendChild(document.createElementNS("http://www.w3.org/1999/xhtml", "test:aÇ")) + this.add_cleanup(function() {element.removeChild(t)}) + assert_array_equals(context.getElementsByTagName("TEST:AÇ"), [t], "All uppercase input") + assert_array_equals(context.getElementsByTagName("test:aÇ"), [t], "Ascii lowercase input") + assert_array_equals(context.getElementsByTagName("test:aç"), [], "All lowercase input") + }, "Element in HTML namespace, prefix, non-ascii characters in name") + + test(function() { + var t = element.appendChild(document.createElementNS("test", "TEST:AÇ")) + this.add_cleanup(function() {element.removeChild(t)}) + assert_array_equals(context.getElementsByTagName("TEST:AÇ"), [t], "All uppercase input") + assert_array_equals(context.getElementsByTagName("test:aÇ"), [], "Ascii lowercase input") + assert_array_equals(context.getElementsByTagName("test:aç"), [], "All lowercase input") + }, "Element in non-HTML namespace, prefix, non-ascii characters in name") + + test(function() { + var actual = context.getElementsByTagName("*"); + var expected = []; + var get_elements = function(node) { + for (var i = 0; i < node.childNodes.length; i++) { + var child = node.childNodes[i]; + if (child.nodeType === child.ELEMENT_NODE) { + expected.push(child); + get_elements(child); + } + } + } + get_elements(context); + assert_array_equals(actual, expected); + }, "getElementsByTagName('*')") + + test(function() { + var t1 = element.appendChild(document.createElement("abc")); + this.add_cleanup(function() {element.removeChild(t1)}); + + var l = context.getElementsByTagName("abc"); + assert_true(l instanceof HTMLCollection); + assert_equals(l.length, 1); + + var t2 = element.appendChild(document.createElement("abc")); + assert_equals(l.length, 2); + + element.removeChild(t2); + assert_equals(l.length, 1); + }, "getElementsByTagName() should be a live collection"); +} diff --git a/tests/wpt/dom/nodes/Document-getElementsByTagName.html b/tests/wpt/dom/nodes/Document-getElementsByTagName.html new file mode 100644 index 00000000..00e3435c --- /dev/null +++ b/tests/wpt/dom/nodes/Document-getElementsByTagName.html @@ -0,0 +1,11 @@ + + +