"use strict" /* Known limitations: - the innerHTML setter and the DOMParser only support a small subset of the true HTML/XML syntax. - `option.selected` can't be set/read when the option doesn't have a `select` parent - `element.attributes` is just a map of attribute names => Attr objects stubs - ... */ /* options: - spy:(f: Function) => Function */ module.exports = function(options) { options = options || {} var spy = options.spy || function(f){return f} var spymap = [] // This way I'm not also implementing a partial `URL` polyfill. Based on the // regexp at https://urlregex.com/, but adapted to allow relative URLs and // care only about HTTP(S) URLs. var urlHash = "#[?!/+=&;%@.\\w_-]*" var urlQuery = "\\?[!/+=&;%@.\\w_-]*" var urlPath = "/[+~%/.\\w_-]*" var urlRelative = urlPath + "(?:" + urlQuery + ")?(?:" + urlHash + ")?" var urlDomain = "https?://[A-Za-z0-9][A-Za-z0-9.-]+[A-Za-z0-9]" var validURLRegex = new RegExp( "^" + urlDomain + "(" + urlRelative + ")?$|" + "^" + urlRelative + "$|" + "^" + urlQuery + "(?:" + urlHash + ")?$|" + "^" + urlHash + "$" ) var hasOwn = ({}.hasOwnProperty) function registerSpies(element, spies) { if(options.spy) { var i = spymap.indexOf(element) if (i === -1) { spymap.push(element, spies) } else { var existing = spymap[i + 1] for (var k in spies) existing[k] = spies[k] } } } function getSpies(element) { if (element == null || typeof element !== "object") throw new Error("Element expected") if(options.spy) return spymap[spymap.indexOf(element) + 1] } function isModernEvent(type) { return type === "transitionstart" || type === "transitionend" || type === "animationstart" || type === "animationend" } function dispatchEvent(e) { var stopped = false e.stopImmediatePropagation = function() { e.stopPropagation() stopped = true } e.currentTarget = this if (this._events[e.type] != null) { for (var i = 0; i < this._events[e.type].handlers.length; i++) { var useCapture = this._events[e.type].options[i].capture if (useCapture && e.eventPhase < 3 || !useCapture && e.eventPhase > 1) { var handler = this._events[e.type].handlers[i] if (typeof handler === "function") try {handler.call(this, e)} catch(e) {setTimeout(function(){throw e})} else try {handler.handleEvent(e)} catch(e) {setTimeout(function(){throw e})} if (stopped) return } } } // this is inaccurate. Normally the event fires in definition order, including legacy events // this would require getters/setters for each of them though and we haven't gotten around to // adding them since it would be at a high perf cost or would entail some heavy refactoring of // the mocks (prototypes instead of closures). if (e.eventPhase > 1 && typeof this["on" + e.type] === "function" && !isModernEvent(e.type)) try {this["on" + e.type](e)} catch(e) {setTimeout(function(){throw e})} } function appendChild(child) { var ancestor = this while (ancestor !== child && ancestor !== null) ancestor = ancestor.parentNode if (ancestor === child) throw new Error("Node cannot be inserted at the specified point in the hierarchy") if (child.nodeType == null) throw new Error("Argument is not a DOM element") var index = this.childNodes.indexOf(child) if (index > -1) this.childNodes.splice(index, 1) if (child.nodeType === 11) { while (child.firstChild != null) appendChild.call(this, child.firstChild) child.childNodes = [] } else { this.childNodes.push(child) if (child.parentNode != null && child.parentNode !== this) removeChild.call(child.parentNode, child) child.parentNode = this } } function removeChild(child) { var index = this.childNodes.indexOf(child) if (index > -1) { this.childNodes.splice(index, 1) child.parentNode = null } else throw new TypeError("Failed to execute 'removeChild'") } function insertBefore(child, reference) { var ancestor = this while (ancestor !== child && ancestor !== null) ancestor = ancestor.parentNode if (ancestor === child) throw new Error("Node cannot be inserted at the specified point in the hierarchy") if (child.nodeType == null) throw new Error("Argument is not a DOM element") var refIndex = this.childNodes.indexOf(reference) var index = this.childNodes.indexOf(child) if (reference !== null && refIndex < 0) throw new TypeError("Invalid argument") if (index > -1) this.childNodes.splice(index, 1) if (reference === null) appendChild.call(this, child) else { if (index !== -1 && refIndex > index) refIndex-- if (child.nodeType === 11) { this.childNodes.splice.apply(this.childNodes, [refIndex, 0].concat(child.childNodes)) while (child.firstChild) { var subchild = child.firstChild removeChild.call(child, subchild) subchild.parentNode = this } child.childNodes = [] } else { this.childNodes.splice(refIndex, 0, child) if (child.parentNode != null && child.parentNode !== this) removeChild.call(child.parentNode, child) child.parentNode = this } } } function getAttribute(name) { if (this.attributes[name] == null) return null return this.attributes[name].value } function setAttribute(name, value) { /*eslint-disable no-implicit-coercion*/ // this is the correct kind of conversion, passing a Symbol throws in browsers too. var nodeValue = "" + value /*eslint-enable no-implicit-coercion*/ this.attributes[name] = { namespaceURI: hasOwn.call(this.attributes, name) ? this.attributes[name].namespaceURI : null, get value() {return nodeValue}, set value(value) { /*eslint-disable no-implicit-coercion*/ nodeValue = "" + value /*eslint-enable no-implicit-coercion*/ }, get nodeValue() {return nodeValue}, set nodeValue(value) { this.value = value } } } function setAttributeNS(ns, name, value) { this.setAttribute(name, value) this.attributes[name].namespaceURI = ns } function removeAttribute(name) { delete this.attributes[name] } function hasAttribute(name) { return name in this.attributes } var declListTokenizer = /;|"(?:\\.|[^"\n])*"|'(?:\\.|[^'\n])*'/g /** * This will split a semicolon-separated CSS declaration list into an array of * individual declarations, ignoring semicolons in strings. * * Comments are also stripped. * * @param {string} declList * @return {string[]} */ function splitDeclList(declList) { var indices = [], res = [], match // remove comments, preserving comments in strings. declList = declList.replace( /("(?:\\.|[^"\n])*"|'(?:\\.|[^'\n])*')|\/\*[\s\S]*?\*\//g, function(m, str){ return str || "" } ) /*eslint-disable no-cond-assign*/ while (match = declListTokenizer.exec(declList)) { if (match[0] === ";") indices.push(match.index) } /*eslint-enable no-cond-assign*/ for (var i = indices.length; i--;){ res.unshift(declList.slice(indices[i] + 1)) declList = declList.slice(0, indices[i]) } res.unshift(declList) return res } function parseMarkup(value, root, voidElements, xmlns) { var depth = 0, stack = [root] value.replace(/<([a-z0-9\-]+?)((?:\s+?[^=]+?=(?:"[^"]*?"|'[^']*?'|[^\s>]*))*?)(\s*\/)?>|<\/([a-z0-9\-]+?)>|([^<]+)/g, function(match, startTag, attrs, selfClosed, endTag, text) { if (startTag) { var element = xmlns == null ? $window.document.createElement(startTag) : $window.document.createElementNS(xmlns, startTag) attrs.replace(/\s+?([^=]+?)=(?:"([^"]*?)"|'([^']*?)'|([^\s>]*))/g, function(match, key, doubleQuoted, singleQuoted, unquoted) { var keyParts = key.split(":") var name = keyParts.pop() var ns = keyParts[0] var value = doubleQuoted || singleQuoted || unquoted || "" if (ns != null) element.setAttributeNS(ns, name, value) else element.setAttribute(name, value) }) appendChild.call(stack[depth], element) if (!selfClosed && voidElements.indexOf(startTag.toLowerCase()) < 0) stack[++depth] = element } else if (endTag) { depth-- } else if (text) { appendChild.call(stack[depth], $window.document.createTextNode(text)) // FIXME handle html entities } }) } function DOMParser() {} DOMParser.prototype.parseFromString = function(src, mime) { if (mime !== "image/svg+xml") throw new Error("The DOMParser mock only supports the \"image/svg+xml\" MIME type") var match = src.match(/^(.*)<\/svg>$/) if (!match) throw new Error("Please provide a bare SVG tag with the xmlns as only attribute") var value = match[1] var root = $window.document.createElementNS("http://www.w3.org/2000/svg", "svg") parseMarkup(value, root, [], "http://www.w3.org/2000/svg") return {documentElement: root} } function camelCase(string) { return string.replace(/-\D/g, function(match) {return match[1].toUpperCase()}) } var activeElement var delay = 16, last = 0 var $window = { DOMParser: DOMParser, requestAnimationFrame: function(callback) { var elapsed = Date.now() - last return setTimeout(function() { callback() last = Date.now() }, delay - elapsed) }, document: { createElement: function(tag) { var cssText = "" var style = {} Object.defineProperties(style, { cssText: { get: function() {return cssText}, set: function (value) { var buf = [] if (typeof value === "string") { for (var key in style) style[key] = "" var rules = splitDeclList(value) for (var i = 0; i < rules.length; i++) { var rule = rules[i] var colonIndex = rule.indexOf(":") if (colonIndex > -1) { var rawKey = rule.slice(0, colonIndex).trim() var key = camelCase(rawKey) var value = rule.slice(colonIndex + 1).trim() if (key !== "cssText") { style[key] = style[rawKey] = value buf.push(rawKey + ": " + value + ";") } } } element.setAttribute("style", cssText = buf.join(" ")) } } }, getPropertyValue: {value: function(key){ return style[key] }}, removeProperty: {value: function(key){ style[key] = style[camelCase(key)] = "" }}, setProperty: {value: function(key, value){ style[key] = style[camelCase(key)] = value }} }) var events = {} var element = { nodeType: 1, nodeName: tag.toUpperCase(), namespaceURI: "http://www.w3.org/1999/xhtml", appendChild: appendChild, removeChild: removeChild, insertBefore: insertBefore, hasAttribute: hasAttribute, getAttribute: getAttribute, setAttribute: setAttribute, setAttributeNS: setAttributeNS, removeAttribute: removeAttribute, parentNode: null, childNodes: [], attributes: {}, contains: function(child) { while (child != null) { if (child === this) return true child = child.parentNode } return false }, get firstChild() { return this.childNodes[0] || null }, get nextSibling() { if (this.parentNode == null) return null var index = this.parentNode.childNodes.indexOf(this) if (index < 0) throw new TypeError("Parent's childNodes is out of sync") return this.parentNode.childNodes[index + 1] || null }, // eslint-disable-next-line accessor-pairs set textContent(value) { this.childNodes = [] if (value !== "") appendChild.call(this, $window.document.createTextNode(value)) }, // eslint-disable-next-line accessor-pairs set innerHTML(value) { var voidElements = ["area", "base", "br", "col", "command", "embed", "hr", "img", "input", "keygen", "link", "meta", "param", "source", "track", "wbr"] while (this.firstChild) removeChild.call(this, this.firstChild) var match = value.match(/^(.*)<\/svg>$/), root, ns if (match) { var value = match[1] root = $window.document.createElementNS("http://www.w3.org/2000/svg", "svg") ns = "http://www.w3.org/2000/svg" appendChild.call(this, root) } else { root = this } parseMarkup(value, root, voidElements, ns) }, get style() { return style }, set style(_){ // https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/style#Setting_style throw new Error("setting element.style is not portable") }, get className() { return this.attributes["class"] ? this.attributes["class"].value : "" }, set className(value) { if (this.namespaceURI === "http://www.w3.org/2000/svg") throw new Error("Cannot set property className of SVGElement") else this.setAttribute("class", value) }, focus: function() {activeElement = this}, addEventListener: function(type, handler, options) { if (arguments.length > 2) { if (typeof options === "object" && options != null) throw new TypeError("NYI: addEventListener options") else if (typeof options !== "boolean") throw new TypeError("boolean expected for useCapture") else options = {capture: options} } else { options = {capture: false} } if (events[type] == null) events[type] = {handlers: [handler], options: [options]} else { var found = false for (var i = 0; i < events[type].handlers.length; i++) { if (events[type].handlers[i] === handler && events[type].options[i].capture === options.capture) { found = true break } } if (!found) { events[type].handlers.push(handler) events[type].options.push(options) } } }, removeEventListener: function(type, handler, options) { if (arguments.length > 2) { if (typeof options === "object" && options != null) throw new TypeError("NYI: addEventListener options") else if (typeof options !== "boolean") throw new TypeError("boolean expected for useCapture") else options = {capture: options} } else { options = {capture: false} } if (events[type] != null) { for (var i = 0; i < events[type].handlers.length; i++) { if (events[type].handlers[i] === handler && events[type].options[i].capture === options.capture) { events[type].handlers.splice(i, 1) events[type].options.splice(i, 1) break; } } } }, dispatchEvent: function(e) { var parents = [] if (this.parentNode != null) { var parent = this.parentNode do { parents.push(parent) parent = parent.parentNode } while (parent != null) } e.target = this var prevented = false e.preventDefault = function() { prevented = true } Object.defineProperty(e, "defaultPrevented", { configurable: true, get: function () { return prevented } }) var stopped = false e.stopPropagation = function() { stopped = true } e.eventPhase = 1 try { for (var i = parents.length - 1; 0 <= i; i--) { dispatchEvent.call(parents[i], e) if (stopped) { return } } e.eventPhase = 2 dispatchEvent.call(this, e) if (stopped) { return } e.eventPhase = 3 for (var i = 0; i < parents.length; i++) { dispatchEvent.call(parents[i], e) if (stopped) { return } } } finally { e.eventPhase = 0 if (!prevented) { if (this.nodeName === "INPUT" && this.attributes["type"] != null && this.attributes["type"].value === "checkbox" && e.type === "click") { this.checked = !this.checked } } } }, onclick: null, _events: events } if (element.nodeName === "A") { Object.defineProperty(element, "href", { get: function() { if (this.namespaceURI === "http://www.w3.org/2000/svg") { var val = this.hasAttribute("href") ? this.attributes.href.value : "" return {baseVal: val, animVal: val} } else if (this.namespaceURI === "http://www.w3.org/1999/xhtml") { if (!this.hasAttribute("href")) return "" // HACK: if it's valid already, there's nothing to implement. var value = this.attributes.href.value if (validURLRegex.test(value)) return value } return "[FIXME implement]" }, set: function(value) { // This is a readonly attribute for SVG, todo investigate MathML which may have yet another IDL if (this.namespaceURI !== "http://www.w3.org/2000/svg") this.setAttribute("href", value) }, enumerable: true, }) } if (element.nodeName === "INPUT") { var checked Object.defineProperty(element, "checked", { get: function() {return checked === undefined ? this.attributes["checked"] !== undefined : checked}, set: function(value) {checked = Boolean(value)}, enumerable: true, }) var value = "" var valueSetter = spy(function(v) { /*eslint-disable no-implicit-coercion*/ value = v === null ? "" : "" + v /*eslint-enable no-implicit-coercion*/ }) Object.defineProperty(element, "value", { get: function() { return value }, set: valueSetter, enumerable: true, }) // we currently emulate the non-ie behavior, but emulating ie may be more useful (throw when an invalid type is set) var typeSetter = spy(function(v) { this.setAttribute("type", v) }) Object.defineProperty(element, "type", { get: function() { if (!this.hasAttribute("type")) return "text" var type = this.getAttribute("type") return (/^(?:radio|button|checkbox|color|date|datetime|datetime-local|email|file|hidden|month|number|password|range|research|search|submit|tel|text|url|week|image)$/) .test(type) ? type : "text" }, set: typeSetter, enumerable: true, }) registerSpies(element, { valueSetter: valueSetter, typeSetter: typeSetter }) } if (element.nodeName === "TEXTAREA") { var wasNeverSet = true var value = "" var valueSetter = spy(function(v) { wasNeverSet = false /*eslint-disable no-implicit-coercion*/ value = v === null ? "" : "" + v /*eslint-enable no-implicit-coercion*/ }) Object.defineProperty(element, "value", { get: function() { return wasNeverSet && this.firstChild ? this.firstChild.nodeValue : value }, set: valueSetter, enumerable: true, }) registerSpies(element, { valueSetter: valueSetter }) } /* eslint-disable radix */ if (element.nodeName === "CANVAS") { Object.defineProperty(element, "width", { get: function() {return this.attributes["width"] ? Math.floor(parseInt(this.attributes["width"].value) || 0) : 300}, set: function(value) {this.setAttribute("width", Math.floor(Number(value) || 0).toString())}, }) Object.defineProperty(element, "height", { get: function() {return this.attributes["height"] ? Math.floor(parseInt(this.attributes["height"].value) || 0) : 300}, set: function(value) {this.setAttribute("height", Math.floor(Number(value) || 0).toString())}, }) } /* eslint-enable radix */ function getOptions(element) { var options = [] for (var i = 0; i < element.childNodes.length; i++) { if (element.childNodes[i].nodeName === "OPTION") options.push(element.childNodes[i]) else if (element.childNodes[i].nodeName === "OPTGROUP") options = options.concat(getOptions(element.childNodes[i])) } return options } function getOptionValue(element) { return element.attributes["value"] != null ? element.attributes["value"].value : element.firstChild != null ? element.firstChild.nodeValue : "" } if (element.nodeName === "SELECT") { // var selectedValue var selectedIndex = 0 Object.defineProperty(element, "selectedIndex", { get: function() {return getOptions(this).length > 0 ? selectedIndex : -1}, set: function(value) { var options = getOptions(this) if (value >= 0 && value < options.length) { // selectedValue = getOptionValue(options[selectedIndex]) selectedIndex = value } else { // selectedValue = "" selectedIndex = -1 } }, enumerable: true, }) var valueSetter = spy(function(value) { if (value === null) { selectedIndex = -1 } else { var options = getOptions(this) /*eslint-disable no-implicit-coercion*/ var stringValue = "" + value /*eslint-enable no-implicit-coercion*/ for (var i = 0; i < options.length; i++) { if (getOptionValue(options[i]) === stringValue) { // selectedValue = stringValue selectedIndex = i return } } // selectedValue = stringValue selectedIndex = -1 } }) Object.defineProperty(element, "value", { get: function() { if (this.selectedIndex > -1) return getOptionValue(getOptions(this)[this.selectedIndex]) return "" }, set: valueSetter, enumerable: true, }) registerSpies(element, { valueSetter: valueSetter }) } if (element.nodeName === "OPTION") { var valueSetter = spy(function(value) { /*eslint-disable no-implicit-coercion*/ this.setAttribute("value", "" + value) /*eslint-enable no-implicit-coercion*/ }) Object.defineProperty(element, "value", { get: function() {return getOptionValue(this)}, set: valueSetter, enumerable: true, }) registerSpies(element, { valueSetter: valueSetter }) Object.defineProperty(element, "selected", { // TODO? handle `selected` without a parent (works in browsers) get: function() { var options = getOptions(this.parentNode) var index = options.indexOf(this) return index === this.parentNode.selectedIndex }, set: function(value) { if (value) { var options = getOptions(this.parentNode) var index = options.indexOf(this) if (index > -1) this.parentNode.selectedIndex = index } else this.parentNode.selectedIndex = 0 }, enumerable: true, }) } return element }, createElementNS: function(ns, tag, is) { var element = this.createElement(tag, is) element.nodeName = tag element.namespaceURI = ns return element }, createTextNode: function(text) { /*eslint-disable no-implicit-coercion*/ var nodeValue = "" + text /*eslint-enable no-implicit-coercion*/ return { nodeType: 3, nodeName: "#text", parentNode: null, get childNodes() { return [] }, get firstChild() { return null }, get nodeValue() {return nodeValue}, set nodeValue(value) { /*eslint-disable no-implicit-coercion*/ nodeValue = "" + value /*eslint-enable no-implicit-coercion*/ }, get nextSibling() { if (this.parentNode == null) return null var index = this.parentNode.childNodes.indexOf(this) if (index < 0) throw new TypeError("Parent's childNodes is out of sync") return this.parentNode.childNodes[index + 1] || null }, } }, createDocumentFragment: function() { return { nodeType: 11, nodeName: "#document-fragment", appendChild: appendChild, insertBefore: insertBefore, removeChild: removeChild, parentNode: null, childNodes: [], get firstChild() { return this.childNodes[0] || null }, } }, createEvent: function() { return { eventPhase: 0, initEvent: function(type) {this.type = type} } }, get activeElement() {return activeElement}, }, } $window.document.documentElement = $window.document.createElement("html") appendChild.call($window.document.documentElement, $window.document.createElement("head")) $window.document.body = $window.document.createElement("body") appendChild.call($window.document.documentElement, $window.document.body) activeElement = $window.document.body if (options.spy) $window.__getSpies = getSpies return $window }