diff --git a/browser/base/content/test/urlbar/Panel.jsm b/browser/base/content/test/urlbar/Panel.jsm new file mode 100644 index 000000000000..ee1fd2ed93c9 --- /dev/null +++ b/browser/base/content/test/urlbar/Panel.jsm @@ -0,0 +1,252 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +this.EXPORTED_SYMBOLS = [ + "Panel", +]; + +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + +const { classes: Cc, interfaces: Ci, utils: Cu } = Components; + +Cu.import("resource://gre/modules/Timer.jsm"); + +this.Panel = function(panelElt, iframeURL) { + this.p = panelElt; + this.iframeURL = iframeURL; + this._initPanel(); + this.urlbar.addEventListener("keydown", this); + this.urlbar.addEventListener("input", this); + this._emitQueue = []; +}; + +this.Panel.prototype = { + + get document() { + return this.p.ownerDocument; + }, + + get window() { + return this.document.defaultView; + }, + + get urlbar() { + return this.window.gURLBar; + }, + + iframe: null, + + get iframeDocument() { + return this.iframe.contentDocument; + }, + + get iframeWindow() { + return this.iframe.contentWindow; + }, + + destroy() { + this.p.destroyAddonIframe(this); + this.urlbar.removeEventListener("keydown", this); + this.urlbar.removeEventListener("input", this); + }, + + _initPanel() { + this.iframe = this.p.initAddonIframe(this, { + _invalidate: this._invalidate.bind(this), + }); + if (!this.iframe) { + // This will be the case when somebody else already owns the iframe. + // First consumer wins right now. + return; + } + let onLoad = event => { + this.iframe.removeEventListener("load", onLoad, true); + this._initIframeContent(event.target.defaultView); + }; + this.iframe.addEventListener("load", onLoad, true); + this.iframe.setAttribute("src", this.iframeURL); + }, + + _initIframeContent(win) { + // Clone the urlbar API functions into the iframe window. + win = XPCNativeWrapper.unwrap(win); + let apiInstance = Cu.cloneInto(iframeAPIPrototype, win, { + cloneFunctions: true, + }); + apiInstance._panel = this; + Object.defineProperty(win, "urlbar", { + get() { + return apiInstance; + }, + }); + }, + + // This is called by the popup directly. It overrides the popup's own + // _invalidate method. + _invalidate() { + this._emit("reset"); + this._currentIndex = 0; + if (this._appendResultTimeout) { + this.window.clearTimeout(this._appendResultTimeout); + } + this._appendCurrentResult(); + }, + + // This emulates the popup's own _appendCurrentResult method, except instead + // of appending results to the popup, it emits "result" events to the iframe. + _appendCurrentResult() { + let controller = this.p.mInput.controller; + for (let i = 0; i < this.p.maxResults; i++) { + let idx = this._currentIndex; + if (idx >= this.p._matchCount) { + break; + } + let url = controller.getValueAt(idx); + let action = this.urlbar._parseActionUrl(url); + this._emit("result", { + url: url, + action: action, + image: controller.getImageAt(idx), + title: controller.getCommentAt(idx), + type: controller.getStyleAt(idx), + text: controller.searchString.replace(/^\s+/, "").replace(/\s+$/, ""), + }); + this._currentIndex++; + } + if (this._currentIndex < this.p.matchCount) { + this._appendResultTimeout = this.window.setTimeout(() => { + this._appendCurrentResult(); + }); + } + }, + + get height() { + return this.iframe.getBoundingClientRect().height; + }, + + set height(val) { + this.p.removeAttribute("height"); + this.iframe.style.height = val + "px"; + }, + + handleEvent(event) { + let methName = "_on" + event.type[0].toUpperCase() + event.type.substr(1); + this[methName](event); + }, + + _onKeydown(event) { + let emittedEvent = this._emitUrlbarEvent(event); + if (emittedEvent && emittedEvent.defaultPrevented) { + event.preventDefault(); + event.stopPropagation(); + } + }, + + _onInput(event) { + this._emitUrlbarEvent(event); + }, + + _emitUrlbarEvent(event) { + let properties = [ + "altKey", + "code", + "ctrlKey", + "key", + "metaKey", + "shiftKey", + ]; + let detail = properties.reduce((memo, prop) => { + memo[prop] = event[prop]; + return memo; + }, {}); + return this._emit(event.type, detail); + }, + + _emit(eventName, detailObj = null) { + this._emitQueue.push({ + name: eventName, + detail: detailObj, + }); + return this._processEmitQueue(); + }, + + _processEmitQueue() { + if (!this._emitQueue.length) { + return null; + } + + // iframe.contentWindow can be undefined right after the iframe is created, + // even after a number of seconds have elapsed. Don't know why. But that's + // entirely the reason for having a queue instead of simply dispatching + // events as they're created, unfortunately. + if (!this.iframeWindow) { + if (!this._processEmitQueueTimer) { + this._processEmitQueueTimer = setInterval(() => { + this._processEmitQueue(); + }, 100); + } + return null; + } + + if (this._processEmitQueueTimer) { + clearInterval(this._processEmitQueueTimer); + delete this._processEmitQueueTimer; + } + + let { name, detail } = this._emitQueue.shift(); + let win = XPCNativeWrapper.unwrap(this.iframeWindow); + let event = new this.iframeWindow.CustomEvent(name, { + detail: Cu.cloneInto(detail, win), + cancelable: true, + }); + this.iframeWindow.dispatchEvent(event); + + // More events may be queued up, so recurse. Do it after a turn of the + // event loop to avoid growing the stack as big as the queue, and to let the + // caller handle the returned event first. + setTimeout(() => { + this._processEmitQueue(); + }, 100); + + return event; + }, +}; + + +// This is the consumer API that's cloned into the iframe window. Be careful of +// defining static values on this, or even getters and setters (that aren't real +// functions). The cloning process means that such values are copied by value, +// at the time of cloning, which is probably not what you want. That's why some +// of these are functions even though it'd be nicer if they were getters and +// setters. +let iframeAPIPrototype = { + + getPanelHeight() { + return this._panel.height; + }, + + setPanelHeight(val) { + this._panel.height = val; + }, + + getValue() { + return this._panel.urlbar.value; + }, + + setValue(val) { + this._panel.urlbar.value = val; + }, + + getMaxResults() { + return this._panel.p.maxResults; + }, + + setMaxResults(val) { + this._panel.p.maxResults = val; + }, + + enter() { + this._panel.urlbar.handleCommand(); + }, +}; diff --git a/browser/base/content/test/urlbar/browser.ini b/browser/base/content/test/urlbar/browser.ini index 02b1f90345bf..5e8783e98fb8 100644 --- a/browser/base/content/test/urlbar/browser.ini +++ b/browser/base/content/test/urlbar/browser.ini @@ -49,6 +49,12 @@ support-files = moz.png [browser_tabMatchesInAwesomebar_perwindowpb.js] skip-if = os == 'linux' # Bug 1104755 +[browser_urlbarAddonIframe.js] +support-files = + Panel.jsm + urlbarAddonIframe.html + urlbarAddonIframe.js + urlbarAddonIframeContentScript.js [browser_urlbarAboutHomeLoading.js] [browser_urlbarAutoFillTrimURLs.js] [browser_urlbarCopying.js] diff --git a/browser/base/content/test/urlbar/browser_urlbarAddonIframe.js b/browser/base/content/test/urlbar/browser_urlbarAddonIframe.js new file mode 100644 index 000000000000..05c54ba251f3 --- /dev/null +++ b/browser/base/content/test/urlbar/browser_urlbarAddonIframe.js @@ -0,0 +1,220 @@ +"use strict"; + +// The purpose of this test is to test the urlbar popup's add-on iframe. It has +// a few parts: +// +// (1) This file, a normal browser mochitest. +// (2) html/js files that are loaded in the urlbar popup's add-on iframe: +// urlbarAddonIframe.{html,js} +// (3) A content script that mediates between the first two parts: +// urlbarAddonIframeContentScript.js +// +// The main test file (this file) sends messages to the content script, which +// forwards them as events to the iframe. These messages tell the iframe js to +// do various things like call functions on the urlbar API and expect events. +// In response, the iframe js dispatches ack events to the content script, which +// forwards them as messages to the main test file. +// +// The content script may not be necessary right now since the iframe is not +// remote. But this structure ensures that if the iframe is made remote in the +// future, then the test won't have to change very much, and ideally not at all. +// +// Actually there's one other part: +// +// (4) The Panel.jsm that's bundled with add-ons that use the iframe. +// +// Panel.jsm defines the API that's made available to add-on scripts running in +// the iframe. This API is orthogonal to the add-on iframe itself. You could +// load any html/js in the iframe, technically. But the purpose of the iframe +// is to support this Panel.jsm API, so that's what this test tests. + +const PANEL_JSM_BASENAME = "Panel.jsm"; +const IFRAME_BASENAME = "urlbarAddonIframe.html"; +const CONTENT_SCRIPT_BASENAME = "urlbarAddonIframeContentScript.js"; + +// The iframe's message manager. +let gMsgMan; + +add_task(function* () { + let rootDirURL = getRootDirectory(gTestPath); + let jsmURL = rootDirURL + PANEL_JSM_BASENAME; + let iframeURL = rootDirURL + IFRAME_BASENAME; + let contentScriptURL = rootDirURL + CONTENT_SCRIPT_BASENAME; + + let { Panel } = Cu.import(jsmURL, {}); + let panel = new Panel(gURLBar.popup, iframeURL); + registerCleanupFunction(() => { + panel.destroy(); + Assert.ok(gURLBar.popup._addonIframe === null, "iframe should be gone"); + }); + + let iframe = gURLBar.popup._addonIframe; + Assert.ok(!!iframe, "iframe should not be null"); + + gMsgMan = + iframe.QueryInterface(Ci.nsIFrameLoaderOwner).frameLoader.messageManager; + gMsgMan.loadFrameScript(contentScriptURL, false); + + yield promiseIframeLoad(); + + // urlbar.getValue + let value = "this value set by the test"; + gURLBar.value = value; + let readValue = yield promiseUrlbarFunctionCall("getValue"); + Assert.equal(readValue, value, "value"); + + // urlbar.setValue + value = "this value set by the iframe"; + yield promiseUrlbarFunctionCall("setValue", value); + Assert.equal(gURLBar.value, value, "setValue"); + + // urlbar.getMaxResults + let maxResults = gURLBar.popup.maxResults; + Assert.equal(typeof(maxResults), "number", "Sanity check"); + let readMaxResults = yield promiseUrlbarFunctionCall("getMaxResults"); + Assert.equal(readMaxResults, maxResults, "getMaxResults"); + + // urlbar.setMaxResults + let newMaxResults = maxResults + 10; + yield promiseUrlbarFunctionCall("setMaxResults", newMaxResults); + Assert.equal(gURLBar.popup.maxResults, newMaxResults, "setMaxResults"); + gURLBar.popup.maxResults = maxResults; + + // urlbar.enter + value = "http://mochi.test:8888/"; + yield promiseUrlbarFunctionCall("setValue", value); + Assert.equal(gURLBar.value, value, "setValue"); + yield promiseUrlbarFunctionCall("enter"); + let browser = gBrowser.selectedBrowser; + yield BrowserTestUtils.browserLoaded(browser); + Assert.equal(browser.currentURI.spec, value, + "enter should have loaded the URL"); + + // input, reset, and result events. There should always be at least one + // result, the heuristic result. + value = "test"; + let promiseValues = yield Promise.all([ + promiseEvent("input")[1], + promiseEvent("reset")[1], + promiseEvent("result")[1], + promiseAutocompleteResultPopup(value, window, true), + ]); + + // Check the heuristic result. + let result = promiseValues[2]; + let engineName = Services.search.currentEngine.name; + Assert.equal(result.url, + `moz-action:searchengine,{"engineName":"${engineName}","input":"test","searchQuery":"test"}`, + "result.url"); + Assert.ok("action" in result, "result.action"); + Assert.equal(result.action.type, "searchengine", "result.action.type"); + Assert.ok("params" in result.action, "result.action.params"); + Assert.equal(result.action.params.engineName, engineName, + "result.action.params.engineName"); + Assert.equal(typeof(result.image), "string", "result.image"); + Assert.equal(result.title, engineName, "result.title"); + Assert.equal(result.type, "action searchengine heuristic", "result.type"); + Assert.equal(result.text, value, "result.text"); + + // keydown event. promiseEvent sends an async message to the iframe, but + // synthesizeKey is sync, so we need to wait until the content JS receives + // the message and adds its event listener before synthesizing the key. + let keydownPromises = promiseEvent("keydown"); + yield keydownPromises[0]; + EventUtils.synthesizeKey("KEY_ArrowDown", { + type: "keydown", + code: "ArrowDown", + }); + yield keydownPromises[1]; + + // urlbar.getPanelHeight + let height = iframe.getBoundingClientRect().height; + let readHeight = yield promiseUrlbarFunctionCall("getPanelHeight"); + Assert.equal(readHeight, height, "getPanelHeight"); + + // urlbar.setPanelHeight + let newHeight = height + 100; + yield promiseUrlbarFunctionCall("setPanelHeight", newHeight); + yield new Promise(resolve => { + // The height change is animated, so give it time to complete. Again, wait + // a sec to be safe. + setTimeout(resolve, 1000); + }); + Assert.equal(iframe.getBoundingClientRect().height, newHeight, + "setPanelHeight"); +}); + +function promiseIframeLoad() { + let msgName = "TestIframeLoadAck"; + return new Promise(resolve => { + info("Waiting for iframe load ack"); + gMsgMan.addMessageListener(msgName, function onMsg(msg) { + info("Received iframe load ack"); + gMsgMan.removeMessageListener(msgName, onMsg); + resolve(); + }); + }); +} + +/** + * Returns a single promise that's resolved when the content JS has called the + * function. + */ +function promiseUrlbarFunctionCall(...args) { + return promiseMessage("function", args)[0]; +} + +/** + * Returns two promises in an array. The first is resolved when the content JS + * has added its event listener. The second is resolved when the content JS + * has received the event. + */ +function promiseEvent(type) { + return promiseMessage("event", type, 2); +} + +let gNextMessageID = 1; + +/** + * Returns an array of promises, one per ack. Each is resolved when the content + * JS acks the message. numExpectedAcks is the number of acks you expect. + */ +function promiseMessage(type, data, numExpectedAcks = 1) { + let testMsgName = "TestMessage"; + let ackMsgName = "TestMessageAck"; + let msgID = gNextMessageID++; + gMsgMan.sendAsyncMessage(testMsgName, { + type: type, + messageID: msgID, + data: data, + }); + let ackPromises = []; + for (let i = 0; i < numExpectedAcks; i++) { + let ackIndex = i; + ackPromises.push(new Promise(resolve => { + info("Waiting for message ack: " + JSON.stringify({ + type: type, + msgID: msgID, + ackIndex: ackIndex, + })); + gMsgMan.addMessageListener(ackMsgName, function onMsg(msg) { + // Messages have IDs so that an ack can be correctly paired with the + // initial message it's replying to. It's not an error if the ack's ID + // isn't equal to msgID here. That will happen when multiple messages + // have been sent in a single turn of the event loop so that they're all + // waiting on acks. Same goes for ackIndex. + if (msg.data.messageID != msgID || msg.data.ackIndex != ackIndex) { + return; + } + info("Received message ack: " + JSON.stringify({ + type: type, + msgID: msg.data.messageID, + ackIndex: ackIndex, + })); + gMsgMan.removeMessageListener(ackMsgName, onMsg); + resolve(msg.data.data); + }); + })); + } + return ackPromises; +} diff --git a/browser/base/content/test/urlbar/urlbarAddonIframe.html b/browser/base/content/test/urlbar/urlbarAddonIframe.html new file mode 100644 index 000000000000..45d553d52dd1 --- /dev/null +++ b/browser/base/content/test/urlbar/urlbarAddonIframe.html @@ -0,0 +1,8 @@ + +
+ + + + Hello + + diff --git a/browser/base/content/test/urlbar/urlbarAddonIframe.js b/browser/base/content/test/urlbar/urlbarAddonIframe.js new file mode 100644 index 000000000000..d25ab0bc95c4 --- /dev/null +++ b/browser/base/content/test/urlbar/urlbarAddonIframe.js @@ -0,0 +1,52 @@ +// Listen for messages from the test. +addEventListener("TestEvent", event => { + let type = event.detail.type; + dump("urlbarAddonIframe.js got TestEvent, type=" + type + + " messageID=" + event.detail.messageID + "\n"); + switch (type) { + case "function": + callUrlbarFunction(event.detail); + break; + case "event": + expectEvent(event.detail); + break; + } +}); + +// Calls a urlbar API function. +function callUrlbarFunction(detail) { + let args = detail.data; + let methodName = args.shift(); + dump("urlbarAddonIframe.js calling urlbar." + methodName + "\n"); + let rv = urlbar[methodName](...args); + ack(detail, rv); +} + +// Waits for an event of a specified type to happen. +function expectEvent(detail) { + let type = detail.data; + dump("urlbarAddonIframe.js expecting event of type " + type + "\n"); + // Ack that the message was received and an event listener was added. + ack(detail, null, 0); + addEventListener(type, function onEvent(event) { + dump("urlbarAddonIframe.js got event of type " + type + "\n"); + if (event.type != type) { + return; + } + dump("urlbarAddonIframe.js got expected event\n"); + removeEventListener(type, onEvent); + // Ack that the event was received. + ack(detail, event.detail, 1); + }); +} + +// Sends an ack to the test. +function ack(originalEventDetail, ackData = null, ackIndex = 0) { + dispatchEvent(new CustomEvent("TestEventAck", { + detail: { + messageID: originalEventDetail.messageID, + ackIndex: ackIndex, + data: ackData, + }, + })); +} diff --git a/browser/base/content/test/urlbar/urlbarAddonIframeContentScript.js b/browser/base/content/test/urlbar/urlbarAddonIframeContentScript.js new file mode 100644 index 000000000000..d37156befea8 --- /dev/null +++ b/browser/base/content/test/urlbar/urlbarAddonIframeContentScript.js @@ -0,0 +1,23 @@ +// Forward messages from the test to the iframe as events. +addMessageListener("TestMessage", msg => { + content.dispatchEvent(new content.CustomEvent("TestEvent", { + detail: Components.utils.cloneInto(msg.data, content), + })); +}); + +// Forward events from the iframe to the test as messages. +addEventListener("TestEventAck", event => { + // The waiveXrays call is copied from the contentSearch.js part of + // browser_ContentSearch.js test. Not sure whether it's necessary here. + sendAsyncMessage("TestMessageAck", Components.utils.waiveXrays(event.detail)); +}, true, true); + +// Send a message to the test when the iframe is loaded. +if (content.document.readyState == "complete") { + sendAsyncMessage("TestIframeLoadAck"); +} else { + addEventListener("load", function onLoad(event) { + removeEventListener("load", onLoad); + sendAsyncMessage("TestIframeLoadAck"); + }, true, true); +} diff --git a/browser/base/content/urlbarBindings.xml b/browser/base/content/urlbarBindings.xml index b49492b85407..cc07fcf610e1 100644 --- a/browser/base/content/urlbarBindings.xml +++ b/browser/base/content/urlbarBindings.xml @@ -1459,7 +1459,7 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. -