tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit b11335d4e2546ebe77787965bdf16af3b81a0b8e
parent 975653d57082e79f623516a3ca4376ac27a2dda3
Author: Drew Willcoxon <adw@mozilla.com>
Date:   Wed, 26 Nov 2025 08:23:24 +0000

Bug 2002383 - Make online AMP suggestions dismissable by their full keyword, and add support for dismissal keys to all Merino suggestions. r=daisuke

This does two things:

1. Make Firefox check for `suggestion.dismissal_key` for all Merino suggestions.
   It already does that for unmanaged Merino suggestions. When defined,
   `QuickSuggest.dismissResult()` will use it when dismissing a `UrlbarResult`
   created from a Merino suggestion [1].
2. In addition, for Merino AMP suggestions, fall back to the full keyword if the
   suggestion does not define `dismissal_key`.

That way Firefox will use full keywords to dismiss Merino AMP suggestions, and
later if Merino starts including `dismissal_key` in AMP suggestions, Firefox
will handle that too.

Please see the bug for details.

[1] https://searchfox.org/firefox-main/rev/af0f713f785417023666ef557e856b3a9d132041/browser/components/urlbar/QuickSuggest.sys.mjs#482-485

Differential Revision: https://phabricator.services.mozilla.com/D274079

Diffstat:
Mbrowser/components/urlbar/UrlbarProviderQuickSuggest.sys.mjs | 19+++++++++++++++----
Mbrowser/components/urlbar/private/AmpSuggestions.sys.mjs | 27++++++++++++++++-----------
Mbrowser/components/urlbar/tests/quicksuggest/QuickSuggestTestUtils.sys.mjs | 2++
Mbrowser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merino.js | 234+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbrowser/components/urlbar/tests/unit/head.js | 7++++++-
5 files changed, 273 insertions(+), 16 deletions(-)

diff --git a/browser/components/urlbar/UrlbarProviderQuickSuggest.sys.mjs b/browser/components/urlbar/UrlbarProviderQuickSuggest.sys.mjs @@ -368,9 +368,21 @@ export class UrlbarProviderQuickSuggest extends UrlbarProvider { result.payload.isSponsored = feature ? feature.isSuggestionSponsored(suggestion) : !!suggestion.is_sponsored; - if (suggestion.source == "rust") { - // `suggestionObject` is passed back into the Rust component on dismissal. - result.payload.suggestionObject = suggestion; + + switch (suggestion.source) { + case "merino": + // Set `dismissalKey` unless the feature already did it. + if ( + suggestion.dismissal_key && + !result.payload.hasOwnProperty("dismissalKey") + ) { + result.payload.dismissalKey = suggestion.dismissal_key; + } + break; + case "rust": + // `suggestionObject` is passed back to the Rust component on dismissal. + result.payload.suggestionObject = suggestion; + break; } // Handle icons here so each feature doesn't have to do it, but use `||=` to @@ -443,7 +455,6 @@ export class UrlbarProviderQuickSuggest extends UrlbarProvider { let payload = { url: suggestion.url, originalUrl: suggestion.original_url, - dismissalKey: suggestion.dismissal_key, isBlockable: true, isManageable: true, }; diff --git a/browser/components/urlbar/private/AmpSuggestions.sys.mjs b/browser/components/urlbar/private/AmpSuggestions.sys.mjs @@ -104,17 +104,13 @@ export class AmpSuggestions extends SuggestProvider { // Normalize the Merino suggestion so it has camelCased properties like // Rust suggestions. - suggestion = { - title: suggestion.title, - url: suggestion.url, - fullKeyword: suggestion.full_keyword, - impressionUrl: suggestion.impression_url, - clickUrl: suggestion.click_url, - blockId: suggestion.block_id, - advertiser: suggestion.advertiser, - iabCategory: suggestion.iab_category, - requestId: suggestion.request_id, - }; + suggestion.fullKeyword = suggestion.full_keyword; + suggestion.impressionUrl = suggestion.impression_url; + suggestion.clickUrl = suggestion.click_url; + suggestion.blockId = suggestion.block_id; + suggestion.iabCategory = suggestion.iab_category; + suggestion.requestId = suggestion.request_id; + suggestion.dismissalKey = suggestion.dismissal_key; } let payload = { @@ -132,6 +128,15 @@ export class AmpSuggestions extends SuggestProvider { isManageable: true, }; + // AMP suggestions are dismissed by their full keyword, not by URL like + // usual. For Rust suggestions, the Rust component handles that for us, no + // need to do anything here. For Merino suggestions, Merino should include + // `dismissal_key`, but fall back to the full keyword in case it doesn't. + if (suggestion.source == "merino") { + payload.dismissalKey = + suggestion.dismissalKey || suggestion.fullKeyword || originalUrl; + } + let isTopPick = lazy.UrlbarPrefs.get("quickSuggestAmpTopPickCharThreshold") && lazy.UrlbarPrefs.get("quickSuggestAmpTopPickCharThreshold") <= diff --git a/browser/components/urlbar/tests/quicksuggest/QuickSuggestTestUtils.sys.mjs b/browser/components/urlbar/tests/quicksuggest/QuickSuggestTestUtils.sys.mjs @@ -469,6 +469,7 @@ class _QuickSuggestTestUtils { isSuggestedIndexRelativeToGroup = true, isBestMatch = false, requestId = undefined, + dismissalKey = undefined, descriptionL10n = { id: "urlbar-result-action-sponsored" }, categories = [], } = {}) { @@ -525,6 +526,7 @@ class _QuickSuggestTestUtils { }); } else { result.payload.icon = icon; + result.payload.dismissalKey = dismissalKey || fullKeyword || originalUrl; } return result; diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merino.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merino.js @@ -392,6 +392,239 @@ add_task(async function dismissals_managed() { merinoClient().resetSession(); }); +// Tests dismissals of Merino AMP suggestions, which have special handling +// around their dismissal keys. +add_task(async function dismissals_amp() { + UrlbarPrefs.set("quicksuggest.online.available", true); + UrlbarPrefs.set("quicksuggest.online.enabled", true); + + UrlbarPrefs.set("suggest.quicksuggest.all", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + await QuickSuggestTestUtils.forceSync(); + + let tests = [ + { + suggestion: { + url: "https://example.com/0", + }, + expected: { + // dismissal key should be the `url` value + dismissalKey: "https://example.com/0", + }, + }, + { + suggestion: { + url: `https://example.com/1-${TIMESTAMP_TEMPLATE}`, + }, + expected: { + // dismissal key should be the original `url` value with the timestamp + // template + dismissalKey: `https://example.com/1-${TIMESTAMP_TEMPLATE}`, + }, + }, + { + suggestion: { + url: "https://example.com/2", + full_keyword: "full keyword 2", + }, + expected: { + // dismissal key should be the `full_keyword` value + dismissalKey: "full keyword 2", + notDismissalKeys: ["https://example.com/2"], + }, + }, + { + suggestion: { + url: `https://example.com/3-${TIMESTAMP_TEMPLATE}`, + full_keyword: "full keyword 3", + }, + expected: { + // dismissal key should be the `full_keyword` value + dismissalKey: "full keyword 3", + notDismissalKeys: [`https://example.com/3-${TIMESTAMP_TEMPLATE}`], + }, + }, + { + suggestion: { + url: "https://example.com/4", + dismissal_key: "4-dismissal-key", + }, + expected: { + // dismissal key should be the `dismissal_key` value + dismissalKey: "4-dismissal-key", + notDismissalKeys: ["https://example.com/4"], + }, + }, + { + suggestion: { + url: `https://example.com/5-${TIMESTAMP_TEMPLATE}`, + dismissal_key: "5-dismissal-key", + }, + expected: { + // dismissal key should be the `dismissal_key` value + dismissalKey: "5-dismissal-key", + notDismissalKeys: [`https://example.com/5-${TIMESTAMP_TEMPLATE}`], + }, + }, + { + suggestion: { + url: "https://example.com/6", + full_keyword: "full keyword 6", + dismissal_key: "6-dismissal-key", + }, + expected: { + // dismissal key should be the `dismissal_key` value + dismissalKey: "6-dismissal-key", + notDismissalKeys: ["full keyword 6", "https://example.com/6"], + }, + }, + { + suggestion: { + url: `https://example.com/7-${TIMESTAMP_TEMPLATE}`, + full_keyword: "full keyword 7", + dismissal_key: "7-dismissal-key", + }, + expected: { + // dismissal key should be the `dismissal_key` value + dismissalKey: "7-dismissal-key", + notDismissalKeys: [ + "full keyword 7", + `https://example.com/7-${TIMESTAMP_TEMPLATE}`, + ], + }, + }, + ]; + + for (let test of tests) { + info("Doing subtest: " + JSON.stringify(test)); + + let { suggestion, expected } = test; + + suggestion = { + provider: "adm", + title: "title", + icon: null, + impression_url: "https://example.com/impression", + click_url: "https://example.com/click", + block_id: 1, + advertiser: "advertiser", + iab_category: "22 - Shopping", + is_sponsored: true, + request_id: "request_id", + score: 1, + ...suggestion, + }; + + MerinoTestUtils.server.response = + MerinoTestUtils.server.makeDefaultResponse(); + MerinoTestUtils.server.response.body.suggestions = [suggestion]; + + let expectedResult = { + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + provider: suggestion.provider, + title: suggestion.title, + url: suggestion.url, + originalUrl: suggestion.original_url || suggestion.url, + displayUrl: suggestion.url.replace(/^https:\/\//, ""), + dismissalKey: expected.dismissalKey, + requestId: suggestion.request_id, + sponsoredImpressionUrl: suggestion.impression_url, + sponsoredClickUrl: suggestion.click_url, + sponsoredBlockId: suggestion.block_id, + sponsoredAdvertiser: suggestion.advertiser, + sponsoredIabCategory: suggestion.iab_category, + qsSuggestion: suggestion.full_keyword, + isBlockable: true, + isManageable: true, + isSponsored: true, + source: "merino", + telemetryType: "adm_sponsored", + descriptionL10n: { id: "urlbar-result-action-sponsored" }, + }, + }; + + // Do a search. The Merino suggestion should be matched. + let context = createContext(SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [expectedResult], + // Ignore values related to the timestamp template. They're not important + // for this test. + conditionalPayloadProperties: { + url: { ignore: true }, + urlTimestampIndex: { ignore: true }, + displayUrl: { ignore: true }, + }, + }); + + let result = context.results[0]; + Assert.equal( + QuickSuggest.getFeatureByResult(result)?.name, + "AmpSuggestions", + "Sanity check: The actual result should be managed by AmpSuggestions" + ); + + // Dismiss the Merino result. + await QuickSuggest.dismissResult(result); + Assert.ok( + await QuickSuggest.isResultDismissed(result), + "isResultDismissed should return true after dismissing result" + ); + + Assert.ok( + await QuickSuggest.rustBackend.isDismissedByKey(expected.dismissalKey), + "isDismissedByKey should return true after dismissing result" + ); + if (expected.notDismissalKeys) { + for (let value of expected.notDismissalKeys) { + Assert.ok( + !(await QuickSuggest.rustBackend.isDismissedByKey(value)), + "isDismissedByKey should return false for notDismissalKey: " + value + ); + } + } + + // Do another search. The remote settings suggestion should now be matched. + await check_results({ + context: createContext(SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [EXPECTED_REMOTE_SETTINGS_URLBAR_RESULT], + }); + + // Clear dismissals. + await QuickSuggest.clearDismissedSuggestions(); + Assert.ok( + !(await QuickSuggest.isResultDismissed(result)), + "isResultDismissed should return false after clearing dismissals" + ); + + // The Merino suggestion should be matched again. + await check_results({ + context: createContext(SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [expectedResult], + conditionalPayloadProperties: { + url: { ignore: true }, + urlTimestampIndex: { ignore: true }, + displayUrl: { ignore: true }, + }, + }); + } + + MerinoTestUtils.server.reset(); + merinoClient().resetSession(); +}); + // Tests dismissals of unmanaged Merino suggestions (suggestions that are not // managed by a `SuggestFeature`). add_task(async function dismissals_unmanaged_1() { @@ -720,6 +953,7 @@ add_task(async function dismissals_unmanaged_2() { ); } + await QuickSuggest.clearDismissedSuggestions(); MerinoTestUtils.server.reset(); merinoClient().resetSession(); }); diff --git a/browser/components/urlbar/tests/unit/head.js b/browser/components/urlbar/tests/unit/head.js @@ -992,6 +992,9 @@ function makeGlobalActionsResult({ * @param {string} [options.completed] * The value that would be filled if the autofill result was confirmed. * Has no effect if `autofilled` is not specified. + * @param {object} [options.conditionalPayloadProperties] + * An object mapping payload property names to objects { optional, ignore }. + * See the code below. * @param {Array} options.matches * An array of UrlbarResults. */ @@ -1001,6 +1004,7 @@ async function check_results({ autofilled, completed, matches = [], + conditionalPayloadProperties = {}, } = {}) { if (!context) { return; @@ -1093,7 +1097,7 @@ async function check_results({ // Always ignore the property. // optional: // Ignore the property if it's not in the expected result. - let conditionalPayloadProperties = { + conditionalPayloadProperties = { frecency: { optional: true }, lastVisit: { optional: true }, // `suggestionObject` is only used for dismissing Suggest Rust results, and @@ -1101,6 +1105,7 @@ async function check_results({ // payload object, so ignore it. There are Suggest tests specifically for // dismissals that indirectly test the important aspects of this property. suggestionObject: { ignore: true }, + ...conditionalPayloadProperties, }; for (let i = 0; i < matches.length; i++) {