forked from mirrors/gecko-dev
Bug 1848408 - Recommended ads should call into attribution api. r=shopping-reviewers,jhirsch
Differential Revision: https://phabricator.services.mozilla.com/D186484
This commit is contained in:
parent
c3c92500ef
commit
39fa7e02a9
9 changed files with 340 additions and 81 deletions
|
|
@ -772,6 +772,8 @@ let JSWINDOWACTORS = {
|
|||
// methods available to the page js on load.
|
||||
DOMDocElementInserted: {},
|
||||
ReportProductAvailable: { wantUntrusted: true },
|
||||
AdClicked: { wantUntrusted: true },
|
||||
AdImpression: { wantUntrusted: true },
|
||||
},
|
||||
},
|
||||
matches: ["about:shoppingsidebar"],
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@ export class ShoppingSidebarChild extends RemotePageChild {
|
|||
}
|
||||
|
||||
handleEvent(event) {
|
||||
let aid;
|
||||
switch (event.type) {
|
||||
case "ContentReady":
|
||||
this.updateContent();
|
||||
|
|
@ -120,6 +121,14 @@ export class ShoppingSidebarChild extends RemotePageChild {
|
|||
case "ReportProductAvailable":
|
||||
this.reportProductAvailable();
|
||||
break;
|
||||
case "AdClicked":
|
||||
aid = event.detail.aid;
|
||||
this.#product.sendAttributionEvent("click", aid);
|
||||
break;
|
||||
case "AdImpression":
|
||||
aid = event.detail.aid;
|
||||
this.#product.sendAttributionEvent("impression", aid);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,51 +21,84 @@ class RecommendedAd extends MozLitElement {
|
|||
static get queries() {
|
||||
return {
|
||||
ratingEl: "moz-five-star",
|
||||
linkEl: "#ad-title",
|
||||
linkEl: "#recommended-ad-wrapper",
|
||||
};
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
this.initialized = true;
|
||||
|
||||
document.addEventListener("visibilitychange", this);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.clearRecommendationAdTimeout();
|
||||
document.removeEventListener("visibilitychange", this);
|
||||
this.resetImpressionTimer();
|
||||
this.revokeImageUrl();
|
||||
}
|
||||
|
||||
clearRecommendationAdTimeout() {
|
||||
if (this.recommendationAdTimeout) {
|
||||
clearTimeout(this.recommendationAdTimeout);
|
||||
startImpressionTimer() {
|
||||
if (!this.timeout && document.visibilityState === "visible") {
|
||||
this.timeout = setTimeout(
|
||||
() => this.recordImpression(),
|
||||
AD_IMPRESSION_TIMEOUT
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
resetImpressionTimer() {
|
||||
this.timeout = clearTimeout(this.timeout);
|
||||
}
|
||||
|
||||
revokeImageUrl() {
|
||||
if (this.imageUrl) {
|
||||
URL.revokeObjectURL(this.imageUrl);
|
||||
}
|
||||
}
|
||||
|
||||
adImpression() {
|
||||
// TODO: https://bugzilla.mozilla.org/show_bug.cgi?id=1846774
|
||||
// We want to send an api call when the ad is view for 1 second
|
||||
// this.dispatchEvent(
|
||||
// new CustomEvent("Shopping:AdImpression", {
|
||||
// bubbles: true,
|
||||
// })
|
||||
// );
|
||||
recordImpression() {
|
||||
if (this.hasImpressed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearRecommendationAdTimeout();
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("AdImpression", {
|
||||
bubbles: true,
|
||||
detail: { aid: this.product.aid },
|
||||
})
|
||||
);
|
||||
|
||||
document.removeEventListener("visibilitychange", this);
|
||||
this.resetImpressionTimer();
|
||||
|
||||
this.hasImpressed = true;
|
||||
}
|
||||
|
||||
handleClick(event) {
|
||||
event.preventDefault();
|
||||
window.open(this.product.url, "_blank");
|
||||
if (event.button === 0 || event.button === 1) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("AdClicked", {
|
||||
bubbles: true,
|
||||
detail: { aid: this.product.aid },
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: https://bugzilla.mozilla.org/show_bug.cgi?id=1846774
|
||||
// We want to send an api call when the ad is clicked
|
||||
// this.dispatchEvent(
|
||||
// new CustomEvent("Shopping:AdClicked", {
|
||||
// bubbles: true,
|
||||
// })
|
||||
// );
|
||||
handleEvent(event) {
|
||||
if (event.type !== "visibilitychange") {
|
||||
return;
|
||||
}
|
||||
if (document.visibilityState === "visible") {
|
||||
this.startImpressionTimer();
|
||||
} else if (!this.hasImpressed) {
|
||||
this.resetImpressionTimer();
|
||||
}
|
||||
}
|
||||
|
||||
priceTemplate() {
|
||||
|
|
@ -75,13 +108,7 @@ class RecommendedAd extends MozLitElement {
|
|||
}
|
||||
|
||||
render() {
|
||||
if (!this.adSeen) {
|
||||
this.recommendationAdTimeout = setTimeout(
|
||||
() => this.adImpression(),
|
||||
AD_IMPRESSION_TIMEOUT
|
||||
);
|
||||
this.adSeen = true;
|
||||
}
|
||||
this.startImpressionTimer();
|
||||
|
||||
this.revokeImageUrl();
|
||||
this.imageUrl = URL.createObjectURL(this.product.image_blob);
|
||||
|
|
@ -97,7 +124,9 @@ class RecommendedAd extends MozLitElement {
|
|||
>
|
||||
<a id="recommended-ad-wrapper" slot="content" href=${
|
||||
this.product.url
|
||||
} target="_blank" @click=${this.handleClick}>
|
||||
} target="_blank" @click=${this.handleClick} @auxclick=${
|
||||
this.handleClick
|
||||
}>
|
||||
<div id="ad-content">
|
||||
<img id="ad-preview-image" src=${this.imageUrl}></img>
|
||||
<span id="ad-title" lang="en">${this.product.name}</span>
|
||||
|
|
|
|||
|
|
@ -791,25 +791,25 @@ export class ShoppingProduct {
|
|||
throw new Error("An Ad ID is required.");
|
||||
}
|
||||
|
||||
let requestOptions = {
|
||||
let requestBody = {
|
||||
event_source: source,
|
||||
};
|
||||
|
||||
switch (eventName) {
|
||||
case "impression":
|
||||
requestOptions.event_name = "trusted_deals_impression";
|
||||
requestOptions.aidvs = [aid];
|
||||
requestBody.event_name = "trusted_deals_impression";
|
||||
requestBody.aidvs = [aid];
|
||||
break;
|
||||
case "click":
|
||||
requestOptions.event_name = "trusted_deals_link_clicked";
|
||||
requestOptions.aid = aid;
|
||||
requestBody.event_name = "trusted_deals_link_clicked";
|
||||
requestBody.aid = aid;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`"${eventName}" is not a valid event name`);
|
||||
}
|
||||
|
||||
let { url, requestSchema, responseSchema } = options;
|
||||
let result = await this.request(url, requestOptions, {
|
||||
let result = await this.request(url, requestBody, {
|
||||
requestSchema,
|
||||
responseSchema,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ support-files = [
|
|||
"server_helper.js",
|
||||
]
|
||||
|
||||
["browser_shopping_ads_test.js"]
|
||||
["browser_shopping_integration.js"]
|
||||
support-files = [
|
||||
"analysis.sjs",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,215 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
"use strict";
|
||||
|
||||
const { sinon } = ChromeUtils.importESModule(
|
||||
"resource://testing-common/Sinon.sys.mjs"
|
||||
);
|
||||
|
||||
add_task(async function test_ad_attribution() {
|
||||
await SpecialPowers.pushPrefEnv({
|
||||
set: [
|
||||
["toolkit.shopping.ohttpRelayURL", ""],
|
||||
["toolkit.shopping.ohttpConfigURL", ""],
|
||||
["browser.shopping.experience2023.ads.enabled", true],
|
||||
["browser.shopping.experience2023.ads.userEnabled", true],
|
||||
],
|
||||
});
|
||||
let recommendedAdsEventListener = function (eventName, sidebar) {
|
||||
return SpecialPowers.spawn(
|
||||
sidebar.querySelector("browser"),
|
||||
[eventName],
|
||||
name => {
|
||||
let shoppingContainer =
|
||||
content.document.querySelector("shopping-container").wrappedJSObject;
|
||||
let adEl = shoppingContainer.recommendedAdEl;
|
||||
return new Promise(resolve => {
|
||||
let listener = () => {
|
||||
resolve();
|
||||
};
|
||||
adEl.addEventListener(name, listener, {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
let recommendedAdVisible = async function (sidebar) {
|
||||
await SpecialPowers.spawn(
|
||||
sidebar.querySelector("browser"),
|
||||
[],
|
||||
async () => {
|
||||
await ContentTaskUtils.waitForCondition(() => {
|
||||
let shoppingContainer =
|
||||
content.document.querySelector(
|
||||
"shopping-container"
|
||||
).wrappedJSObject;
|
||||
return (
|
||||
shoppingContainer?.recommendedAdEl &&
|
||||
ContentTaskUtils.is_visible(shoppingContainer?.recommendedAdEl)
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
await BrowserTestUtils.withNewTab(PRODUCT_TEST_URL, async browser => {
|
||||
// Test that impression event is fired when opening sidebar
|
||||
let sidebar = gBrowser.getPanel(browser).querySelector("shopping-sidebar");
|
||||
Assert.ok(sidebar, "Sidebar should exist");
|
||||
Assert.ok(
|
||||
BrowserTestUtils.is_visible(sidebar),
|
||||
"Sidebar should be visible."
|
||||
);
|
||||
let shoppingButton = document.getElementById("shopping-sidebar-button");
|
||||
ok(
|
||||
BrowserTestUtils.is_visible(shoppingButton),
|
||||
"Shopping Button should be visible on a product page"
|
||||
);
|
||||
|
||||
info("Waiting for sidebar to update.");
|
||||
await promiseSidebarUpdated(sidebar, PRODUCT_TEST_URL);
|
||||
await recommendedAdVisible(sidebar);
|
||||
|
||||
let impressionEvent = recommendedAdsEventListener("AdImpression", sidebar);
|
||||
|
||||
info("Verifying product info for initial product.");
|
||||
await verifyProductInfo(sidebar, {
|
||||
productURL: PRODUCT_TEST_URL,
|
||||
adjustedRating: "4.1",
|
||||
letterGrade: "B",
|
||||
});
|
||||
|
||||
info("Waiting for ad impression event.");
|
||||
await impressionEvent;
|
||||
Assert.ok(true, "Got ad impression event");
|
||||
|
||||
//
|
||||
// Test that impression event is fired after switching to a tab that was
|
||||
// opened in the background
|
||||
|
||||
let tab = BrowserTestUtils.addTab(gBrowser, PRODUCT_TEST_URL);
|
||||
await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
|
||||
|
||||
let tabSidebar = gBrowser
|
||||
.getPanel(tab.linkedBrowser)
|
||||
.querySelector("shopping-sidebar");
|
||||
Assert.ok(tabSidebar, "Sidebar should exist");
|
||||
|
||||
info("Waiting for sidebar to update.");
|
||||
await promiseSidebarUpdated(tabSidebar, PRODUCT_TEST_URL);
|
||||
await recommendedAdVisible(tabSidebar);
|
||||
|
||||
// Need to wait the impression timeout to confirm that no impression event
|
||||
// has been dispatched
|
||||
// Bug 1859029 should update this to use sinon fake timers instead of using
|
||||
// setTimeout
|
||||
// eslint-disable-next-line mozilla/no-arbitrary-setTimeout
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
|
||||
let hasImpressed = await SpecialPowers.spawn(
|
||||
tabSidebar.querySelector("browser"),
|
||||
[],
|
||||
() => {
|
||||
let shoppingContainer =
|
||||
content.document.querySelector("shopping-container").wrappedJSObject;
|
||||
let adEl = shoppingContainer.recommendedAdEl;
|
||||
return adEl.hasImpressed;
|
||||
}
|
||||
);
|
||||
Assert.ok(!hasImpressed, "We haven't seend the ad yet");
|
||||
|
||||
impressionEvent = recommendedAdsEventListener("AdImpression", tabSidebar);
|
||||
await BrowserTestUtils.switchTab(gBrowser, tab);
|
||||
await recommendedAdVisible(tabSidebar);
|
||||
|
||||
info("Waiting for ad impression event.");
|
||||
await impressionEvent;
|
||||
Assert.ok(true, "Got ad impression event");
|
||||
|
||||
//
|
||||
// Test that the impression event is fired after opening foreground tab,
|
||||
// switching away and the event is not fired, then switching back and the
|
||||
// event does fire
|
||||
|
||||
gBrowser.removeTab(tab);
|
||||
|
||||
tab = BrowserTestUtils.addTab(gBrowser, PRODUCT_TEST_URL);
|
||||
await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
|
||||
|
||||
tabSidebar = gBrowser
|
||||
.getPanel(tab.linkedBrowser)
|
||||
.querySelector("shopping-sidebar");
|
||||
Assert.ok(tabSidebar, "Sidebar should exist");
|
||||
|
||||
info("Waiting for sidebar to update.");
|
||||
await promiseSidebarUpdated(tabSidebar, PRODUCT_TEST_URL);
|
||||
await recommendedAdVisible(tabSidebar);
|
||||
|
||||
// Switch to new sidebar tab
|
||||
await BrowserTestUtils.switchTab(gBrowser, tab);
|
||||
// switch back to original tab
|
||||
await BrowserTestUtils.switchTab(
|
||||
gBrowser,
|
||||
gBrowser.getTabForBrowser(browser)
|
||||
);
|
||||
|
||||
// Need to wait the impression timeout to confirm that no impression event
|
||||
// has been dispatched
|
||||
// Bug 1859029 should update this to use sinon fake timers instead of using
|
||||
// setTimeout
|
||||
// eslint-disable-next-line mozilla/no-arbitrary-setTimeout
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
|
||||
hasImpressed = await SpecialPowers.spawn(
|
||||
tabSidebar.querySelector("browser"),
|
||||
[],
|
||||
() => {
|
||||
let shoppingContainer =
|
||||
content.document.querySelector("shopping-container").wrappedJSObject;
|
||||
let adEl = shoppingContainer.recommendedAdEl;
|
||||
return adEl.hasImpressed;
|
||||
}
|
||||
);
|
||||
Assert.ok(!hasImpressed, "We haven't seend the ad yet");
|
||||
|
||||
impressionEvent = recommendedAdsEventListener("AdImpression", tabSidebar);
|
||||
await BrowserTestUtils.switchTab(gBrowser, tab);
|
||||
await recommendedAdVisible(tabSidebar);
|
||||
|
||||
info("Waiting for ad impression event.");
|
||||
await impressionEvent;
|
||||
Assert.ok(true, "Got ad impression event");
|
||||
|
||||
gBrowser.removeTab(tab);
|
||||
|
||||
//
|
||||
// Test ad clicked event
|
||||
|
||||
let adOpenedTabPromise = BrowserTestUtils.waitForNewTab(
|
||||
gBrowser,
|
||||
PRODUCT_TEST_URL,
|
||||
true
|
||||
);
|
||||
|
||||
let clickedEvent = recommendedAdsEventListener("AdClicked", sidebar);
|
||||
await SpecialPowers.spawn(sidebar.querySelector("browser"), [], () => {
|
||||
let shoppingContainer =
|
||||
content.document.querySelector("shopping-container").wrappedJSObject;
|
||||
let adEl = shoppingContainer.recommendedAdEl;
|
||||
adEl.linkEl.click();
|
||||
});
|
||||
|
||||
let adTab = await adOpenedTabPromise;
|
||||
|
||||
info("Waiting for ad clicked event.");
|
||||
await clickedEvent;
|
||||
Assert.ok(true, "Got ad clicked event");
|
||||
|
||||
gBrowser.removeTab(adTab);
|
||||
});
|
||||
|
||||
await SpecialPowers.popPrefEnv();
|
||||
});
|
||||
|
|
@ -3,48 +3,6 @@
|
|||
|
||||
"use strict";
|
||||
|
||||
const PRODUCT_TEST_URL = "https://example.com/Some-Product/dp/ABCDEFG123";
|
||||
const OTHER_PRODUCT_TEST_URL =
|
||||
"https://example.com/Another-Product/dp/HIJKLMN456";
|
||||
const BAD_PRODUCT_TEST_URL = "https://example.com/Bad-Product/dp/0000000000";
|
||||
const NEEDS_ANALYSIS_TEST_URL = "https://example.com/Bad-Product/dp/OPQRSTU789";
|
||||
|
||||
async function verifyProductInfo(sidebar, expectedProductInfo) {
|
||||
await SpecialPowers.spawn(
|
||||
sidebar.querySelector("browser"),
|
||||
[expectedProductInfo],
|
||||
async prodInfo => {
|
||||
let doc = content.document;
|
||||
let container = doc.querySelector("shopping-container");
|
||||
let root = container.shadowRoot;
|
||||
let reviewReliability = root.querySelector("review-reliability");
|
||||
// The async fetch could take some time.
|
||||
while (!reviewReliability) {
|
||||
info("Waiting for update.");
|
||||
await container.updateComplete;
|
||||
}
|
||||
let adjustedRating = root.querySelector("adjusted-rating");
|
||||
Assert.equal(
|
||||
reviewReliability.getAttribute("letter"),
|
||||
prodInfo.letterGrade,
|
||||
`Should have correct letter grade for product ${prodInfo.id}.`
|
||||
);
|
||||
Assert.equal(
|
||||
adjustedRating.getAttribute("rating"),
|
||||
prodInfo.adjustedRating,
|
||||
`Should have correct adjusted rating for product ${prodInfo.id}.`
|
||||
);
|
||||
Assert.equal(
|
||||
content.windowGlobalChild
|
||||
.getExistingActor("ShoppingSidebar")
|
||||
?.getProductURI()?.spec,
|
||||
prodInfo.productURL,
|
||||
`Should have correct url in the child.`
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
add_task(async function test_sidebar_navigation() {
|
||||
// Disable OHTTP for now to get this landed; we'll re-enable with proper
|
||||
// mocking in the near future.
|
||||
|
|
@ -328,7 +286,10 @@ add_task(async function test_sidebar_error() {
|
|||
|
||||
add_task(async function test_ads_requested_after_enabled() {
|
||||
await SpecialPowers.pushPrefEnv({
|
||||
set: [["browser.shopping.experience2023.ads.enabled", true]],
|
||||
set: [
|
||||
["browser.shopping.experience2023.ads.enabled", true],
|
||||
["browser.shopping.experience2023.ads.userEnabled", false],
|
||||
],
|
||||
});
|
||||
await BrowserTestUtils.withNewTab(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,6 +3,12 @@
|
|||
|
||||
"use strict";
|
||||
|
||||
const PRODUCT_TEST_URL = "https://example.com/Some-Product/dp/ABCDEFG123";
|
||||
const OTHER_PRODUCT_TEST_URL =
|
||||
"https://example.com/Another-Product/dp/HIJKLMN456";
|
||||
const BAD_PRODUCT_TEST_URL = "https://example.com/Bad-Product/dp/0000000000";
|
||||
const NEEDS_ANALYSIS_TEST_URL = "https://example.com/Bad-Product/dp/OPQRSTU789";
|
||||
|
||||
async function promiseSidebarUpdated(sidebar, expectedProduct) {
|
||||
let browser = sidebar.querySelector("browser");
|
||||
if (
|
||||
|
|
@ -43,3 +49,39 @@ async function promiseSidebarUpdated(sidebar, expectedProduct) {
|
|||
).then(e => true);
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyProductInfo(sidebar, expectedProductInfo) {
|
||||
await SpecialPowers.spawn(
|
||||
sidebar.querySelector("browser"),
|
||||
[expectedProductInfo],
|
||||
async prodInfo => {
|
||||
let doc = content.document;
|
||||
let container = doc.querySelector("shopping-container");
|
||||
let root = container.shadowRoot;
|
||||
let reviewReliability = root.querySelector("review-reliability");
|
||||
// The async fetch could take some time.
|
||||
while (!reviewReliability) {
|
||||
info("Waiting for update.");
|
||||
await container.updateComplete;
|
||||
}
|
||||
let adjustedRating = root.querySelector("adjusted-rating");
|
||||
Assert.equal(
|
||||
reviewReliability.getAttribute("letter"),
|
||||
prodInfo.letterGrade,
|
||||
`Should have correct letter grade for product ${prodInfo.id}.`
|
||||
);
|
||||
Assert.equal(
|
||||
adjustedRating.getAttribute("rating"),
|
||||
prodInfo.adjustedRating,
|
||||
`Should have correct adjusted rating for product ${prodInfo.id}.`
|
||||
);
|
||||
Assert.equal(
|
||||
content.windowGlobalChild
|
||||
.getExistingActor("ShoppingSidebar")
|
||||
?.getProductURI()?.spec,
|
||||
prodInfo.productURL,
|
||||
`Should have correct url in the child.`
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ let gResponses = new Map(
|
|||
ABCDEFG123: [
|
||||
{
|
||||
name: "VIVO Electric 60 x 24 inch Stand Up Desk | Black Table Top, Black Frame, Height Adjustable Standing Workstation with Memory Preset Controller (DESK-KIT-1B6B)",
|
||||
url: "https://amazon.com/dp/B07V6ZSHF4",
|
||||
url: "https://example.com/Some-Product/dp/ABCDEFG123",
|
||||
image_url: "https://example.com/api/image.jpg",
|
||||
price: "249.99",
|
||||
currency: "USD",
|
||||
|
|
|
|||
Loading…
Reference in a new issue