summaryrefslogtreecommitdiffstats
path: root/toolkit/components/shopping
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
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')
-rw-r--r--toolkit/components/shopping/content/ProductConfig.mjs139
-rw-r--r--toolkit/components/shopping/content/ProductValidator.sys.mjs36
-rw-r--r--toolkit/components/shopping/content/ShoppingProduct.mjs962
-rw-r--r--toolkit/components/shopping/jar.mn20
-rw-r--r--toolkit/components/shopping/metrics.yaml160
-rw-r--r--toolkit/components/shopping/moz.build14
-rw-r--r--toolkit/components/shopping/schemas/analysis_request.schema.json19
-rw-r--r--toolkit/components/shopping/schemas/analysis_response.schema.json109
-rw-r--r--toolkit/components/shopping/schemas/analysis_status_request.schema.json19
-rw-r--r--toolkit/components/shopping/schemas/analysis_status_response.schema.json29
-rw-r--r--toolkit/components/shopping/schemas/analyze_request.schema.json19
-rw-r--r--toolkit/components/shopping/schemas/analyze_response.schema.json22
-rw-r--r--toolkit/components/shopping/schemas/attribution_request.schema.json38
-rw-r--r--toolkit/components/shopping/schemas/attribution_response.schema.json16
-rw-r--r--toolkit/components/shopping/schemas/recommendations_request.schema.json19
-rw-r--r--toolkit/components/shopping/schemas/recommendations_response.schema.json57
-rw-r--r--toolkit/components/shopping/schemas/reporting_request.schema.json19
-rw-r--r--toolkit/components/shopping/schemas/reporting_response.schema.json13
-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
58 files changed, 4659 insertions, 0 deletions
diff --git a/toolkit/components/shopping/content/ProductConfig.mjs b/toolkit/components/shopping/content/ProductConfig.mjs
new file mode 100644
index 0000000000..ddbb5317af
--- /dev/null
+++ b/toolkit/components/shopping/content/ProductConfig.mjs
@@ -0,0 +1,139 @@
+/* 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/. */
+
+let { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const ANALYSIS_RESPONSE_SCHEMA =
+ "chrome://global/content/shopping/analysis_response.schema.json";
+const ANALYSIS_REQUEST_SCHEMA =
+ "chrome://global/content/shopping/analysis_request.schema.json";
+
+const ANALYZE_RESPONSE_SCHEMA =
+ "chrome://global/content/shopping/analyze_response.schema.json";
+const ANALYZE_REQUEST_SCHEMA =
+ "chrome://global/content/shopping/analyze_request.schema.json";
+
+const ANALYSIS_STATUS_RESPONSE_SCHEMA =
+ "chrome://global/content/shopping/analysis_status_response.schema.json";
+const ANALYSIS_STATUS_REQUEST_SCHEMA =
+ "chrome://global/content/shopping/analysis_status_request.schema.json";
+
+const RECOMMENDATIONS_RESPONSE_SCHEMA =
+ "chrome://global/content/shopping/recommendations_response.schema.json";
+const RECOMMENDATIONS_REQUEST_SCHEMA =
+ "chrome://global/content/shopping/recommendations_request.schema.json";
+
+const ATTRIBUTION_RESPONSE_SCHEMA =
+ "chrome://global/content/shopping/attribution_response.schema.json";
+const ATTRIBUTION_REQUEST_SCHEMA =
+ "chrome://global/content/shopping/attribution_request.schema.json";
+
+const REPORTING_RESPONSE_SCHEMA =
+ "chrome://global/content/shopping/reporting_response.schema.json";
+const REPORTING_REQUEST_SCHEMA =
+ "chrome://global/content/shopping/reporting_request.schema.json";
+
+// Allow switching to the stage or test environments by changing the
+// "toolkit.shopping.environment" pref from "prod" to "stage" or "test".
+const lazy = {};
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "env",
+ "toolkit.shopping.environment",
+ "prod",
+ null,
+ // If the pref is set to an unexpected string value, "prod" will be used.
+ prefValue =>
+ ["prod", "stage", "test"].includes(prefValue) ? prefValue : "prod"
+);
+
+// prettier-ignore
+const Environments = {
+ prod: {
+ ANALYSIS_API: "https://trustwerty.com/api/v2/fx/analysis",
+ ANALYSIS_STATUS_API: "https://trustwerty.com/api/v1/fx/analysis_status",
+ ANALYZE_API: "https://trustwerty.com/api/v1/fx/analyze",
+ ATTRIBUTION_API: "https://pe.fakespot.com/api/v1/fx/events",
+ RECOMMENDATIONS_API: "https://a.fakespot.com/v1/fx/sp_search",
+ REPORTING_API: "https://trustwerty.com/api/v1/fx/report",
+ },
+ stage: {
+ ANALYSIS_API: "https://staging.trustwerty.com/api/v2/fx/analysis",
+ ANALYSIS_STATUS_API: "https://staging.trustwerty.com/api/v1/fx/analysis_status",
+ ANALYZE_API: "https://staging.trustwerty.com/api/v1/fx/analyze",
+ ATTRIBUTION_API: "https://staging-partner-ads.fakespot.io/api/v1/fx/events",
+ RECOMMENDATIONS_API: "https://staging-affiliates.fakespot.io/v1/fx/sp_search",
+ REPORTING_API: "https://staging.trustwerty.com/api/v1/fx/report",
+ },
+ test: {
+ ANALYSIS_API: "https://example.com/browser/toolkit/components/shopping/test/browser/analysis.sjs",
+ ANALYSIS_STATUS_API: "https://example.com/browser/toolkit/components/shopping/test/browser/analysis_status.sjs",
+ ANALYZE_API: "https://example.com/browser/toolkit/components/shopping/test/browser/analyze.sjs",
+ ATTRIBUTION_API: "https://example.com/browser/toolkit/components/shopping/test/browser/attribution.sjs",
+ RECOMMENDATIONS_API: "https://example.com/browser/toolkit/components/shopping/test/browser/recommendations.sjs",
+ REPORTING_API: "https://example.com/browser/toolkit/components/shopping/test/browser/reporting.sjs",
+ },
+};
+
+const ShoppingEnvironment = {
+ get ANALYSIS_API() {
+ return Environments[lazy.env].ANALYSIS_API;
+ },
+ get ANALYSIS_STATUS_API() {
+ return Environments[lazy.env].ANALYSIS_STATUS_API;
+ },
+ get ANALYZE_API() {
+ return Environments[lazy.env].ANALYZE_API;
+ },
+ get ATTRIBUTION_API() {
+ return Environments[lazy.env].ATTRIBUTION_API;
+ },
+ get RECOMMENDATIONS_API() {
+ return Environments[lazy.env].RECOMMENDATIONS_API;
+ },
+ get REPORTING_API() {
+ return Environments[lazy.env].REPORTING_API;
+ },
+};
+
+const ProductConfig = {
+ amazon: {
+ productIdFromURLRegex:
+ /(?:[\/]|$|%2F)(?<productId>[A-Z0-9]{10})(?:[\/]|$|\#|\?|%2F)/,
+ validTLDs: ["com", "de", "fr"],
+ },
+ walmart: {
+ productIdFromURLRegex:
+ /\/ip\/(?:[A-Za-z0-9-]{1,320}\/)?(?<productId>[0-9]{3,13})/,
+ validTLDs: ["com"],
+ },
+ bestbuy: {
+ productIdFromURLRegex: /\/(?<productId>\d+\.p)/,
+ validTLDs: ["com"],
+ },
+};
+
+if (lazy.env == "test") {
+ // Also allow example.com to allow for testing.
+ ProductConfig.example = ProductConfig.amazon;
+}
+
+export {
+ ANALYSIS_RESPONSE_SCHEMA,
+ ANALYSIS_REQUEST_SCHEMA,
+ ANALYZE_RESPONSE_SCHEMA,
+ ANALYZE_REQUEST_SCHEMA,
+ ANALYSIS_STATUS_RESPONSE_SCHEMA,
+ ANALYSIS_STATUS_REQUEST_SCHEMA,
+ RECOMMENDATIONS_RESPONSE_SCHEMA,
+ RECOMMENDATIONS_REQUEST_SCHEMA,
+ ATTRIBUTION_RESPONSE_SCHEMA,
+ ATTRIBUTION_REQUEST_SCHEMA,
+ REPORTING_RESPONSE_SCHEMA,
+ REPORTING_REQUEST_SCHEMA,
+ ProductConfig,
+ ShoppingEnvironment,
+};
diff --git a/toolkit/components/shopping/content/ProductValidator.sys.mjs b/toolkit/components/shopping/content/ProductValidator.sys.mjs
new file mode 100644
index 0000000000..113093151a
--- /dev/null
+++ b/toolkit/components/shopping/content/ProductValidator.sys.mjs
@@ -0,0 +1,36 @@
+/* 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/. */
+
+import { JsonSchema } from "resource://gre/modules/JsonSchema.sys.mjs";
+
+let schemas = {};
+
+/**
+ * Validate JSON result from the shopping API.
+ *
+ * @param {object} json
+ * JSON object from the API request.
+ * @param {string} SchemaURL
+ * URL string for the schema to validate with.
+ * @param {boolean} logErrors
+ * Should invalid JSON log out the errors.
+ * @returns {boolean} result
+ * If the JSON is valid or not.
+ */
+async function validate(json, SchemaURL, logErrors) {
+ if (!schemas[SchemaURL]) {
+ schemas[SchemaURL] = await fetch(SchemaURL).then(rsp => rsp.json());
+ }
+
+ let result = JsonSchema.validate(json, schemas[SchemaURL]);
+ let { errors } = result;
+ if (!result.valid && logErrors) {
+ console.error(`Invalid result: ${JSON.stringify(errors, undefined, 2)}`);
+ }
+ return result.valid;
+}
+
+export const ProductValidator = {
+ validate,
+};
diff --git a/toolkit/components/shopping/content/ShoppingProduct.mjs b/toolkit/components/shopping/content/ShoppingProduct.mjs
new file mode 100644
index 0000000000..d457ac1579
--- /dev/null
+++ b/toolkit/components/shopping/content/ShoppingProduct.mjs
@@ -0,0 +1,962 @@
+/* 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/. */
+
+import {
+ ANALYSIS_RESPONSE_SCHEMA,
+ ANALYSIS_REQUEST_SCHEMA,
+ ANALYZE_RESPONSE_SCHEMA,
+ ANALYZE_REQUEST_SCHEMA,
+ ANALYSIS_STATUS_RESPONSE_SCHEMA,
+ ANALYSIS_STATUS_REQUEST_SCHEMA,
+ RECOMMENDATIONS_RESPONSE_SCHEMA,
+ RECOMMENDATIONS_REQUEST_SCHEMA,
+ ATTRIBUTION_RESPONSE_SCHEMA,
+ ATTRIBUTION_REQUEST_SCHEMA,
+ REPORTING_RESPONSE_SCHEMA,
+ REPORTING_REQUEST_SCHEMA,
+ ProductConfig,
+ ShoppingEnvironment,
+} from "chrome://global/content/shopping/ProductConfig.mjs";
+
+let { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+let { EventEmitter } = ChromeUtils.importESModule(
+ "resource://gre/modules/EventEmitter.sys.mjs"
+);
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ HPKEConfigManager: "resource://gre/modules/HPKEConfigManager.sys.mjs",
+ ProductValidator: "chrome://global/content/shopping/ProductValidator.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+const API_RETRIES = 3;
+const API_RETRY_TIMEOUT = 100;
+const API_POLL_ATTEMPTS = 260;
+const API_POLL_INITIAL_WAIT = 1000;
+const API_POLL_WAIT = 1000;
+
+XPCOMUtils.defineLazyServiceGetters(lazy, {
+ ohttpService: [
+ "@mozilla.org/network/oblivious-http-service;1",
+ Ci.nsIObliviousHttpService,
+ ],
+});
+
+ChromeUtils.defineLazyGetter(lazy, "decoder", () => new TextDecoder());
+
+const StringInputStream = Components.Constructor(
+ "@mozilla.org/io/string-input-stream;1",
+ "nsIStringInputStream",
+ "setData"
+);
+
+const BinaryInputStream = Components.Constructor(
+ "@mozilla.org/binaryinputstream;1",
+ "nsIBinaryInputStream",
+ "setInputStream"
+);
+
+function readFromStream(stream, count) {
+ let binaryStream = new BinaryInputStream(stream);
+ 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 arrayBuffer;
+}
+
+/**
+ * @typedef {object} Product
+ * A parsed product for a URL
+ * @property {number} id
+ * The product id of the product.
+ * @property {string} host
+ * The host of a product url (without www)
+ * @property {string} tld
+ * The top level domain of a URL
+ * @property {string} sitename
+ * The name of a website (without TLD or subdomains)
+ * @property {boolean} valid
+ * If the product is valid or not
+ */
+
+/**
+ * Class for working with the products shopping API,
+ * with helpers for parsing the product from a url
+ * and querying the shopping API for information on it.
+ *
+ * @example
+ * let product = new ShoppingProduct(url);
+ * if (product.isProduct()) {
+ * let analysis = await product.requestAnalysis();
+ * let recommendations = await product.requestRecommendations();
+ * }
+ * @example
+ * if (!isProductURL(url)) {
+ * return;
+ * }
+ * let product = new ShoppingProduct(url);
+ * let analysis = await product.requestAnalysis();
+ * let recommendations = await product.requestRecommendations();
+ */
+export class ShoppingProduct extends EventEmitter {
+ /**
+ * Creates a product.
+ *
+ * @param {URL} url
+ * URL to get the product info from.
+ * @param {object} [options]
+ * @param {boolean} [options.allowValidationFailure=true]
+ * Should validation failures be allowed or return null
+ */
+ constructor(url, options = { allowValidationFailure: true }) {
+ super();
+
+ this.allowValidationFailure = !!options.allowValidationFailure;
+
+ this._abortController = new AbortController();
+
+ if (url instanceof Ci.nsIURI) {
+ url = URL.fromURI(url);
+ }
+
+ if (url && URL.isInstance(url)) {
+ let product = this.constructor.fromURL(url);
+ this.assignProduct(product);
+ }
+ }
+
+ /**
+ * Gets a Product from a URL.
+ *
+ * @param {URL} url
+ * URL to find a product in.
+ * @returns {Product}
+ */
+ static fromURL(url) {
+ let host, sitename, tld;
+ let result = { valid: false };
+
+ if (!url || !URL.isInstance(url)) {
+ return result;
+ }
+
+ // Lowercase hostname and remove www.
+ host = url.hostname.replace(/^www\./i, "");
+ result.host = host;
+
+ // Get host TLD
+ try {
+ tld = Services.eTLD.getPublicSuffixFromHost(host);
+ } catch (_) {
+ return result;
+ }
+ if (!tld.length) {
+ return result;
+ }
+
+ // Remove tld and the preceding period to get the sitename
+ sitename = host.slice(0, -(tld.length + 1));
+
+ // Check if sitename is one the API has products for
+ let siteConfig = ProductConfig[sitename];
+ if (!siteConfig) {
+ return result;
+ }
+ result.sitename = sitename;
+
+ // Check if API has products for this TLD
+ if (!siteConfig.validTLDs.includes(tld)) {
+ return result;
+ }
+ result.tld = tld;
+
+ // Try to find a product id from the pathname.
+ let found = url.pathname.match(siteConfig.productIdFromURLRegex);
+ if (!found?.groups) {
+ return result;
+ }
+
+ let { productId } = found.groups;
+ if (!productId) {
+ return result;
+ }
+ result.id = productId;
+
+ // Mark product as valid and complete.
+ result.valid = true;
+
+ return result;
+ }
+
+ /**
+ * Check if a Product is a valid product.
+ *
+ * @param {Product} product
+ * Product to check.
+ * @returns {boolean}
+ */
+ static isProduct(product) {
+ return !!(
+ product &&
+ product.valid &&
+ product.id &&
+ product.host &&
+ product.sitename &&
+ product.tld
+ );
+ }
+
+ /**
+ * Check if a the instances product is a valid product.
+ *
+ * @returns {boolean}
+ */
+ isProduct() {
+ return this.constructor.isProduct(this.product);
+ }
+
+ /**
+ * Assign a product to this instance.
+ */
+ assignProduct(product) {
+ if (this.constructor.isProduct(product)) {
+ this.product = product;
+ }
+ }
+
+ /**
+ * Request analysis for a product from the API.
+ *
+ * @param {Product} product
+ * Product to request for (defaults to the instances product).
+ * @param {object} options
+ * Override default API url and schema.
+ * @returns {object} result
+ * Parsed JSON API result or null.
+ */
+ async requestAnalysis(
+ product = this.product,
+ options = {
+ url: ShoppingEnvironment.ANALYSIS_API,
+ requestSchema: ANALYSIS_REQUEST_SCHEMA,
+ responseSchema: ANALYSIS_RESPONSE_SCHEMA,
+ }
+ ) {
+ if (!product) {
+ return null;
+ }
+
+ let requestOptions = {
+ product_id: product.id,
+ website: product.host,
+ };
+
+ let { url, requestSchema, responseSchema } = options;
+ let { allowValidationFailure } = this;
+
+ let result = await ShoppingProduct.request(url, requestOptions, {
+ requestSchema,
+ responseSchema,
+ allowValidationFailure,
+ });
+
+ return result;
+ }
+
+ /**
+ * Request recommended related products from the API.
+ * Currently only provides recommendations for Amazon products,
+ * which may be paid ads.
+ *
+ * @param {Product} product
+ * Product to request for (defaults to the instances product).
+ * @param {object} options
+ * Override default API url and schema.
+ * @returns {object} result
+ * Parsed JSON API result or null.
+ */
+ async requestRecommendations(
+ product = this.product,
+ options = {
+ url: ShoppingEnvironment.RECOMMENDATIONS_API,
+ requestSchema: RECOMMENDATIONS_REQUEST_SCHEMA,
+ responseSchema: RECOMMENDATIONS_RESPONSE_SCHEMA,
+ }
+ ) {
+ if (!product) {
+ return null;
+ }
+
+ let requestOptions = {
+ product_id: product.id,
+ website: product.host,
+ };
+ let { url, requestSchema, responseSchema } = options;
+ let { allowValidationFailure } = this;
+ let result = await ShoppingProduct.request(url, requestOptions, {
+ requestSchema,
+ responseSchema,
+ allowValidationFailure,
+ });
+
+ for (let ad of result) {
+ ad.image_blob = await ShoppingProduct.requestImageBlob(ad.image_url, {
+ signal: this._abortController.signal,
+ });
+ }
+
+ return result;
+ }
+
+ /**
+ * Request method for shopping API.
+ *
+ * @param {string} apiURL
+ * URL string for the API to request.
+ * @param {object} bodyObj
+ * What to send to the API.
+ * @param {object} [options]
+ * Options for validation and retries.
+ * @param {string} [options.requestSchema]
+ * URL string for the JSON Schema to validated the request.
+ * @param {string} [options.responseSchema]
+ * URL string for the JSON Schema to validated the response.
+ * @param {int} [options.failCount]
+ * Current number of failures for this request.
+ * @param {int} [options.maxRetries=API_RETRIES]
+ * Max number of allowed failures.
+ * @param {int} [options.retryTimeout=API_RETRY_TIMEOUT]
+ * Minimum time to wait.
+ * @param {AbortSignal} [options.signal]
+ * Signal to check if the request needs to be aborted.
+ * @param {boolean} [options.allowValidationFailure=true]
+ * Should validation failures be allowed.
+ * @returns {object} result
+ * Parsed JSON API result or null.
+ */
+ static async request(apiURL, bodyObj = {}, options = {}) {
+ let {
+ requestSchema,
+ responseSchema,
+ failCount = 0,
+ maxRetries = API_RETRIES,
+ retryTimeout = API_RETRY_TIMEOUT,
+ signal = new AbortController().signal,
+ allowValidationFailure = true,
+ } = options;
+
+ if (signal.aborted) {
+ return null;
+ }
+
+ if (bodyObj && requestSchema) {
+ let validRequest = await lazy.ProductValidator.validate(
+ bodyObj,
+ requestSchema,
+ allowValidationFailure
+ );
+ if (!validRequest) {
+ Glean?.shoppingProduct?.invalidRequest.record();
+ if (!allowValidationFailure) {
+ return null;
+ }
+ }
+ }
+
+ let requestOptions = {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ },
+ body: JSON.stringify(bodyObj),
+ signal,
+ };
+
+ let requestPromise;
+ let ohttpRelayURL = Services.prefs.getStringPref(
+ "toolkit.shopping.ohttpRelayURL",
+ ""
+ );
+ let ohttpConfigURL = Services.prefs.getStringPref(
+ "toolkit.shopping.ohttpConfigURL",
+ ""
+ );
+ if (ohttpRelayURL && ohttpConfigURL) {
+ let config = await ShoppingProduct.getOHTTPConfig(ohttpConfigURL);
+ // In the time it took to fetch the OHTTP config, we might have been
+ // aborted...
+ if (signal.aborted) {
+ Glean?.shoppingProduct?.requestAborted.record();
+ return null;
+ }
+ if (!config) {
+ Glean?.shoppingProduct?.invalidOhttpConfig.record();
+ console.error(
+ new Error(
+ "OHTTP was configured for shopping but we couldn't get a valid config."
+ )
+ );
+ return null;
+ }
+ requestPromise = ShoppingProduct.ohttpRequest(
+ ohttpRelayURL,
+ config,
+ apiURL,
+ requestOptions
+ );
+ } else {
+ requestPromise = fetch(apiURL, requestOptions);
+ }
+
+ let result;
+ let responseOk;
+ let responseStatus;
+ try {
+ let response = await requestPromise;
+ responseOk = response.ok;
+ responseStatus = response.status;
+ result = await response.json();
+
+ if (responseOk && responseSchema) {
+ let validResponse = await lazy.ProductValidator.validate(
+ result,
+ responseSchema,
+ allowValidationFailure
+ );
+ if (!validResponse) {
+ Glean?.shoppingProduct?.invalidResponse.record();
+ if (!allowValidationFailure) {
+ return null;
+ }
+ }
+ }
+ } catch (error) {
+ Glean?.shoppingProduct?.requestError.record();
+ console.error(error);
+ }
+
+ if (!responseOk && responseStatus < 500) {
+ Glean?.shoppingProduct?.requestFailure.record();
+ }
+
+ // Retry 500 errors.
+ if (!responseOk && responseStatus >= 500) {
+ failCount++;
+
+ Glean?.shoppingProduct?.serverFailure.record();
+
+ // Make sure we still want to retry
+ if (failCount > maxRetries) {
+ Glean?.shoppingProduct?.requestRetriesFailed.record();
+ return null;
+ }
+
+ Glean?.shoppingProduct?.requestRetried.record();
+ // Wait for a back off timeout base on the number of failures.
+ let backOff = retryTimeout * Math.pow(2, failCount - 1);
+
+ await new Promise(resolve => lazy.setTimeout(resolve, backOff));
+
+ // Try the request again.
+ options.failCount = failCount;
+ result = await ShoppingProduct.request(apiURL, bodyObj, options);
+ }
+
+ return result;
+ }
+
+ /**
+ * Get a cached, or fetch a copy of, an OHTTP config from a given URL.
+ *
+ *
+ * @param {string} gatewayConfigURL
+ * The URL for the config that needs to be fetched.
+ * The URL should be complete (i.e. include the full path to the config).
+ * @returns {Uint8Array}
+ * The config bytes.
+ */
+ static async getOHTTPConfig(gatewayConfigURL) {
+ return lazy.HPKEConfigManager.get(gatewayConfigURL);
+ }
+
+ /**
+ * Make a request over OHTTP.
+ *
+ * @param {string} obliviousHTTPRelay
+ * The URL of the OHTTP relay to use.
+ * @param {Uint8Array} config
+ * A byte array representing the OHTTP config.
+ * @param {string} requestURL
+ * The URL of the request we want to make over the relay.
+ * @param {object} options
+ * @param {string} options.method
+ * The HTTP method to use for the inner request. Only GET and POST
+ * are supported right now.
+ * @param {string} options.body
+ * The body content to send over the request.
+ * @param {object} options.headers
+ * The request headers to set. Each property of the object represents
+ * a header, with the key the header name and the value the header value.
+ * @param {AbortSignal} options.signal
+ * If the consumer passes an AbortSignal object, aborting the signal
+ * will abort the request.
+ *
+ * @returns {object}
+ * Returns an object with properties mimicking that of a normal fetch():
+ * .ok = boolean indicating whether the request was successful.
+ * .status = integer representation of the HTTP status code
+ * .headers = object representing the response headers.
+ * .json() = method that returns the parsed JSON response body.
+ */
+ static async ohttpRequest(
+ obliviousHTTPRelay,
+ config,
+ requestURL,
+ { method, body, headers, signal } = {}
+ ) {
+ let relayURI = Services.io.newURI(obliviousHTTPRelay);
+ let requestURI = Services.io.newURI(requestURL);
+ let obliviousHttpChannel = lazy.ohttpService
+ .newChannel(relayURI, requestURI, config)
+ .QueryInterface(Ci.nsIHttpChannel);
+
+ if (method == "POST") {
+ let uploadChannel = obliviousHttpChannel.QueryInterface(
+ Ci.nsIUploadChannel2
+ );
+ let bodyStream = new StringInputStream(body, body.length);
+ uploadChannel.explicitSetUploadStream(
+ bodyStream,
+ null,
+ -1,
+ "POST",
+ false
+ );
+ }
+
+ for (let headerName of Object.keys(headers)) {
+ obliviousHttpChannel.setRequestHeader(
+ headerName,
+ headers[headerName],
+ false
+ );
+ }
+ let abortHandler = e => {
+ Glean?.shoppingProduct?.requestAborted.record();
+ obliviousHttpChannel.cancel(Cr.NS_BINDING_ABORTED);
+ };
+ signal.addEventListener("abort", abortHandler);
+ return new Promise((resolve, reject) => {
+ let listener = {
+ _buffer: [],
+ _headers: null,
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIStreamListener",
+ "nsIRequestObserver",
+ ]),
+ onStartRequest(request) {
+ this._headers = {};
+ try {
+ request
+ .QueryInterface(Ci.nsIHttpChannel)
+ .visitResponseHeaders((header, value) => {
+ this._headers[header.toLowerCase()] = value;
+ });
+ } catch (error) {
+ this._headers = null;
+ }
+ },
+ onDataAvailable(request, stream, offset, count) {
+ this._buffer.push(readFromStream(stream, count));
+ },
+ onStopRequest(request, requestStatus) {
+ signal.removeEventListener("abort", abortHandler);
+ let result = this._buffer;
+ try {
+ let ohttpStatus = request.QueryInterface(Ci.nsIObliviousHttpChannel)
+ .relayChannel.responseStatus;
+ if (ohttpStatus == 200) {
+ let httpStatus = request.QueryInterface(
+ Ci.nsIHttpChannel
+ ).responseStatus;
+ resolve({
+ ok: requestStatus == Cr.NS_OK && httpStatus == 200,
+ status: httpStatus,
+ headers: this._headers,
+ json() {
+ let decodedBuffer = result.reduce((accumulator, currVal) => {
+ return accumulator + lazy.decoder.decode(currVal);
+ }, "");
+ return JSON.parse(decodedBuffer);
+ },
+ blob() {
+ return new Blob(result, { type: "image/jpeg" });
+ },
+ });
+ } else {
+ resolve({
+ ok: false,
+ status: ohttpStatus,
+ json() {
+ return null;
+ },
+ blob() {
+ return null;
+ },
+ });
+ }
+ } catch (error) {
+ reject(error);
+ }
+ },
+ };
+ obliviousHttpChannel.asyncOpen(listener);
+ });
+ }
+
+ /**
+ * Requests an image for a recommended product.
+ *
+ * @param {string} imageUrl
+ * @returns {blob} A blob of the image
+ */
+ static async requestImageBlob(imageUrl, options = {}) {
+ let { signal = new AbortController().signal } = options;
+ let ohttpRelayURL = Services.prefs.getStringPref(
+ "toolkit.shopping.ohttpRelayURL",
+ ""
+ );
+ let ohttpConfigURL = Services.prefs.getStringPref(
+ "toolkit.shopping.ohttpConfigURL",
+ ""
+ );
+
+ let imgRequestPromise;
+ if (ohttpRelayURL && ohttpConfigURL) {
+ let config = await ShoppingProduct.getOHTTPConfig(ohttpConfigURL);
+ if (!config) {
+ Glean?.shoppingProduct?.invalidOhttpConfig.record();
+ console.error(
+ new Error(
+ "OHTTP was configured for shopping but we couldn't get a valid config."
+ )
+ );
+ return null;
+ }
+
+ let imgRequestOptions = {
+ signal,
+ headers: {
+ Accept: "image/jpeg",
+ "Content-Type": "image/jpeg",
+ },
+ };
+
+ imgRequestPromise = ShoppingProduct.ohttpRequest(
+ ohttpRelayURL,
+ config,
+ imageUrl,
+ imgRequestOptions
+ );
+ } else {
+ imgRequestPromise = fetch(imageUrl);
+ }
+
+ let imgResult;
+ try {
+ let response = await imgRequestPromise;
+ imgResult = await response.blob();
+ } catch (error) {
+ console.error(error);
+ }
+
+ return imgResult;
+ }
+
+ /**
+ * Poll Analysis Status API until an analysis has finished.
+ *
+ * After an initial wait keep checking the api for results,
+ * until we have reached a maximum of tries.
+ *
+ * Passes all arguments to requestAnalysisCreationStatus().
+ *
+ * @example
+ * let analysis;
+ * let { status } = await product.pollForAnalysisCompleted();
+ * // Check if analysis has finished
+ * if(status != "pending" && status != "in_progress") {
+ * // Get the new analysis
+ * analysis = await product.requestAnalysis();
+ * }
+ *
+ * @example
+ * // Check the current status
+ * let { status } = await product.requestAnalysisCreationStatus();
+ * if(status == "pending" && status == "in_progress") {
+ * // Start polling without the initial timeout if the analysis
+ * // is already in progress.
+ * await product.pollForAnalysisCompleted({
+ * pollInitialWait: analysisStatus == "in_progress" ? 0 : undefined,
+ * });
+ * }
+ * @param {object} options
+ * Override default API url and schema.
+ * @returns {object} result
+ * Parsed JSON API result or null.
+ */
+ async pollForAnalysisCompleted(options) {
+ let pollCount = 0;
+ let initialWait = options?.pollInitialWait || API_POLL_INITIAL_WAIT;
+ let pollTimeout = options?.pollTimeout || API_POLL_WAIT;
+ let pollAttempts = options?.pollAttempts || API_POLL_ATTEMPTS;
+ let isFinished = false;
+ let result;
+
+ while (!isFinished && pollCount < pollAttempts) {
+ if (this._abortController.signal.aborted) {
+ Glean?.shoppingProduct?.requestAborted.record();
+ return null;
+ }
+ let backOff = pollCount == 0 ? initialWait : pollTimeout;
+ if (backOff) {
+ await new Promise(resolve => lazy.setTimeout(resolve, backOff));
+ }
+ try {
+ result = await this.requestAnalysisCreationStatus(undefined, options);
+ if (result?.progress) {
+ this.emit("analysis-progress", result.progress);
+ }
+ isFinished =
+ result &&
+ result.status != "pending" &&
+ result.status != "in_progress";
+ } catch (error) {
+ console.error(error);
+ return null;
+ }
+ pollCount++;
+ }
+ return result;
+ }
+
+ /**
+ * Request that the API creates an analysis for a product.
+ *
+ * Once the processing status indicates that analyzing is complete,
+ * the new analysis data that can be requested with `requestAnalysis`.
+ *
+ * If the product is currently being analyzed, this will return a
+ * status of "in_progress" and not trigger a reanalyzing the product.
+ *
+ * @param {Product} product
+ * Product to request for (defaults to the instances product).
+ * @param {object} options
+ * Override default API url and schema.
+ * @returns {object} result
+ * Parsed JSON API result or null.
+ */
+ async requestCreateAnalysis(product = this.product, options = {}) {
+ let url = options?.url || ShoppingEnvironment.ANALYZE_API;
+ let requestSchema = options?.requestSchema || ANALYZE_REQUEST_SCHEMA;
+ let responseSchema = options?.responseSchema || ANALYZE_RESPONSE_SCHEMA;
+ let signal = options?.signal || this._abortController.signal;
+ let allowValidationFailure = this.allowValidationFailure;
+
+ if (!product) {
+ return null;
+ }
+
+ let requestOptions = {
+ product_id: product.id,
+ website: product.host,
+ };
+
+ let result = await ShoppingProduct.request(url, requestOptions, {
+ requestSchema,
+ responseSchema,
+ signal,
+ allowValidationFailure,
+ });
+
+ return result;
+ }
+
+ /**
+ * Check the status of creating an analysis for a product.
+ *
+ * API returns a progress of 0-100 complete and the processing status.
+ *
+ * @param {Product} product
+ * Product to request for (defaults to the instances product).
+ * @param {object} options
+ * Override default API url and schema.
+ * @returns {object} result
+ * Parsed JSON API result or null.
+ */
+ async requestAnalysisCreationStatus(product = this.product, options = {}) {
+ let url = options?.url || ShoppingEnvironment.ANALYSIS_STATUS_API;
+ let requestSchema =
+ options?.requestSchema || ANALYSIS_STATUS_REQUEST_SCHEMA;
+ let responseSchema =
+ options?.responseSchema || ANALYSIS_STATUS_RESPONSE_SCHEMA;
+ let signal = options?.signal || this._abortController.signal;
+ let allowValidationFailure = this.allowValidationFailure;
+
+ if (!product) {
+ return null;
+ }
+
+ let requestOptions = {
+ product_id: product.id,
+ website: product.host,
+ };
+
+ let result = await ShoppingProduct.request(url, requestOptions, {
+ requestSchema,
+ responseSchema,
+ signal,
+ allowValidationFailure,
+ });
+
+ return result;
+ }
+
+ /**
+ * Send an event to the Ad Attribution API
+ *
+ * @param {string} eventName
+ * Event name options are:
+ * - "impression"
+ * - "click"
+ * @param {string} aid
+ * The aid (Ad ID) from the recommendation.
+ * @param {string} [source]
+ * Source of the event
+ * @param {object} [options]
+ * Override default API url and schema.
+ * @returns {object} result
+ * Parsed JSON API result or null.
+ */
+ static async sendAttributionEvent(
+ eventName,
+ aid,
+ source = "firefox_sidebar",
+ options = {}
+ ) {
+ let {
+ url = ShoppingEnvironment.ATTRIBUTION_API,
+ requestSchema = ATTRIBUTION_REQUEST_SCHEMA,
+ responseSchema = ATTRIBUTION_RESPONSE_SCHEMA,
+ signal = new AbortController().signal,
+ allowValidationFailure = true,
+ } = options;
+
+ if (!eventName) {
+ throw new Error("An event name is required.");
+ }
+ if (!aid) {
+ throw new Error("An Ad ID is required.");
+ }
+
+ let requestBody = {
+ event_source: source,
+ };
+
+ switch (eventName) {
+ case "impression":
+ requestBody.event_name = "trusted_deals_impression";
+ requestBody.aidvs = [aid];
+ break;
+ case "click":
+ requestBody.event_name = "trusted_deals_link_clicked";
+ requestBody.aid = aid;
+ break;
+ case "placement":
+ requestBody.event_name = "trusted_deals_placement";
+ requestBody.aidvs = [aid];
+ break;
+ default:
+ throw new Error(`"${eventName}" is not a valid event name`);
+ }
+
+ let result = await ShoppingProduct.request(url, requestBody, {
+ requestSchema,
+ responseSchema,
+ signal,
+ allowValidationFailure,
+ });
+
+ return result;
+ }
+
+ /**
+ * Send a report that a product is back in stock.
+ *
+ * @param {Product} product
+ * Product to request for (defaults to the instances product).
+ * @param {object} options
+ * Override default API url and schema.
+ * @returns {object} result
+ * Parsed JSON API result or null.
+ */
+ async sendReport(product = this.product, options = {}) {
+ if (!product) {
+ return null;
+ }
+
+ let url = options?.url || ShoppingEnvironment.REPORTING_API;
+ let requestSchema = options?.requestSchema || REPORTING_REQUEST_SCHEMA;
+ let responseSchema = options?.responseSchema || REPORTING_RESPONSE_SCHEMA;
+ let signal = options?.signal || this._abortController.signal;
+ let allowValidationFailure = this.allowValidationFailure;
+
+ let requestOptions = {
+ product_id: product.id,
+ website: product.host,
+ };
+
+ let result = await ShoppingProduct.request(url, requestOptions, {
+ requestSchema,
+ responseSchema,
+ signal,
+ allowValidationFailure,
+ });
+
+ return result;
+ }
+
+ uninit() {
+ this._abortController.abort();
+ this.product = null;
+ }
+}
+
+/**
+ * Check if a URL is a valid product.
+ *
+ * @param {URL | nsIURI } url
+ * URL to check.
+ * @returns {boolean}
+ */
+export function isProductURL(url) {
+ if (url instanceof Ci.nsIURI) {
+ url = URL.fromURI(url);
+ }
+ if (!URL.isInstance(url)) {
+ return false;
+ }
+ let productInfo = ShoppingProduct.fromURL(url);
+ return ShoppingProduct.isProduct(productInfo);
+}
diff --git a/toolkit/components/shopping/jar.mn b/toolkit/components/shopping/jar.mn
new file mode 100644
index 0000000000..d6a9720b10
--- /dev/null
+++ b/toolkit/components/shopping/jar.mn
@@ -0,0 +1,20 @@
+# 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/.
+
+toolkit.jar:
+ content/global/shopping/ProductConfig.mjs (content/ProductConfig.mjs)
+ content/global/shopping/ProductValidator.sys.mjs (content/ProductValidator.sys.mjs)
+ content/global/shopping/ShoppingProduct.mjs (content/ShoppingProduct.mjs)
+ content/global/shopping/analysis_response.schema.json (schemas/analysis_response.schema.json)
+ content/global/shopping/recommendations_response.schema.json (schemas/recommendations_response.schema.json)
+ content/global/shopping/analysis_request.schema.json (schemas/analysis_request.schema.json)
+ content/global/shopping/recommendations_request.schema.json (schemas/recommendations_request.schema.json)
+ content/global/shopping/attribution_response.schema.json (schemas/attribution_response.schema.json)
+ content/global/shopping/attribution_request.schema.json (schemas/attribution_request.schema.json)
+ content/global/shopping/reporting_response.schema.json (schemas/reporting_response.schema.json)
+ content/global/shopping/reporting_request.schema.json (schemas/reporting_request.schema.json)
+ content/global/shopping/analysis_status_request.schema.json (schemas/analysis_status_request.schema.json)
+ content/global/shopping/analysis_status_response.schema.json (schemas/analysis_status_response.schema.json)
+ content/global/shopping/analyze_request.schema.json (schemas/analyze_request.schema.json)
+ content/global/shopping/analyze_response.schema.json (schemas/analyze_response.schema.json)
diff --git a/toolkit/components/shopping/metrics.yaml b/toolkit/components/shopping/metrics.yaml
new file mode 100644
index 0000000000..b357c9ed5f
--- /dev/null
+++ b/toolkit/components/shopping/metrics.yaml
@@ -0,0 +1,160 @@
+# 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/.
+
+# Adding a new metric? We have docs for that!
+# https://firefox-source-docs.mozilla.org/toolkit/components/glean/user/new_definitions_file.html
+
+---
+$schema: moz://mozilla.org/schemas/glean/metrics/2-0-0
+$tags:
+ - "Firefox :: Shopping"
+
+shopping_product:
+ request_error:
+ type: event
+ description: |
+ There was an error requesting the Fakespot API.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1848386
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1848386
+ data_sensitivity:
+ - technical
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - events
+ request_failure:
+ type: event
+ description: |
+ There was a failure with the request to the Fakespot API.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1848386
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1848386
+ data_sensitivity:
+ - technical
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - events
+ server_failure:
+ type: event
+ description: |
+ There was a Fakespot API server issue that prevented
+ the request from succeeding.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1848386
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1848386
+ data_sensitivity:
+ - technical
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - events
+ request_retried:
+ type: event
+ description: |
+ Status returned a 500 error when requesting the Fakespot API
+ and will be retried.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1848386
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1848386
+ data_sensitivity:
+ - technical
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - events
+ request_retries_failed:
+ type: event
+ description: |
+ Request still failed after the maxiumn number of
+ retry events.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1848386
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1848386
+ data_sensitivity:
+ - technical
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - events
+ request_aborted:
+ type: event
+ description: |
+ Request to the Fakespot API was aborted.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1848386
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1848386
+ data_sensitivity:
+ - technical
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - events
+ invalid_request:
+ type: event
+ description: |
+ An invalid JSON request was sent to the Fakespot API.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1848386
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1848386
+ data_sensitivity:
+ - technical
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - events
+ invalid_response:
+ type: event
+ description: |
+ An invalid JSON response was received from the Fakespot API.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1848386
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1848386
+ data_sensitivity:
+ - technical
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - events
+ invalid_ohttp_config:
+ type: event
+ description: |
+ OHTTP was configured for shopping but the config is invalid.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1848386
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1848386
+ data_sensitivity:
+ - technical
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - events
diff --git a/toolkit/components/shopping/moz.build b/toolkit/components/shopping/moz.build
new file mode 100644
index 0000000000..927854a5e6
--- /dev/null
+++ b/toolkit/components/shopping/moz.build
@@ -0,0 +1,14 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+with Files("**"):
+ BUG_COMPONENT = ("Firefox", "Shopping")
+
+XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell/xpcshell.toml"]
+
+BROWSER_CHROME_MANIFESTS += ["test/browser/browser.toml"]
+
+JAR_MANIFESTS += ["jar.mn"]
diff --git a/toolkit/components/shopping/schemas/analysis_request.schema.json b/toolkit/components/shopping/schemas/analysis_request.schema.json
new file mode 100644
index 0000000000..5332f2f842
--- /dev/null
+++ b/toolkit/components/shopping/schemas/analysis_request.schema.json
@@ -0,0 +1,19 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$id": "chrome://global/content/shopping/analysis_request.schema.json",
+ "title": "Product request",
+ "type": "object",
+ "properties": {
+ "product_id": {
+ "description": "Product identifier (ASIN / SKU).",
+ "type": "string",
+ "examples": ["B07W59LRL9", "5200904.p", "1752657021"]
+ },
+ "website": {
+ "description": "Product websites.",
+ "type": "string",
+ "examples": ["amazon.com", "amazon.ca", "bestbuy.com", "walmart.com"]
+ }
+ },
+ "required": ["product_id", "website"]
+}
diff --git a/toolkit/components/shopping/schemas/analysis_response.schema.json b/toolkit/components/shopping/schemas/analysis_response.schema.json
new file mode 100644
index 0000000000..cb84f9981e
--- /dev/null
+++ b/toolkit/components/shopping/schemas/analysis_response.schema.json
@@ -0,0 +1,109 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$id": "chrome://global/content/shopping/analysis_response.schema.json",
+ "title": "Product",
+ "description": "A product analysis result",
+ "type": "object",
+ "properties": {
+ "product_id": {
+ "description": "Product identifier (ASIN / SKU).",
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "examples": ["B07W59LRL9", "5200904.p", "1752657021"]
+ },
+ "grade": {
+ "description": "Reliability grade for the product's reviews.",
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "examples": ["A", "B", "C", "D", "F"]
+ },
+ "adjusted_rating": {
+ "description": "Product rating adjusted to exclude untrusted reviews.",
+ "anyOf": [
+ {
+ "type": "number"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "examples": [4.7, null]
+ },
+ "needs_analysis": {
+ "description": "Boolean indicating if the analysis is stale.",
+ "type": "boolean"
+ },
+ "page_not_supported": {
+ "description": "Boolean indicating if current product page is supported or not.",
+ "type": "boolean"
+ },
+ "not_enough_reviews": {
+ "description": "Boolean indicating if the product has enough reviews to analyze.",
+ "type": "boolean"
+ },
+ "last_analysis_time": {
+ "description": "Integer indicating last analysis time since 1970-01-01 00:00:00 +0000",
+ "type": "number"
+ },
+ "deleted_product": {
+ "description": "Boolean indicating if product is marked as deleted by website in Fakespot database",
+ "type": "boolean"
+ },
+ "deleted_product_reported": {
+ "description": "Boolean indicating if product marked as deleted has already been reported as in stock by a user",
+ "type": "boolean"
+ },
+ "highlights": {
+ "description": "Object containing highlights for Amazon product.",
+ "type": "object",
+ "properties": {
+ "quality": {
+ "description": "Highlights related to quality.",
+ "type": "object",
+ "$ref": "#/$defs/highlights"
+ },
+ "price": {
+ "description": "Highlights related to price.",
+ "type": "object",
+ "$ref": "#/$defs/highlights"
+ },
+ "shipping": {
+ "description": "Highlights related to shipping.",
+ "type": "object",
+ "$ref": "#/$defs/highlights"
+ },
+ "packaging/appearance": {
+ "description": "Highlights related to packaging or appearance.",
+ "type": "object",
+ "$ref": "#/$defs/highlights"
+ },
+ "competitiveness": {
+ "description": "Highlights related to competitiveness.",
+ "type": "object",
+ "$ref": "#/$defs/highlights"
+ }
+ }
+ }
+ },
+ "$defs": {
+ "highlights": {
+ "description": "Possibly empty array of highlights.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+}
diff --git a/toolkit/components/shopping/schemas/analysis_status_request.schema.json b/toolkit/components/shopping/schemas/analysis_status_request.schema.json
new file mode 100644
index 0000000000..8015b1bbcb
--- /dev/null
+++ b/toolkit/components/shopping/schemas/analysis_status_request.schema.json
@@ -0,0 +1,19 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$id": "chrome://global/content/shopping/analysis_status_request.schema.json",
+ "title": "Analysis Status request",
+ "type": "object",
+ "properties": {
+ "product_id": {
+ "description": "Product identifier (ASIN / SKU).",
+ "type": "string",
+ "examples": ["B07W59LRL9", "5200904.p", "1752657021"]
+ },
+ "website": {
+ "description": "Product websites.",
+ "type": "string",
+ "examples": ["amazon.com", "amazon.ca", "bestbuy.com", "walmart.com"]
+ }
+ },
+ "required": ["product_id", "website"]
+}
diff --git a/toolkit/components/shopping/schemas/analysis_status_response.schema.json b/toolkit/components/shopping/schemas/analysis_status_response.schema.json
new file mode 100644
index 0000000000..bb116280de
--- /dev/null
+++ b/toolkit/components/shopping/schemas/analysis_status_response.schema.json
@@ -0,0 +1,29 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$id": "chrome://global/content/shopping/analysis_status_response.schema.json",
+ "title": "Analysis Status response",
+ "type": "object",
+ "properties": {
+ "status": {
+ "description": "Current Analysis status",
+ "type": "string",
+ "examples": [
+ "not_found",
+ "pending",
+ "in_progress",
+ "completed",
+ "not_analyzable",
+ "unprocessable",
+ "page_not_supported",
+ "not_enough_reviews",
+ "stale"
+ ]
+ },
+ "progress": {
+ "description": "Current Analysis progress 0.0..100.0",
+ "type": "number",
+ "examples": [10.0]
+ }
+ },
+ "required": ["status", "progress"]
+}
diff --git a/toolkit/components/shopping/schemas/analyze_request.schema.json b/toolkit/components/shopping/schemas/analyze_request.schema.json
new file mode 100644
index 0000000000..f2d957b50d
--- /dev/null
+++ b/toolkit/components/shopping/schemas/analyze_request.schema.json
@@ -0,0 +1,19 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$id": "chrome://global/content/shopping/analyze_request.schema.json",
+ "title": "Analyze request",
+ "type": "object",
+ "properties": {
+ "product_id": {
+ "description": "Product identifier (ASIN / SKU).",
+ "type": "string",
+ "examples": ["B07W59LRL9", "5200904.p", "1752657021"]
+ },
+ "website": {
+ "description": "Product websites.",
+ "type": "string",
+ "examples": ["amazon.com", "amazon.ca", "bestbuy.com", "walmart.com"]
+ }
+ },
+ "required": ["product_id", "website"]
+}
diff --git a/toolkit/components/shopping/schemas/analyze_response.schema.json b/toolkit/components/shopping/schemas/analyze_response.schema.json
new file mode 100644
index 0000000000..ad9ea31707
--- /dev/null
+++ b/toolkit/components/shopping/schemas/analyze_response.schema.json
@@ -0,0 +1,22 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$id": "chrome://global/content/shopping/analyze_response.schema.json",
+ "title": "Analyze response",
+ "type": "object",
+ "properties": {
+ "status": {
+ "description": "Current Analysis status",
+ "type": "string",
+ "examples": [
+ "pending",
+ "in_progress",
+ "completed",
+ "not_analyzable",
+ "unprocessable",
+ "page_not_supported",
+ "not_enough_reviews"
+ ]
+ }
+ },
+ "required": ["status"]
+}
diff --git a/toolkit/components/shopping/schemas/attribution_request.schema.json b/toolkit/components/shopping/schemas/attribution_request.schema.json
new file mode 100644
index 0000000000..1ab3bd146a
--- /dev/null
+++ b/toolkit/components/shopping/schemas/attribution_request.schema.json
@@ -0,0 +1,38 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$id": "chrome://global/content/shopping/events_request.schema.json",
+ "title": "Ad events request",
+ "type": "object",
+ "properties": {
+ "event_name": {
+ "description": "Event name string",
+ "type": "string",
+ "examples": [
+ "trusted_deals_impression",
+ "trusted_deals_link_clicked",
+ "trusted_deals_placement"
+ ]
+ },
+ "event_source": {
+ "description": "Where event was dispatched from",
+ "type": "string",
+ "examples": ["firefox_sidebar"]
+ },
+ "aidvs": {
+ "description": "Ad identifiers for impression",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "aid": {
+ "description": "Ad identifier for clicks",
+ "type": "string"
+ },
+ "properties": {
+ "description": "Extra properties",
+ "type": "object"
+ }
+ },
+ "required": ["event_name", "event_source"]
+}
diff --git a/toolkit/components/shopping/schemas/attribution_response.schema.json b/toolkit/components/shopping/schemas/attribution_response.schema.json
new file mode 100644
index 0000000000..db3f8aacdd
--- /dev/null
+++ b/toolkit/components/shopping/schemas/attribution_response.schema.json
@@ -0,0 +1,16 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$id": "chrome://global/content/shopping/events_response.schema.json",
+ "title": "Ad events response",
+ "type": "object",
+ "additionalProperties": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+}
diff --git a/toolkit/components/shopping/schemas/recommendations_request.schema.json b/toolkit/components/shopping/schemas/recommendations_request.schema.json
new file mode 100644
index 0000000000..f76b32ea40
--- /dev/null
+++ b/toolkit/components/shopping/schemas/recommendations_request.schema.json
@@ -0,0 +1,19 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$id": "chrome://global/content/shopping/recommendations_request.schema.json",
+ "title": "Recommendations request",
+ "type": "object",
+ "properties": {
+ "product_id": {
+ "description": "Product identifier (ASIN / SKU).",
+ "type": "string",
+ "examples": ["B07W59LRL9", "5200904.p", "1752657021"]
+ },
+ "website": {
+ "description": "Product websites.",
+ "type": "string",
+ "examples": ["amazon.com", "amazon.ca", "bestbuy.com", "walmart.com"]
+ }
+ },
+ "required": ["product_id", "website"]
+}
diff --git a/toolkit/components/shopping/schemas/recommendations_response.schema.json b/toolkit/components/shopping/schemas/recommendations_response.schema.json
new file mode 100644
index 0000000000..da4a69c26d
--- /dev/null
+++ b/toolkit/components/shopping/schemas/recommendations_response.schema.json
@@ -0,0 +1,57 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$id": "chrome://global/content/shopping/recommendations_response.schema.json",
+ "title": "Recommendations",
+ "description": "Recommendations for a product",
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/recommendation"
+ },
+ "$defs": {
+ "recommendation": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "url": {
+ "type": "string"
+ },
+ "image_url": {
+ "type": "string"
+ },
+ "price": {
+ "type": "string"
+ },
+ "currency": {
+ "type": "string",
+ "examples": ["USD"]
+ },
+ "grade": {
+ "description": "Reliability grade for the product's reviews.",
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "examples": ["A", "B", "C", "D", "F"]
+ },
+ "adjusted_rating": {
+ "type": "number"
+ },
+ "analysis_url": {
+ "type": "string"
+ },
+ "sponsored": {
+ "type": "boolean"
+ },
+ "aid": {
+ "type": "string"
+ }
+ }
+ }
+ }
+}
diff --git a/toolkit/components/shopping/schemas/reporting_request.schema.json b/toolkit/components/shopping/schemas/reporting_request.schema.json
new file mode 100644
index 0000000000..09e4237df5
--- /dev/null
+++ b/toolkit/components/shopping/schemas/reporting_request.schema.json
@@ -0,0 +1,19 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$id": "chrome://global/content/shopping/reporting_request.schema.json",
+ "title": "Report back in stock request",
+ "type": "object",
+ "properties": {
+ "product_id": {
+ "description": "Product identifier (ASIN / SKU).",
+ "type": "string",
+ "examples": ["B07W59LRL9", "5200904.p", "1752657021"]
+ },
+ "website": {
+ "description": "Product websites.",
+ "type": "string",
+ "examples": ["amazon.com", "amazon.ca", "bestbuy.com", "walmart.com"]
+ }
+ },
+ "required": ["product_id", "website"]
+}
diff --git a/toolkit/components/shopping/schemas/reporting_response.schema.json b/toolkit/components/shopping/schemas/reporting_response.schema.json
new file mode 100644
index 0000000000..57ebebc66b
--- /dev/null
+++ b/toolkit/components/shopping/schemas/reporting_response.schema.json
@@ -0,0 +1,13 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$id": "chrome://global/content/shopping/reporting_request.schema.json",
+ "title": "Report back in stock response",
+ "type": "object",
+ "properties": {
+ "message": {
+ "description": "String indicating current state",
+ "type": "string",
+ "examples": ["report created", "already reported", "not deleted"]
+ }
+ }
+}
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"]