diff --git a/test-utils/domMock.js b/test-utils/domMock.js index b8f5d1a3..a4c7c598 100644 --- a/test-utils/domMock.js +++ b/test-utils/domMock.js @@ -37,6 +37,30 @@ module.exports = function(options) { 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 @@ -260,35 +284,99 @@ module.exports = function(options) { else this.setAttribute("class", value) }, focus: function() {activeElement = this}, - addEventListener: function(type, callback) { - if (events[type] == null) events[type] = [callback] - else events[type].push(callback) + 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, callback) { + 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) { - var index = events[type].indexOf(callback) - if (index > -1) events[type].splice(index, 1) + 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) { - if (this.nodeName === "INPUT" && this.attributes["type"] != null && this.attributes["type"].value === "checkbox" && e.type === "click") { - this.checked = !this.checked + var parents = [] + if (this.parentNode != null) { + var parent = this.parentNode + do { + parents.push(parent) + parent = parent.parentNode + } while (parent != null) } - e.target = this - if (events[e.type] != null) { - for (var i = 0; i < events[e.type].length; i++) { - var handler = events[e.type][i] - if (typeof handler === "function") handler.call(this, e) - else handler.handleEvent(e) + var prevented = false + e.preventDefault = function() { + prevented = true + } + 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 + } + } + } catch(e) { + throw e + } 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 + } } } - e.preventDefault = function() { - // TODO: should this do something? - } - if (typeof this["on" + e.type] === "function" && !isModernEvent(e.type)) this["on" + e.type](e) + }, onclick: null, + _events: events } if (element.nodeName === "A") { @@ -515,7 +603,8 @@ module.exports = function(options) { }, createEvent: function() { return { - initEvent: function(type) {this.type = type}, + eventPhase: 0, + initEvent: function(type) {this.type = type} } }, get activeElement() {return activeElement}, diff --git a/test-utils/tests/test-domMock.js b/test-utils/tests/test-domMock.js index 3e4a9567..f05d24c9 100644 --- a/test-utils/tests/test-domMock.js +++ b/test-utils/tests/test-domMock.js @@ -611,13 +611,57 @@ o.spec("domMock", function() { o(spy.args[0].type).equals("click") o(spy.args[0].target).equals(div) }) - o("removeEventListener works", function(done) { + o("removeEventListener works (bubbling phase)", function() { div.addEventListener("click", spy, false) div.removeEventListener("click", spy, false) div.dispatchEvent(e) o(spy.callCount).equals(0) - done() + }) + o("removeEventListener works (capture phase)", function() { + div.addEventListener("click", spy, true) + div.removeEventListener("click", spy, true) + div.dispatchEvent(e) + + o(spy.callCount).equals(0) + }) + o("removeEventListener is selective (bubbling phase)", function() { + var other = o.spy() + div.addEventListener("click", spy, false) + div.addEventListener("click", other, false) + div.removeEventListener("click", spy, false) + div.dispatchEvent(e) + + o(spy.callCount).equals(0) + o(other.callCount).equals(1) + }) + o("removeEventListener is selective (capture phase)", function() { + var other = o.spy() + div.addEventListener("click", spy, true) + div.addEventListener("click", other, true) + div.removeEventListener("click", spy, true) + div.dispatchEvent(e) + + o(spy.callCount).equals(0) + o(other.callCount).equals(1) + }) + o("removeEventListener only removes the handler related to a given phase (1/2)", function() { + spy = o.spy(function(e) {o(e.eventPhase).equals(3)}) + $document.body.addEventListener("click", spy, true) + $document.body.addEventListener("click", spy, false) + $document.body.removeEventListener("click", spy, true) + div.dispatchEvent(e) + + o(spy.callCount).equals(1) + }) + o("removeEventListener only removes the handler related to a given phase (2/2)", function() { + spy = o.spy(function(e) {o(e.eventPhase).equals(1)}) + $document.body.addEventListener("click", spy, true) + $document.body.addEventListener("click", spy, false) + $document.body.removeEventListener("click", spy, false) + div.dispatchEvent(e) + + o(spy.callCount).equals(1) }) o("click fires onclick", function() { div.onclick = spy @@ -655,6 +699,488 @@ o.spec("domMock", function() { done() }) }) + o.spec("capture and bubbling phases", function() { + var div, e + o.beforeEach(function() { + div = $document.createElement("div") + e = $document.createEvent("MouseEvents") + e.initEvent("click", true, true) + + $document.body.appendChild(div) + }) + o.afterEach(function() { + $document.body.removeChild(div) + }) + o("capture and bubbling events both fire on the target in the order they were defined, regardless of the phase", function () { + var sequence = [] + var capture = o.spy(function(ev){ + sequence.push("capture") + + o(ev).equals(e) + o(ev.eventPhase).equals(2) + o(ev.target).equals(div) + o(ev.currentTarget).equals(div) + }) + var bubble = o.spy(function(ev){ + sequence.push("bubble") + + o(ev).equals(e) + o(ev.eventPhase).equals(2) + o(ev.target).equals(div) + o(ev.currentTarget).equals(div) + }) + + div.addEventListener("click", bubble, false) + div.addEventListener("click", capture, true) + div.dispatchEvent(e) + + o(capture.callCount).equals(1) + o(bubble.callCount).equals(1) + o(sequence).deepEquals(["bubble", "capture"]) + }) + o("capture and bubbling events both fire on the parent", function () { + var sequence = [] + var capture = o.spy(function(ev){ + sequence.push("capture") + + o(ev).equals(e) + o(ev.eventPhase).equals(1) + o(ev.target).equals(div) + o(ev.currentTarget).equals($document.body) + }) + var bubble = o.spy(function(ev){ + sequence.push("bubble") + + o(ev).equals(e) + o(ev.eventPhase).equals(3) + o(ev.target).equals(div) + o(ev.currentTarget).equals($document.body) + }) + + $document.body.addEventListener("click", bubble, false) + $document.body.addEventListener("click", capture, true) + div.dispatchEvent(e) + + o(capture.callCount).equals(1) + o(bubble.callCount).equals(1) + o(sequence).deepEquals(["capture", "bubble"]) + }) + o("useCapture defaults to false", function () { + var sequence = [] + var parent = o.spy(function(ev){ + sequence.push("parent") + + o(ev).equals(e) + o(ev.eventPhase).equals(3) + o(ev.target).equals(div) + o(ev.currentTarget).equals($document.body) + }) + var target = o.spy(function(ev){ + sequence.push("target") + + o(ev).equals(e) + o(ev.eventPhase).equals(2) + o(ev.target).equals(div) + o(ev.currentTarget).equals(div) + }) + + $document.body.addEventListener("click", parent) + div.addEventListener("click", target) + div.dispatchEvent(e) + + o(parent.callCount).equals(1) + o(target.callCount).equals(1) + o(sequence).deepEquals(["target", "parent"]) + }) + o("legacy handlers fire on the bubbling phase", function () { + var sequence = [] + var parent = o.spy(function(ev){ + sequence.push("parent") + + o(ev).equals(e) + o(ev.eventPhase).equals(3) + o(ev.target).equals(div) + o(ev.currentTarget).equals($document.body) + }) + var target = o.spy(function(ev){ + sequence.push("target") + + o(ev).equals(e) + o(ev.eventPhase).equals(2) + o(ev.target).equals(div) + o(ev.currentTarget).equals(div) + }) + + $document.body.addEventListener("click", parent) + $document.body.onclick = parent + div.addEventListener("click", target) + div.dispatchEvent(e) + + o(parent.callCount).equals(2) + o(target.callCount).equals(1) + o(sequence).deepEquals(["target", "parent", "parent"]) + }) + o("events do not propagate to child nodes", function() { + var target = o.spy(function(ev){ + o(ev).equals(e) + o(ev.eventPhase).equals(2) + o(ev.target).equals($document.body) + o(ev.currentTarget).equals($document.body) + }) + var child = o.spy(function(){ + }) + + $document.body.addEventListener("click", target) + div.addEventListener("click", child) + $document.body.dispatchEvent(e) + + o(target.callCount).equals(1) + o(child.callCount).equals(0) + }) + o("e.stopPropagation 1/6", function () { + var capParent = o.spy(function(e){e.stopPropagation()}) + var capTarget = o.spy() + var bubTarget = o.spy() + var legacyTarget = o.spy() + var bubParent = o.spy() + var legacyParent = o.spy() + + $document.body.addEventListener("click", capParent, true) + $document.body.addEventListener("click", bubParent, false) + $document.body.onclick = legacyParent + + div.addEventListener("click", capTarget, true) + div.addEventListener("click", bubTarget, false) + div.onclick = legacyTarget + + div.dispatchEvent(e) + + o(capParent.callCount).equals(1) + o(capTarget.callCount).equals(0) + o(bubTarget.callCount).equals(0) + o(legacyTarget.callCount).equals(0) + o(bubParent.callCount).equals(0) + o(legacyParent.callCount).equals(0) + }) + o("e.stopPropagation 2/6", function () { + var capParent = o.spy() + var capTarget = o.spy(function(e){e.stopPropagation()}) + var bubTarget = o.spy() + var legacyTarget = o.spy() + var bubParent = o.spy() + var legacyParent = o.spy() + + $document.body.addEventListener("click", capParent, true) + $document.body.addEventListener("click", bubParent, false) + $document.body.onclick = legacyParent + + div.addEventListener("click", capTarget, true) + div.addEventListener("click", bubTarget, false) + div.onclick = legacyTarget + + div.dispatchEvent(e) + + o(capParent.callCount).equals(1) + o(capTarget.callCount).equals(1) + o(bubTarget.callCount).equals(1) + o(legacyTarget.callCount).equals(1) + o(bubParent.callCount).equals(0) + o(legacyParent.callCount).equals(0) + }) + + o("e.stopPropagation 3/6", function () { + var capParent = o.spy() + var capTarget = o.spy() + var bubTarget = o.spy(function(e){e.stopPropagation()}) + var legacyTarget = o.spy() + var bubParent = o.spy() + var legacyParent = o.spy() + + $document.body.addEventListener("click", capParent, true) + $document.body.addEventListener("click", bubParent, false) + $document.body.onclick = legacyParent + + div.addEventListener("click", capTarget, true) + div.addEventListener("click", bubTarget, false) + div.onclick = legacyTarget + + div.dispatchEvent(e) + + o(capParent.callCount).equals(1) + o(capTarget.callCount).equals(1) + o(bubTarget.callCount).equals(1) + o(legacyTarget.callCount).equals(1) + o(bubParent.callCount).equals(0) + o(legacyParent.callCount).equals(0) + }) + o("e.stopPropagation 4/6", function () { + var capParent = o.spy() + var capTarget = o.spy() + var bubTarget = o.spy() + var legacyTarget = o.spy(function(e){e.stopPropagation()}) + var bubParent = o.spy() + var legacyParent = o.spy() + + $document.body.addEventListener("click", capParent, true) + $document.body.addEventListener("click", bubParent, false) + $document.body.onclick = legacyParent + + div.addEventListener("click", capTarget, true) + div.addEventListener("click", bubTarget, false) + div.onclick = legacyTarget + + div.dispatchEvent(e) + + o(capParent.callCount).equals(1) + o(capTarget.callCount).equals(1) + o(bubTarget.callCount).equals(1) + o(legacyTarget.callCount).equals(1) + o(bubParent.callCount).equals(0) + o(legacyParent.callCount).equals(0) + }) + o("e.stopPropagation 5/6", function () { + var capParent = o.spy() + var capTarget = o.spy() + var bubTarget = o.spy() + var legacyTarget = o.spy() + var bubParent = o.spy(function(e){e.stopPropagation()}) + var legacyParent = o.spy() + + $document.body.addEventListener("click", capParent, true) + $document.body.addEventListener("click", bubParent, false) + $document.body.onclick = legacyParent + + div.addEventListener("click", capTarget, true) + div.addEventListener("click", bubTarget, false) + div.onclick = legacyTarget + + div.dispatchEvent(e) + + o(capParent.callCount).equals(1) + o(capTarget.callCount).equals(1) + o(bubTarget.callCount).equals(1) + o(legacyTarget.callCount).equals(1) + o(bubParent.callCount).equals(1) + o(legacyParent.callCount).equals(1) + }) + o("e.stopPropagation 6/6", function () { + var capParent = o.spy() + var capTarget = o.spy() + var legacyTarget = o.spy() + var bubTarget = o.spy() + var bubParent = o.spy() + var legacyParent = o.spy(function(e){e.stopPropagation()}) + + $document.body.addEventListener("click", capParent, true) + $document.body.addEventListener("click", bubParent, false) + $document.body.onclick = legacyParent + + div.addEventListener("click", capTarget, true) + div.addEventListener("click", bubTarget, false) + div.onclick = legacyTarget + + div.dispatchEvent(e) + + o(capParent.callCount).equals(1) + o(capTarget.callCount).equals(1) + o(bubTarget.callCount).equals(1) + o(legacyTarget.callCount).equals(1) + o(bubParent.callCount).equals(1) + o(legacyParent.callCount).equals(1) + }) + o("e.stopImmediatePropagation 1/6", function () { + var capParent = o.spy(function(e){e.stopImmediatePropagation()}) + var capTarget = o.spy() + var bubTarget = o.spy() + var legacyTarget = o.spy() + var bubParent = o.spy() + var legacyParent = o.spy() + + $document.body.addEventListener("click", capParent, true) + $document.body.addEventListener("click", bubParent, false) + $document.body.onclick = legacyParent + + div.addEventListener("click", capTarget, true) + div.addEventListener("click", bubTarget, false) + div.onclick = legacyTarget + + div.dispatchEvent(e) + + o(capParent.callCount).equals(1) + o(capTarget.callCount).equals(0) + o(bubTarget.callCount).equals(0) + o(legacyTarget.callCount).equals(0) + o(bubParent.callCount).equals(0) + o(legacyParent.callCount).equals(0) + }) + o("e.stopImmediatePropagation 2/6", function () { + var capParent = o.spy() + var capTarget = o.spy(function(e){e.stopImmediatePropagation()}) + var bubTarget = o.spy() + var legacyTarget = o.spy() + var bubParent = o.spy() + var legacyParent = o.spy() + + $document.body.addEventListener("click", capParent, true) + $document.body.addEventListener("click", bubParent, false) + $document.body.onclick = legacyParent + + div.addEventListener("click", capTarget, true) + div.addEventListener("click", bubTarget, false) + div.onclick = legacyTarget + + div.dispatchEvent(e) + + o(capParent.callCount).equals(1) + o(capTarget.callCount).equals(1) + o(bubTarget.callCount).equals(0) + o(legacyTarget.callCount).equals(0) + o(bubParent.callCount).equals(0) + o(legacyParent.callCount).equals(0) + }) + + o("e.stopImmediatePropagation 3/6", function () { + var capParent = o.spy() + var capTarget = o.spy() + var bubTarget = o.spy(function(e){e.stopImmediatePropagation()}) + var legacyTarget = o.spy() + var bubParent = o.spy() + var legacyParent = o.spy() + + $document.body.addEventListener("click", capParent, true) + $document.body.addEventListener("click", bubParent, false) + $document.body.onclick = legacyParent + + div.addEventListener("click", capTarget, true) + div.addEventListener("click", bubTarget, false) + div.onclick = legacyTarget + + div.dispatchEvent(e) + + o(capParent.callCount).equals(1) + o(capTarget.callCount).equals(1) + o(bubTarget.callCount).equals(1) + o(legacyTarget.callCount).equals(0) + o(bubParent.callCount).equals(0) + o(legacyParent.callCount).equals(0) + }) + o("e.stopImmediatePropagation 4/6", function () { + var capParent = o.spy() + var capTarget = o.spy() + var bubTarget = o.spy() + var legacyTarget = o.spy(function(e){e.stopImmediatePropagation()}) + var bubParent = o.spy() + var legacyParent = o.spy() + + $document.body.addEventListener("click", capParent, true) + $document.body.addEventListener("click", bubParent, false) + $document.body.onclick = legacyParent + + div.addEventListener("click", capTarget, true) + div.addEventListener("click", bubTarget, false) + div.onclick = legacyTarget + + div.dispatchEvent(e) + + o(capParent.callCount).equals(1) + o(capTarget.callCount).equals(1) + o(bubTarget.callCount).equals(1) + o(legacyTarget.callCount).equals(1) + o(bubParent.callCount).equals(0) + o(legacyParent.callCount).equals(0) + }) + o("e.stopImmediatePropagation 5/6", function () { + var capParent = o.spy() + var capTarget = o.spy() + var bubTarget = o.spy() + var legacyTarget = o.spy() + var bubParent = o.spy(function(e){e.stopImmediatePropagation()}) + var legacyParent = o.spy() + + $document.body.addEventListener("click", capParent, true) + $document.body.addEventListener("click", bubParent, false) + $document.body.onclick = legacyParent + + div.addEventListener("click", capTarget, true) + div.addEventListener("click", bubTarget, false) + div.onclick = legacyTarget + + div.dispatchEvent(e) + + o(capParent.callCount).equals(1) + o(capTarget.callCount).equals(1) + o(bubTarget.callCount).equals(1) + o(legacyTarget.callCount).equals(1) + o(bubParent.callCount).equals(1) + o(legacyParent.callCount).equals(0) + }) + o("e.stopImmediatePropagation 6/6", function () { + var capParent = o.spy() + var capTarget = o.spy() + var legacyTarget = o.spy() + var bubTarget = o.spy() + var bubParent = o.spy() + var legacyParent = o.spy(function(e){e.stopImmediatePropagation()}) + + $document.body.addEventListener("click", capParent, true) + $document.body.addEventListener("click", bubParent, false) + $document.body.onclick = legacyParent + + div.addEventListener("click", capTarget, true) + div.addEventListener("click", bubTarget, false) + div.onclick = legacyTarget + + div.dispatchEvent(e) + + o(capParent.callCount).equals(1) + o(capTarget.callCount).equals(1) + o(bubTarget.callCount).equals(1) + o(legacyTarget.callCount).equals(1) + o(bubParent.callCount).equals(1) + o(legacyParent.callCount).equals(1) + }) + o("errors thrown in handlers don't interrupt the chain", function(done) { + var errMsg = "The presence of these six errors in the log is expected in non-Node.js environments" + var handler = o.spy(function(){throw errMsg}) + + $document.body.addEventListener("click", handler, true) + $document.body.addEventListener("click", handler, false) + $document.body.onclick = handler + + div.addEventListener("click", handler, true) + div.addEventListener("click", handler, false) + div.onclick = handler + + div.dispatchEvent(e) + + o(handler.callCount).equals(6) + + // Swallow the async errors in NodeJS + if (typeof process !== "undefined" && typeof process.once === "function"){ + process.once("uncaughtException", function(e) { + if (e !== errMsg) throw e + process.once("uncaughtException", function(e) { + if (e !== errMsg) throw e + process.once("uncaughtException", function(e) { + if (e !== errMsg) throw e + process.once("uncaughtException", function(e) { + if (e !== errMsg) throw e + process.once("uncaughtException", function(e) { + if (e !== errMsg) throw e + process.once("uncaughtException", function(e) { + if (e !== errMsg) throw e + done() + }) + }) + }) + }) + }) + }) + } else { + done() + } + }) + }) }) o.spec("attributes", function() { o.spec("a[href]", function() { @@ -731,6 +1257,18 @@ o.spec("domMock", function() { o(input.checked).equals(true) }) + o("doesn't toggle on click when preventDefault() is used", function() { + var input = $document.createElement("input") + input.setAttribute("type", "checkbox") + input.checked = false + input.onclick = function(e) {e.preventDefault()} + + var e = $document.createEvent("MouseEvents") + e.initEvent("click", true, true) + input.dispatchEvent(e) + + o(input.checked).equals(false) + }) }) o.spec("input[value]", function() { o("only exists in input elements", function() {