From c5e820dd3d9da14aa2f47b26b7da2cadaf4e7f79 Mon Sep 17 00:00:00 2001 From: Luca Greco Date: Mon, 6 May 2019 18:45:01 +0000 Subject: [PATCH] Bug 1543377 - Add abuse report submission helpers. r=janerik,aswan This patch contains a new jsm file which provides some helpers to be used for the abuse report submission in the UI components related to abuse reporting, and a new xpcshell test that unit test these helpers. Differential Revision: https://phabricator.services.mozilla.com/D27938 --HG-- extra : moz-landing-system : lando --- modules/libpref/init/all.js | 2 + toolkit/mozapps/extensions/AbuseReporter.jsm | 327 +++++++++++++++++ toolkit/mozapps/extensions/moz.build | 1 + .../test/xpcshell/test_AbuseReporter.js | 331 ++++++++++++++++++ .../extensions/test/xpcshell/xpcshell.ini | 3 +- 5 files changed, 663 insertions(+), 1 deletion(-) create mode 100644 toolkit/mozapps/extensions/AbuseReporter.jsm create mode 100644 toolkit/mozapps/extensions/test/xpcshell/test_AbuseReporter.js diff --git a/modules/libpref/init/all.js b/modules/libpref/init/all.js index 189189c29cbf..d94f07bf030d 100644 --- a/modules/libpref/init/all.js +++ b/modules/libpref/init/all.js @@ -2730,6 +2730,8 @@ pref("services.settings.security.onecrl.collection", "onecrl"); pref("services.settings.security.onecrl.signer", "onecrl.content-signature.mozilla.org"); pref("services.settings.security.onecrl.checked", 0); +pref("extensions.abuseReport.url", "https://addons.mozilla.org/api/v4/abuse/report/addon/"); + // Blocklist preferences pref("extensions.blocklist.enabled", true); // OneCRL freshness checking depends on this value, so if you change it, diff --git a/toolkit/mozapps/extensions/AbuseReporter.jsm b/toolkit/mozapps/extensions/AbuseReporter.jsm new file mode 100644 index 000000000000..42b8ee669aa2 --- /dev/null +++ b/toolkit/mozapps/extensions/AbuseReporter.jsm @@ -0,0 +1,327 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = [ "AbuseReporter", "AbuseReportError" ]; + +const {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm"); + +Cu.importGlobalProperties(["fetch"]); + +const PREF_ABUSE_REPORT_URL = "extensions.abuseReport.url"; +// Minimum time between report submissions (in ms). +const MIN_MS_BETWEEN_SUBMITS = 30000; + +XPCOMUtils.defineLazyModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.jsm", + AppConstants: "resource://gre/modules/AppConstants.jsm", + ClientID: "resource://gre/modules/ClientID.jsm", + Services: "resource://gre/modules/Services.jsm", +}); + +XPCOMUtils.defineLazyPreferenceGetter(this, "ABUSE_REPORT_URL", PREF_ABUSE_REPORT_URL); + +const PRIVATE_REPORT_PROPS = Symbol("privateReportProps"); + +const ERROR_TYPES = Object.freeze([ + "ERROR_ABORTED_SUBMIT", + "ERROR_ADDON_NOTFOUND", + "ERROR_CLIENT", + "ERROR_NETWORK", + "ERROR_UNKNOWN", + "ERROR_RECENT_SUBMIT", + "ERROR_SERVER", +]); + +class AbuseReportError extends Error { + constructor(errorType) { + if (!ERROR_TYPES.includes(errorType)) { + throw new Error(`Unknown AbuseReportError type "${errorType}"`); + } + super(errorType); + this.name = "AbuseReportError"; + this.errorType = errorType; + } +} + +/** + * A singleton object used to create new AbuseReport instances for a given addonId + * and enforce a minium amount of time between two report submissions . + */ +const AbuseReporter = { + _lastReportTimestamp: null, + + // Error types. + updateLastReportTimestamp() { + this._lastReportTimestamp = Date.now(); + }, + + getTimeFromLastReport() { + const currentTimestamp = Date.now(); + if (this._lastReportTimestamp > currentTimestamp) { + // Reset the last report timestamp if it is in the future. + this._lastReportTimestamp = null; + } + + if (!this._lastReportTimestamp) { + return Infinity; + } + + return currentTimestamp - this._lastReportTimestamp; + }, + + /** + * Create an AbuseReport instance, given the addonId and a reportEntryPoint. + * + * @param {string} addonId + * The id of the addon to create the report instance for. + * @param {object} options + * @param {string} options.reportEntryPoint + * An identifier that represent the entry point for the report flow. + * + * @returns {AbuseReport} + * An instance of the AbuseReport class, which represent an ongoing + * report. + */ + async createAbuseReport(addonId, {reportEntryPoint} = {}) { + const addon = await AddonManager.getAddonByID(addonId); + + if (!addon) { + throw new AbuseReportError("ERROR_ADDON_NOTFOUND"); + } + + const reportData = await this.getReportData(addon); + + return new AbuseReport({ + addon, + reportData, + reportEntryPoint, + }); + }, + + /** + * Helper function that retrieves from an addon object all the data to send + * as part of the submission request, besides the `reason`, `message` which are + * going to be received from the submit method of the report object returned + * by `createAbuseReport`. + * (See https://addons-server.readthedocs.io/en/latest/topics/api/abuse.html) + * + * @param {AddonWrapper} addon + * The addon object to collect the detail from. + * + * @return {object} + * An object that contains the collected details. + */ + async getReportData(addon) { + const data = { + addon: addon.id, + addon_version: addon.version, + addon_summary: addon.description, + addon_install_origin: addon.sourceURI && addon.sourceURI.spec, + install_date: addon.installDate && addon.installDate.toISOString(), + }; + + // Map addon.installTelemetryInfo values to the supported addon_install_method + // values supported by the API endpoint (See API endpoint docs at + // https://addons-server.readthedocs.io/en/latest/topics/api/abuse.html). + let install_method = "other"; + if (addon.installTelemetryInfo) { + const {source, method} = addon.installTelemetryInfo; + switch (source) { + case "enterprise-policy": + case "file-uri": + case "system-addon": + case "temporary-addon": + install_method = source.replace(/-/g, "_"); + break; + case "distribution": + case "sideload": + case "sync": + install_method = source; + break; + default: + install_method = "other"; + } + + switch (method) { + case "link": + install_method = method; + break; + case "amWebAPI": + case "installTrigger": + install_method = method.toLowerCase(); + break; + case "drag-and-drop": + case "install-from-file": + case "management-webext-api": + install_method = method.replace(/-/g, "_"); + break; + } + } + data.addon_install_method = install_method; + + // TODO: Add support for addon_signature "curated" in AbuseReport + // (Bug 1549290). + switch (addon.signedState) { + case AddonManager.SIGNEDSTATE_BROKEN: + data.addon_signature = "broken"; + break; + case AddonManager.SIGNEDSTATE_UNKNOWN: + data.addon_signature = "unknown"; + break; + case AddonManager.SIGNEDSTATE_MISSING: + data.addon_signature = "missing"; + break; + case AddonManager.SIGNEDSTATE_PRELIMINARY: + data.addon_signature = "preliminary"; + break; + case AddonManager.SIGNEDSTATE_SIGNED: + data.addon_signature = "signed"; + break; + case AddonManager.SIGNEDSTATE_SYSTEM: + data.addon_signature = "system"; + break; + case AddonManager.SIGNEDSTATE_PRIVILEGED: + data.addon_signature = "privileged"; + break; + default: + data.addon_signature = `unknown: ${addon.signedState}`; + } + + data.client_id = await ClientID.getClientIdHash(); + + data.app = Services.appinfo.name.toLowerCase(); + data.appversion = Services.appinfo.version; + data.lang = Services.locale.appLocaleAsLangTag; + data.operating_system = AppConstants.platform; + data.operating_system_version = Services.sysinfo.getProperty("version"); + + return data; + }, +}; + +/** + * Represents an ongoing abuse report. Instances of this class are created + * by the `AbuseReporter.createAbuseReport` method. + * + * This object is used by the reporting UI panel and message bars to: + * + * - get an errorType in case of a report creation error (e.g. because of a + * previously submitted report) + * - get the addon details used inside the reporting panel + * - submit the abuse report (and re-submit if a previous submission failed + * and the user choose to retry to submit it again) + * - abort an ongoing submission + * + * @param {object} options + * @param {AddonWrapper|null} options.addon + * AddonWrapper instance for the extension/theme being reported. + * (May be null if the extension has not been found). + * @param {object|null} options.reportData + * An object which contains addon and environment details to send as part of a submission + * (may be null if the report has a createErrorType). + * @param {string} options.reportEntryPoint + * A string that identify how the report has been triggered. + */ +class AbuseReport { + constructor({addon, createErrorType, reportData, reportEntryPoint}) { + this[PRIVATE_REPORT_PROPS] = { + aborted: false, + abortController: new AbortController(), + addon, + reportData, + reportEntryPoint, + }; + } + + /** + * Submit the current report, given a reason and a message. + * + * @params {object} options + * @params {string} options.reason + * String identifier for the report reason. + * @params {string} [options.message] + * An optional string which contains a description for the reported issue. + * + * @returns {Promise} + * Resolves once the report has been successfully submitted. + * It rejects with an AbuseReportError if the report couldn't be + * submitted for a known reason (or another Error type otherwise). + */ + async submit({reason, message}) { + const { + aborted, abortController, + reportData, + reportEntryPoint, + } = this[PRIVATE_REPORT_PROPS]; + + if (aborted) { + // Report aborted before being actually submitted. + throw new AbuseReportError("ERROR_ABORTED_SUBMIT"); + } + + // Prevent submit of a new abuse report in less than MIN_MS_BETWEEN_SUBMITS. + let msFromLastReport = AbuseReporter.getTimeFromLastReport(); + if (msFromLastReport < MIN_MS_BETWEEN_SUBMITS) { + throw new AbuseReportError("ERROR_RECENT_SUBMIT"); + } + + let response; + try { + response = await fetch(ABUSE_REPORT_URL, { + signal: abortController.signal, + method: "POST", + credentials: "omit", + referrerPolicy: "no-referrer", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({ + ...reportData, + report_entry_point: reportEntryPoint, + message, + reason, + }), + }); + } catch (err) { + if (err.name === "AbortError") { + throw new AbuseReportError("ERROR_ABORTED_SUBMIT"); + } + Cu.reportError(err); + throw new AbuseReportError("ERROR_NETWORK"); + } + + if (response.ok && response.status >= 200 && response.status < 400) { + // Ensure that the response is also a valid json format. + await response.json(); + AbuseReporter.updateLastReportTimestamp(); + return; + } + + if (response.status >= 400 && response.status < 500) { + throw new AbuseReportError("ERROR_CLIENT"); + } + + if (response.status >= 500 && response.status < 600) { + throw new AbuseReportError("ERROR_SERVER"); + } + + // We got an unexpected HTTP status code. + throw new AbuseReportError("ERROR_UNKNOWN"); + } + + /** + * Abort the report submission. + */ + abort() { + const {abortController} = this[PRIVATE_REPORT_PROPS]; + abortController.abort(); + this[PRIVATE_REPORT_PROPS].aborted = true; + } + + get addon() { + return this[PRIVATE_REPORT_PROPS].addon; + } + + get reportEntryPoint() { + return this[PRIVATE_REPORT_PROPS].reportEntryPoint; + } +} diff --git a/toolkit/mozapps/extensions/moz.build b/toolkit/mozapps/extensions/moz.build index 172268c904c5..7f5982d13652 100644 --- a/toolkit/mozapps/extensions/moz.build +++ b/toolkit/mozapps/extensions/moz.build @@ -51,6 +51,7 @@ EXTRA_PP_COMPONENTS += [ ] EXTRA_JS_MODULES += [ + 'AbuseReporter.jsm', 'addonManager.js', 'AddonManager.jsm', 'amContentHandler.jsm', diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_AbuseReporter.js b/toolkit/mozapps/extensions/test/xpcshell/test_AbuseReporter.js new file mode 100644 index 000000000000..0e5821f12a75 --- /dev/null +++ b/toolkit/mozapps/extensions/test/xpcshell/test_AbuseReporter.js @@ -0,0 +1,331 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +const { + AbuseReporter, + AbuseReportError, +} = ChromeUtils.import("resource://gre/modules/AbuseReporter.jsm"); + +const {ClientID} = ChromeUtils.import("resource://gre/modules/ClientID.jsm"); + +const APPNAME = "XPCShell"; +const APPVERSION = "1"; +const ADDON_ID = "test-addon@tests.mozilla.org"; +const ADDON_ID2 = "test-addon2@tests.mozilla.org"; +const FAKE_INSTALL_INFO = {source: "fake-install-method"}; +const REPORT_OPTIONS = {reportEntryPoint: "menu"}; + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "49"); + +let apiRequestHandler; +const server = createHttpServer({hosts: ["test.addons.org"]}); +server.registerPathHandler("/api/report/", (request, response) => { + const stream = request.bodyInputStream; + const buffer = NetUtil.readInputStream(stream, stream.available()); + const data = new TextDecoder().decode(buffer); + apiRequestHandler({data, request, response}); +}); + +function handleSubmitRequest({request, response}) { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "application/json", false); + response.write("{}"); +} + +async function clearAbuseReportState() { + // Clear the timestamp of the last submission. + AbuseReporter._lastReportTimestamp = null; +} + +async function installTestExtension(overrideOptions = {}) { + const extOptions = { + manifest: { + applications: {gecko: {id: ADDON_ID}}, + name: "Test Extension", + }, + useAddonManager: "permanent", + amInstallTelemetryInfo: FAKE_INSTALL_INFO, + ...overrideOptions, + }; + + const extension = ExtensionTestUtils.loadExtension(extOptions); + await extension.startup(); + + const addon = await AddonManager.getAddonByID(ADDON_ID); + + return {extension, addon}; +} + +async function assertRejectsAbuseReportError(promise, errorType) { + await Assert.rejects(promise, error => { + ok(error instanceof AbuseReportError); + return error.errorType === errorType; + }); +} + +async function assertBaseReportData({reportData, addon}) { + // Report properties related to addon metadata. + equal(reportData.addon, ADDON_ID, "Got expected 'addon'"); + equal(reportData.addon_version, addon.version, "Got expected 'addon_version'"); + equal(reportData.install_date, addon.installDate.toISOString(), + "Got expected 'install_date' in ISO format"); + equal(reportData.addon_install_origin, addon.sourceURI.spec, + "Got expected 'addon_install_origin'"); + equal(reportData.addon_install_method, "other", + "Got expected 'addon_install_method'"); + equal(reportData.addon_signature, "privileged", "Got expected 'addon_signature'"); + + // Report properties related to the environment. + equal(reportData.client_id, await ClientID.getClientIdHash(), + "Got the expected 'client_id'"); + equal(reportData.app, APPNAME.toLowerCase(), "Got expected 'app'"); + equal(reportData.appversion, APPVERSION, "Got expected 'appversion'"); + equal(reportData.lang, Services.locale.appLocaleAsLangTag, "Got expected 'lang'"); + equal(reportData.operating_system, AppConstants.platform, "Got expected 'operating_system'"); + equal(reportData.operating_system_version, Services.sysinfo.getProperty("version"), + "Got expected 'operating_system_version'"); +} + +add_task(async function test_setup() { + Services.prefs.setCharPref("extensions.abuseReport.url", "http://test.addons.org/api/report/"); + await promiseStartupManager(); +}); + +add_task(async function test_addon_report_data() { + info("Verify report property for a privileged extension"); + const {addon, extension} = await installTestExtension(); + const data = await AbuseReporter.getReportData(addon); + await assertBaseReportData({reportData: data, addon}); + await extension.unload(); + + info("Verify 'addon_signature' report property for non privileged extension"); + AddonTestUtils.usePrivilegedSignatures = false; + const { + addon: addon2, + extension: extension2, + } = await installTestExtension(); + const data2 = await AbuseReporter.getReportData(addon2); + equal(data2.addon_signature, "signed", + "Got expected 'addon_signature' for non privileged extension"); + await extension2.unload(); + + info("Verify 'addon_install_method' report property on temporary install"); + const { + addon: addon3, + extension: extension3, + } = await installTestExtension({useAddonManager: "temporary"}); + const data3 = await AbuseReporter.getReportData(addon3); + equal(data3.addon_install_method, "temporary_addon", + "Got expected 'addon_install_method' on temporary install"); + await extension3.unload(); +}); + +add_task(async function test_report_on_not_installed_addon() { + await assertRejectsAbuseReportError( + AbuseReporter.createAbuseReport(ADDON_ID, REPORT_OPTIONS), + "ERROR_ADDON_NOTFOUND"); +}); + +// This tests verifies the mapping between the addon installTelemetryInfo +// values and the addon_install_method expected by the API endpoint. +add_task(async function test_addon_install_method_mapping() { + async function assertAddonInstallMethod(amInstallTelemetryInfo, expected) { + const {addon, extension} = await installTestExtension({amInstallTelemetryInfo}); + const {addon_install_method} = await AbuseReporter.getReportData(addon); + equal(addon_install_method, expected, + `Got the expected addon_install_method for ${JSON.stringify(amInstallTelemetryInfo)}`); + await extension.unload(); + } + + // Array of [ expected, amInstallTelemetryInfo ] + const TEST_CASES = [ + ["amwebapi", {source: "amo", method: "amWebAPI"}], + ["amwebapi", {source: "disco", method: "amWebAPI"}], + ["distribution", {source: "distribution"}], + ["drag_and_drop", {source: "about:addons", method: "drag-and-drop"}], + ["enterprise_policy", {source: "enterprise-policy"}], + ["file_uri", {source: "file-uri"}], + ["install_from_file", {source: "about:addons", method: "install-from-file"}], + ["installtrigger", {source: "test-host", method: "installTrigger"}], + ["link", {source: "unknown", method: "link"}], + ["management_webext_api", {source: "extension", method: "management-webext-api"}], + ["sideload", {source: "sideload"}], + ["sync", {source: "sync"}], + ["system_addon", {source: "system-addon"}], + ["temporary_addon", {source: "temporary-addon"}], + ["other", {source: "internal"}], + ["other", {source: "about:debugging"}], + ["other", {source: "webide"}], + ]; + + for (const [expected, telemetryInfo] of TEST_CASES) { + await assertAddonInstallMethod(telemetryInfo, expected); + } +}); + +add_task(async function test_report_create_and_submit() { + // Override the test api server request handler, to be able to + // intercept the submittions to the test api server. + let reportSubmitted; + apiRequestHandler = ({data, request, response}) => { + reportSubmitted = JSON.parse(data); + handleSubmitRequest({request, response}); + }; + + const {addon, extension} = await installTestExtension(); + + const reportEntryPoint = "menu"; + const report = await AbuseReporter.createAbuseReport(ADDON_ID, {reportEntryPoint}); + + equal(report.addon, addon, "Got the expected addon property"); + equal(report.reportEntryPoint, reportEntryPoint, "Got the expected reportEntryPoint"); + + const baseReportData = await AbuseReporter.getReportData(addon); + const reportProperties = { + message: "test message", + reason: "test-reason", + }; + + info("Submitting report"); + await report.submit(reportProperties); + + const expectedEntries = Object.entries({ + report_entry_point: reportEntryPoint, + ...baseReportData, + ...reportProperties, + }); + + for (const [expectedKey, expectedValue] of expectedEntries) { + equal(reportSubmitted[expectedKey], expectedValue, + `Got the expected submitted value for "${expectedKey}"`); + } + + await extension.unload(); +}); + +add_task(async function test_error_recent_submit() { + await clearAbuseReportState(); + + let reportSubmitted; + apiRequestHandler = ({data, request, response}) => { + reportSubmitted = JSON.parse(data); + handleSubmitRequest({request, response}); + }; + + const {extension} = await installTestExtension(); + const report = await AbuseReporter.createAbuseReport(ADDON_ID, REPORT_OPTIONS); + + const {extension: extension2} = await installTestExtension({ + manifest: { + applications: {gecko: {id: ADDON_ID2}}, + name: "Test Extension2", + }, + }); + const report2 = await AbuseReporter.createAbuseReport(ADDON_ID2, REPORT_OPTIONS); + + // Submit the two reports in fast sequence. + await report.submit({reason: "reason1"}); + await assertRejectsAbuseReportError(report2.submit({reason: "reason2"}), + "ERROR_RECENT_SUBMIT"); + equal(reportSubmitted.reason, "reason1", + "Server only received the data from the first submission"); + + await extension.unload(); + await extension2.unload(); +}); + +add_task(async function test_submission_server_error() { + const {extension} = await installTestExtension(); + + async function testErrorCode( + responseStatus, expectedErrorType, expectRequest = true + ) { + info(`Test expected AbuseReportError on response status "${responseStatus}"`); + await clearAbuseReportState(); + + let requestReceived = false; + apiRequestHandler = ({request, response}) => { + requestReceived = true; + response.setStatusLine(request.httpVersion, responseStatus, "Error"); + response.write(""); + }; + + const report = await AbuseReporter.createAbuseReport(ADDON_ID, REPORT_OPTIONS); + const promiseSubmit = report.submit({reason: "a-reason"}); + if (typeof expectedErrorType === "string") { + // Assert a specific AbuseReportError errorType. + await assertRejectsAbuseReportError(promiseSubmit, expectedErrorType); + } else { + // Assert on a given Error class. + await Assert.rejects(promiseSubmit, expectedErrorType); + } + equal(requestReceived, expectRequest, + `${expectRequest ? "" : "Not "}received a request as expected`); + } + + await testErrorCode(500, "ERROR_SERVER"); + await testErrorCode(404, "ERROR_CLIENT"); + // Test response with unexpected status code. + await testErrorCode(604, "ERROR_UNKNOWN"); + // Test response status 200 with invalid json data. + await testErrorCode(200, /SyntaxError: JSON.parse/); + + // Test on invalid url. + Services.prefs.setCharPref("extensions.abuseReport.url", + "invalid-protocol://abuse-report"); + await testErrorCode(200, "ERROR_NETWORK", false); + + await extension.unload(); +}); + +add_task(async function set_test_abusereport_url() { + Services.prefs.setCharPref("extensions.abuseReport.url", + "http://test.addons.org/api/report/"); +}); + +add_task(async function test_submission_aborting() { + await clearAbuseReportState(); + + const {extension} = await installTestExtension(); + + // override the api request handler with one that is never going to reply. + let receivedRequestsCount = 0; + let resolvePendingResponses; + const waitToReply = new Promise(resolve => resolvePendingResponses = resolve); + + const onRequestReceived = new Promise(resolve => { + apiRequestHandler = ({request, response}) => { + response.processAsync(); + response.setStatusLine(request.httpVersion, 200, "OK"); + receivedRequestsCount++; + resolve(); + + // Keep the request pending until resolvePendingResponses have been + // called. + waitToReply.then(() => { + response.finish(); + }); + }; + }); + + const report = await AbuseReporter.createAbuseReport(ADDON_ID, REPORT_OPTIONS); + const promiseResult = report.submit({reason: "a-reason"}); + + await onRequestReceived; + + ok(receivedRequestsCount > 0, "Got the expected number of requests"); + ok(await Promise.race([promiseResult, Promise.resolve("pending")]) === "pending", + "Submission fetch request should still be pending"); + + report.abort(); + + await assertRejectsAbuseReportError(promiseResult, "ERROR_ABORTED_SUBMIT"); + + await extension.unload(); + + // Unblock pending requests on the server request handler side, so that the + // test file can shutdown (otherwise the test run will be stuck after this + // task completed). + resolvePendingResponses(); +}); diff --git a/toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini b/toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini index 8ac149cf3616..ba9b42837955 100644 --- a/toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini +++ b/toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini @@ -7,7 +7,7 @@ dupe-manifest = support-files = data/** -[test_addon_manager_telemetry_events.js] +[test_AbuseReporter.js] [test_AddonRepository.js] [test_AddonRepository_cache.js] # Bug 676992: test consistently hangs on Android @@ -17,6 +17,7 @@ skip-if = os == "android" [test_ProductAddonChecker.js] [test_XPIStates.js] [test_XPIcancel.js] +[test_addon_manager_telemetry_events.js] [test_addonStartup.js] [test_bad_json.js] [test_badschema.js]