Bug 1951146 - Provide button to generate ai preview with first time opt-in - r=Mardak,fluent-reviewers,firefox-ai-ml-reviewers,bolsson

- static skeleton animation
- start download / inference only after opt-in
- update model-optin to allow placing icon on either right or left depending on iconAtEnd
- update background-color of optin-wrapper
- add new prefs browser.ml.linkPreview.optin and browser.ml.linkPreview.collapse
- use css order attribute for placement of icon
- update to final text for opt-in modal
- don't generate key points if collapsed is true
- add optin test suite in browser_link_preview_optin.js
- add telemetry to link review optin
- remove learn more link
- refactor updateCardProperty

Differential Revision: https://phabricator.services.mozilla.com/D249104
This commit is contained in:
Tim Xia
2025-05-17 19:42:32 +00:00
committed by elee@mozilla.com
parent 6a2624c304
commit 842057e5ca
11 changed files with 734 additions and 18 deletions

View File

@@ -2142,8 +2142,9 @@ pref("browser.ml.chat.shortcuts.longPress", 60000);
pref("browser.ml.chat.sidebar", true);
pref("browser.ml.linkPreview.allowedLanguages", "en");
pref("browser.ml.linkPreview.enabled", false);
pref("browser.ml.linkPreview.blockListEnabled", true);
pref("browser.ml.linkPreview.collapsed", false);
pref("browser.ml.linkPreview.enabled", false);
pref("browser.ml.linkPreview.longPress", true);
pref("browser.ml.linkPreview.noKeyPointsRegions", "AD,AT,BE,BG,CH,CY,CZ,DE,DK,EE,ES,FI,FR,GR,HR,HU,IE,IS,IT,LI,LT,LU,LV,MT,NL,NO,PL,PT,RO,SE,SI,SK");
pref("browser.ml.linkPreview.optin", false);

View File

@@ -16,11 +16,18 @@ XPCOMUtils.defineLazyPreferenceGetter(
"allowedLanguages",
"browser.ml.linkPreview.allowedLanguages"
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"collapsed",
"browser.ml.linkPreview.collapsed",
null,
(_pref, _old, val) => LinkPreview.onCollapsedPref(val)
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"enabled",
"browser.ml.linkPreview.enabled",
false,
null,
(_pref, _old, val) => LinkPreview.onEnabledPref(val)
);
XPCOMUtils.defineLazyPreferenceGetter(
@@ -28,6 +35,13 @@ XPCOMUtils.defineLazyPreferenceGetter(
"noKeyPointsRegions",
"browser.ml.linkPreview.noKeyPointsRegions"
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"optin",
"browser.ml.linkPreview.optin",
null,
(_pref, _old, val) => LinkPreview.onOptinPref(val)
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"prefetchOnEnable",
@@ -102,12 +116,60 @@ export const LinkPreview = {
Glean.genaiLinkpreview.labsCheckbox.record({ enabled });
},
/**
* Updates a property on the link-preview-card element for all window states.
*
* @param {string} prop - The property to update.
* @param {*} value - The value to set for the property.
*/
updateCardProperty(prop, value) {
for (const [win] of this._windowStates) {
const panel = win.document.getElementById(this.linkPreviewPanelId);
if (!panel) {
continue;
}
const card = panel.querySelector("link-preview-card");
if (card) {
card[prop] = value;
}
}
},
/**
* Handles the preference change for opt-in state.
* Updates all link preview cards with the new opt-in state.
*
* @param {boolean} optin - The new state of the opt-in preference.
*/
onOptinPref(optin) {
this.updateCardProperty("optin", optin);
Glean.genaiLinkpreview.cardAiConsent.record({
option: optin ? "continue" : "cancel",
});
},
/**
* Handles the preference change for collapsed state.
* Updates all link preview cards with the new collapsed state.
*
* @param {boolean} collapsed - The new state of the collapsed preference.
*/
onCollapsedPref(collapsed) {
this.updateCardProperty("collapsed", collapsed);
},
/**
* Handles startup tasks such as telemetry and adding listeners.
*
* @param {Window} win - The window context used to add event listeners.
*/
init(win) {
// Access getters for side effects of observing pref changes
lazy.collapsed;
lazy.enabled;
lazy.optin;
this._windowStates.set(win, {});
if (!win.customElements.get("link-preview-card")) {
win.ChromeUtils.importESModule(
@@ -260,6 +322,9 @@ export const LinkPreview = {
ogCard.style.width = "100%";
ogCard.pageData = pageData;
ogCard.optin = lazy.optin;
ogCard.collapsed = lazy.collapsed;
// Reflect the shared download progress to this preview.
const updateProgress = () => {
ogCard.progress = this.progress;
@@ -303,6 +368,12 @@ export const LinkPreview = {
* @param {boolean} _retry Indicates whether to retry the operation.
*/
async generateKeyPoints(ogCard, _retry = false) {
// Prevent keypoints if user not opt-in to link preview or user is set
// keypoints to be collapsed.
if (!lazy.optin || lazy.collapsed) {
return;
}
// Support prefetching without a card by mocking expected properties.
let outcome = ogCard ? "success" : "prefetch";
if (!ogCard) {
@@ -370,6 +441,22 @@ export const LinkPreview = {
}
},
/**
* Handles key points generation requests from different user actions.
* This is a shared handler for both retry and initial generation events.
* Resets error states and triggers key points generation.
*
* @param {LinkPreviewCard} ogCard - The card element to generate key points for
* @private
*/
_handleKeyPointsGenerationEvent(ogCard) {
// Reset error states
ogCard.isMissingDataErrorState = false;
ogCard.isGenerationErrorState = false;
this.generateKeyPoints(ogCard, true);
},
/**
* Renders the link preview panel at the specified coordinates.
*
@@ -453,16 +540,15 @@ export const LinkPreview = {
Glean.genaiLinkpreview.cardLink.record({ source: event.detail });
});
// Add event listener for the retry event
ogCard.addEventListener("LinkPreviewCard:retry", _event => {
// Reset error states
ogCard.isMissingDataErrorState = false;
ogCard.isGenerationErrorState = false;
this.generateKeyPoints(ogCard, true);
//TODO: review if glean record is correct
// Glean.genaiLinkpreview.cardLink.record({ source: url, op: "retry" });
this._handleKeyPointsGenerationEvent(ogCard, "retry");
Glean.genaiLinkpreview.cardLink.record({ source: "retry" });
});
ogCard.addEventListener("LinkPreviewCard:generate", _event => {
this._handleKeyPointsGenerationEvent(ogCard, "generate");
});
openPopup();
},

View File

@@ -49,6 +49,7 @@
}
.ai-content {
position: relative;
padding: var(--og-padding);
.og-error-message-container {
margin:0;
@@ -162,6 +163,11 @@
border-color: var(--skeleton-loader-motion-element-color);
}
&.static div {
animation: none;
background: var(--skeleton-loader-background-color);
}
div:nth-of-type(1) {
max-width: 95%;
}
@@ -182,3 +188,13 @@
display: flex;
justify-content: space-between;
}
model-optin {
--font-size-xxlarge: var(--og-main-font-size);
inset-inline: 0;
margin-inline: auto;
position: absolute;
top: 60px;
width: 75%;
z-index: 10;
}

View File

@@ -4,7 +4,11 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { html } from "chrome://global/content/vendor/lit.all.mjs";
import {
createRef,
html,
ref,
} from "chrome://global/content/vendor/lit.all.mjs";
import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
const lazy = {};
@@ -24,6 +28,13 @@ ChromeUtils.defineLazyGetter(
() => new Services.intl.PluralRules()
);
ChromeUtils.importESModule(
"chrome://browser/content/genai/content/model-optin.mjs",
{
global: "current",
}
);
const FEEDBACK_LINK =
"https://connect.mozilla.org/t5/discussions/try-out-link-previews-on-firefox-labs/td-p/92012";
@@ -35,24 +46,30 @@ window.MozXULElement.insertFTLIfNeeded("preview/linkPreview.ftl");
* @augments MozLitElement
*/
class LinkPreviewCard extends MozLitElement {
static AI_ICON = "chrome://global/skin/icons/highlights.svg";
// Number of placeholder rows to show when loading
static PLACEHOLDER_COUNT = 3;
static properties = {
collapsed: { type: Boolean },
generating: { type: Number }, // 0 = off, 1-4 = generating & dots state
isGenerationErrorState: { type: Boolean },
isMissingDataErrorState: { type: Boolean },
keyPoints: { type: Array },
optin: { type: Boolean },
pageData: { type: Object },
progress: { type: Number }, // -1 = off, 0-100 = download progress
isMissingDataErrorState: { type: Boolean },
isGenerationErrorState: { type: Boolean },
};
constructor() {
super();
this.keyPoints = [];
this.progress = -1;
this.isMissingDataErrorState = false;
this.collapsed = false;
this.isGenerationErrorState = false;
this.isMissingDataErrorState = false;
this.keyPoints = [];
this.optin = false;
this.optinRef = createRef();
this.progress = -1;
}
/**
@@ -120,6 +137,10 @@ class LinkPreviewCard extends MozLitElement {
}
updated(properties) {
if (this.optinRef.value) {
this.optinRef.value.headingIcon = LinkPreviewCard.AI_ICON;
}
if (properties.has("generating")) {
if (this.generating > 0) {
// Count up to 4 so that we can show 0 to 3 dots.
@@ -177,6 +198,44 @@ class LinkPreviewCard extends MozLitElement {
`;
}
/**
* Renders a placeholder generation card for the opt-in mode,
* showing only loading animations without real content.
*
* @returns {import('lit').TemplateResult} The opt-in placeholder card HTML
*/
renderOptInPlaceholderCard() {
return html`
<div class="ai-content">
<h3>
Key points
<img
class="icon"
xmlns="http://www.w3.org/1999/xhtml"
role="presentation"
src="chrome://global/skin/icons/highlights.svg"
/>
</h3>
<ul class="keypoints-list">
${
/* Always show 3 placeholder loading items */
Array(LinkPreviewCard.PLACEHOLDER_COUNT)
.fill()
.map(
() =>
html` <li class="content-item loading static">
<div></div>
<div></div>
<div></div>
</li>`
)
}
</ul>
${this.renderModelOptIn()}
</div>
`;
}
/**
* Renders the normal generation card for displaying key points.
*
@@ -284,6 +343,48 @@ class LinkPreviewCard extends MozLitElement {
`;
}
/**
* Renders the model opt-in component that prompts users to optin to AI features.
* This component allows users to opt in or out of the link preview AI functionality
* and includes a support link for more information.
*
* @returns {import('lit').TemplateResult} The model opt-in component HTML
*/
renderModelOptIn() {
return html`
<model-optin
${ref(this.optinRef)}
headingIcon=${LinkPreviewCard.AI_ICON}
headingL10nId="link-preview-optin-title"
iconAtEnd
messageL10nId="link-preview-optin-message"
@MlModelOptinConfirm=${this._handleOptinConfirm}
@MlModelOptinDeny=${this._handleOptinDeny}
>
</model-optin>
`;
}
/**
* Handles the user confirming the opt-in prompt for link preview.
* Sets preference values to enable the feature, hides the prompt for future sessions,
* and triggers a retry to generate the preview.
*/
_handleOptinConfirm() {
Services.prefs.setBoolPref("browser.ml.linkPreview.optin", true);
this.dispatchEvent(new CustomEvent("LinkPreviewCard:generate"));
}
/**
* Handles the user denying the opt-in prompt for link preview.
* Sets preference values to disable the feature and hides
* the prompt for future sessions.
*/
_handleOptinDeny() {
Services.prefs.setBoolPref("browser.ml.linkPreview.optin", false);
Services.prefs.setBoolPref("browser.ml.linkPreview.collapsed", true);
}
/**
* Renders the appropriate content card based on state.
*
@@ -295,6 +396,16 @@ class LinkPreviewCard extends MozLitElement {
const isGenerationError =
this.isMissingDataErrorState || this.isGenerationErrorState;
// If we should show the opt-in prompt, show our special placeholder card
if (!this.optin && !this.collapsed) {
return this.renderOptInPlaceholderCard();
}
// If user has opted out, don't show any AI content
if (!this.optin) {
return "";
}
if (isGenerationError) {
return this.renderErrorGenerationCard(pageUrl);
}

View File

@@ -3,6 +3,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
.optin-wrapper {
background-color: var(--background-color-canvas);
border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
border-radius: 8px;
padding: var(--space-large);
@@ -41,6 +42,10 @@
height: 12px;
fill: currentColor;
-moz-context-properties: fill, fill-opacity, stroke;
margin-inline-start: var(--space-small);
&.icon-at-end {
order: 1;
}
}
.optin-progress-bar-wrapper {

View File

@@ -15,6 +15,7 @@ class ModelOptin extends MozLitElement {
static properties = {
headingL10nId: { type: String, fluent: true },
headingIcon: { type: String },
iconAtEnd: { type: Boolean },
messageL10nId: { type: String, fluent: true },
optinButtonL10nId: { type: String, fluent: true },
optoutButtonL10nId: { type: String, fluent: true },
@@ -42,9 +43,11 @@ class ModelOptin extends MozLitElement {
super();
this.isLoading = false;
this.isHidden = false;
this.iconAtEnd = false;
this.optinButtonL10nId = "genai-model-optin-continue";
this.optoutButtonL10nId = "genai-model-optin-optout";
this.cancelDownloadButtonL10nId = "genai-model-optin-cancel";
this.footerMessageL10nId = "";
}
dispatch(event) {
@@ -109,7 +112,9 @@ class ModelOptin extends MozLitElement {
? html`<img
src=${this.headingIcon}
alt=${this.headingL10nId}
class="optin-heading-icon"
class="optin-heading-icon ${this.iconAtEnd
? "icon-at-end"
: ""}"
/>`
: ""}
<h3 class="optin-heading" data-l10n-id=${this.headingL10nId}></h3>

View File

@@ -682,6 +682,26 @@ genai.linkpreview:
# events
card_ai_consent:
type: event
description: >
Recorded when the user interacts with the AI consent dialog
bugs:
- https://bugzilla.mozilla.org/show_bug.cgi?id=1951146
data_reviews:
- https://phabricator.services.mozilla.com/D249104
data_sensitivity:
- interaction
expires: 150
notification_emails:
- elee@mozilla.com
send_in_pings:
- events
extra_keys:
option:
type: string
description: User's response - cancel, continue, or learn
card_close:
type: event
description: >
@@ -720,7 +740,7 @@ genai.linkpreview:
extra_keys:
source:
type: string
description: Which link, e.g., title, feedback, visit
description: Which link, e.g., error, feedback, retry, title, visit
fetch:
type: event

View File

@@ -2,6 +2,8 @@
prefs = [
"browser.ml.chat.enabled=true",
"browser.ml.chat.openSidebarOnProviderChange=false",
"browser.ml.linkPreview.collapsed=false",
"browser.ml.linkPreview.optin=true",
"browser.ml.linkPreview.prefetchOnEnable=false",
"browser.ml.linkPreview.shiftAlt=true",
]
@@ -27,3 +29,11 @@ support-files = [
"data/readableFr.html",
"data/readableEn.html"
]
["browser_link_preview_optin.js"]
support-files = [
"data/readableFr.html",
"data/readableEn.html"
]
["browser_link_preview_telemetry.js"]

View File

@@ -0,0 +1,395 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
const { LinkPreview } = ChromeUtils.importESModule(
"moz-src:///browser/components/genai/LinkPreview.sys.mjs"
);
const { LinkPreviewModel } = ChromeUtils.importESModule(
"moz-src:///browser/components/genai/LinkPreviewModel.sys.mjs"
);
const { sinon } = ChromeUtils.importESModule(
"resource://testing-common/Sinon.sys.mjs"
);
const TEST_LINK_URL = "https://example.com";
/**
* Test that optin and collapsed preferences properly update existing cards.
*
* This test ensures that when preference values change, they properly
* update the properties of already created link preview cards.
*/
add_task(async function test_pref_updates_existing_cards() {
await SpecialPowers.pushPrefEnv({
set: [
["browser.ml.linkPreview.enabled", true],
["browser.ml.linkPreview.optin", true],
["browser.ml.linkPreview.collapsed", false],
],
});
const generateStub = sinon.stub(LinkPreviewModel, "generateTextAI");
const READABLE_PAGE_URL =
"https://example.com/browser/browser/components/genai/tests/browser/data/readableEn.html";
LinkPreview.keyboardComboActive = true;
XULBrowserWindow.setOverLink(READABLE_PAGE_URL, {});
const panel = await TestUtils.waitForCondition(() =>
document.getElementById("link-preview-panel")
);
await BrowserTestUtils.waitForEvent(panel, "popupshown");
const card = panel.querySelector("link-preview-card");
ok(card, "card created for link preview");
is(card.optin, true, "card has optin=true initially");
is(card.collapsed, false, "card has collapsed=false initially");
await SpecialPowers.pushPrefEnv({
set: [["browser.ml.linkPreview.optin", false]],
});
is(card.optin, false, "card optin updated to false");
await SpecialPowers.pushPrefEnv({
set: [["browser.ml.linkPreview.collapsed", true]],
});
is(card.collapsed, true, "card collapsed updated to true");
panel.remove();
generateStub.restore();
LinkPreview.keyboardComboActive = false;
});
/**
* Test that link preview doesn't generate key points when optin is false.
*
* This test verifies that when the optin preference is set to false,
* the link preview feature will not attempt to generate key points.
*/
add_task(async function test_no_keypoints_when_optin_false() {
await SpecialPowers.pushPrefEnv({
set: [
["browser.ml.linkPreview.enabled", true],
["browser.ml.linkPreview.optin", false],
["browser.ml.linkPreview.collapsed", false],
],
});
const generateStub = sinon.stub(LinkPreviewModel, "generateTextAI");
LinkPreview.keyboardComboActive = true;
XULBrowserWindow.setOverLink(
"https://example.com/browser/browser/components/genai/tests/browser/data/readableEn.html",
{}
);
let panel = await TestUtils.waitForCondition(() =>
document.getElementById("link-preview-panel")
);
await BrowserTestUtils.waitForEvent(panel, "popupshown");
is(
generateStub.callCount,
0,
"generateTextAI should not be called when optin is false"
);
panel.remove();
LinkPreview.keyboardComboActive = false;
generateStub.restore();
Services.prefs.clearUserPref("browser.ml.linkPreview.optin");
Services.prefs.clearUserPref("browser.ml.linkPreview.collapsed");
});
/**
* Test that link preview doesn't generate key points when collapsed is true.
*
* This test verifies that when the collapsed preference is set to true,
* the link preview feature will not attempt to generate key points even if
* optin is true.
*/
add_task(async function test_no_keypoints_when_collapsed_true() {
await SpecialPowers.pushPrefEnv({
set: [
["browser.ml.linkPreview.enabled", true],
["browser.ml.linkPreview.optin", true],
["browser.ml.linkPreview.collapsed", true],
],
});
const generateStub = sinon.stub(LinkPreviewModel, "generateTextAI");
const READABLE_PAGE_URL =
"https://example.com/browser/browser/components/genai/tests/browser/data/readableEn.html";
LinkPreview.keyboardComboActive = true;
XULBrowserWindow.setOverLink(READABLE_PAGE_URL, {});
const panel = await TestUtils.waitForCondition(() =>
document.getElementById("link-preview-panel")
);
await BrowserTestUtils.waitForEvent(panel, "popupshown");
is(
generateStub.callCount,
0,
"generateTextAI should not be called when collapsed is true"
);
const card = panel.querySelector("link-preview-card");
ok(card, "card created for link preview");
ok(!card.generating, "card should not be in generating state");
is(card.optin, true, "card has optin=true");
is(card.collapsed, true, "card has collapsed=true");
panel.remove();
generateStub.restore();
LinkPreview.keyboardComboActive = false;
});
/**
* Test that link preview does generate key points when optin is true and collapsed is false.
*
* This test verifies that when both the optin preference is true and collapsed
* preference is false, the link preview feature will attempt to generate key points.
*/
add_task(async function test_generate_keypoints_when_opted_in() {
await SpecialPowers.pushPrefEnv({
set: [
["browser.ml.linkPreview.enabled", true],
["browser.ml.linkPreview.optin", true],
["browser.ml.linkPreview.collapsed", false],
],
});
let onDownload, toResolve;
const stub = sinon
.stub(LinkPreviewModel, "generateTextAI")
.callsFake(async (text, options) => {
onDownload = options.onDownload;
toResolve = Promise.withResolvers();
return toResolve.promise;
});
const READABLE_PAGE_URL =
"https://example.com/browser/browser/components/genai/tests/browser/data/readableEn.html";
LinkPreview.keyboardComboActive = true;
XULBrowserWindow.setOverLink(READABLE_PAGE_URL, {});
const panel = await TestUtils.waitForCondition(() =>
document.getElementById("link-preview-panel")
);
await BrowserTestUtils.waitForEvent(panel, "popupshown");
is(
stub.callCount,
1,
"generateTextAI should be called when optin=true and collapsed=false"
);
const card = panel.querySelector("link-preview-card");
onDownload(false);
ok(card, "card created for link preview");
ok(card.generating, "card should be in generating state");
is(card.optin, true, "card has optin=true");
is(card.collapsed, false, "card has collapsed=false");
if (toResolve) {
toResolve.resolve();
await LinkPreview.lastRequest;
}
panel.remove();
stub.restore();
LinkPreview.keyboardComboActive = false;
});
/**
* Test that when optin=true and collapsed=true, keypoints are not generated.
*
* This test represents a user who has opted in but doesn't want to see key points.
* It verifies that even with optin=true, if collapsed=true then key points are not generated.
*/
add_task(async function test_no_keypoints_when_opted_in_but_collapsed() {
await SpecialPowers.pushPrefEnv({
set: [
["browser.ml.linkPreview.enabled", true],
["browser.ml.linkPreview.optin", true], // Opted in
["browser.ml.linkPreview.collapsed", true], // But collapsed (don't show keypoints)
],
});
const generateStub = sinon.stub(LinkPreviewModel, "generateTextAI");
const READABLE_PAGE_URL =
"https://example.com/browser/browser/components/genai/tests/browser/data/readableEn.html";
LinkPreview.keyboardComboActive = true;
XULBrowserWindow.setOverLink(READABLE_PAGE_URL, {});
const panel = await TestUtils.waitForCondition(() =>
document.getElementById("link-preview-panel")
);
await BrowserTestUtils.waitForEvent(panel, "popupshown");
is(
generateStub.callCount,
0,
"generateTextAI should not be called when collapsed is true"
);
const card = panel.querySelector("link-preview-card");
ok(card, "card created for link preview");
ok(!card.generating, "card should not be in generating state");
is(card.optin, true, "card has optin=true");
is(card.collapsed, true, "card has collapsed=true");
panel.remove();
generateStub.restore();
LinkPreview.keyboardComboActive = false;
});
/**
* Test that opt-out action in initial state works correctly.
*
* This test verifies that when a user denies the opt-in prompt,
* the optin preference stays false and the collapsed preference is set to true.
*/
add_task(async function test_model_optin_deny_action() {
await SpecialPowers.pushPrefEnv({
set: [
["browser.ml.linkPreview.enabled", true],
["browser.ml.linkPreview.optin", false], // Initial state - not opted in
["browser.ml.linkPreview.collapsed", false], // Not collapsed
],
});
const generateStub = sinon.stub(LinkPreviewModel, "generateTextAI");
const READABLE_PAGE_URL =
"https://example.com/browser/browser/components/genai/tests/browser/data/readableEn.html";
LinkPreview.keyboardComboActive = true;
XULBrowserWindow.setOverLink(READABLE_PAGE_URL, {});
const panel = await TestUtils.waitForCondition(() =>
document.getElementById("link-preview-panel")
);
await BrowserTestUtils.waitForEvent(panel, "popupshown");
const card = panel.querySelector("link-preview-card");
ok(card, "card created for link preview");
const modelOptinElement = await TestUtils.waitForCondition(() => {
if (card.shadowRoot) {
return card.shadowRoot.querySelector("model-optin");
}
return null;
}, "Waiting for model-optin element");
ok(modelOptinElement, "model-optin element is present");
const optinDenyEvent = new CustomEvent("MlModelOptinDeny", {
bubbles: true,
composed: true,
});
modelOptinElement.dispatchEvent(optinDenyEvent);
is(
Services.prefs.getBoolPref("browser.ml.linkPreview.optin"),
false,
"optin preference should remain false after denying"
);
is(
Services.prefs.getBoolPref("browser.ml.linkPreview.collapsed"),
true,
"collapsed preference should be true after denying"
);
is(
generateStub.callCount,
0,
"generateTextAI should not be called after user denies opt-in"
);
panel.remove();
generateStub.restore();
LinkPreview.keyboardComboActive = false;
Services.prefs.setBoolPref("browser.ml.linkPreview.optin", false);
Services.prefs.setBoolPref("browser.ml.linkPreview.collapsed", false);
});
/**
* Test that opt-in confirm action in initial state works correctly.
*
* This test verifies that when a user confirms the opt-in prompt,
* the optin preference is set to true and key points generation is triggered.
*/
add_task(async function test_model_optin_confirm_action() {
await SpecialPowers.pushPrefEnv({
set: [
["browser.ml.linkPreview.enabled", true],
["browser.ml.linkPreview.optin", false], // Initial state - not opted in
["browser.ml.linkPreview.collapsed", false], // Not collapsed
],
});
const generateStub = sinon.stub(LinkPreviewModel, "generateTextAI");
const READABLE_PAGE_URL =
"https://example.com/browser/browser/components/genai/tests/browser/data/readableEn.html";
LinkPreview.keyboardComboActive = true;
XULBrowserWindow.setOverLink(READABLE_PAGE_URL, {});
const panel = await TestUtils.waitForCondition(() =>
document.getElementById("link-preview-panel")
);
await BrowserTestUtils.waitForEvent(panel, "popupshown");
const card = panel.querySelector("link-preview-card");
ok(card, "card created for link preview");
const modelOptinElement = await TestUtils.waitForCondition(() => {
if (card.shadowRoot) {
return card.shadowRoot.querySelector("model-optin");
}
return null;
}, "Waiting for model-optin element");
ok(modelOptinElement, "model-optin element is present");
const optinConfirmEvent = new CustomEvent("MlModelOptinConfirm", {
bubbles: true,
composed: true,
});
modelOptinElement.dispatchEvent(optinConfirmEvent);
is(
Services.prefs.getBoolPref("browser.ml.linkPreview.optin"),
true,
"optin preference should be set to true after confirming"
);
is(
Services.prefs.getBoolPref("browser.ml.linkPreview.collapsed"),
false,
"collapsed preference should remain false after confirming"
);
await TestUtils.waitForCondition(
() => generateStub.callCount > 0,
"Waiting for generateTextAI to be called"
);
Assert.greater(
generateStub.callCount,
0,
"generateTextAI should be called after user confirms opt-in"
);
panel.remove();
generateStub.restore();
LinkPreview.keyboardComboActive = false;
Services.prefs.setBoolPref("browser.ml.linkPreview.optin", false);
Services.prefs.setBoolPref("browser.ml.linkPreview.collapsed", false);
});

View File

@@ -0,0 +1,55 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
const { LinkPreview } = ChromeUtils.importESModule(
"moz-src:///browser/components/genai/LinkPreview.sys.mjs"
);
add_setup(async () => {
await SpecialPowers.pushPrefEnv({
clear: [["browser.ml.linkPreview.optin"]],
});
});
/**
* Test that AI consent telemetry records the "continue" (accept) option
*/
add_task(async function test_link_preview_ai_consent_continue() {
Services.fog.testResetFOG();
await SpecialPowers.pushPrefEnv({
set: [["browser.ml.linkPreview.optin", true]],
});
let events = Glean.genaiLinkpreview.cardAiConsent.testGetValue();
Assert.equal(events.length, 1, "One consent event recorded");
Assert.equal(events[0].extra.option, "continue", "Continue option recorded");
});
/**
* Test that AI consent telemetry records the "cancel" (deny) option
*/
add_task(async function test_link_preview_ai_consent_cancel() {
Services.fog.testResetFOG();
await SpecialPowers.pushPrefEnv({
set: [["browser.ml.linkPreview.optin", false]],
});
let events = Glean.genaiLinkpreview.cardAiConsent.testGetValue();
Assert.equal(events.length, 1, "One consent event recorded");
Assert.equal(events[0].extra.option, "cancel", "Cancel option recorded");
});
/**
* Test that AI consent telemetry records the "learn" option
*/
add_task(async function test_link_preview_ai_consent_learn() {
Services.fog.testResetFOG();
Glean.genaiLinkpreview.cardAiConsent.record({ option: "learn" });
let events = Glean.genaiLinkpreview.cardAiConsent.testGetValue();
Assert.equal(events.length, 1, "One consent event recorded");
Assert.equal(events[0].extra.option, "learn", "Learn option recorded");
});

View File

@@ -35,3 +35,15 @@ link-preview-settings-shift-alt =
}
link-preview-settings-long-press =
.label = Shortcut: Click and hold the link for 1 second (long press)
# Title that appears when user is shown the opt-in flow for link previews
link-preview-optin-title = See more with AI?
# Message that appears when user is shown the opt-in flow for link previews
link-preview-optin-message = { -brand-short-name } uses AI to read the beginning of the page and generate a few key points. To prioritize your privacy, this happens on your device.
# Title displayed during first-time download of AI model
link-preview-optin-title-download = Setting up link previews…
# Message displayed during first-time download of AI model
link-preview-optin-message-download = This may take a moment.