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 : moz-landing-system : lando
This commit is contained in:
Drew Willcoxon 2019-09-26 13:53:14 +00:00
Родитель b219613dd5
Коммит e505b9b12c
16 изменённых файлов: 346 добавлений и 9 удалений

Просмотреть файл

@ -192,6 +192,23 @@ this.urlbar = class extends ExtensionAPI {
}, },
}).api(), }).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( openViewOnFocus: getSettingsAPI(
context.extension.id, context.extension.id,
"openViewOnFocus", "openViewOnFocus",

Просмотреть файл

@ -166,6 +166,31 @@
}, },
"description": "The results that the provider fetched for the query." "description": "The results that the provider fetched for the query."
} }
},
{
"name": "onResultPicked",
"type": "function",
"description": "Typically, a provider includes a <code>url</code> 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 <code>ResultType</code> is <code>tip</code>.",
"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."
}
]
} }
] ]
}, },

Просмотреть файл

@ -270,6 +270,7 @@ skip-if = os == 'mac' # Save as PDF not supported on Mac OS X
[browser_ext_themes_validation.js] [browser_ext_themes_validation.js]
[browser_ext_topSites.js] [browser_ext_topSites.js]
[browser_ext_url_overrides_newtab.js] [browser_ext_url_overrides_newtab.js]
[browser_ext_urlbar.js]
[browser_ext_urlbar_contextual_tip.js] [browser_ext_urlbar_contextual_tip.js]
[browser_ext_user_events.js] [browser_ext_user_events.js]
[browser_ext_webRequest.js] [browser_ext_webRequest.js]

Просмотреть файл

@ -0,0 +1,242 @@
"use strict";
XPCOMUtils.defineLazyModuleGetters(this, {
UrlbarProviderExtension: "resource:///modules/UrlbarProviderExtension.jsm",
UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.jsm",
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");
});
},
});
await ext.startup();
ext.sendMessage(options);
// Wait for the provider to be registered before continuing. The provider
// will be registered once the parent process receives the first addListener
// call from the extension. There's no better way to do this, unfortunately.
// For example, if the extension sends a message to the test after it adds its
// listeners and then we wait here for that message, there's no guarantee that
// the addListener calls will have been received in the parent yet.
await BrowserTestUtils.waitForCondition(
() => UrlbarProvidersManager.getProvider("test"),
"Waiting for provider to be registered"
);
Assert.ok(
UrlbarProvidersManager.getProvider("test"),
"Provider should have been registered"
);
return ext;
}
add_task(async function setUp() {
// Set the notification timeout to a really high value to avoid intermittent
// failures due to the mock extensions not responding in time.
let originalTimeout = UrlbarProviderExtension.notificationTimeout;
UrlbarProviderExtension.notificationTimeout = 5000;
registerCleanupFunction(() => {
UrlbarProviderExtension.notificationTimeout = originalTimeout;
});
});
// 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();
});

Просмотреть файл

@ -20,6 +20,7 @@ XPCOMUtils.defineLazyModuleGetters(this, {
UrlbarController: "resource:///modules/UrlbarController.jsm", UrlbarController: "resource:///modules/UrlbarController.jsm",
UrlbarEventBufferer: "resource:///modules/UrlbarEventBufferer.jsm", UrlbarEventBufferer: "resource:///modules/UrlbarEventBufferer.jsm",
UrlbarPrefs: "resource:///modules/UrlbarPrefs.jsm", UrlbarPrefs: "resource:///modules/UrlbarPrefs.jsm",
UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.jsm",
UrlbarQueryContext: "resource:///modules/UrlbarUtils.jsm", UrlbarQueryContext: "resource:///modules/UrlbarUtils.jsm",
UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.jsm", UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.jsm",
UrlbarUtils: "resource:///modules/UrlbarUtils.jsm", UrlbarUtils: "resource:///modules/UrlbarUtils.jsm",
@ -653,10 +654,10 @@ class UrlbarInput {
break; break;
} }
case UrlbarUtils.RESULT_TYPE.TIP: { 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; url = result.payload.helpUrl;
} }
if (!url) { if (!url) {
this.handleRevert(); this.handleRevert();
this.controller.engagementEvent.record(event, { this.controller.engagementEvent.record(event, {
@ -664,11 +665,16 @@ class UrlbarInput {
selIndex, selIndex,
selType: "tip", selType: "tip",
}); });
let provider = UrlbarProvidersManager.getProvider(
// TODO: Call out to UrlbarProvider.pickElement as part of bug 1578584. result.providerName
);
if (!provider) {
Cu.reportError(`Provider not found: ${result.providerName}`);
return;
}
provider.pickResult(result, { helpPicked });
return; return;
} }
break; break;
} }
case UrlbarUtils.RESULT_TYPE.OMNIBOX: { case UrlbarUtils.RESULT_TYPE.OMNIBOX: {

Просмотреть файл

@ -213,24 +213,38 @@ class UrlbarProviderExtension extends UrlbarProvider {
this._notifyListener("queryCanceled", context); 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. * Calls a listener function set by the extension API implementation, if any.
* *
* @param {string} eventName * @param {string} eventName
* The name of the listener to call (i.e., the name of the event to fire). * The name of the listener to call (i.e., the name of the event to fire).
* @param {UrlbarQueryContext} context * @param {arguments} args
* The query context relevant to the event. * The arguments to pass to the listener.
* @returns {*} * @returns {*}
* The value returned by the listener function, if any. * The value returned by the listener function, if any.
*/ */
async _notifyListener(eventName, context) { async _notifyListener(eventName, ...args) {
let listener = this._eventListeners.get(eventName); let listener = this._eventListeners.get(eventName);
if (!listener) { if (!listener) {
return undefined; return undefined;
} }
let result; let result;
try { try {
result = listener(context); result = listener(...args);
} catch (error) { } catch (error) {
Cu.reportError(error); Cu.reportError(error);
return undefined; return undefined;

Просмотреть файл

@ -367,6 +367,7 @@ class Query {
return; return;
} }
match.providerName = provider.name;
this.context.results.push(match); this.context.results.push(match);
let notifyResults = () => { let notifyResults = () => {

Просмотреть файл

@ -607,6 +607,7 @@ class UrlbarMuxer {
get name() { get name() {
return "UrlbarMuxerBase"; return "UrlbarMuxerBase";
} }
/** /**
* Sorts queryContext results in-place. * Sorts queryContext results in-place.
* @param {UrlbarQueryContext} queryContext the context to sort results for. * @param {UrlbarQueryContext} queryContext the context to sort results for.
@ -630,6 +631,7 @@ class UrlbarProvider {
get name() { get name() {
return "UrlbarProviderBase"; return "UrlbarProviderBase";
} }
/** /**
* The type of the provider, must be one of UrlbarUtils.PROVIDER_TYPE. * The type of the provider, must be one of UrlbarUtils.PROVIDER_TYPE.
* @abstract * @abstract
@ -637,6 +639,7 @@ class UrlbarProvider {
get type() { get type() {
throw new Error("Trying to access the base class, must be overridden"); throw new Error("Trying to access the base class, must be overridden");
} }
/** /**
* Whether this provider should be invoked for the given context. * Whether this provider should be invoked for the given context.
* If this method returns false, the providers manager won't start a query * If this method returns false, the providers manager won't start a query
@ -648,6 +651,7 @@ class UrlbarProvider {
isActive(queryContext) { isActive(queryContext) {
throw new Error("Trying to access the base class, must be overridden"); throw new Error("Trying to access the base class, must be overridden");
} }
/** /**
* Whether this provider wants to restrict results to just itself. * Whether this provider wants to restrict results to just itself.
* Other providers won't be invoked, unless this provider doesn't * Other providers won't be invoked, unless this provider doesn't
@ -659,6 +663,7 @@ class UrlbarProvider {
isRestricting(queryContext) { isRestricting(queryContext) {
throw new Error("Trying to access the base class, must be overridden"); throw new Error("Trying to access the base class, must be overridden");
} }
/** /**
* Starts querying. * Starts querying.
* @param {UrlbarQueryContext} queryContext The query context object * @param {UrlbarQueryContext} queryContext The query context object
@ -671,6 +676,7 @@ class UrlbarProvider {
startQuery(queryContext, addCallback) { startQuery(queryContext, addCallback) {
throw new Error("Trying to access the base class, must be overridden"); throw new Error("Trying to access the base class, must be overridden");
} }
/** /**
* Cancels a running query, * Cancels a running query,
* @param {UrlbarQueryContext} queryContext the query context object to cancel * @param {UrlbarQueryContext} queryContext the query context object to cancel
@ -680,6 +686,19 @@ class UrlbarProvider {
cancelQuery(queryContext) { cancelQuery(queryContext) {
throw new Error("Trying to access the base class, must be overridden"); 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");
}
} }
/** /**

Просмотреть файл

@ -37,6 +37,7 @@ class TipTestProvider extends UrlbarProvider {
} }
} }
cancelQuery(context) {} cancelQuery(context) {}
pickResult(result, details) {}
} }
add_task(async function tipIsSecondResult() { add_task(async function tipIsSecondResult() {

Просмотреть файл

@ -844,6 +844,7 @@ class TipTestProvider extends UrlbarProvider {
} }
} }
cancelQuery(context) {} cancelQuery(context) {}
pickResult(result, details) {}
} }
let tipMatches = [ let tipMatches = [

Просмотреть файл

@ -107,6 +107,7 @@ class TipTestProvider extends UrlbarProvider {
} }
} }
cancelQuery(context) {} cancelQuery(context) {}
pickResult(result, details) {}
} }
const matches = [ const matches = [

Просмотреть файл

@ -138,4 +138,5 @@ class TestProvider extends UrlbarProvider {
} }
} }
cancelQuery(context) {} cancelQuery(context) {}
pickResult(result, details) {}
} }

Просмотреть файл

@ -134,6 +134,7 @@ class TestProvider extends UrlbarProvider {
this._cancelCallback(); this._cancelCallback();
} }
} }
pickResult(result, details) {}
} }
/** /**

Просмотреть файл

@ -66,6 +66,7 @@ class DelayedProvider extends UrlbarProvider {
this._resultsAdded(); this._resultsAdded();
} }
} }
pickResult(result, details) {}
} }
/** /**

Просмотреть файл

@ -189,6 +189,7 @@ add_task(async function test_filter_isActive() {
} }
} }
cancelQuery(context) {} cancelQuery(context) {}
pickResult(result, details) {}
} }
UrlbarProvidersManager.registerProvider(new NoInvokeProvider()); UrlbarProvidersManager.registerProvider(new NoInvokeProvider());
@ -235,6 +236,7 @@ add_task(async function test_filter_queryContext() {
Assert.ok(false, "Provider should no be invoked"); Assert.ok(false, "Provider should no be invoked");
} }
cancelQuery(context) {} cancelQuery(context) {}
pickResult(result, details) {}
} }
UrlbarProvidersManager.registerProvider(new NoInvokeProvider()); UrlbarProvidersManager.registerProvider(new NoInvokeProvider());
@ -346,6 +348,7 @@ add_task(async function test_nofilter_restrict() {
} }
} }
cancelQuery(context) {} cancelQuery(context) {}
pickResult(result, details) {}
} }
let provider = new TestProvider(); let provider = new TestProvider();
UrlbarProvidersManager.registerProvider(provider); UrlbarProvidersManager.registerProvider(provider);
@ -403,6 +406,7 @@ add_task(async function test_filter_isRestricting() {
Assert.ok(true, "expected provider was invoked"); Assert.ok(true, "expected provider was invoked");
} }
cancelQuery(context) {} cancelQuery(context) {}
pickResult(result, details) {}
} }
UrlbarProvidersManager.registerProvider(new TestProvider()); UrlbarProvidersManager.registerProvider(new TestProvider());
@ -426,6 +430,7 @@ add_task(async function test_filter_isRestricting() {
Assert.ok(false, "Provider should no be invoked"); Assert.ok(false, "Provider should no be invoked");
} }
cancelQuery(context) {} cancelQuery(context) {}
pickResult(result, details) {}
} }
UrlbarProvidersManager.registerProvider(new NoInvokeProvider()); UrlbarProvidersManager.registerProvider(new NoInvokeProvider());

Просмотреть файл

@ -139,4 +139,5 @@ class TipProvider extends UrlbarProvider {
} }
} }
cancelQuery(context) {} cancelQuery(context) {}
pickResult(result, details) {}
} }