From 35081f529d1ecd3c256fe764aca9f190bd7fd527 Mon Sep 17 00:00:00 2001 From: Drew Willcoxon Date: Thu, 26 Sep 2019 01:20:07 +0000 Subject: [PATCH] Bug 1578584 - Quantumbar WebExt API: Add onResultPicked event. r=harry,mixedpuppy Adds a new event listener to `browser.urlbar` called `onResultPicked`. This event is fired for tip results when they don't specify a URL. Hypothetically it could be fired for any type of result that didn't specify a URL, but that's only tips for now. The listener is passed two arguments: the payload of the result that was picked, and a "details" object whose properties depend on the type of result. For tips, details is `{ helpPicked }`, where `helpPicked` is true if the help button was picked and false if the main button was picked. Differential Revision: https://phabricator.services.mozilla.com/D46254 --HG-- extra : source : febf4480bc0bce19a0c8883e0c9296c40013e01e --- .../extensions/parent/ext-urlbar.js | 17 ++ .../components/extensions/schemas/urlbar.json | 25 ++ .../extensions/test/browser/browser.ini | 1 + .../test/browser/browser_ext_urlbar.js | 215 ++++++++++++++++++ browser/components/urlbar/UrlbarInput.jsm | 16 +- .../urlbar/UrlbarProviderExtension.jsm | 22 +- .../urlbar/UrlbarProvidersManager.jsm | 1 + browser/components/urlbar/UrlbarUtils.jsm | 19 ++ 8 files changed, 307 insertions(+), 9 deletions(-) create mode 100644 browser/components/extensions/test/browser/browser_ext_urlbar.js diff --git a/browser/components/extensions/parent/ext-urlbar.js b/browser/components/extensions/parent/ext-urlbar.js index 059b25a8118c..3eb90c386fb1 100644 --- a/browser/components/extensions/parent/ext-urlbar.js +++ b/browser/components/extensions/parent/ext-urlbar.js @@ -192,6 +192,23 @@ this.urlbar = class extends ExtensionAPI { }, }).api(), + onResultPicked: new EventManager({ + context, + name: "urlbar.onResultPicked", + register: (fire, providerName) => { + let provider = UrlbarProviderExtension.getOrCreate(providerName); + provider.setEventListener( + "resultPicked", + async (resultPayload, details) => { + return fire.async(resultPayload, details).catch(error => { + throw context.normalizeError(error); + }); + } + ); + return () => provider.setEventListener("resultPicked", null); + }, + }).api(), + openViewOnFocus: getSettingsAPI( context.extension.id, "openViewOnFocus", diff --git a/browser/components/extensions/schemas/urlbar.json b/browser/components/extensions/schemas/urlbar.json index 8f1f7a23e146..3b988223aa06 100644 --- a/browser/components/extensions/schemas/urlbar.json +++ b/browser/components/extensions/schemas/urlbar.json @@ -166,6 +166,31 @@ }, "description": "The results that the provider fetched for the query." } + }, + { + "name": "onResultPicked", + "type": "function", + "description": "Typically, a provider includes a url property in its results' payloads. When the user picks a result with a URL, Firefox automatically loads the URL. URLs don't make sense for every result type, however. When the user picks a result without a URL, this event is fired. The provider should take an appropriate action in response. Currently the only applicable ResultType is tip.", + "parameters": [ + { + "name": "payload", + "type": "object", + "description": "The payload of the result that was picked." + }, + { + "name": "details", + "type": "object", + "description": "Details about the pick. The specific properties depend on the result type." + } + ], + "extraParameters": [ + { + "name": "providerName", + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$", + "description": "The listener will be called for the results of the provider with this name." + } + ] } ] }, diff --git a/browser/components/extensions/test/browser/browser.ini b/browser/components/extensions/test/browser/browser.ini index 47152bacf120..4a433f240b3d 100644 --- a/browser/components/extensions/test/browser/browser.ini +++ b/browser/components/extensions/test/browser/browser.ini @@ -270,6 +270,7 @@ skip-if = os == 'mac' # Save as PDF not supported on Mac OS X [browser_ext_themes_validation.js] [browser_ext_topSites.js] [browser_ext_url_overrides_newtab.js] +[browser_ext_urlbar.js] [browser_ext_urlbar_contextual_tip.js] [browser_ext_user_events.js] [browser_ext_webRequest.js] diff --git a/browser/components/extensions/test/browser/browser_ext_urlbar.js b/browser/components/extensions/test/browser/browser_ext_urlbar.js new file mode 100644 index 000000000000..b798551cd9c2 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_urlbar.js @@ -0,0 +1,215 @@ +"use strict"; + +XPCOMUtils.defineLazyModuleGetters(this, { + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.jsm", +}); + +async function loadExtension(options = {}) { + let ext = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["urlbar"], + }, + isPrivileged: true, + background() { + browser.test.onMessage.addListener(options => { + browser.urlbar.onBehaviorRequested.addListener(query => { + return "restricting"; + }, "test"); + browser.urlbar.onResultsRequested.addListener(query => { + return [ + { + type: "tip", + source: "local", + heuristic: true, + payload: { + text: "Test", + buttonText: "OK", + data: "testData", + buttonUrl: options.buttonUrl, + helpUrl: options.helpUrl, + }, + }, + ]; + }, "test"); + browser.urlbar.onResultPicked.addListener((payload, details) => { + browser.test.assertEq(payload.text, "Test", "payload.text"); + browser.test.assertEq(payload.buttonText, "OK", "payload.buttonText"); + browser.test.assertEq(payload.data, "testData", "payload.data"); + browser.test.sendMessage("onResultPicked received", details); + }, "test"); + browser.test.sendMessage("ready"); + }); + }, + }); + await ext.startup(); + await Promise.all([ext.sendMessage(options), ext.awaitMessage("ready")]); + return ext; +} + +// Loads an extension without a main button URL and presses enter on the main +// button. +add_task(async function testOnResultPicked_mainButton_noURL_enter() { + let ext = await loadExtension(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + waitForFocus, + value: "test", + }); + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("KEY_Enter"); + let details = await ext.awaitMessage("onResultPicked received"); + Assert.deepEqual(details, { helpPicked: false }); + await ext.unload(); +}); + +// Loads an extension without a main button URL and clicks the main button. +add_task(async function testOnResultPicked_mainButton_noURL_mouse() { + let ext = await loadExtension(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + waitForFocus, + value: "test", + }); + let mainButton = document.querySelector( + "#urlbarView-row-0 .urlbarView-tip-button" + ); + Assert.ok(mainButton); + EventUtils.synthesizeMouseAtCenter(mainButton, {}); + let details = await ext.awaitMessage("onResultPicked received"); + Assert.deepEqual(details, { helpPicked: false }); + await ext.unload(); +}); + +// Loads an extension with a main button URL and presses enter on the main +// button. +add_task(async function testOnResultPicked_mainButton_url_enter() { + let ext = await loadExtension({ buttonUrl: "http://example.com/" }); + await BrowserTestUtils.withNewTab("about:blank", async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + waitForFocus, + value: "test", + }); + let loadedPromise = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser + ); + ext.onMessage("onResultPicked received", () => { + Assert.ok(false, "onResultPicked should not be called"); + }); + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("KEY_Enter"); + await loadedPromise; + Assert.equal(gBrowser.currentURI.spec, "http://example.com/"); + }); + await ext.unload(); +}); + +// Loads an extension with a main button URL and clicks the main button. +add_task(async function testOnResultPicked_mainButton_url_mouse() { + let ext = await loadExtension({ buttonUrl: "http://example.com/" }); + await BrowserTestUtils.withNewTab("about:blank", async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + waitForFocus, + value: "test", + }); + let mainButton = document.querySelector( + "#urlbarView-row-0 .urlbarView-tip-button" + ); + Assert.ok(mainButton); + let loadedPromise = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser + ); + ext.onMessage("onResultPicked received", () => { + Assert.ok(false, "onResultPicked should not be called"); + }); + EventUtils.synthesizeMouseAtCenter(mainButton, {}); + await loadedPromise; + Assert.equal(gBrowser.currentURI.spec, "http://example.com/"); + }); + await ext.unload(); +}); + +// Loads an extension without a help button URL and presses enter on the help +// button. +add_task(async function testOnResultPicked_helpButton_noURL_enter() { + let ext = await loadExtension(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + waitForFocus, + value: "test", + }); + EventUtils.synthesizeKey("KEY_ArrowDown", { repeat: 2 }); + EventUtils.synthesizeKey("KEY_Enter"); + let details = await ext.awaitMessage("onResultPicked received"); + Assert.deepEqual(details, { helpPicked: true }); + await ext.unload(); +}); + +// Loads an extension without a help button URL and clicks the help button. +add_task(async function testOnResultPicked_helpButton_noURL_mouse() { + let ext = await loadExtension(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + waitForFocus, + value: "test", + }); + let helpButton = document.querySelector( + "#urlbarView-row-0 .urlbarView-tip-help" + ); + Assert.ok(helpButton); + EventUtils.synthesizeMouseAtCenter(helpButton, {}); + let details = await ext.awaitMessage("onResultPicked received"); + Assert.deepEqual(details, { helpPicked: true }); + await ext.unload(); +}); + +// Loads an extension with a help button URL and presses enter on the help +// button. +add_task(async function testOnResultPicked_helpButton_url_enter() { + let ext = await loadExtension({ helpUrl: "http://example.com/" }); + await BrowserTestUtils.withNewTab("about:blank", async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + waitForFocus, + value: "test", + }); + let loadedPromise = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser + ); + ext.onMessage("onResultPicked received", () => { + Assert.ok(false, "onResultPicked should not be called"); + }); + EventUtils.synthesizeKey("KEY_ArrowDown", { repeat: 2 }); + EventUtils.synthesizeKey("KEY_Enter"); + await loadedPromise; + Assert.equal(gBrowser.currentURI.spec, "http://example.com/"); + }); + await ext.unload(); +}); + +// Loads an extension with a help button URL and clicks the help button. +add_task(async function testOnResultPicked_helpButton_url_mouse() { + let ext = await loadExtension({ helpUrl: "http://example.com/" }); + await BrowserTestUtils.withNewTab("about:blank", async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + waitForFocus, + value: "test", + }); + let helpButton = document.querySelector( + "#urlbarView-row-0 .urlbarView-tip-help" + ); + Assert.ok(helpButton); + let loadedPromise = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser + ); + ext.onMessage("onResultPicked received", () => { + Assert.ok(false, "onResultPicked should not be called"); + }); + EventUtils.synthesizeMouseAtCenter(helpButton, {}); + await loadedPromise; + Assert.equal(gBrowser.currentURI.spec, "http://example.com/"); + }); + await ext.unload(); +}); diff --git a/browser/components/urlbar/UrlbarInput.jsm b/browser/components/urlbar/UrlbarInput.jsm index 61b27d50f87b..dc6f42179f27 100644 --- a/browser/components/urlbar/UrlbarInput.jsm +++ b/browser/components/urlbar/UrlbarInput.jsm @@ -20,6 +20,7 @@ XPCOMUtils.defineLazyModuleGetters(this, { UrlbarController: "resource:///modules/UrlbarController.jsm", UrlbarEventBufferer: "resource:///modules/UrlbarEventBufferer.jsm", UrlbarPrefs: "resource:///modules/UrlbarPrefs.jsm", + UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.jsm", UrlbarQueryContext: "resource:///modules/UrlbarUtils.jsm", UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.jsm", UrlbarUtils: "resource:///modules/UrlbarUtils.jsm", @@ -653,10 +654,10 @@ class UrlbarInput { break; } case UrlbarUtils.RESULT_TYPE.TIP: { - if (element.classList.contains("urlbarView-tip-help")) { + let helpPicked = element.classList.contains("urlbarView-tip-help"); + if (helpPicked) { url = result.payload.helpUrl; } - if (!url) { this.handleRevert(); this.controller.engagementEvent.record(event, { @@ -664,11 +665,16 @@ class UrlbarInput { selIndex, selType: "tip", }); - - // TODO: Call out to UrlbarProvider.pickElement as part of bug 1578584. + let provider = UrlbarProvidersManager.getProvider( + result.providerName + ); + if (!provider) { + Cu.reportError(`Provider not found: ${result.providerName}`); + return; + } + provider.pickResult(result, { helpPicked }); return; } - break; } case UrlbarUtils.RESULT_TYPE.OMNIBOX: { diff --git a/browser/components/urlbar/UrlbarProviderExtension.jsm b/browser/components/urlbar/UrlbarProviderExtension.jsm index b2cd7ffa23a3..a995c7cffc67 100644 --- a/browser/components/urlbar/UrlbarProviderExtension.jsm +++ b/browser/components/urlbar/UrlbarProviderExtension.jsm @@ -213,24 +213,38 @@ class UrlbarProviderExtension extends UrlbarProvider { this._notifyListener("queryCanceled", context); } + /** + * This method is called when a result from the provider without a URL is + * picked, but currently only for tip results. The provider should handle the + * pick. + * + * @param {UrlbarResult} result + * The result that was picked. + * @param {object} details + * Details about the pick, depending on the result type. + */ + pickResult(result, details) { + this._notifyListener("resultPicked", result.payload, details); + } + /** * Calls a listener function set by the extension API implementation, if any. * * @param {string} eventName * The name of the listener to call (i.e., the name of the event to fire). - * @param {UrlbarQueryContext} context - * The query context relevant to the event. + * @param {arguments} args + * The arguments to pass to the listener. * @returns {*} * The value returned by the listener function, if any. */ - async _notifyListener(eventName, context) { + async _notifyListener(eventName, ...args) { let listener = this._eventListeners.get(eventName); if (!listener) { return undefined; } let result; try { - result = listener(context); + result = listener(...args); } catch (error) { Cu.reportError(error); return undefined; diff --git a/browser/components/urlbar/UrlbarProvidersManager.jsm b/browser/components/urlbar/UrlbarProvidersManager.jsm index 9f7093fb526f..4a61bc3aa812 100644 --- a/browser/components/urlbar/UrlbarProvidersManager.jsm +++ b/browser/components/urlbar/UrlbarProvidersManager.jsm @@ -367,6 +367,7 @@ class Query { return; } + match.providerName = provider.name; this.context.results.push(match); let notifyResults = () => { diff --git a/browser/components/urlbar/UrlbarUtils.jsm b/browser/components/urlbar/UrlbarUtils.jsm index 1cd3198cf2b3..756c712f5784 100644 --- a/browser/components/urlbar/UrlbarUtils.jsm +++ b/browser/components/urlbar/UrlbarUtils.jsm @@ -607,6 +607,7 @@ class UrlbarMuxer { get name() { return "UrlbarMuxerBase"; } + /** * Sorts queryContext results in-place. * @param {UrlbarQueryContext} queryContext the context to sort results for. @@ -630,6 +631,7 @@ class UrlbarProvider { get name() { return "UrlbarProviderBase"; } + /** * The type of the provider, must be one of UrlbarUtils.PROVIDER_TYPE. * @abstract @@ -637,6 +639,7 @@ class UrlbarProvider { get type() { throw new Error("Trying to access the base class, must be overridden"); } + /** * Whether this provider should be invoked for the given context. * If this method returns false, the providers manager won't start a query @@ -648,6 +651,7 @@ class UrlbarProvider { isActive(queryContext) { throw new Error("Trying to access the base class, must be overridden"); } + /** * Whether this provider wants to restrict results to just itself. * Other providers won't be invoked, unless this provider doesn't @@ -659,6 +663,7 @@ class UrlbarProvider { isRestricting(queryContext) { throw new Error("Trying to access the base class, must be overridden"); } + /** * Starts querying. * @param {UrlbarQueryContext} queryContext The query context object @@ -671,6 +676,7 @@ class UrlbarProvider { startQuery(queryContext, addCallback) { throw new Error("Trying to access the base class, must be overridden"); } + /** * Cancels a running query, * @param {UrlbarQueryContext} queryContext the query context object to cancel @@ -680,6 +686,19 @@ class UrlbarProvider { cancelQuery(queryContext) { throw new Error("Trying to access the base class, must be overridden"); } + + /** + * Called when a result from the provider without a URL is picked, but + * currently only for tip results. The provider should handle the pick. + * @param {UrlbarResult} result + * The result that was picked. + * @param {object} details + * Details about the pick, depending on the result type. + * @abstract + */ + pickResult(result, details) { + throw new Error("Trying to access the base class, must be overridden"); + } } /**