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:
Niklas Baumgardner 2023-10-18 03:04:30 +00:00
parent c3c92500ef
commit 39fa7e02a9
9 changed files with 340 additions and 81 deletions

View file

@ -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"],

View file

@ -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;
}
}

View file

@ -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>

View file

@ -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,
});

View file

@ -12,6 +12,7 @@ support-files = [
"server_helper.js",
]
["browser_shopping_ads_test.js"]
["browser_shopping_integration.js"]
support-files = [
"analysis.sjs",

View file

@ -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();
});

View file

@ -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(
{

View file

@ -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.`
);
}
);
}

View file

@ -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",