| // This library supports HTML5lib-style test cases. |
| // |
| // The HTMLlib test case format describes an actual DOM tree. For testing, and |
| // particular for testing of DOM parsers and DOM parser-related functionality, |
| // this has the advantage of being able to represent edge cases. |
| // |
| // Example: If `.replaceWithChildren` is called on the `<span>` element as a |
| // result of parsing `"<p>Hello<span>World</span></p>"`, then this results in |
| // a tree with two adjacent text nodes. This behaviour will affect subsequent |
| // DOM operations and should thus be tested. The HTML5lib format makes it easy |
| // to describe the expected result unambiguously. |
| // |
| // References: |
| // - HTML5lib: https://github.com/html5lib |
| // - HTML5lib testcases: https://github.com/html5lib/html5lib-tests/tree/master/tree-construction |
| // - test case format description: |
| // https://github.com/html5lib/html5lib-tests/blob/master/tree-construction/README.md |
| // |
| // The main "API" is: |
| // |
| // - parse_html5lib_testcases(string) |
| // This returns an array of dictionaries, where the dictionary contains the |
| // the text of the test file, keyed by the lines starting with a hashtag. |
| // |
| // E.g. #data\nbla results in [{data: "bla"}]. |
| // |
| // - html5lib_testcases_from_script() |
| // Wrapper for parse_html5lib_testcases that gets the test data from a script |
| // element with type "html5lib-tests". This allows to specify the test data |
| // in the test file, but requires working around closing script tags. |
| // |
| // - html5lib_testcases_from_response(response_promise) |
| // Wrapper for parse_html5lib_testcases that gets the data from a Response |
| // Promise, as is returned from `fetch()`, and returns a Promise for the array |
| // of testcases. This allows getting the test dat from a text resource. |
| // |
| // - build_node_tree(node, documentstr) |
| // This builds a node tree from the "#document" string from a testcase, and |
| // appends it to the node argument. Returns node. |
| // |
| // - assert_subtree_equals(node1, node2) |
| // Asserts that the child trees of node1 and node2 are equals. This |
| // recursively descends the trees. |
| // |
| // - assert_testcase(node, testcase) |
| // Wrapper for build_node_tree and assert_subtree_equals, for use with a |
| // result of parse_html5lib_testcases. |
| // |
| |
| function html5lib_testcases_from_script() { |
| return parse_html5lib_testcases( |
| document.querySelector("script[type='html5lib-tests']").textContent); |
| } |
| |
| function html5lib_testcases_from_response(response_promise) { |
| return response_promise |
| .then(response => response.text()) |
| .then(parse_html5lib_testcases); |
| } |
| |
| function add_html5lib_testcase(testcases, current) { |
| for (const item in current) { |
| current[item] = current[item].join("\n"); |
| } |
| if (Object.entries(current).length) { |
| testcases.push(current); |
| } |
| } |
| |
| function parse_html5lib_testcases(content) { |
| const testcases = []; |
| var state = undefined; |
| var current = {}; |
| for (const line of content.split("\n")) { |
| if (!line) { |
| add_html5lib_testcase(testcases, current); |
| state = undefined; |
| current = {}; |
| } else if (line[0] == "#") { |
| state = line.substring(1); |
| current[state] = []; |
| } else if (state) { |
| current[state].push(line); |
| } else { |
| // Error handling is for another day. |
| } |
| } |
| return testcases; |
| } |
| |
| function get_child_at(node, level) { |
| for (i = 0; i < level; i++) { |
| if (is_html_template(node)) { |
| // For <template>, continue with the content fragment. |
| node = node.content; |
| } else { |
| node = node.lastChild; |
| } |
| } |
| return node; |
| } |
| |
| function append_child_at(node, level, child) { |
| get_child_at(node, level).appendChild(child); |
| } |
| |
| function is_element(node) { |
| return node.tagName && node.namespaceURI; |
| } |
| |
| function is_html_template(node) { |
| return is_element(node) && node.tagName == "TEMPLATE" && |
| node.namespaceURI == "http://www.w3.org/1999/xhtml"; |
| } |
| |
| function create_element(name, maybe_namespace) { |
| // `For the HTML namespace, the namespace designator is the empty string, |
| // i.e. there's no prefix. For the SVG namespace, the namespace designator is |
| // "svg ". For the MathML namespace, the namespace designator is "math ". |
| if (maybe_namespace == "svg ") { |
| return document.createElementNS("http://www.w3.org/2000/svg", name); |
| } else if (maybe_namespace == "math ") { |
| return document.createElementNS("http://www.w3.org/1998/Math/MathML", name); |
| } else if (!maybe_namespace) { |
| return document.createElement(name); |
| } else { |
| assert_unreached(`Invalid element name: "${maybe_namespace}${name}"`); |
| } |
| } |
| |
| function set_attribute(node, name, maybe_namespace, value) { |
| // `The attribute name string is the local name prefixed by a namespace |
| // designator. For no namespace, the namespace designator is the empty string, |
| // i.e. there's no prefix. For the XLink namespace, the namespace designator |
| // is "xlink " [Likewise, "xml " and "xmlns ".] |
| if (maybe_namespace == "xlink ") { |
| node.setAttributeNS("http://www.w3.org/1999/xlink", name, value); |
| } else if (maybe_namespace == "xml ") { |
| node.setAttributeNS("http://www.w3.org/XML/1998/namespace", name, value); |
| } else if (maybe_namespace == "xmlns ") { |
| node.setAttributeNS("http://www.w3.org/2000/xmlns/", name, value); |
| } else if (!maybe_namespace) { |
| node.setAttribute(name, value); |
| } else { |
| assert_unreached(`Invalid attribute name: "${maybe_namespace}${name}"`); |
| } |
| } |
| |
| function build_node_tree(root, docstr) { |
| // Format described here: |
| // https://github.com/html5lib/html5lib-tests/blob/master/tree-construction/README.md |
| |
| // Special-case empty string: Don't build anything. |
| // (Happens for test docs that cause parse errors, but also for genuinely |
| // empty expectation documents.) |
| if (!docstr) return root; |
| |
| for (const line of docstr.split("\n")) { |
| const [_, indent, remainder] = line.match(/^\| ( *)(.*)/); |
| const level = indent.length / 2; |
| if (match = remainder.match(/^<([a-z]* )?([a-zA-Z0-9_-]*)>$/)) { |
| // `Element nodes must be represented by a "<, the tag name string, ">".` |
| append_child_at(root, level, create_element(match[2], match[1])); |
| } else if (match = remainder.match(/^"([^"]*)"$/)) { |
| // `Text nodes must be the string, in double quotes.` |
| append_child_at(root, level, document.createTextNode(match[1])); |
| } else if (match = remainder.match(/^([a-z]* )?(.*)="(.*)"$/)) { |
| // `Attribute nodes must have the attribute name string, then an "=" sign, |
| // then the attribute value in double quotes (").` |
| set_attribute(get_child_at(root, level), match[2], match[1], match[3]); |
| } else if (match = remainder.match(/^<!--(.*)-->$/)) { |
| // `Comments must be "<" then "!-- " then the data then " -->".` |
| append_child_at(root, level, document.createComment(match[1])); |
| } else if (match = remainder.match( |
| /^<!DOCTYPE ([^ ]*)( "([^"]*)"( "([^"]*)")?)?>$/)) { |
| // `DOCTYPEs must be "<!DOCTYPE " then [... bla bla ...]` |
| append_child_at(root, level, |
| document.implementation.createDocumentType(match[1], match[3], match[5])); |
| } else if (match = remainder.match(/^<?([a-z]*)( (.*))>$/)) { |
| // `Processing instructions must be "<?", then the target, then [...]` |
| append_child_at(root, level, document.createProcessingInstruction( |
| match[1], match[3])); |
| } else if (remainder == "content") { |
| // Template contents are represented by the string "content" with the |
| // children below it. |
| // Nothing to do here; so let's just check we're actually in a template. |
| assert_true(is_html_template(get_child_at(root, level)), |
| "\"content\" only expected as child of a <template>."); |
| } else { |
| assert_unreached( |
| `Unknown line type. Maybe test data is malformed. ("${line}")`); |
| } |
| } |
| return root; |
| } |
| |
| function assert_subtree_equals(node1, node2) { |
| // Iterate in parallel over both trees. |
| const tree1 = document.createNodeIterator(node1); |
| const tree2 = document.createNodeIterator(node2); |
| // Skip the root/context node, so that we can re-use the test with different |
| // context types. |
| var current1 = tree1.nextNode(); |
| var current2 = tree2.nextNode(); |
| do { |
| current1 = tree1.nextNode(); |
| current2 = tree2.nextNode(); |
| |
| if (!current1) break; |
| |
| // Conceptually, we only want to check whether a.isEqualNode(b). But that |
| // yields terrible error messages ("expected true but got false"). With |
| // this being a test suite and all, let's invest a bit of effort into nice |
| // error messages. |
| if (!current1.isEqualNode(current2)) { |
| let breadcrumbs = ""; |
| let current = current1; |
| while (current) { |
| const here = is_element(current) ? `<${current.tagName}>` : `${current}`; |
| breadcrumbs = `${here} / ${breadcrumbs}`; |
| current = current.parentNode; |
| } |
| breadcrumbs = breadcrumbs.substring(0, breadcrumbs.length - 3); |
| assert_true(current1.isEqualNode(current2), |
| `${current1}.isEqual(${current2}) fails. Path: ${breadcrumbs}.`); |
| } |
| |
| // NodeIterator does not recurse into template contents. So we need to do |
| // this manually. |
| if (is_html_template(current1) && is_html_template(current2)) { |
| assert_subtree_equals(current1.content, current2.content); |
| } |
| } while (current1); |
| |
| // Ensure that both iterators have come to an end. |
| assert_false(!!current2, "Additional nodes at the of node2.\n"); |
| } |
| |
| function assert_testcase(node, testcase) { |
| const context = document.createElement(testcase["document-fragment"] ?? "div"); |
| const tree = build_node_tree(context, testcase.document); |
| assert_subtree_equals(node, tree); |
| } |