summaryrefslogtreecommitdiffstats
path: root/toolkit/components/shopping/test
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /toolkit/components/shopping/test
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/shopping/test')
-rw-r--r--toolkit/components/shopping/test/browser/browser.toml31
-rw-r--r--toolkit/components/shopping/test/browser/browser_shopping_ad_not_available.js47
-rw-r--r--toolkit/components/shopping/test/browser/browser_shopping_ads_test.js236
-rw-r--r--toolkit/components/shopping/test/browser/browser_shopping_integration.js277
-rw-r--r--toolkit/components/shopping/test/browser/browser_shopping_request_telemetry.js149
-rw-r--r--toolkit/components/shopping/test/browser/browser_shopping_sidebar_messages.js195
-rw-r--r--toolkit/components/shopping/test/browser/head.js106
-rw-r--r--toolkit/components/shopping/test/mockapis/analysis.sjs47
-rw-r--r--toolkit/components/shopping/test/mockapis/analysis_status.sjs37
-rw-r--r--toolkit/components/shopping/test/mockapis/analyze.sjs23
-rw-r--r--toolkit/components/shopping/test/mockapis/attribution.sjs41
-rw-r--r--toolkit/components/shopping/test/mockapis/recommendations.sjs45
-rw-r--r--toolkit/components/shopping/test/mockapis/reporting.sjs40
-rw-r--r--toolkit/components/shopping/test/mockapis/server_helper.js28
-rw-r--r--toolkit/components/shopping/test/xpcshell/data/analysis_request.json4
-rw-r--r--toolkit/components/shopping/test/xpcshell/data/analysis_response.json24
-rw-r--r--toolkit/components/shopping/test/xpcshell/data/analysis_status_completed_response.json4
-rw-r--r--toolkit/components/shopping/test/xpcshell/data/analysis_status_in_progress_response.json4
-rw-r--r--toolkit/components/shopping/test/xpcshell/data/analysis_status_pending_response.json4
-rw-r--r--toolkit/components/shopping/test/xpcshell/data/analyze_pending.json3
-rw-r--r--toolkit/components/shopping/test/xpcshell/data/attribution_response.json3
-rw-r--r--toolkit/components/shopping/test/xpcshell/data/bad_request.json4
-rw-r--r--toolkit/components/shopping/test/xpcshell/data/image.jpgbin0 -> 12152 bytes
-rw-r--r--toolkit/components/shopping/test/xpcshell/data/invalid_analysis_request.json4
-rw-r--r--toolkit/components/shopping/test/xpcshell/data/invalid_analysis_response.json7
-rw-r--r--toolkit/components/shopping/test/xpcshell/data/invalid_recommendations_request.json4
-rw-r--r--toolkit/components/shopping/test/xpcshell/data/invalid_recommendations_response.json14
-rw-r--r--toolkit/components/shopping/test/xpcshell/data/needs_analysis_response.json6
-rw-r--r--toolkit/components/shopping/test/xpcshell/data/recommendations_request.json4
-rw-r--r--toolkit/components/shopping/test/xpcshell/data/recommendations_response.json14
-rw-r--r--toolkit/components/shopping/test/xpcshell/data/report_response.json3
-rw-r--r--toolkit/components/shopping/test/xpcshell/data/service_unavailable.json4
-rw-r--r--toolkit/components/shopping/test/xpcshell/data/too_many_requests.json4
-rw-r--r--toolkit/components/shopping/test/xpcshell/data/unprocessable_entity.json4
-rw-r--r--toolkit/components/shopping/test/xpcshell/head.js57
-rw-r--r--toolkit/components/shopping/test/xpcshell/test_fetchImage.js106
-rw-r--r--toolkit/components/shopping/test/xpcshell/test_product.js940
-rw-r--r--toolkit/components/shopping/test/xpcshell/test_product_urls.js297
-rw-r--r--toolkit/components/shopping/test/xpcshell/test_product_validator.js91
-rw-r--r--toolkit/components/shopping/test/xpcshell/xpcshell.toml38
40 files changed, 2949 insertions, 0 deletions
diff --git a/toolkit/components/shopping/test/browser/browser.toml b/toolkit/components/shopping/test/browser/browser.toml
new file mode 100644
index 0000000000..878080690d
--- /dev/null
+++ b/toolkit/components/shopping/test/browser/browser.toml
@@ -0,0 +1,31 @@
+[DEFAULT]
+prefs = [
+ "browser.shopping.experience2023.enabled=true",
+ "browser.shopping.experience2023.optedIn=1",
+ # Disable the fakespot feature callouts to avoid interference. Individual tests
+ # that need them can re-enable them as needed.
+ "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features=false",
+ "toolkit.shopping.environment=test",
+ "browser.shopping.experience2023.autoOpen.enabled=false",
+ "browser.shopping.experience2023.autoOpen.userEnabled=true",
+]
+support-files = [
+ "head.js",
+ "../mockapis/server_helper.js",
+ "../mockapis/analysis_status.sjs",
+ "../mockapis/analysis.sjs",
+ "../mockapis/analyze.sjs",
+ "../mockapis/attribution.sjs",
+ "../mockapis/recommendations.sjs",
+ "../mockapis/reporting.sjs",
+]
+
+["browser_shopping_ad_not_available.js"]
+
+["browser_shopping_ads_test.js"]
+
+["browser_shopping_integration.js"]
+
+["browser_shopping_request_telemetry.js"]
+
+["browser_shopping_sidebar_messages.js"]
diff --git a/toolkit/components/shopping/test/browser/browser_shopping_ad_not_available.js b/toolkit/components/shopping/test/browser/browser_shopping_ad_not_available.js
new file mode 100644
index 0000000000..c4b9c69277
--- /dev/null
+++ b/toolkit/components/shopping/test/browser/browser_shopping_ad_not_available.js
@@ -0,0 +1,47 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_setup(async function () {
+ await Services.fog.testFlushAllChildren();
+ Services.fog.testResetFOG();
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["toolkit.shopping.ohttpRelayURL", ""],
+ ["toolkit.shopping.ohttpConfigURL", ""],
+ ["browser.shopping.experience2023.ads.enabled", true],
+ ["browser.shopping.experience2023.ads.userEnabled", true],
+ ],
+ });
+});
+
+/**
+ * Check that we send the right telemetry if no ad is available.
+ */
+add_task(async function test_no_ad_available_telemetry() {
+ await BrowserTestUtils.withNewTab(OTHER_PRODUCT_TEST_URL, async browser => {
+ let sidebar = gBrowser.getPanel(browser).querySelector("shopping-sidebar");
+ Assert.ok(sidebar, "Sidebar should exist");
+ Assert.ok(
+ BrowserTestUtils.isVisible(sidebar),
+ "Sidebar should be visible."
+ );
+
+ info("Waiting for sidebar to update.");
+ await promiseSidebarAdsUpdated(sidebar, OTHER_PRODUCT_TEST_URL);
+ // Test the lack of ad was recorded by telemetry
+ await Services.fog.testFlushAllChildren();
+ let noAdsAvailableEvents =
+ Glean.shopping.surfaceNoAdsAvailable.testGetValue();
+ Assert.equal(
+ noAdsAvailableEvents?.length,
+ 1,
+ "Should have recorded lack of ads."
+ );
+ let noAdsEvent = noAdsAvailableEvents?.[0];
+ Assert.equal(noAdsEvent?.category, "shopping");
+ Assert.equal(noAdsEvent?.name, "surface_no_ads_available");
+ });
+});
diff --git a/toolkit/components/shopping/test/browser/browser_shopping_ads_test.js b/toolkit/components/shopping/test/browser/browser_shopping_ads_test.js
new file mode 100644
index 0000000000..1061cf7fa6
--- /dev/null
+++ b/toolkit/components/shopping/test/browser/browser_shopping_ads_test.js
@@ -0,0 +1,236 @@
+/* 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"
+);
+
+function recommendedAdsEventListener(eventName, sidebar) {
+ return SpecialPowers.spawn(
+ sidebar.querySelector("browser"),
+ [eventName],
+ name => {
+ let shoppingContainer =
+ content.document.querySelector("shopping-container").wrappedJSObject;
+ let adEl = shoppingContainer.recommendedAdEl;
+ return ContentTaskUtils.waitForEvent(adEl, name, false, null, true).then(
+ ev => null
+ );
+ }
+ );
+}
+
+function recommendedAdVisible(sidebar) {
+ return SpecialPowers.spawn(sidebar.querySelector("browser"), [], async () => {
+ await ContentTaskUtils.waitForCondition(() => {
+ let shoppingContainer =
+ content.document.querySelector("shopping-container").wrappedJSObject;
+ return (
+ shoppingContainer?.recommendedAdEl &&
+ ContentTaskUtils.isVisible(shoppingContainer?.recommendedAdEl)
+ );
+ });
+ });
+}
+
+add_setup(async function () {
+ await Services.fog.testFlushAllChildren();
+ Services.fog.testResetFOG();
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["toolkit.shopping.ohttpRelayURL", ""],
+ ["toolkit.shopping.ohttpConfigURL", ""],
+ ["browser.shopping.experience2023.ads.enabled", true],
+ ["browser.shopping.experience2023.ads.userEnabled", true],
+ ],
+ });
+});
+
+add_task(async function test_ad_attribution() {
+ 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.isVisible(sidebar),
+ "Sidebar should be visible."
+ );
+ let shoppingButton = document.getElementById("shopping-sidebar-button");
+ ok(
+ BrowserTestUtils.isVisible(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);
+
+ info("Verifying product info for initial product.");
+ await verifyProductInfo(sidebar, {
+ productURL: PRODUCT_TEST_URL,
+ adjustedRating: "4.1",
+ letterGrade: "B",
+ });
+
+ // Test placement was recorded by telemetry
+ info("Verifying ad placement event.");
+ await Services.fog.testFlushAllChildren();
+ var adsPlacementEvents = Glean.shopping.surfaceAdsPlacement.testGetValue();
+ Assert.equal(adsPlacementEvents.length, 1, "should have recorded an event");
+ Assert.equal(adsPlacementEvents[0].category, "shopping");
+ Assert.equal(adsPlacementEvents[0].name, "surface_ads_placement");
+
+ let impressionEvent = recommendedAdsEventListener("AdImpression", sidebar);
+
+ info("Waiting for ad impression event.");
+ await impressionEvent;
+ Assert.ok(true, "Got ad impression event");
+
+ // Test the impression was recorded by telemetry
+ await Services.fog.testFlushAllChildren();
+ var adsImpressionEvents =
+ Glean.shopping.surfaceAdsImpression.testGetValue();
+ Assert.equal(
+ adsImpressionEvents.length,
+ 1,
+ "should have recorded an event"
+ );
+ Assert.equal(adsImpressionEvents[0].category, "shopping");
+ Assert.equal(adsImpressionEvents[0].name, "surface_ads_impression");
+
+ //
+ // 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");
+
+ // Test the click was recorded by telemetry
+ await Services.fog.testFlushAllChildren();
+ var adsClickedEvents = Glean.shopping.surfaceAdsClicked.testGetValue();
+ Assert.equal(adsClickedEvents.length, 1, "should have recorded a click");
+ Assert.equal(adsClickedEvents[0].category, "shopping");
+ Assert.equal(adsClickedEvents[0].name, "surface_ads_clicked");
+
+ gBrowser.removeTab(adTab);
+ Services.fog.testResetFOG();
+ });
+});
diff --git a/toolkit/components/shopping/test/browser/browser_shopping_integration.js b/toolkit/components/shopping/test/browser/browser_shopping_integration.js
new file mode 100644
index 0000000000..d16b021eb3
--- /dev/null
+++ b/toolkit/components/shopping/test/browser/browser_shopping_integration.js
@@ -0,0 +1,277 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+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.
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["toolkit.shopping.ohttpRelayURL", ""],
+ ["toolkit.shopping.ohttpConfigURL", ""],
+ ],
+ });
+ await BrowserTestUtils.withNewTab(PRODUCT_TEST_URL, async browser => {
+ let sidebar = gBrowser.getPanel(browser).querySelector("shopping-sidebar");
+ Assert.ok(sidebar, "Sidebar should exist");
+ Assert.ok(
+ BrowserTestUtils.isVisible(sidebar),
+ "Sidebar should be visible."
+ );
+ info("Waiting for sidebar to update.");
+ await promiseSidebarUpdated(sidebar, PRODUCT_TEST_URL);
+ info("Verifying product info for initial product.");
+ await verifyProductInfo(sidebar, {
+ productURL: PRODUCT_TEST_URL,
+ adjustedRating: "4.1",
+ letterGrade: "B",
+ });
+
+ // Navigate the browser from the parent:
+ let loadedPromise = Promise.all([
+ BrowserTestUtils.browserLoaded(browser, false, OTHER_PRODUCT_TEST_URL),
+ promiseSidebarUpdated(sidebar, OTHER_PRODUCT_TEST_URL),
+ ]);
+ BrowserTestUtils.startLoadingURIString(browser, OTHER_PRODUCT_TEST_URL);
+ info("Loading another product.");
+ await loadedPromise;
+ Assert.ok(sidebar, "Sidebar should exist.");
+ Assert.ok(
+ BrowserTestUtils.isVisible(sidebar),
+ "Sidebar should be visible."
+ );
+ info("Verifying another product.");
+ await verifyProductInfo(sidebar, {
+ productURL: OTHER_PRODUCT_TEST_URL,
+ adjustedRating: "1",
+ letterGrade: "F",
+ });
+
+ // Navigate to a non-product URL:
+ loadedPromise = BrowserTestUtils.browserLoaded(
+ browser,
+ false,
+ "https://example.com/1"
+ );
+ BrowserTestUtils.startLoadingURIString(browser, "https://example.com/1");
+ info("Go to a non-product.");
+ await loadedPromise;
+ Assert.ok(BrowserTestUtils.isHidden(sidebar));
+
+ // Navigate using pushState:
+ loadedPromise = BrowserTestUtils.waitForLocationChange(
+ gBrowser,
+ PRODUCT_TEST_URL
+ );
+ info("Navigate to the first product using pushState.");
+ await SpecialPowers.spawn(browser, [PRODUCT_TEST_URL], urlToUse => {
+ content.history.pushState({}, null, urlToUse);
+ });
+ info("Waiting to load first product again.");
+ await loadedPromise;
+ info("Waiting for the sidebar to have updated.");
+ await promiseSidebarUpdated(sidebar, PRODUCT_TEST_URL);
+ Assert.ok(sidebar, "Sidebar should exist");
+ Assert.ok(
+ BrowserTestUtils.isVisible(sidebar),
+ "Sidebar should be visible."
+ );
+
+ info("Waiting to verify the first product a second time.");
+ await verifyProductInfo(sidebar, {
+ productURL: PRODUCT_TEST_URL,
+ adjustedRating: "4.1",
+ letterGrade: "B",
+ });
+
+ // Navigate to a product URL with query params:
+ loadedPromise = BrowserTestUtils.browserLoaded(
+ browser,
+ false,
+ PRODUCT_TEST_URL + "?th=1"
+ );
+ // Navigate to the same product, but with a th=1 added.
+ BrowserTestUtils.startLoadingURIString(browser, PRODUCT_TEST_URL + "?th=1");
+ // When just comparing URLs product info would be cleared out,
+ // but when comparing the parsed product ids, we do nothing as the product
+ // has not changed.
+ info("Verifying product has not changed before load.");
+ await verifyProductInfo(sidebar, {
+ productURL: PRODUCT_TEST_URL,
+ adjustedRating: "4.1",
+ letterGrade: "B",
+ });
+ // Wait for the page to load, but don't wait for the sidebar to update so
+ // we can be sure we still have the previous product info.
+ await loadedPromise;
+ info("Verifying product has not changed after load.");
+ await verifyProductInfo(sidebar, {
+ productURL: PRODUCT_TEST_URL,
+ adjustedRating: "4.1",
+ letterGrade: "B",
+ });
+ });
+});
+
+add_task(async function test_button_visible_when_opted_out() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: PRODUCT_TEST_URL,
+ gBrowser,
+ },
+ async browser => {
+ let shoppingBrowser = gBrowser.ownerDocument.querySelector(
+ "browser.shopping-sidebar"
+ );
+
+ let shoppingButton = document.getElementById("shopping-sidebar-button");
+
+ ok(
+ BrowserTestUtils.isVisible(shoppingButton),
+ "Shopping Button should be visible on a product page"
+ );
+
+ let sidebar = gBrowser
+ .getPanel(browser)
+ .querySelector("shopping-sidebar");
+ Assert.ok(sidebar, "Sidebar should exist");
+ Assert.ok(
+ BrowserTestUtils.isVisible(sidebar),
+ "Sidebar should be visible."
+ );
+ info("Waiting for sidebar to update.");
+ await promiseSidebarUpdated(sidebar, PRODUCT_TEST_URL);
+
+ await SpecialPowers.spawn(shoppingBrowser, [], async () => {
+ let shoppingContainer =
+ content.document.querySelector("shopping-container").wrappedJSObject;
+ await shoppingContainer.updateComplete;
+ let shoppingSettings = shoppingContainer.settingsEl;
+ await shoppingSettings.updateComplete;
+
+ shoppingSettings.shoppingCardEl.detailsEl.open = true;
+ let optOutButton = shoppingSettings.optOutButtonEl;
+ optOutButton.click();
+ });
+
+ await BrowserTestUtils.waitForMutationCondition(
+ shoppingButton,
+ { attributes: false, attributeFilter: ["shoppingsidebaropen"] },
+ () => shoppingButton.getAttribute("shoppingsidebaropen")
+ );
+
+ ok(
+ !Services.prefs.getBoolPref("browser.shopping.experience2023.active"),
+ "Shopping sidebar is no longer active"
+ );
+ is(
+ Services.prefs.getIntPref("browser.shopping.experience2023.optedIn"),
+ 2,
+ "Opted out of shopping experience"
+ );
+
+ ok(
+ BrowserTestUtils.isVisible(shoppingButton),
+ "Shopping Button should be visible after opting out"
+ );
+
+ Services.prefs.setBoolPref(
+ "browser.shopping.experience2023.active",
+ true
+ );
+ Services.prefs.setIntPref("browser.shopping.experience2023.optedIn", 1);
+ }
+ );
+});
+
+add_task(async function test_sidebar_button_open_close() {
+ // Disable OHTTP for now to get this landed; we'll re-enable with proper
+ // mocking in the near future.
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["toolkit.shopping.ohttpRelayURL", ""],
+ ["toolkit.shopping.ohttpConfigURL", ""],
+ ],
+ });
+ await BrowserTestUtils.withNewTab(PRODUCT_TEST_URL, async browser => {
+ let sidebar = gBrowser.getPanel(browser).querySelector("shopping-sidebar");
+ Assert.ok(sidebar, "Sidebar should exist");
+ Assert.ok(
+ BrowserTestUtils.isVisible(sidebar),
+ "Sidebar should be visible."
+ );
+ let shoppingButton = document.getElementById("shopping-sidebar-button");
+ ok(
+ BrowserTestUtils.isVisible(shoppingButton),
+ "Shopping Button should be visible on a product page"
+ );
+
+ info("Waiting for sidebar to update.");
+ await promiseSidebarUpdated(sidebar, PRODUCT_TEST_URL);
+
+ info("Verifying product info for initial product.");
+ await verifyProductInfo(sidebar, {
+ productURL: PRODUCT_TEST_URL,
+ adjustedRating: "4.1",
+ letterGrade: "B",
+ });
+
+ // close the sidebar
+ shoppingButton.click();
+ ok(BrowserTestUtils.isHidden(sidebar), "Sidebar should be hidden");
+
+ // reopen the sidebar
+ shoppingButton.click();
+ Assert.ok(
+ BrowserTestUtils.isVisible(sidebar),
+ "Sidebar should be visible."
+ );
+
+ info("Waiting for sidebar to update.");
+ await promiseSidebarUpdated(sidebar, PRODUCT_TEST_URL);
+
+ info("Verifying product info for has not changed.");
+ await verifyProductInfo(sidebar, {
+ productURL: PRODUCT_TEST_URL,
+ adjustedRating: "4.1",
+ letterGrade: "B",
+ });
+ });
+});
+
+add_task(async function test_no_reliability_available() {
+ Services.fog.testResetFOG();
+ await Services.fog.testFlushAllChildren();
+ // Disable OHTTP for now to get this landed; we'll re-enable with proper
+ // mocking in the near future.
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["toolkit.shopping.ohttpRelayURL", ""],
+ ["toolkit.shopping.ohttpConfigURL", ""],
+ ],
+ });
+ await BrowserTestUtils.withNewTab(NEEDS_ANALYSIS_TEST_URL, async browser => {
+ let sidebar = gBrowser.getPanel(browser).querySelector("shopping-sidebar");
+
+ Assert.ok(sidebar, "Sidebar should exist");
+
+ Assert.ok(
+ BrowserTestUtils.isVisible(sidebar),
+ "Sidebar should be visible."
+ );
+ info("Waiting for sidebar to update.");
+ await promiseSidebarUpdated(sidebar, NEEDS_ANALYSIS_TEST_URL);
+ });
+
+ await Services.fog.testFlushAllChildren();
+ var sawPageEvents =
+ Glean.shopping.surfaceNoReviewReliabilityAvailable.testGetValue();
+
+ Assert.equal(sawPageEvents.length, 1);
+ Assert.equal(sawPageEvents[0].category, "shopping");
+ Assert.equal(
+ sawPageEvents[0].name,
+ "surface_no_review_reliability_available"
+ );
+});
diff --git a/toolkit/components/shopping/test/browser/browser_shopping_request_telemetry.js b/toolkit/components/shopping/test/browser/browser_shopping_request_telemetry.js
new file mode 100644
index 0000000000..d4b5147e48
--- /dev/null
+++ b/toolkit/components/shopping/test/browser/browser_shopping_request_telemetry.js
@@ -0,0 +1,149 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const INVALID_RESPONSE = "https://example.com/Some-Product/dp/INVALID123";
+const SERVICE_UNAVAILABLE = "https://example.com/Some-Product/dp/HTTPERR503";
+const TOO_MANY_REQUESTS = "https://example.com/Some-Product/dp/HTTPERR429";
+
+function assertEventMatches(gleanEvents, requiredValues) {
+ if (!gleanEvents?.length) {
+ return Assert.ok(
+ !!gleanEvents?.length,
+ `${requiredValues?.name} event recorded`
+ );
+ }
+ let limitedEvent = Object.assign({}, gleanEvents[0]);
+ for (let k of Object.keys(limitedEvent)) {
+ if (!requiredValues.hasOwnProperty(k)) {
+ delete limitedEvent[k];
+ }
+ }
+ return Assert.deepEqual(limitedEvent, requiredValues);
+}
+
+async function testProductURL(url) {
+ await BrowserTestUtils.withNewTab(
+ {
+ url,
+ gBrowser,
+ },
+ async browser => {
+ let sidebar = gBrowser
+ .getPanel(browser)
+ .querySelector("shopping-sidebar");
+
+ await promiseSidebarUpdated(sidebar, url);
+ }
+ );
+}
+
+add_task(async function test_shopping_server_failure_telemetry() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["toolkit.shopping.ohttpRelayURL", ""],
+ ["toolkit.shopping.ohttpConfigURL", ""],
+ ],
+ });
+ Services.fog.testResetFOG();
+
+ await testProductURL(SERVICE_UNAVAILABLE);
+
+ await Services.fog.testFlushAllChildren();
+
+ const events = Glean.shoppingProduct.serverFailure.testGetValue();
+ assertEventMatches(events, {
+ category: "shopping_product",
+ name: "server_failure",
+ });
+
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_shopping_request_failure_telemetry() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["toolkit.shopping.ohttpRelayURL", ""],
+ ["toolkit.shopping.ohttpConfigURL", ""],
+ ],
+ });
+ Services.fog.testResetFOG();
+
+ await testProductURL(TOO_MANY_REQUESTS);
+
+ await Services.fog.testFlushAllChildren();
+
+ const events = Glean.shoppingProduct.requestFailure.testGetValue();
+ assertEventMatches(events, {
+ category: "shopping_product",
+ name: "request_failure",
+ });
+
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_shopping_request_retried_telemetry() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["toolkit.shopping.ohttpRelayURL", ""],
+ ["toolkit.shopping.ohttpConfigURL", ""],
+ ],
+ });
+ Services.fog.testResetFOG();
+
+ await testProductURL(SERVICE_UNAVAILABLE);
+
+ await Services.fog.testFlushAllChildren();
+
+ const events = Glean.shoppingProduct.requestRetried.testGetValue();
+ assertEventMatches(events, {
+ category: "shopping_product",
+ name: "request_retried",
+ });
+
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_shopping_response_invalid_telemetry() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["toolkit.shopping.ohttpRelayURL", ""],
+ ["toolkit.shopping.ohttpConfigURL", ""],
+ ],
+ });
+ Services.fog.testResetFOG();
+
+ await testProductURL(INVALID_RESPONSE);
+
+ await Services.fog.testFlushAllChildren();
+ const events = Glean.shoppingProduct.invalidResponse.testGetValue();
+ assertEventMatches(events, {
+ category: "shopping_product",
+ name: "invalid_response",
+ });
+
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_shopping_ohttp_invalid_telemetry() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["toolkit.shopping.ohttpRelayURL", "https://example.com/api"],
+ ["toolkit.shopping.ohttpConfigURL", "https://example.com/config"],
+ ],
+ });
+ Services.fog.testResetFOG();
+
+ await testProductURL(PRODUCT_TEST_URL);
+
+ await Services.fog.testFlushAllChildren();
+
+ const events = Glean.shoppingProduct.invalidOhttpConfig.testGetValue();
+ assertEventMatches(events, {
+ category: "shopping_product",
+ name: "invalid_ohttp_config",
+ });
+
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/toolkit/components/shopping/test/browser/browser_shopping_sidebar_messages.js b/toolkit/components/shopping/test/browser/browser_shopping_sidebar_messages.js
new file mode 100644
index 0000000000..969b49481d
--- /dev/null
+++ b/toolkit/components/shopping/test/browser/browser_shopping_sidebar_messages.js
@@ -0,0 +1,195 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const NOT_ENOUGH_REVIEWS_TEST_URL =
+ "https://example.com/Bad-Product/dp/N0T3NOUGHR";
+const NOT_SUPPORTED_TEST_URL = "https://example.com/Bad-Product/dp/PAG3N0TSUP";
+const UNPROCESSABLE_TEST_URL = "https://example.com/Bad-Product/dp/UNPR0C3SSA";
+
+add_task(async function test_sidebar_error() {
+ // Disable OHTTP for now to get this landed; we'll re-enable with proper
+ // mocking in the near future.
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["toolkit.shopping.ohttpRelayURL", ""],
+ ["toolkit.shopping.ohttpConfigURL", ""],
+ ],
+ });
+ await BrowserTestUtils.withNewTab(BAD_PRODUCT_TEST_URL, async browser => {
+ let sidebar = gBrowser.getPanel(browser).querySelector("shopping-sidebar");
+
+ Assert.ok(sidebar, "Sidebar should exist");
+
+ Assert.ok(
+ BrowserTestUtils.isVisible(sidebar),
+ "Sidebar should be visible."
+ );
+ info("Waiting for sidebar to update.");
+ await promiseSidebarUpdated(sidebar, BAD_PRODUCT_TEST_URL);
+
+ info("Verifying a generic error is shown.");
+ await SpecialPowers.spawn(
+ sidebar.querySelector("browser"),
+ [],
+ async prodInfo => {
+ let doc = content.document;
+ let shoppingContainer =
+ doc.querySelector("shopping-container").wrappedJSObject;
+
+ ok(
+ shoppingContainer.shoppingMessageBarEl,
+ "Got shopping-message-bar element"
+ );
+ is(
+ shoppingContainer.shoppingMessageBarEl.getAttribute("type"),
+ "generic-error",
+ "generic-error type should be correct"
+ );
+ }
+ );
+ });
+});
+
+add_task(async function test_sidebar_analysis_status_page_not_supported() {
+ // Disable OHTTP for now to get this landed; we'll re-enable with proper
+ // mocking in the near future.
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["toolkit.shopping.ohttpRelayURL", ""],
+ ["toolkit.shopping.ohttpConfigURL", ""],
+ ],
+ });
+
+ // Product not supported status
+ await BrowserTestUtils.withNewTab(NOT_SUPPORTED_TEST_URL, async browser => {
+ let sidebar = gBrowser.getPanel(browser).querySelector("shopping-sidebar");
+
+ Assert.ok(sidebar, "Sidebar should exist");
+
+ Assert.ok(
+ BrowserTestUtils.isVisible(sidebar),
+ "Sidebar should be visible."
+ );
+ info("Waiting for sidebar to update.");
+ await promiseSidebarUpdated(sidebar, NOT_SUPPORTED_TEST_URL);
+
+ info("Verifying a generic error is shown.");
+ await SpecialPowers.spawn(
+ sidebar.querySelector("browser"),
+ [],
+ async prodInfo => {
+ let doc = content.document;
+ let shoppingContainer =
+ doc.querySelector("shopping-container").wrappedJSObject;
+
+ ok(
+ shoppingContainer.shoppingMessageBarEl,
+ "Got shopping-message-bar element"
+ );
+ is(
+ shoppingContainer.shoppingMessageBarEl.getAttribute("type"),
+ "page-not-supported",
+ "message type should be correct"
+ );
+ }
+ );
+ });
+});
+
+add_task(async function test_sidebar_analysis_status_unprocessable() {
+ // Disable OHTTP for now to get this landed; we'll re-enable with proper
+ // mocking in the near future.
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["toolkit.shopping.ohttpRelayURL", ""],
+ ["toolkit.shopping.ohttpConfigURL", ""],
+ ],
+ });
+
+ // Unprocessable status
+ await BrowserTestUtils.withNewTab(UNPROCESSABLE_TEST_URL, async browser => {
+ let sidebar = gBrowser.getPanel(browser).querySelector("shopping-sidebar");
+
+ Assert.ok(sidebar, "Sidebar should exist");
+
+ Assert.ok(
+ BrowserTestUtils.isVisible(sidebar),
+ "Sidebar should be visible."
+ );
+ info("Waiting for sidebar to update.");
+ await promiseSidebarUpdated(sidebar, UNPROCESSABLE_TEST_URL);
+
+ info("Verifying a generic error is shown.");
+ await SpecialPowers.spawn(
+ sidebar.querySelector("browser"),
+ [],
+ async prodInfo => {
+ let doc = content.document;
+ let shoppingContainer =
+ doc.querySelector("shopping-container").wrappedJSObject;
+
+ ok(
+ shoppingContainer.shoppingMessageBarEl,
+ "Got shopping-message-bar element"
+ );
+ is(
+ shoppingContainer.shoppingMessageBarEl.getAttribute("type"),
+ "generic-error",
+ "message type should be correct"
+ );
+ }
+ );
+ });
+});
+
+add_task(async function test_sidebar_analysis_status_not_enough_reviews() {
+ // Disable OHTTP for now to get this landed; we'll re-enable with proper
+ // mocking in the near future.
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["toolkit.shopping.ohttpRelayURL", ""],
+ ["toolkit.shopping.ohttpConfigURL", ""],
+ ],
+ });
+ // Not enough reviews status
+ await BrowserTestUtils.withNewTab(
+ NOT_ENOUGH_REVIEWS_TEST_URL,
+ async browser => {
+ let sidebar = gBrowser
+ .getPanel(browser)
+ .querySelector("shopping-sidebar");
+
+ Assert.ok(sidebar, "Sidebar should exist");
+
+ Assert.ok(
+ BrowserTestUtils.isVisible(sidebar),
+ "Sidebar should be visible."
+ );
+ info("Waiting for sidebar to update.");
+ await promiseSidebarUpdated(sidebar, NOT_ENOUGH_REVIEWS_TEST_URL);
+
+ info("Verifying a generic error is shown.");
+ await SpecialPowers.spawn(
+ sidebar.querySelector("browser"),
+ [],
+ async prodInfo => {
+ let doc = content.document;
+ let shoppingContainer =
+ doc.querySelector("shopping-container").wrappedJSObject;
+
+ ok(
+ shoppingContainer.shoppingMessageBarEl,
+ "Got shopping-message-bar element"
+ );
+ is(
+ shoppingContainer.shoppingMessageBarEl.getAttribute("type"),
+ "not-enough-reviews",
+ "message type should be correct"
+ );
+ }
+ );
+ }
+ );
+});
diff --git a/toolkit/components/shopping/test/browser/head.js b/toolkit/components/shopping/test/browser/head.js
new file mode 100644
index 0000000000..af676bbc33
--- /dev/null
+++ b/toolkit/components/shopping/test/browser/head.js
@@ -0,0 +1,106 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"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 (
+ !browser.currentURI?.equals(Services.io.newURI("about:shoppingsidebar"))
+ ) {
+ await BrowserTestUtils.browserLoaded(
+ browser,
+ false,
+ "about:shoppingsidebar"
+ );
+ }
+ return SpecialPowers.spawn(browser, [expectedProduct], prod => {
+ function isProductCurrent() {
+ let actor = content.windowGlobalChild.getExistingActor("ShoppingSidebar");
+ return actor?.getProductURI()?.spec == prod;
+ }
+ if (
+ isProductCurrent() &&
+ !!content.document.querySelector("shopping-container").wrappedJSObject
+ .data
+ ) {
+ info("Product already loaded.");
+ return true;
+ }
+ info(
+ "Waiting for product to be updated. Document: " +
+ content.document.location.href
+ );
+ return ContentTaskUtils.waitForEvent(
+ content.document,
+ "Update",
+ true,
+ e => {
+ info("Sidebar updated for product: " + JSON.stringify(e.detail));
+ return !!e.detail.data && isProductCurrent();
+ },
+ true
+ ).then(e => true);
+ });
+}
+
+async function promiseSidebarAdsUpdated(sidebar, expectedProduct) {
+ await promiseSidebarUpdated(sidebar, expectedProduct);
+ let browser = sidebar.querySelector("browser");
+ return SpecialPowers.spawn(browser, [], () => {
+ let container =
+ content.document.querySelector("shopping-container").wrappedJSObject;
+ if (container.recommendationData) {
+ return true;
+ }
+ return ContentTaskUtils.waitForEvent(
+ content.document,
+ "UpdateRecommendations",
+ true,
+ null,
+ true
+ ).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.`
+ );
+ }
+ );
+}
diff --git a/toolkit/components/shopping/test/mockapis/analysis.sjs b/toolkit/components/shopping/test/mockapis/analysis.sjs
new file mode 100644
index 0000000000..2cc154803e
--- /dev/null
+++ b/toolkit/components/shopping/test/mockapis/analysis.sjs
@@ -0,0 +1,47 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function loadHelperScript(path) {
+ let scriptFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ scriptFile.initWithPath(getState("__LOCATION__"));
+ scriptFile = scriptFile.parent;
+ scriptFile.append(path);
+ let scriptSpec = Services.io.newFileURI(scriptFile).spec;
+ Services.scriptloader.loadSubScript(scriptSpec, this);
+}
+/* import-globals-from ./server_helper.js */
+loadHelperScript("server_helper.js");
+
+let gResponses = new Map(
+ Object.entries({
+ ABCDEFG123: { needs_analysis: false, grade: "B", adjusted_rating: 4.1 },
+ HIJKLMN456: { needs_analysis: false, grade: "F", adjusted_rating: 1.0 },
+ OPQRSTU789: { needs_analysis: true },
+ INVALID123: { needs_analysis: false, grade: 0.85, adjusted_rating: 1.0 },
+ HTTPERR503: { status: 503, error: "Service Unavailable" },
+ HTTPERR429: { status: 429, error: "Too Many Requests" },
+ })
+);
+
+function handleRequest(request, response) {
+ var body = getPostBody(request.bodyInputStream);
+ let requestData = JSON.parse(body);
+ let productDetails = gResponses.get(requestData.product_id);
+ if (!productDetails) {
+ response.setStatusLine(request.httpVersion, 400, "Bad Request");
+ productDetails = {
+ status: 400,
+ error: "Bad Request",
+ };
+ }
+ if (productDetails?.status) {
+ response.setStatusLine(
+ request.httpVersion,
+ productDetails.status,
+ productDetails.error
+ );
+ }
+ response.write(JSON.stringify(productDetails));
+}
diff --git a/toolkit/components/shopping/test/mockapis/analysis_status.sjs b/toolkit/components/shopping/test/mockapis/analysis_status.sjs
new file mode 100644
index 0000000000..4d943a9476
--- /dev/null
+++ b/toolkit/components/shopping/test/mockapis/analysis_status.sjs
@@ -0,0 +1,37 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function loadHelperScript(path) {
+ let scriptFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ scriptFile.initWithPath(getState("__LOCATION__"));
+ scriptFile = scriptFile.parent;
+ scriptFile.append(path);
+ let scriptSpec = Services.io.newFileURI(scriptFile).spec;
+ Services.scriptloader.loadSubScript(scriptSpec, this);
+}
+/* import-globals-from ./server_helper.js */
+loadHelperScript("server_helper.js");
+
+let gResponses = new Map(
+ Object.entries({
+ N0T3NOUGHR: { status: "not_enough_reviews", progress: 100.0 },
+ PAG3N0TSUP: { status: "page_not_supported", progress: 100.0 },
+ UNPR0C3SSA: { status: "unprocessable", progress: 100.0 },
+ })
+);
+
+function handleRequest(request, response) {
+ let body = getPostBody(request.bodyInputStream);
+ let requestData = JSON.parse(body);
+ let responseData = gResponses.get(requestData.product_id);
+ if (!responseData) {
+ // We want the status to be completed for most tests.
+ responseData = {
+ status: "completed",
+ progress: 100.0,
+ };
+ }
+ response.write(JSON.stringify(responseData));
+}
diff --git a/toolkit/components/shopping/test/mockapis/analyze.sjs b/toolkit/components/shopping/test/mockapis/analyze.sjs
new file mode 100644
index 0000000000..6f9a8cbcee
--- /dev/null
+++ b/toolkit/components/shopping/test/mockapis/analyze.sjs
@@ -0,0 +1,23 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function loadHelperScript(path) {
+ let scriptFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ scriptFile.initWithPath(getState("__LOCATION__"));
+ scriptFile = scriptFile.parent;
+ scriptFile.append(path);
+ let scriptSpec = Services.io.newFileURI(scriptFile).spec;
+ Services.scriptloader.loadSubScript(scriptSpec, this);
+}
+/* import-globals-from ./server_helper.js */
+loadHelperScript("server_helper.js");
+
+function handleRequest(_request, response) {
+ // We always want the status to be pending for the current tests.
+ let status = {
+ status: "pending",
+ };
+ response.write(JSON.stringify(status));
+}
diff --git a/toolkit/components/shopping/test/mockapis/attribution.sjs b/toolkit/components/shopping/test/mockapis/attribution.sjs
new file mode 100644
index 0000000000..b25cf78b3b
--- /dev/null
+++ b/toolkit/components/shopping/test/mockapis/attribution.sjs
@@ -0,0 +1,41 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function loadHelperScript(path) {
+ let scriptFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ scriptFile.initWithPath(getState("__LOCATION__"));
+ scriptFile = scriptFile.parent;
+ scriptFile.append(path);
+ let scriptSpec = Services.io.newFileURI(scriptFile).spec;
+ Services.scriptloader.loadSubScript(scriptSpec, this);
+}
+/* import-globals-from ./server_helper.js */
+loadHelperScript("server_helper.js");
+
+function getPostBody(stream) {
+ let binaryStream = new BinaryInputStream(stream);
+ let count = binaryStream.available();
+ let arrayBuffer = new ArrayBuffer(count);
+ while (count > 0) {
+ let actuallyRead = binaryStream.readArrayBuffer(count, arrayBuffer);
+ if (!actuallyRead) {
+ throw new Error("Nothing read from input stream!");
+ }
+ count -= actuallyRead;
+ }
+ return new TextDecoder().decode(arrayBuffer);
+}
+
+function handleRequest(request, response) {
+ var body = getPostBody(request.bodyInputStream);
+ let requestData = JSON.parse(body);
+
+ let key = requestData.aid || (requestData.aidvs && requestData.aidvs[0]);
+ let responseObj = {
+ [key]: null,
+ };
+
+ response.write(JSON.stringify(responseObj));
+}
diff --git a/toolkit/components/shopping/test/mockapis/recommendations.sjs b/toolkit/components/shopping/test/mockapis/recommendations.sjs
new file mode 100644
index 0000000000..2c4abc23d2
--- /dev/null
+++ b/toolkit/components/shopping/test/mockapis/recommendations.sjs
@@ -0,0 +1,45 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function loadHelperScript(path) {
+ let scriptFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ scriptFile.initWithPath(getState("__LOCATION__"));
+ scriptFile = scriptFile.parent;
+ scriptFile.append(path);
+ let scriptSpec = Services.io.newFileURI(scriptFile).spec;
+ Services.scriptloader.loadSubScript(scriptSpec, this);
+}
+/* import-globals-from ./server_helper.js */
+loadHelperScript("server_helper.js");
+
+let gResponses = new Map(
+ Object.entries({
+ 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://example.com/Some-Product/dp/ABCDEFG123",
+ image_url: "https://example.com/api/image.jpg",
+ price: "249.99",
+ currency: "USD",
+ grade: "A",
+ adjusted_rating: 4.6,
+ analysis_url:
+ "https://www.fakespot.com/product/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",
+ sponsored: true,
+ aid: "ELcC6OziGKu2jjQsCf5xMYHTpyPKFtjl/4mKPeygbSuQMyIqF/gkY3bTTznoMmNv0OsPV5uql0/NdzNsoguccIS0BujM3DwBADvkGIKLLF2WX0u3G+B2tvRpZmbTmC1NQW0ivS/KX7dTrRjae3Z84fs0i0PySM68buCo5JY848wvzdlyTfCrcT0B3/Ov0tJjZdy9FupF9skLuFtx0/lgElSRsnoGow/H--uLo2Tq7E++RNxgsl--YqlLhv5n8iGFYQwBD61VBg==",
+ },
+ ],
+ HIJKLMN456: [],
+ OPQRSTU789: [],
+ })
+);
+
+function handleRequest(request, response) {
+ var body = getPostBody(request.bodyInputStream);
+ let requestData = JSON.parse(body);
+ let recommendation = gResponses.get(requestData.product_id);
+
+ response.write(JSON.stringify(recommendation));
+}
diff --git a/toolkit/components/shopping/test/mockapis/reporting.sjs b/toolkit/components/shopping/test/mockapis/reporting.sjs
new file mode 100644
index 0000000000..1f4a4115e5
--- /dev/null
+++ b/toolkit/components/shopping/test/mockapis/reporting.sjs
@@ -0,0 +1,40 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const BinaryInputStream = Components.Constructor(
+ "@mozilla.org/binaryinputstream;1",
+ "nsIBinaryInputStream",
+ "setInputStream"
+);
+
+function getPostBody(stream) {
+ let binaryStream = new BinaryInputStream(stream);
+ let count = binaryStream.available();
+ let arrayBuffer = new ArrayBuffer(count);
+ while (count > 0) {
+ let actuallyRead = binaryStream.readArrayBuffer(count, arrayBuffer);
+ if (!actuallyRead) {
+ throw new Error("Nothing read from input stream!");
+ }
+ count -= actuallyRead;
+ }
+ return new TextDecoder().decode(arrayBuffer);
+}
+
+let gResponses = new Map(
+ Object.entries({
+ ABCDEFG123: { message: "report created" },
+ HIJKLMN456: { message: "already reported" },
+ OPQRSTU789: { message: "not deleted" },
+ })
+);
+
+function handleRequest(request, response) {
+ var body = getPostBody(request.bodyInputStream);
+ let requestData = JSON.parse(body);
+ let report = gResponses.get(requestData.product_id);
+
+ response.write(JSON.stringify(report));
+}
diff --git a/toolkit/components/shopping/test/mockapis/server_helper.js b/toolkit/components/shopping/test/mockapis/server_helper.js
new file mode 100644
index 0000000000..e5d3a1d591
--- /dev/null
+++ b/toolkit/components/shopping/test/mockapis/server_helper.js
@@ -0,0 +1,28 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+/* exported getPostBody */
+
+const BinaryInputStream = Components.Constructor(
+ "@mozilla.org/binaryinputstream;1",
+ "nsIBinaryInputStream",
+ "setInputStream"
+);
+
+// eslint-disable-next-line mozilla/reject-importGlobalProperties
+Cu.importGlobalProperties(["TextDecoder"]);
+
+function getPostBody(stream) {
+ let binaryStream = new BinaryInputStream(stream);
+ let count = binaryStream.available();
+ let arrayBuffer = new ArrayBuffer(count);
+ while (count > 0) {
+ let actuallyRead = binaryStream.readArrayBuffer(count, arrayBuffer);
+ if (!actuallyRead) {
+ throw new Error("Nothing read from input stream!");
+ }
+ count -= actuallyRead;
+ }
+ return new TextDecoder().decode(arrayBuffer);
+}
diff --git a/toolkit/components/shopping/test/xpcshell/data/analysis_request.json b/toolkit/components/shopping/test/xpcshell/data/analysis_request.json
new file mode 100644
index 0000000000..28efa08fdf
--- /dev/null
+++ b/toolkit/components/shopping/test/xpcshell/data/analysis_request.json
@@ -0,0 +1,4 @@
+{
+ "product_id": "B07W59LRL9",
+ "website": "amazon.com"
+}
diff --git a/toolkit/components/shopping/test/xpcshell/data/analysis_response.json b/toolkit/components/shopping/test/xpcshell/data/analysis_response.json
new file mode 100644
index 0000000000..d3413b0643
--- /dev/null
+++ b/toolkit/components/shopping/test/xpcshell/data/analysis_response.json
@@ -0,0 +1,24 @@
+{
+ "product_id": "B07W59LRL9",
+ "grade": "B",
+ "adjusted_rating": 4.7,
+ "needs_analysis": false,
+ "analysis_url": "https://staging.fakespot.com/product/garmin-010-02157-10-fenix-6x-sapphire-premium-multisport-gps-watch-features-mapping-music-grade-adjusted-pace-guidance-and-pulse-ox-sensors-dark-gray-with-black-band",
+ "highlights": {
+ "price": ["This watch is great and the price was even better."],
+ "quality": [
+ "Other than that, I am very impressed with the watch and it’s capabilities.",
+ "This watch performs above expectations in every way with the exception of the heart rate monitor.",
+ "Battery life is no better than the 3 even with the solar gimmick, probably worse.",
+ "I have small wrists and still went with the 6X and glad I did.",
+ "I can deal with the looks, as Im now retired."
+ ],
+ "competitiveness": [
+ "Bought this to replace my vivoactive 3.",
+ "I like that this watch has so many features, especially those that monitor health like SP02, respiration, sleep, HRV status, stress, and heart rate.",
+ "I do not use it for sleep or heartrate monitoring so not sure how accurate they are.",
+ "I've avoided getting a smartwatch for so long due to short battery life on most of them."
+ ],
+ "packaging/appearance": ["I loved the minimalist cardboard packaging"]
+ }
+}
diff --git a/toolkit/components/shopping/test/xpcshell/data/analysis_status_completed_response.json b/toolkit/components/shopping/test/xpcshell/data/analysis_status_completed_response.json
new file mode 100644
index 0000000000..4c17e006ba
--- /dev/null
+++ b/toolkit/components/shopping/test/xpcshell/data/analysis_status_completed_response.json
@@ -0,0 +1,4 @@
+{
+ "status": "completed",
+ "progress": 100.0
+}
diff --git a/toolkit/components/shopping/test/xpcshell/data/analysis_status_in_progress_response.json b/toolkit/components/shopping/test/xpcshell/data/analysis_status_in_progress_response.json
new file mode 100644
index 0000000000..6f42fa495c
--- /dev/null
+++ b/toolkit/components/shopping/test/xpcshell/data/analysis_status_in_progress_response.json
@@ -0,0 +1,4 @@
+{
+ "status": "in_progress",
+ "progress": 50.0
+}
diff --git a/toolkit/components/shopping/test/xpcshell/data/analysis_status_pending_response.json b/toolkit/components/shopping/test/xpcshell/data/analysis_status_pending_response.json
new file mode 100644
index 0000000000..aa9233cff5
--- /dev/null
+++ b/toolkit/components/shopping/test/xpcshell/data/analysis_status_pending_response.json
@@ -0,0 +1,4 @@
+{
+ "status": "pending",
+ "progress": 0.0
+}
diff --git a/toolkit/components/shopping/test/xpcshell/data/analyze_pending.json b/toolkit/components/shopping/test/xpcshell/data/analyze_pending.json
new file mode 100644
index 0000000000..500a1fe0d4
--- /dev/null
+++ b/toolkit/components/shopping/test/xpcshell/data/analyze_pending.json
@@ -0,0 +1,3 @@
+{
+ "status": "pending"
+}
diff --git a/toolkit/components/shopping/test/xpcshell/data/attribution_response.json b/toolkit/components/shopping/test/xpcshell/data/attribution_response.json
new file mode 100644
index 0000000000..4d11d41ca4
--- /dev/null
+++ b/toolkit/components/shopping/test/xpcshell/data/attribution_response.json
@@ -0,0 +1,3 @@
+{
+ "1ALhiNLkZ2yR4al5lcP1Npbtlpl5toDfKRgJOATjeieAL6i5Dul99l9+ZTiIWyybUzGysChAdrOA6BWrMqr0EvjoymiH3veZ++XuOvJnC0y1NB/IQQtUzlYEO028XqVUJWJeJte47nPhnK2pSm2QhbdeKbxEnauKAty1cFQeEaBUP7LkvUgxh1GDzflwcVfuKcgMr7hOM3NzjYR2RN3vhmT385Ps4wUj--cv2ucc+1nozldFrl--i9GYyjuHYFFi+EgXXZ3ZsA==": null
+}
diff --git a/toolkit/components/shopping/test/xpcshell/data/bad_request.json b/toolkit/components/shopping/test/xpcshell/data/bad_request.json
new file mode 100644
index 0000000000..b6a9dff9e0
--- /dev/null
+++ b/toolkit/components/shopping/test/xpcshell/data/bad_request.json
@@ -0,0 +1,4 @@
+{
+ "status": 400,
+ "error": "Bad Request"
+}
diff --git a/toolkit/components/shopping/test/xpcshell/data/image.jpg b/toolkit/components/shopping/test/xpcshell/data/image.jpg
new file mode 100644
index 0000000000..78e77baed6
--- /dev/null
+++ b/toolkit/components/shopping/test/xpcshell/data/image.jpg
Binary files differ
diff --git a/toolkit/components/shopping/test/xpcshell/data/invalid_analysis_request.json b/toolkit/components/shopping/test/xpcshell/data/invalid_analysis_request.json
new file mode 100644
index 0000000000..008fce718c
--- /dev/null
+++ b/toolkit/components/shopping/test/xpcshell/data/invalid_analysis_request.json
@@ -0,0 +1,4 @@
+{
+ "product_id": 12345,
+ "website": "amazon.com"
+}
diff --git a/toolkit/components/shopping/test/xpcshell/data/invalid_analysis_response.json b/toolkit/components/shopping/test/xpcshell/data/invalid_analysis_response.json
new file mode 100644
index 0000000000..2210e97487
--- /dev/null
+++ b/toolkit/components/shopping/test/xpcshell/data/invalid_analysis_response.json
@@ -0,0 +1,7 @@
+{
+ "product_id": "B07W59LRL9",
+ "grade": 0.85,
+ "adjusted_rating": "4.7",
+ "needs_analysis": true,
+ "highlights": {}
+}
diff --git a/toolkit/components/shopping/test/xpcshell/data/invalid_recommendations_request.json b/toolkit/components/shopping/test/xpcshell/data/invalid_recommendations_request.json
new file mode 100644
index 0000000000..454cf49942
--- /dev/null
+++ b/toolkit/components/shopping/test/xpcshell/data/invalid_recommendations_request.json
@@ -0,0 +1,4 @@
+{
+ "product_id": 12345,
+ "website": ""
+}
diff --git a/toolkit/components/shopping/test/xpcshell/data/invalid_recommendations_response.json b/toolkit/components/shopping/test/xpcshell/data/invalid_recommendations_response.json
new file mode 100644
index 0000000000..7f3ffb8029
--- /dev/null
+++ b/toolkit/components/shopping/test/xpcshell/data/invalid_recommendations_response.json
@@ -0,0 +1,14 @@
+[
+ {
+ "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": "http://amazon.com/dp/B07V6ZSHF4",
+ "image_url": "https://i.fakespot.io/b6vx27xf3rgwr1a597q6qd3rutp6",
+ "price": 249.99,
+ "currency": "USD",
+ "grade": 0.5,
+ "adjusted_rating": "4.6",
+ "analysis_url": "https://www.fakespot.com/product/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",
+ "sponsored": true,
+ "aid": "ELcC6OziGKu2jjQsCf5xMYHTpyPKFtjl/4mKPeygbSuQMyIqF/gkY3bTTznoMmNv0OsPV5uql0/NdzNsoguccIS0BujM3DwBADvkGIKLLF2WX0u3G+B2tvRpZmbTmC1NQW0ivS/KX7dTrRjae3Z84fs0i0PySM68buCo5JY848wvzdlyTfCrcT0B3/Ov0tJjZdy9FupF9skLuFtx0/lgElSRsnoGow/H--uLo2Tq7E++RNxgsl--YqlLhv5n8iGFYQwBD61VBg=="
+ }
+]
diff --git a/toolkit/components/shopping/test/xpcshell/data/needs_analysis_response.json b/toolkit/components/shopping/test/xpcshell/data/needs_analysis_response.json
new file mode 100644
index 0000000000..3c1cc93a3f
--- /dev/null
+++ b/toolkit/components/shopping/test/xpcshell/data/needs_analysis_response.json
@@ -0,0 +1,6 @@
+{
+ "product_id": null,
+ "grade": null,
+ "adjusted_rating": null,
+ "needs_analysis": true
+}
diff --git a/toolkit/components/shopping/test/xpcshell/data/recommendations_request.json b/toolkit/components/shopping/test/xpcshell/data/recommendations_request.json
new file mode 100644
index 0000000000..fed419f993
--- /dev/null
+++ b/toolkit/components/shopping/test/xpcshell/data/recommendations_request.json
@@ -0,0 +1,4 @@
+{
+ "product_id": "B0C2T6SQJC",
+ "website": "amazon.com"
+}
diff --git a/toolkit/components/shopping/test/xpcshell/data/recommendations_response.json b/toolkit/components/shopping/test/xpcshell/data/recommendations_response.json
new file mode 100644
index 0000000000..89dda89581
--- /dev/null
+++ b/toolkit/components/shopping/test/xpcshell/data/recommendations_response.json
@@ -0,0 +1,14 @@
+[
+ {
+ "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": "http://amazon.com/dp/B07V6ZSHF4",
+ "image_url": "http://example.com/api/image.jpg",
+ "price": "249.99",
+ "currency": "USD",
+ "grade": "A",
+ "adjusted_rating": 4.6,
+ "analysis_url": "https://www.fakespot.com/product/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",
+ "sponsored": true,
+ "aid": "ELcC6OziGKu2jjQsCf5xMYHTpyPKFtjl/4mKPeygbSuQMyIqF/gkY3bTTznoMmNv0OsPV5uql0/NdzNsoguccIS0BujM3DwBADvkGIKLLF2WX0u3G+B2tvRpZmbTmC1NQW0ivS/KX7dTrRjae3Z84fs0i0PySM68buCo5JY848wvzdlyTfCrcT0B3/Ov0tJjZdy9FupF9skLuFtx0/lgElSRsnoGow/H--uLo2Tq7E++RNxgsl--YqlLhv5n8iGFYQwBD61VBg=="
+ }
+]
diff --git a/toolkit/components/shopping/test/xpcshell/data/report_response.json b/toolkit/components/shopping/test/xpcshell/data/report_response.json
new file mode 100644
index 0000000000..9f049555b1
--- /dev/null
+++ b/toolkit/components/shopping/test/xpcshell/data/report_response.json
@@ -0,0 +1,3 @@
+{
+ "message": "report created"
+}
diff --git a/toolkit/components/shopping/test/xpcshell/data/service_unavailable.json b/toolkit/components/shopping/test/xpcshell/data/service_unavailable.json
new file mode 100644
index 0000000000..c863e52262
--- /dev/null
+++ b/toolkit/components/shopping/test/xpcshell/data/service_unavailable.json
@@ -0,0 +1,4 @@
+{
+ "status": 503,
+ "error": "Service Unavailable"
+}
diff --git a/toolkit/components/shopping/test/xpcshell/data/too_many_requests.json b/toolkit/components/shopping/test/xpcshell/data/too_many_requests.json
new file mode 100644
index 0000000000..f2c5e48524
--- /dev/null
+++ b/toolkit/components/shopping/test/xpcshell/data/too_many_requests.json
@@ -0,0 +1,4 @@
+{
+ "status": 429,
+ "error": "Too Many Requests"
+}
diff --git a/toolkit/components/shopping/test/xpcshell/data/unprocessable_entity.json b/toolkit/components/shopping/test/xpcshell/data/unprocessable_entity.json
new file mode 100644
index 0000000000..8f3fdc745a
--- /dev/null
+++ b/toolkit/components/shopping/test/xpcshell/data/unprocessable_entity.json
@@ -0,0 +1,4 @@
+{
+ "status": 422,
+ "error": "Unprocessable entity"
+}
diff --git a/toolkit/components/shopping/test/xpcshell/head.js b/toolkit/components/shopping/test/xpcshell/head.js
new file mode 100644
index 0000000000..22ec9a9742
--- /dev/null
+++ b/toolkit/components/shopping/test/xpcshell/head.js
@@ -0,0 +1,57 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+/* exported createHttpServer, loadJSONfromFile, readFile */
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+const { NetUtil } = ChromeUtils.importESModule(
+ "resource://gre/modules/NetUtil.sys.mjs"
+);
+
+const createHttpServer = (...args) => {
+ AddonTestUtils.maybeInit(this);
+ return AddonTestUtils.createHttpServer(...args);
+};
+
+async function loadJSONfromFile(path) {
+ let file = do_get_file(path);
+ let uri = Services.io.newFileURI(file);
+ return fetch(uri.spec).then(resp => {
+ if (!resp.ok) {
+ return undefined;
+ }
+ return resp.json();
+ });
+}
+
+function readFile(path) {
+ let file = do_get_file(path);
+ let fstream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(
+ Ci.nsIFileInputStream
+ );
+ fstream.init(file, -1, 0, 0);
+ let data = NetUtil.readInputStreamToString(fstream, fstream.available());
+ fstream.close();
+ return data;
+}
+
+/* These are constants but declared `var` so they can be used by the individual
+ * test files.
+ */
+var API_OHTTP_RELAY = "http://example.com/relay/";
+var API_OHTTP_CONFIG = "http://example.com/ohttp-config";
+
+function enableOHTTP(configURL = API_OHTTP_CONFIG) {
+ Services.prefs.setCharPref("toolkit.shopping.ohttpConfigURL", configURL);
+ Services.prefs.setCharPref("toolkit.shopping.ohttpRelayURL", API_OHTTP_RELAY);
+}
+
+function disableOHTTP() {
+ for (let pref of ["ohttpRelayURL", "ohttpConfigURL"]) {
+ Services.prefs.setCharPref(`toolkit.shopping.${pref}`, "");
+ }
+}
diff --git a/toolkit/components/shopping/test/xpcshell/test_fetchImage.js b/toolkit/components/shopping/test/xpcshell/test_fetchImage.js
new file mode 100644
index 0000000000..c0f6965904
--- /dev/null
+++ b/toolkit/components/shopping/test/xpcshell/test_fetchImage.js
@@ -0,0 +1,106 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { ShoppingProduct } = ChromeUtils.importESModule(
+ "chrome://global/content/shopping/ShoppingProduct.mjs"
+);
+const IMAGE_URL = "http://example.com/api/image.jpg";
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/api/", do_get_file("/data"));
+
+function BinaryHttpResponse(status, headerNames, headerValues, content) {
+ this.status = status;
+ this.headerNames = headerNames;
+ this.headerValues = headerValues;
+ this.content = content;
+}
+
+BinaryHttpResponse.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["nsIBinaryHttpResponse"]),
+};
+
+let ohttp = Cc["@mozilla.org/network/oblivious-http;1"].getService(
+ Ci.nsIObliviousHttp
+);
+let ohttpServer = ohttp.server();
+
+server.registerPathHandler(
+ new URL(API_OHTTP_CONFIG).pathname,
+ (request, response) => {
+ let bstream = Cc["@mozilla.org/binaryoutputstream;1"].createInstance(
+ Ci.nsIBinaryOutputStream
+ );
+ bstream.setOutputStream(response.bodyOutputStream);
+ bstream.writeByteArray(ohttpServer.encodedConfig);
+ }
+);
+
+let gExpectedOHTTPMethod = "GET";
+server.registerPathHandler(
+ new URL(API_OHTTP_RELAY).pathname,
+ async (request, response) => {
+ let inputStream = Cc["@mozilla.org/binaryinputstream;1"].createInstance(
+ Ci.nsIBinaryInputStream
+ );
+ inputStream.setInputStream(request.bodyInputStream);
+ let requestBody = inputStream.readByteArray(inputStream.available());
+ let ohttpRequest = ohttpServer.decapsulate(requestBody);
+ let bhttp = Cc["@mozilla.org/network/binary-http;1"].getService(
+ Ci.nsIBinaryHttp
+ );
+ let decodedRequest = bhttp.decodeRequest(ohttpRequest.request);
+ Assert.equal(
+ decodedRequest.method,
+ gExpectedOHTTPMethod,
+ "Should get expected HTTP method"
+ );
+ Assert.deepEqual(decodedRequest.headerNames.sort(), [
+ "Accept",
+ "Content-Type",
+ ]);
+ Assert.deepEqual(decodedRequest.headerValues, ["image/jpeg", "image/jpeg"]);
+
+ response.processAsync();
+ let innerResponse = await fetch("http://example.com" + decodedRequest.path);
+ let bytes = new Uint8Array(await innerResponse.arrayBuffer());
+ let binaryResponse = new BinaryHttpResponse(
+ innerResponse.status,
+ ["Content-Type"],
+ ["image/jpeg"],
+ bytes
+ );
+ let encResponse = ohttpRequest.encapsulate(
+ bhttp.encodeResponse(binaryResponse)
+ );
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "message/ohttp-res", false);
+
+ let bstream = Cc["@mozilla.org/binaryoutputstream;1"].createInstance(
+ Ci.nsIBinaryOutputStream
+ );
+ bstream.setOutputStream(response.bodyOutputStream);
+ bstream.writeByteArray(encResponse);
+ response.finish();
+ }
+);
+
+add_task(async function test_product_requestImageBlob() {
+ let uri = new URL("https://www.walmart.com/ip/926485654");
+ let product = new ShoppingProduct(uri, { allowValidationFailure: false });
+
+ Assert.ok(product.isProduct(), "Should recognize a valid product.");
+
+ let img = await ShoppingProduct.requestImageBlob(IMAGE_URL);
+
+ Assert.ok(Blob.isInstance(img), "Image is loaded and returned as a blob");
+
+ enableOHTTP();
+ img = await ShoppingProduct.requestImageBlob(IMAGE_URL);
+ disableOHTTP();
+
+ Assert.ok(Blob.isInstance(img), "Image is loaded and returned as a blob");
+});
diff --git a/toolkit/components/shopping/test/xpcshell/test_product.js b/toolkit/components/shopping/test/xpcshell/test_product.js
new file mode 100644
index 0000000000..ec11e502f6
--- /dev/null
+++ b/toolkit/components/shopping/test/xpcshell/test_product.js
@@ -0,0 +1,940 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+/* global createHttpServer, readFile */
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+
+function BinaryHttpResponse(status, headerNames, headerValues, content) {
+ this.status = status;
+ this.headerNames = headerNames;
+ this.headerValues = headerValues;
+ this.content = content;
+}
+
+BinaryHttpResponse.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["nsIBinaryHttpResponse"]),
+};
+
+const {
+ ANALYSIS_RESPONSE_SCHEMA,
+ ANALYSIS_REQUEST_SCHEMA,
+ RECOMMENDATIONS_RESPONSE_SCHEMA,
+ RECOMMENDATIONS_REQUEST_SCHEMA,
+ ATTRIBUTION_RESPONSE_SCHEMA,
+ ATTRIBUTION_REQUEST_SCHEMA,
+ ANALYZE_RESPONSE_SCHEMA,
+ ANALYZE_REQUEST_SCHEMA,
+ ANALYSIS_STATUS_RESPONSE_SCHEMA,
+ ANALYSIS_STATUS_REQUEST_SCHEMA,
+} = ChromeUtils.importESModule(
+ "chrome://global/content/shopping/ProductConfig.mjs"
+);
+
+const { ShoppingProduct } = ChromeUtils.importESModule(
+ "chrome://global/content/shopping/ShoppingProduct.mjs"
+);
+
+const ANALYSIS_API_MOCK = "http://example.com/api/analysis_response.json";
+const RECOMMENDATIONS_API_MOCK =
+ "http://example.com/api/recommendations_response.json";
+const ATTRIBUTION_API_MOCK = "http://example.com/api/attribution_response.json";
+const ANALYSIS_API_MOCK_INVALID =
+ "http://example.com/api/invalid_analysis_response.json";
+const API_SERVICE_UNAVAILABLE =
+ "http://example.com/errors/service_unavailable.json";
+const API_ERROR_ONCE = "http://example.com/errors/error_once.json";
+const API_ERROR_BAD_REQUEST = "http://example.com/errors/bad_request.json";
+const API_ERROR_UNPROCESSABLE =
+ "http://example.com/errors/unprocessable_entity.json";
+const API_ERROR_TOO_MANY_REQUESTS =
+ "http://example.com/errors/too_many_requests.json";
+const API_POLL = "http://example.com/poll/poll_analysis_response.json";
+const API_ANALYSIS_IN_PROGRESS =
+ "http://example.com/poll/analysis_in_progress.json";
+const REPORTING_API_MOCK = "http://example.com/api/report_response.json";
+const ANALYZE_API_MOCK = "http://example.com/api/analyze_pending.json";
+
+const TEST_AID =
+ "1ALhiNLkZ2yR4al5lcP1Npbtlpl5toDfKRgJOATjeieAL6i5Dul99l9+ZTiIWyybUzGysChAdrOA6BWrMqr0EvjoymiH3veZ++XuOvJnC0y1NB/IQQtUzlYEO028XqVUJWJeJte47nPhnK2pSm2QhbdeKbxEnauKAty1cFQeEaBUP7LkvUgxh1GDzflwcVfuKcgMr7hOM3NzjYR2RN3vhmT385Ps4wUj--cv2ucc+1nozldFrl--i9GYyjuHYFFi+EgXXZ3ZsA==";
+
+const server = createHttpServer({ hosts: ["example.com"] });
+server.registerDirectory("/api/", do_get_file("/data"));
+
+// Path to test API call that will always fail.
+server.registerPathHandler(
+ new URL(API_SERVICE_UNAVAILABLE).pathname,
+ (request, response) => {
+ response.setStatusLine(request.httpVersion, 503, "Service Unavailable");
+ response.setHeader(
+ "Content-Type",
+ "application/json; charset=utf-8",
+ false
+ );
+ response.write(readFile("data/service_unavailable.json", false));
+ }
+);
+
+// Path to test API call that will fail once and then succeeded.
+let apiErrors = 0;
+server.registerPathHandler(
+ new URL(API_ERROR_ONCE).pathname,
+ (request, response) => {
+ response.setHeader(
+ "Content-Type",
+ "application/json; charset=utf-8",
+ false
+ );
+ if (apiErrors == 0) {
+ response.setStatusLine(request.httpVersion, 503, "Service Unavailable");
+ response.write(readFile("/data/service_unavailable.json"));
+ apiErrors++;
+ } else {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.write(readFile("/data/analysis_response.json"));
+ apiErrors = 0;
+ }
+ }
+);
+
+// Request is missing required parameters.
+server.registerPathHandler(
+ new URL(API_ERROR_BAD_REQUEST).pathname,
+ (request, response) => {
+ response.setStatusLine(request.httpVersion, 400, "Bad Request");
+ response.setHeader(
+ "Content-Type",
+ "application/json; charset=utf-8",
+ false
+ );
+ response.write(readFile("data/bad_request.json", false));
+ }
+);
+
+// Request contains a nonsense product identifier or non supported website.
+server.registerPathHandler(
+ new URL(API_ERROR_UNPROCESSABLE).pathname,
+ (request, response) => {
+ response.setStatusLine(request.httpVersion, 422, "Unprocessable entity");
+ response.setHeader(
+ "Content-Type",
+ "application/json; charset=utf-8",
+ false
+ );
+ response.write(readFile("data/unprocessable_entity.json", false));
+ }
+);
+
+// Too many requests to the API.
+server.registerPathHandler(
+ new URL(API_ERROR_TOO_MANY_REQUESTS).pathname,
+ (request, response) => {
+ response.setStatusLine(request.httpVersion, 429, "Too many requests");
+ response.setHeader(
+ "Content-Type",
+ "application/json; charset=utf-8",
+ false
+ );
+ response.write(readFile("data/too_many_requests.json", false));
+ }
+);
+
+// Path to test API call that will still be processing twice and then succeeded.
+let pollingTries = 0;
+server.registerPathHandler(new URL(API_POLL).pathname, (request, response) => {
+ response.setHeader("Content-Type", "application/json; charset=utf-8", false);
+ if (pollingTries == 0) {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.write(readFile("/data/analysis_status_pending_response.json"));
+ pollingTries++;
+ } else if (pollingTries == 1) {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.write(readFile("/data/analysis_status_in_progress_response.json"));
+ pollingTries++;
+ } else {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.write(readFile("/data/analysis_status_completed_response.json"));
+ pollingTries = 0;
+ }
+});
+
+// Path to test API call that will always need analysis.
+server.registerPathHandler(
+ new URL(API_ANALYSIS_IN_PROGRESS).pathname,
+ (request, response) => {
+ response.setHeader(
+ "Content-Type",
+ "application/json; charset=utf-8",
+ false
+ );
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.write(readFile("/data/analysis_status_in_progress_response.json"));
+ }
+);
+
+let ohttp = Cc["@mozilla.org/network/oblivious-http;1"].getService(
+ Ci.nsIObliviousHttp
+);
+let ohttpServer = ohttp.server();
+
+server.registerPathHandler(
+ new URL(API_OHTTP_CONFIG).pathname,
+ (request, response) => {
+ let bstream = Cc["@mozilla.org/binaryoutputstream;1"].createInstance(
+ Ci.nsIBinaryOutputStream
+ );
+ bstream.setOutputStream(response.bodyOutputStream);
+ bstream.writeByteArray(ohttpServer.encodedConfig);
+ }
+);
+
+let gExpectedOHTTPMethod = "POST";
+let gExpectedProductDetails;
+server.registerPathHandler(
+ new URL(API_OHTTP_RELAY).pathname,
+ async (request, response) => {
+ let inputStream = Cc["@mozilla.org/binaryinputstream;1"].createInstance(
+ Ci.nsIBinaryInputStream
+ );
+ inputStream.setInputStream(request.bodyInputStream);
+ let requestBody = inputStream.readByteArray(inputStream.available());
+ let ohttpRequest = ohttpServer.decapsulate(requestBody);
+ let bhttp = Cc["@mozilla.org/network/binary-http;1"].getService(
+ Ci.nsIBinaryHttp
+ );
+ let decodedRequest = bhttp.decodeRequest(ohttpRequest.request);
+ Assert.equal(
+ decodedRequest.method,
+ gExpectedOHTTPMethod,
+ "Should get expected HTTP method"
+ );
+ Assert.deepEqual(decodedRequest.headerNames.sort(), [
+ "Accept",
+ "Content-Type",
+ ]);
+ Assert.deepEqual(decodedRequest.headerValues, [
+ "application/json",
+ "application/json",
+ ]);
+ if (gExpectedOHTTPMethod == "POST") {
+ Assert.equal(
+ new TextDecoder().decode(new Uint8Array(decodedRequest.content)),
+ gExpectedProductDetails,
+ "Expected body content."
+ );
+ }
+
+ response.processAsync();
+ let innerResponse = await fetch("http://example.com" + decodedRequest.path);
+ let bytes = new Uint8Array(await innerResponse.arrayBuffer());
+ let binaryResponse = new BinaryHttpResponse(
+ innerResponse.status,
+ ["Content-Type"],
+ ["application/json"],
+ bytes
+ );
+ let encResponse = ohttpRequest.encapsulate(
+ bhttp.encodeResponse(binaryResponse)
+ );
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "message/ohttp-res", false);
+
+ let bstream = Cc["@mozilla.org/binaryoutputstream;1"].createInstance(
+ Ci.nsIBinaryOutputStream
+ );
+ bstream.setOutputStream(response.bodyOutputStream);
+ bstream.writeByteArray(encResponse);
+ response.finish();
+ }
+);
+
+add_task(async function test_product_requestAnalysis() {
+ let uri = new URL("https://www.walmart.com/ip/926485654");
+ let product = new ShoppingProduct(uri, { allowValidationFailure: false });
+
+ Assert.ok(product.isProduct(), "Should recognize a valid product.");
+
+ let analysis = await product.requestAnalysis(undefined, {
+ url: ANALYSIS_API_MOCK,
+ requestSchema: ANALYSIS_REQUEST_SCHEMA,
+ responseSchema: ANALYSIS_RESPONSE_SCHEMA,
+ });
+
+ Assert.ok(
+ typeof analysis == "object",
+ "Analysis object is loaded from JSON and validated"
+ );
+});
+
+add_task(async function test_product_requestAnalysis_OHTTP() {
+ let uri = new URL("https://www.walmart.com/ip/926485654");
+ let product = new ShoppingProduct(uri, { allowValidationFailure: false });
+
+ Assert.ok(product.isProduct(), "Should recognize a valid product.");
+
+ gExpectedProductDetails = JSON.stringify({
+ product_id: "926485654",
+ website: "walmart.com",
+ });
+
+ enableOHTTP();
+
+ let analysis = await product.requestAnalysis(undefined, {
+ url: ANALYSIS_API_MOCK,
+ requestSchema: ANALYSIS_REQUEST_SCHEMA,
+ responseSchema: ANALYSIS_RESPONSE_SCHEMA,
+ });
+
+ Assert.deepEqual(
+ analysis,
+ await fetch(ANALYSIS_API_MOCK).then(r => r.json()),
+ "Analysis object is loaded from JSON and validated"
+ );
+
+ disableOHTTP();
+});
+
+add_task(async function test_product_requestAnalysis_invalid() {
+ let uri = new URL("https://www.walmart.com/ip/926485654");
+ let product = new ShoppingProduct(uri, { allowValidationFailure: false });
+
+ Assert.ok(product.isProduct(), "Should recognize a valid product.");
+ let analysis = await product.requestAnalysis(undefined, {
+ url: ANALYSIS_API_MOCK_INVALID,
+ requestSchema: ANALYSIS_REQUEST_SCHEMA,
+ responseSchema: ANALYSIS_RESPONSE_SCHEMA,
+ });
+
+ Assert.equal(analysis, undefined, "Analysis object is invalidated");
+});
+
+add_task(async function test_product_requestAnalysis_invalid_allowed() {
+ let uri = new URL("https://www.walmart.com/ip/926485654");
+ let product = new ShoppingProduct(uri, { allowValidationFailure: true });
+
+ Assert.ok(product.isProduct(), "Should recognize a valid product.");
+ let analysis = await product.requestAnalysis(undefined, {
+ url: ANALYSIS_API_MOCK_INVALID,
+ requestSchema: ANALYSIS_REQUEST_SCHEMA,
+ responseSchema: ANALYSIS_RESPONSE_SCHEMA,
+ });
+
+ Assert.equal(analysis.grade, 0.85, "Analysis is invalid but allowed");
+});
+
+add_task(async function test_product_requestAnalysis_broken_config() {
+ let uri = new URL("https://www.walmart.com/ip/926485654");
+ let product = new ShoppingProduct(uri, { allowValidationFailure: false });
+
+ Assert.ok(product.isProduct(), "Should recognize a valid product.");
+
+ gExpectedProductDetails = JSON.stringify({
+ product_id: "926485654",
+ website: "walmart.com",
+ });
+
+ enableOHTTP("http://example.com/thisdoesntexist");
+
+ let analysis = await product.requestAnalysis(undefined, {
+ url: ANALYSIS_API_MOCK,
+ requestSchema: ANALYSIS_REQUEST_SCHEMA,
+ responseSchema: ANALYSIS_RESPONSE_SCHEMA,
+ });
+
+ // Because the config is missing, the OHTTP request can't be constructed,
+ // so we should fail.
+ Assert.equal(analysis, undefined, "Analysis object is invalidated");
+
+ disableOHTTP();
+});
+
+add_task(async function test_product_requestAnalysis_invalid_ohttp() {
+ let uri = new URL("https://www.walmart.com/ip/926485654");
+ let product = new ShoppingProduct(uri, { allowValidationFailure: false });
+
+ Assert.ok(product.isProduct(), "Should recognize a valid product.");
+
+ gExpectedProductDetails = JSON.stringify({
+ product_id: "926485654",
+ website: "walmart.com",
+ });
+
+ enableOHTTP();
+
+ let analysis = await product.requestAnalysis(undefined, {
+ url: ANALYSIS_API_MOCK_INVALID,
+ requestSchema: ANALYSIS_REQUEST_SCHEMA,
+ responseSchema: ANALYSIS_RESPONSE_SCHEMA,
+ });
+
+ Assert.equal(analysis, undefined, "Analysis object is invalidated");
+
+ disableOHTTP();
+});
+
+add_task(async function test_product_requestAnalysis_invalid_allowed_ohttp() {
+ let uri = new URL("https://www.walmart.com/ip/926485654");
+ let product = new ShoppingProduct(uri, { allowValidationFailure: true });
+
+ Assert.ok(product.isProduct(), "Should recognize a valid product.");
+
+ gExpectedProductDetails = JSON.stringify({
+ product_id: "926485654",
+ website: "walmart.com",
+ });
+
+ enableOHTTP();
+
+ let analysis = await product.requestAnalysis(undefined, {
+ url: ANALYSIS_API_MOCK_INVALID,
+ requestSchema: ANALYSIS_REQUEST_SCHEMA,
+ responseSchema: ANALYSIS_RESPONSE_SCHEMA,
+ });
+
+ Assert.equal(analysis.grade, 0.85, "Analysis is invalid but allowed");
+
+ disableOHTTP();
+});
+
+add_task(async function test_product_requestRecommendations() {
+ let uri = new URL("https://www.walmart.com/ip/926485654");
+ let product = new ShoppingProduct(uri, { allowValidationFailure: false });
+ if (product.isProduct()) {
+ let recommendations = await product.requestRecommendations(undefined, {
+ url: RECOMMENDATIONS_API_MOCK,
+ requestSchema: RECOMMENDATIONS_REQUEST_SCHEMA,
+ responseSchema: RECOMMENDATIONS_RESPONSE_SCHEMA,
+ });
+ Assert.ok(
+ Array.isArray(recommendations),
+ "Recommendations array is loaded from JSON and validated"
+ );
+ }
+});
+
+add_task(async function test_product_requestAnalysis_retry_failure() {
+ const TEST_TIMEOUT = 100;
+ const RETRIES = 3;
+ let uri = new URL("https://www.walmart.com/ip/926485654");
+ let product = new ShoppingProduct(uri, { allowValidationFailure: false });
+ let sandbox = sinon.createSandbox();
+ let spy = sandbox.spy(ShoppingProduct, "request");
+ let startTime = Cu.now();
+ let totalTime = TEST_TIMEOUT * Math.pow(2, RETRIES - 1);
+
+ if (product.isProduct()) {
+ let analysis = await product.requestAnalysis(undefined, {
+ url: API_SERVICE_UNAVAILABLE,
+ requestSchema: ANALYSIS_REQUEST_SCHEMA,
+ responseSchema: ANALYSIS_RESPONSE_SCHEMA,
+ });
+ Assert.equal(analysis, null, "Analysis object is null");
+ Assert.equal(
+ spy.callCount,
+ RETRIES + 1,
+ `Request was retried ${RETRIES} times after a failure`
+ );
+ Assert.ok(
+ Cu.now() - startTime >= totalTime,
+ `Waited for at least ${totalTime}ms`
+ );
+ }
+ sandbox.restore();
+});
+
+add_task(async function test_product_requestAnalysis_retry_success() {
+ let uri = new URL("https://www.walmart.com/ip/926485654");
+ let product = new ShoppingProduct(uri, { allowValidationFailure: false });
+ let sandbox = sinon.createSandbox();
+ let spy = sandbox.spy(ShoppingProduct, "request");
+ // Make sure API error count is reset
+ apiErrors = 0;
+ if (product.isProduct()) {
+ let analysis = await product.requestAnalysis(undefined, {
+ url: API_ERROR_ONCE,
+ requestSchema: ANALYSIS_REQUEST_SCHEMA,
+ responseSchema: ANALYSIS_RESPONSE_SCHEMA,
+ });
+ Assert.equal(spy.callCount, 2, `Request succeeded after a failure`);
+ Assert.ok(
+ typeof analysis == "object",
+ "Analysis object is loaded from JSON and validated"
+ );
+ }
+ sandbox.restore();
+});
+
+add_task(async function test_product_bad_request() {
+ let uri = new URL("https://www.walmart.com/ip/926485654");
+ let product = new ShoppingProduct(uri, { allowValidationFailure: false });
+
+ if (product.isProduct()) {
+ let errorResult = await product.requestAnalysis(undefined, {
+ url: API_ERROR_BAD_REQUEST,
+ requestSchema: ANALYSIS_REQUEST_SCHEMA,
+ responseSchema: ANALYSIS_RESPONSE_SCHEMA,
+ });
+ Assert.ok(
+ typeof errorResult == "object",
+ "Error object is loaded from JSON"
+ );
+ Assert.equal(errorResult.status, 400, "Error status is passed");
+ Assert.equal(errorResult.error, "Bad Request", "Error message is passed");
+ }
+});
+
+add_task(async function test_product_unprocessable_entity() {
+ let uri = new URL("https://www.walmart.com/ip/926485654");
+ let product = new ShoppingProduct(uri, { allowValidationFailure: false });
+
+ if (product.isProduct()) {
+ let errorResult = await product.requestAnalysis(undefined, {
+ url: API_ERROR_UNPROCESSABLE,
+ requestSchema: ANALYSIS_REQUEST_SCHEMA,
+ responseSchema: ANALYSIS_RESPONSE_SCHEMA,
+ });
+ Assert.ok(
+ typeof errorResult == "object",
+ "Error object is loaded from JSON"
+ );
+ Assert.equal(errorResult.status, 422, "Error status is passed");
+ Assert.equal(
+ errorResult.error,
+ "Unprocessable entity",
+ "Error message is passed"
+ );
+ }
+});
+
+add_task(async function test_ohttp_headers() {
+ let uri = new URL("https://www.walmart.com/ip/926485654");
+ let product = new ShoppingProduct(uri, { allowValidationFailure: false });
+
+ Assert.ok(product.isProduct(), "Should recognize a valid product.");
+
+ gExpectedProductDetails = JSON.stringify({
+ product_id: "926485654",
+ website: "walmart.com",
+ });
+
+ enableOHTTP();
+
+ let configURL = Services.prefs.getCharPref("toolkit.shopping.ohttpConfigURL");
+ let config = await ShoppingProduct.getOHTTPConfig(configURL);
+ Assert.ok(config, "Should have gotten a config.");
+ let ohttpDetails = await ShoppingProduct.ohttpRequest(
+ API_OHTTP_RELAY,
+ config,
+ ANALYSIS_API_MOCK,
+ {
+ method: "POST",
+ body: gExpectedProductDetails,
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ signal: new AbortController().signal,
+ }
+ );
+ Assert.equal(ohttpDetails.status, 200, "Request should return 200 OK.");
+ Assert.ok(ohttpDetails.ok, "Request should succeed.");
+ let responseHeaders = ohttpDetails.headers;
+ Assert.deepEqual(
+ responseHeaders,
+ { "content-type": "application/json" },
+ "Should have expected response headers."
+ );
+ disableOHTTP();
+});
+
+add_task(async function test_ohttp_too_many_requests() {
+ let uri = new URL("https://www.walmart.com/ip/926485654");
+ let product = new ShoppingProduct(uri, { allowValidationFailure: false });
+
+ Assert.ok(product.isProduct(), "Should recognize a valid product.");
+
+ gExpectedProductDetails = JSON.stringify({
+ product_id: "926485654",
+ website: "walmart.com",
+ });
+
+ enableOHTTP();
+
+ let configURL = Services.prefs.getCharPref("toolkit.shopping.ohttpConfigURL");
+ let config = await ShoppingProduct.getOHTTPConfig(configURL);
+ Assert.ok(config, "Should have gotten a config.");
+ let ohttpDetails = await ShoppingProduct.ohttpRequest(
+ API_OHTTP_RELAY,
+ config,
+ API_ERROR_TOO_MANY_REQUESTS,
+ {
+ method: "POST",
+ body: gExpectedProductDetails,
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ signal: new AbortController().signal,
+ }
+ );
+ Assert.equal(ohttpDetails.status, 429, "Request should return 429.");
+ Assert.equal(ohttpDetails.ok, false, "Request should not be ok.");
+
+ disableOHTTP();
+});
+
+add_task(async function test_product_uninit() {
+ let product = new ShoppingProduct();
+
+ Assert.equal(
+ product._abortController.signal.aborted,
+ false,
+ "Abort signal is false"
+ );
+
+ product.uninit();
+
+ Assert.equal(
+ product._abortController.signal.aborted,
+ true,
+ "Abort signal is given after uninit"
+ );
+});
+
+add_task(async function test_product_sendAttributionEvent_impression() {
+ let uri = new URL("https://www.walmart.com/ip/926485654");
+ let product = new ShoppingProduct(uri, { allowValidationFailure: false });
+ if (product.isProduct()) {
+ let event = await ShoppingProduct.sendAttributionEvent(
+ "impression",
+ TEST_AID,
+ "firefox_toolkit_tests",
+ {
+ url: ATTRIBUTION_API_MOCK,
+ requestSchema: ATTRIBUTION_REQUEST_SCHEMA,
+ responseSchema: ATTRIBUTION_RESPONSE_SCHEMA,
+ }
+ );
+ Assert.deepEqual(
+ event,
+ await fetch(ATTRIBUTION_API_MOCK).then(r => r.json()),
+ "Events object is loaded from JSON and validated"
+ );
+ }
+});
+
+add_task(async function test_product_sendAttributionEvent_click() {
+ let uri = new URL("https://www.walmart.com/ip/926485654");
+ let product = new ShoppingProduct(uri, { allowValidationFailure: false });
+ if (product.isProduct()) {
+ let event = await ShoppingProduct.sendAttributionEvent(
+ "click",
+ TEST_AID,
+ "firefox_toolkit_tests",
+ {
+ url: ATTRIBUTION_API_MOCK,
+ requestSchema: ATTRIBUTION_REQUEST_SCHEMA,
+ responseSchema: ATTRIBUTION_RESPONSE_SCHEMA,
+ }
+ );
+ Assert.deepEqual(
+ event,
+ await fetch(ATTRIBUTION_API_MOCK).then(r => r.json()),
+ "Events object is loaded from JSON and validated"
+ );
+ }
+});
+
+add_task(async function test_product_sendAttributionEvent_impression_OHTTP() {
+ let uri = new URL("https://www.walmart.com/ip/926485654");
+ let product = new ShoppingProduct(uri, { allowValidationFailure: false });
+
+ Assert.ok(product.isProduct(), "Should recognize a valid product.");
+
+ gExpectedProductDetails = JSON.stringify({
+ event_source: "firefox_toolkit_tests",
+ event_name: "trusted_deals_impression",
+ aidvs: [TEST_AID],
+ });
+
+ enableOHTTP();
+
+ let event = await ShoppingProduct.sendAttributionEvent(
+ "impression",
+ TEST_AID,
+ "firefox_toolkit_tests",
+ {
+ url: ATTRIBUTION_API_MOCK,
+ requestSchema: ATTRIBUTION_REQUEST_SCHEMA,
+ responseSchema: ATTRIBUTION_RESPONSE_SCHEMA,
+ }
+ );
+
+ Assert.deepEqual(
+ event,
+ await fetch(ATTRIBUTION_API_MOCK).then(r => r.json()),
+ "Events object is loaded from JSON and validated"
+ );
+
+ disableOHTTP();
+});
+
+add_task(async function test_product_sendAttributionEvent_click_OHTTP() {
+ let uri = new URL("https://www.walmart.com/ip/926485654");
+ let product = new ShoppingProduct(uri, { allowValidationFailure: false });
+
+ Assert.ok(product.isProduct(), "Should recognize a valid product.");
+
+ gExpectedProductDetails = JSON.stringify({
+ event_source: "firefox_toolkit_tests",
+ event_name: "trusted_deals_link_clicked",
+ aid: TEST_AID,
+ });
+
+ enableOHTTP();
+
+ let event = await ShoppingProduct.sendAttributionEvent(
+ "click",
+ TEST_AID,
+ "firefox_toolkit_tests",
+ {
+ url: ATTRIBUTION_API_MOCK,
+ requestSchema: ATTRIBUTION_REQUEST_SCHEMA,
+ responseSchema: ATTRIBUTION_RESPONSE_SCHEMA,
+ }
+ );
+
+ Assert.deepEqual(
+ event,
+ await fetch(ATTRIBUTION_API_MOCK).then(r => r.json()),
+ "Events object is loaded from JSON and validated"
+ );
+
+ disableOHTTP();
+});
+
+add_task(async function test_product_sendAttributionEvent_placement_OHTTP() {
+ let uri = new URL("https://www.walmart.com/ip/926485654");
+ let product = new ShoppingProduct(uri, { allowValidationFailure: false });
+
+ Assert.ok(product.isProduct(), "Should recognize a valid product.");
+
+ gExpectedProductDetails = JSON.stringify({
+ event_source: "firefox_toolkit_tests",
+ event_name: "trusted_deals_placement",
+ aidvs: [TEST_AID],
+ });
+
+ enableOHTTP();
+
+ let event = await ShoppingProduct.sendAttributionEvent(
+ "placement",
+ TEST_AID,
+ "firefox_toolkit_tests",
+ {
+ url: ATTRIBUTION_API_MOCK,
+ requestSchema: ATTRIBUTION_REQUEST_SCHEMA,
+ responseSchema: ATTRIBUTION_RESPONSE_SCHEMA,
+ }
+ );
+
+ Assert.deepEqual(
+ event,
+ await fetch(ATTRIBUTION_API_MOCK).then(r => r.json()),
+ "Events object is loaded from JSON and validated"
+ );
+
+ disableOHTTP();
+});
+
+add_task(async function test_product_requestAnalysis_poll() {
+ let uri = new URL("https://www.walmart.com/ip/926485654");
+ let product = new ShoppingProduct(uri, { allowValidationFailure: false });
+ let sandbox = sinon.createSandbox();
+ let spy = sandbox.spy(ShoppingProduct, "request");
+ let startTime = Cu.now();
+ const INITIAL_TIMEOUT = 100;
+ const TIMEOUT = 50;
+ const TRIES = 10;
+ let totalTime = INITIAL_TIMEOUT + TIMEOUT;
+
+ pollingTries = 0;
+ if (!product.isProduct()) {
+ return;
+ }
+ let analysis = await product.pollForAnalysisCompleted({
+ url: API_POLL,
+ requestSchema: ANALYSIS_STATUS_REQUEST_SCHEMA,
+ responseSchema: ANALYSIS_STATUS_RESPONSE_SCHEMA,
+ pollInitialWait: INITIAL_TIMEOUT,
+ pollTimeout: TIMEOUT,
+ pollAttempts: TRIES,
+ });
+
+ Assert.equal(spy.callCount, 3, "Request is done processing");
+ Assert.ok(
+ typeof analysis == "object",
+ "Analysis object is loaded from JSON and validated"
+ );
+ Assert.equal(analysis.status, "completed", "Analysis is completed");
+ Assert.equal(analysis.progress, 100.0, "Progress is 100%");
+ Assert.ok(
+ Cu.now() - startTime >= totalTime,
+ `Waited for at least ${totalTime}ms`
+ );
+
+ sandbox.restore();
+});
+
+add_task(async function test_product_requestAnalysis_poll_max() {
+ let uri = new URL("https://www.walmart.com/ip/926485654");
+ let product = new ShoppingProduct(uri, { allowValidationFailure: false });
+ let sandbox = sinon.createSandbox();
+ let spy = sandbox.spy(ShoppingProduct, "request");
+ let startTime = Cu.now();
+
+ const INITIAL_TIMEOUT = 100;
+ const TIMEOUT = 50;
+ const TRIES = 4;
+ let totalTime = INITIAL_TIMEOUT + TIMEOUT * 3;
+
+ pollingTries = 0;
+ if (!product.isProduct()) {
+ return;
+ }
+ let analysis = await product.pollForAnalysisCompleted({
+ url: API_ANALYSIS_IN_PROGRESS,
+ requestSchema: ANALYSIS_STATUS_REQUEST_SCHEMA,
+ responseSchema: ANALYSIS_STATUS_RESPONSE_SCHEMA,
+ pollInitialWait: INITIAL_TIMEOUT,
+ pollTimeout: TIMEOUT,
+ pollAttempts: TRIES,
+ });
+
+ Assert.equal(spy.callCount, TRIES, "Request is done processing");
+ Assert.ok(
+ typeof analysis == "object",
+ "Analysis object is loaded from JSON and validated"
+ );
+ Assert.equal(analysis.status, "in_progress", "Analysis not done");
+ Assert.ok(
+ Cu.now() - startTime >= totalTime,
+ `Waited for at least ${totalTime}ms`
+ );
+ sandbox.restore();
+});
+
+add_task(async function test_product_requestAnalysisCreationStatus() {
+ let uri = new URL("https://www.walmart.com/ip/926485654");
+ let product = new ShoppingProduct(uri, { allowValidationFailure: false });
+ if (!product.isProduct()) {
+ return;
+ }
+ let analysis = await product.requestAnalysisCreationStatus(undefined, {
+ url: API_ANALYSIS_IN_PROGRESS,
+ requestSchema: ANALYSIS_STATUS_REQUEST_SCHEMA,
+ responseSchema: ANALYSIS_STATUS_RESPONSE_SCHEMA,
+ });
+ Assert.ok(
+ typeof analysis == "object",
+ "Analysis object is loaded from JSON and validated"
+ );
+ Assert.equal(analysis.status, "in_progress", "Analysis is in progress");
+ Assert.equal(analysis.progress, 50.0, "Progress is 50%");
+});
+
+add_task(async function test_product_requestCreateAnalysis() {
+ let uri = new URL("https://www.walmart.com/ip/926485654");
+ let product = new ShoppingProduct(uri, { allowValidationFailure: false });
+ if (!product.isProduct()) {
+ return;
+ }
+ let analysis = await product.requestCreateAnalysis(undefined, {
+ url: ANALYZE_API_MOCK,
+ requestSchema: ANALYZE_REQUEST_SCHEMA,
+ responseSchema: ANALYZE_RESPONSE_SCHEMA,
+ });
+ Assert.ok(
+ typeof analysis == "object",
+ "Analyze object is loaded from JSON and validated"
+ );
+ Assert.equal(analysis.status, "pending", "Analysis is pending");
+});
+
+add_task(async function test_product_sendReport() {
+ let uri = new URL("https://www.walmart.com/ip/926485654");
+ let product = new ShoppingProduct(uri, { allowValidationFailure: false });
+
+ Assert.ok(product.isProduct(), "Should recognize a valid product.");
+
+ let report = await product.sendReport(undefined, {
+ url: REPORTING_API_MOCK,
+ });
+
+ Assert.ok(
+ typeof report == "object",
+ "Report object is loaded from JSON and validated"
+ );
+ Assert.equal(report.message, "report created", "Report is created.");
+});
+
+add_task(async function test_product_sendReport_OHTTP() {
+ let uri = new URL("https://www.walmart.com/ip/926485654");
+ let product = new ShoppingProduct(uri, { allowValidationFailure: false });
+
+ Assert.ok(product.isProduct(), "Should recognize a valid product.");
+
+ gExpectedProductDetails = JSON.stringify({
+ product_id: "926485654",
+ website: "walmart.com",
+ });
+
+ enableOHTTP();
+
+ let report = await product.sendReport(undefined, {
+ url: REPORTING_API_MOCK,
+ });
+
+ Assert.ok(
+ typeof report == "object",
+ "Report object is loaded from JSON and validated"
+ );
+ Assert.equal(report.message, "report created", "Report is created.");
+ disableOHTTP();
+});
+
+add_task(async function test_product_analysisProgress_event() {
+ let uri = new URL("https://www.walmart.com/ip/926485654");
+ let product = new ShoppingProduct(uri, { allowValidationFailure: false });
+
+ const INITIAL_TIMEOUT = 0;
+ const TIMEOUT = 0;
+ const TRIES = 1;
+
+ if (!product.isProduct()) {
+ return;
+ }
+
+ let analysisProgressEventData;
+ product.on("analysis-progress", (eventName, progress) => {
+ analysisProgressEventData = progress;
+ });
+
+ await product.pollForAnalysisCompleted({
+ url: API_ANALYSIS_IN_PROGRESS,
+ requestSchema: ANALYSIS_STATUS_REQUEST_SCHEMA,
+ responseSchema: ANALYSIS_STATUS_RESPONSE_SCHEMA,
+ pollInitialWait: INITIAL_TIMEOUT,
+ pollTimeout: TIMEOUT,
+ pollAttempts: TRIES,
+ });
+
+ Assert.equal(
+ analysisProgressEventData,
+ 50,
+ "Analysis progress event data is emitted"
+ );
+});
diff --git a/toolkit/components/shopping/test/xpcshell/test_product_urls.js b/toolkit/components/shopping/test/xpcshell/test_product_urls.js
new file mode 100644
index 0000000000..ea3fc6da71
--- /dev/null
+++ b/toolkit/components/shopping/test/xpcshell/test_product_urls.js
@@ -0,0 +1,297 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { ShoppingProduct, isProductURL } = ChromeUtils.importESModule(
+ "chrome://global/content/shopping/ShoppingProduct.mjs"
+);
+
+add_task(function test_product_fromUrl() {
+ Assert.deepEqual(
+ ShoppingProduct.fromURL(),
+ { valid: false },
+ "Passing a nothing returns empty result object"
+ );
+
+ Assert.deepEqual(
+ ShoppingProduct.fromURL(12345),
+ { valid: false },
+ "Passing a number returns empty result object"
+ );
+
+ Assert.deepEqual(
+ ShoppingProduct.fromURL("https://www.walmart.com/ip/926485654"),
+ { valid: false },
+ "String urls returns empty result object"
+ );
+
+ Assert.deepEqual(
+ ShoppingProduct.fromURL(new URL("https://www.mozilla.org")),
+ { host: "mozilla.org", valid: false },
+ "Invalid Url returns a full result object"
+ );
+
+ Assert.deepEqual(
+ ShoppingProduct.fromURL(new URL("https://www.walmart.com/ip/926485654"))
+ .host,
+ "walmart.com",
+ "WWW in host is ignored"
+ );
+
+ Assert.deepEqual(
+ ShoppingProduct.fromURL(
+ new URL("https://staging.walmart.com/ip/926485654")
+ ),
+ { host: "staging.walmart.com", valid: false },
+ "Subdomain in valid Product Url returns partial result"
+ );
+
+ Assert.deepEqual(
+ ShoppingProduct.fromURL(new URL("https://walmart.co.uk/ip/926485654")),
+ { host: "walmart.co.uk", sitename: "walmart", valid: false },
+ "Invalid in Product TLD returns partial result"
+ );
+
+ Assert.deepEqual(
+ ShoppingProduct.fromURL(new URL("https://walmart.com")),
+ { host: "walmart.com", sitename: "walmart", tld: "com", valid: false },
+ "Non-Product page returns partial result"
+ );
+
+ Assert.deepEqual(
+ ShoppingProduct.fromURL(new URL("https://walmart.com/ip/926485654")),
+ {
+ host: "walmart.com",
+ sitename: "walmart",
+ tld: "com",
+ id: "926485654",
+ valid: true,
+ },
+ "Valid Product Url returns a full result object"
+ );
+
+ Assert.deepEqual(
+ ShoppingProduct.fromURL(new URL("http://walmart.com/ip/926485654")),
+ {
+ host: "walmart.com",
+ sitename: "walmart",
+ tld: "com",
+ id: "926485654",
+ valid: true,
+ },
+ "Protocol is not checked"
+ );
+
+ Assert.deepEqual(
+ ShoppingProduct.fromURL(new URL("https://amazon.fr/product/dp/ABCDEFG123")),
+ {
+ host: "amazon.fr",
+ sitename: "amazon",
+ tld: "fr",
+ id: "ABCDEFG123",
+ valid: true,
+ },
+ "Valid French Product Url returns a full result object"
+ );
+
+ Assert.deepEqual(
+ ShoppingProduct.fromURL(new URL("https://amazon.de/product/dp/ABCDEFG123")),
+ {
+ host: "amazon.de",
+ sitename: "amazon",
+ tld: "de",
+ id: "ABCDEFG123",
+ valid: true,
+ },
+ "Valid German Product Url returns a full result object"
+ );
+});
+
+add_task(function test_product_isProduct() {
+ let product = {
+ host: "walmart.com",
+ sitename: "walmart",
+ tld: "com",
+ id: "926485654",
+ valid: true,
+ };
+ Assert.equal(
+ ShoppingProduct.isProduct(product),
+ true,
+ "Passing a Product object returns true"
+ );
+ Assert.equal(
+ ShoppingProduct.isProduct({ host: "walmart.com", sitename: "walmart" }),
+ false,
+ "Passing an incomplete ShoppingProduct object returns false"
+ );
+ Assert.equal(
+ ShoppingProduct.isProduct(),
+ false,
+ "Passing nothing returns false"
+ );
+});
+
+add_task(function test_amazon_product_urls() {
+ let product;
+ let url_com = new URL(
+ "https://www.amazon.com/Furmax-Electric-Adjustable-Standing-Computer/dp/B09TJGHL5F/"
+ );
+ let url_ca = new URL(
+ "https://www.amazon.ca/JBL-Flip-Essential-Waterproof-Bluetooth/dp/B0C3NNGWFN/"
+ );
+ let url_uk = new URL(
+ "https://www.amazon.co.uk/placeholder_title/dp/B0B8KGPHS7/"
+ );
+ let url_content = new URL("https://www.amazon.com/stores/node/20648519011");
+
+ product = ShoppingProduct.fromURL(url_com);
+ Assert.equal(ShoppingProduct.isProduct(product), true, "Url is a product");
+ Assert.equal(product.id, "B09TJGHL5F", "Product id was found in Url");
+
+ product = ShoppingProduct.fromURL(url_ca);
+ Assert.equal(
+ ShoppingProduct.isProduct(product),
+ false,
+ "Url is not a supported tld"
+ );
+
+ product = ShoppingProduct.fromURL(url_uk);
+ Assert.equal(
+ ShoppingProduct.isProduct(product),
+ false,
+ "Url is not a supported tld"
+ );
+
+ product = ShoppingProduct.fromURL(url_content);
+ Assert.equal(
+ ShoppingProduct.isProduct(product),
+ false,
+ "Url is not a product"
+ );
+});
+
+add_task(function test_walmart_product_urls() {
+ let product;
+ let url_com = new URL(
+ "https://www.walmart.com/ip/Kent-Bicycles-29-Men-s-Trouvaille-Mountain-Bike-Medium-Black-and-Taupe/823391155"
+ );
+ let url_ca = new URL(
+ "https://www.walmart.ca/en/ip/cherries-jumbo/6000187473587"
+ );
+ let url_content = new URL(
+ "https://www.walmart.com/browse/food/grilling-foods/976759_1567409_8808777"
+ );
+
+ product = ShoppingProduct.fromURL(url_com);
+ Assert.equal(ShoppingProduct.isProduct(product), true, "Url is a product");
+ Assert.equal(product.id, "823391155", "Product id was found in Url");
+
+ product = ShoppingProduct.fromURL(url_ca);
+ Assert.equal(
+ ShoppingProduct.isProduct(product),
+ false,
+ "Url is not a valid tld"
+ );
+
+ product = ShoppingProduct.fromURL(url_content);
+ Assert.equal(
+ ShoppingProduct.isProduct(product),
+ false,
+ "Url is not a product"
+ );
+});
+
+add_task(function test_bestbuy_product_urls() {
+ let product;
+ let url_com = new URL(
+ "https://www.bestbuy.com/site/ge-profile-ultrafast-4-8-cu-ft-large-capacity-all-in-one-washer-dryer-combo-with-ventless-heat-pump-technology-carbon-graphite/6530134.p?skuId=6530134"
+ );
+ let url_ca = new URL(
+ "https://www.bestbuy.ca/en-ca/product/segway-ninebot-kickscooter-f40-electric-scooter-40km-range-30km-h-top-speed-dark-grey/15973012"
+ );
+ let url_content = new URL(
+ "https://www.bestbuy.com/site/home-appliances/major-appliances-sale-event/pcmcat321600050000.c"
+ );
+
+ product = ShoppingProduct.fromURL(url_com);
+ Assert.equal(ShoppingProduct.isProduct(product), true, "Url is a product");
+ Assert.equal(product.id, "6530134.p", "Product id was found in Url");
+
+ product = ShoppingProduct.fromURL(url_ca);
+ Assert.equal(
+ ShoppingProduct.isProduct(product),
+ false,
+ "Url is not a valid tld"
+ );
+
+ product = ShoppingProduct.fromURL(url_content);
+ Assert.equal(
+ ShoppingProduct.isProduct(product),
+ false,
+ "Url is not a product"
+ );
+});
+
+add_task(function test_isProductURL() {
+ let product_string =
+ "https://www.amazon.com/Furmax-Electric-Adjustable-Standing-Computer/dp/B09TJGHL5F/";
+ let product_url = new URL(product_string);
+ let product_uri = Services.io.newURI(product_string);
+ Assert.equal(
+ isProductURL(product_url),
+ true,
+ "Passing a product URL returns true"
+ );
+ Assert.equal(
+ isProductURL(product_uri),
+ true,
+ "Passing a product URI returns true"
+ );
+
+ let content_string =
+ "https://www.walmart.com/browse/food/grilling-foods/976759_1567409_8808777";
+ let content_url = new URL(content_string);
+ let content_uri = Services.io.newURI(content_string);
+ Assert.equal(
+ isProductURL(content_url),
+ false,
+ "Passing a content URL returns false"
+ );
+ Assert.equal(
+ isProductURL(content_uri),
+ false,
+ "Passing a content URI returns false"
+ );
+
+ Assert.equal(isProductURL(), false, "Passing nothing returns false");
+
+ Assert.equal(isProductURL(1234), false, "Passing a number returns false");
+
+ Assert.equal(
+ isProductURL("1234"),
+ false,
+ "Passing a junk string returns false"
+ );
+});
+
+add_task(function test_new_ShoppingProduct() {
+ let product_string =
+ "https://www.amazon.com/Furmax-Electric-Adjustable-Standing-Computer/dp/B09TJGHL5F/";
+ let product_url = new URL(product_string);
+ let product_uri = Services.io.newURI(product_string);
+ let productURL = new ShoppingProduct(product_url);
+ Assert.equal(
+ productURL.isProduct(),
+ true,
+ "Passing a product URL returns a valid product"
+ );
+ let productURI = new ShoppingProduct(product_uri);
+ Assert.equal(
+ productURI.isProduct(),
+ true,
+ "Passing a product URI returns a valid product"
+ );
+});
diff --git a/toolkit/components/shopping/test/xpcshell/test_product_validator.js b/toolkit/components/shopping/test/xpcshell/test_product_validator.js
new file mode 100644
index 0000000000..5aab07dbf5
--- /dev/null
+++ b/toolkit/components/shopping/test/xpcshell/test_product_validator.js
@@ -0,0 +1,91 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+/* global loadJSONfromFile */
+
+const {
+ ANALYSIS_RESPONSE_SCHEMA,
+ ANALYSIS_REQUEST_SCHEMA,
+ RECOMMENDATIONS_RESPONSE_SCHEMA,
+ RECOMMENDATIONS_REQUEST_SCHEMA,
+} = ChromeUtils.importESModule(
+ "chrome://global/content/shopping/ProductConfig.mjs"
+);
+
+const { ProductValidator } = ChromeUtils.importESModule(
+ "chrome://global/content/shopping/ProductValidator.sys.mjs"
+);
+
+add_task(async function test_validate_analysis() {
+ const json = await loadJSONfromFile("data/analysis_response.json");
+ let valid = await ProductValidator.validate(json, ANALYSIS_RESPONSE_SCHEMA);
+
+ Assert.equal(valid, true, "Analysis JSON is valid");
+});
+
+add_task(async function test_validate_analysis_invalid() {
+ const json = await loadJSONfromFile("data/invalid_analysis_response.json");
+ let valid = await ProductValidator.validate(json, ANALYSIS_RESPONSE_SCHEMA);
+
+ Assert.equal(valid, false, "Analysis JSON is invalid");
+});
+
+add_task(async function test_validate_recommendations() {
+ const json = await loadJSONfromFile("data/recommendations_response.json");
+ let valid = await ProductValidator.validate(
+ json,
+ RECOMMENDATIONS_RESPONSE_SCHEMA
+ );
+
+ Assert.equal(valid, true, "Recommendations JSON is valid");
+});
+
+add_task(async function test_validate_recommendations_invalid() {
+ const json = await loadJSONfromFile(
+ "data/invalid_recommendations_response.json"
+ );
+ let valid = await ProductValidator.validate(
+ json,
+ RECOMMENDATIONS_RESPONSE_SCHEMA
+ );
+
+ Assert.equal(valid, false, "Recommendations JSON is invalid");
+});
+
+add_task(async function test_validate_analysis() {
+ const json = await loadJSONfromFile("data/analysis_request.json");
+ let valid = await ProductValidator.validate(json, ANALYSIS_REQUEST_SCHEMA);
+
+ Assert.equal(valid, true, "Analysis JSON is valid");
+});
+
+add_task(async function test_validate_analysis_invalid() {
+ const json = await loadJSONfromFile("data/invalid_analysis_request.json");
+ let valid = await ProductValidator.validate(json, ANALYSIS_REQUEST_SCHEMA);
+
+ Assert.equal(valid, false, "Analysis JSON is invalid");
+});
+
+add_task(async function test_validate_recommendations() {
+ const json = await loadJSONfromFile("data/recommendations_request.json");
+ let valid = await ProductValidator.validate(
+ json,
+ RECOMMENDATIONS_REQUEST_SCHEMA
+ );
+
+ Assert.equal(valid, true, "Recommendations JSON is valid");
+});
+
+add_task(async function test_validate_recommendations_invalid() {
+ const json = await loadJSONfromFile(
+ "data/invalid_recommendations_request.json"
+ );
+ let valid = await ProductValidator.validate(
+ json,
+ RECOMMENDATIONS_REQUEST_SCHEMA
+ );
+
+ Assert.equal(valid, false, "Recommendations JSON is invalid");
+});
diff --git a/toolkit/components/shopping/test/xpcshell/xpcshell.toml b/toolkit/components/shopping/test/xpcshell/xpcshell.toml
new file mode 100644
index 0000000000..a95d53dca3
--- /dev/null
+++ b/toolkit/components/shopping/test/xpcshell/xpcshell.toml
@@ -0,0 +1,38 @@
+[DEFAULT]
+head = "head.js"
+prefs = [
+ "toolkit.shopping.environment='test'",
+ "toolkit.shopping.ohttpConfigURL=''",
+ "toolkit.shopping.ohttpRelayURL=''",
+]
+
+support-files = [
+ "data/analysis_response.json",
+ "data/recommendations_response.json",
+ "data/invalid_analysis_response.json",
+ "data/invalid_recommendations_response.json",
+ "data/analysis_request.json",
+ "data/recommendations_request.json",
+ "data/invalid_analysis_request.json",
+ "data/invalid_recommendations_request.json",
+ "data/service_unavailable.json",
+ "data/bad_request.json",
+ "data/unprocessable_entity.json",
+ "data/needs_analysis_response.json",
+ "data/attribution_response.json",
+ "data/image.jpg",
+ "data/report_response.json",
+ "data/analysis_status_completed_response.json",
+ "data/analysis_status_in_progress_response.json",
+ "data/analysis_status_pending_response.json",
+ "data/analyze_pending.json",
+ "data/too_many_requests.json",
+]
+
+["test_fetchImage.js"]
+
+["test_product.js"]
+
+["test_product_urls.js"]
+
+["test_product_validator.js"]