mithril-vndb/test-utils/domMock.js
Isiah Meadows d4551f49f5
Add streams to releases again, include minified bundle, drop in… (#2539)
* Minify stream, add stream stuff to releases again

* Kill off a lot of tech debt, drop internal utilities from npm

1. Kill `module/`, internalize `bundler/`, privatize `test-utils/`

We've been telling people to move elsewhere from these for a while, and
it's about time we just pull the plug here and finally remove them.

- We officially removed the bundler from the public API in v2.0, and
  that was the only one of these that was ever publicly documented.
  Usage should be low enough by now it shouldn't break anyone- I'm not
  seeing bundler bugs being reported anymore, either.
- The `module/` utility was so narrow and caveat-filled that I'm not
  sure anyone really used it (even us core Mithril devs never really
  used it), and we only had it documented in the repo folder it lived
  in. I think only one bug was ever filed, and it's because it somehow
  ended up completely non-functional without any of us realizing it.
- The test utilities were meant to be internal from day 1, but people
  started using it despite us core developers constantly telling people
  to look elsewhere and even the docs recommending specific alternatives
  without mention of our internal mocks. (Now if people would RTFM,
  that'd be nice...)

2. Add dedicated HTML test files to verify ospec and the promise
   polyfill, and ensure the promise tests are in pure ES5.

These are made specially for those and should be much easier to just run
now.

3. Fix the benchmark script to use the real DOM in browsers and to not
   require as many dependencies to create. Also, tweak them to be much
   more effective and precise on what's being tested.

Previously, it was rendering to the HTML file itself, while now it's
rendering to the `body`. This means in browsers, it's triggering layout
and everything, benchmarking how well Mithril optimizes for style and
layout recalcs, too. It also puts some pressure on the hyperscript
parser attribute application, so that can be noticed as well.

* Update dependencies
2019-10-03 04:58:14 -04:00

717 lines
24 KiB
JavaScript

"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 xmlns="http:\/\/www\.w3\.org\/2000\/svg">(.*)<\/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 xmlns="http:\/\/www\.w3\.org\/2000\/svg">(.*)<\/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
}