/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
const { XPCOMUtils } = ChromeUtils.importESModule(
"resource://gre/modules/XPCOMUtils.sys.mjs"
);
const { AppConstants } = ChromeUtils.importESModule(
"resource://gre/modules/AppConstants.sys.mjs"
);
ChromeUtils.defineESModuleGetters(
this, {
AddonManager:
"resource://gre/modules/AddonManager.sys.mjs",
AddonTestUtils:
"resource://testing-common/AddonTestUtils.sys.mjs",
FileUtils:
"resource://gre/modules/FileUtils.sys.mjs",
HttpServer:
"resource://testing-common/httpd.sys.mjs",
Log:
"resource://gre/modules/Log.sys.mjs",
NetUtil:
"resource://gre/modules/NetUtil.sys.mjs",
TelemetryController:
"resource://gre/modules/TelemetryController.sys.mjs",
TelemetryScheduler:
"resource://gre/modules/TelemetryScheduler.sys.mjs",
TelemetrySend:
"resource://gre/modules/TelemetrySend.sys.mjs",
TelemetryStorage:
"resource://gre/modules/TelemetryStorage.sys.mjs",
TelemetryUtils:
"resource://gre/modules/TelemetryUtils.sys.mjs",
});
const gIsWindows = AppConstants.platform ==
"win";
const gIsMac = AppConstants.platform ==
"macosx";
const gIsAndroid = AppConstants.platform ==
"android";
const gIsLinux = AppConstants.platform ==
"linux";
// Desktop Firefox, ie. not mobile Firefox or Thunderbird.
const gIsFirefox = AppConstants.MOZ_APP_NAME ==
"firefox";
const Telemetry = Services.telemetry;
const MILLISECONDS_PER_MINUTE =
60 *
1000;
const MILLISECONDS_PER_HOUR =
60 * MILLISECONDS_PER_MINUTE;
const MILLISECONDS_PER_DAY =
24 * MILLISECONDS_PER_HOUR;
const UUID_REGEX =
/^[
0-
9a-f]{
8}-[
0-
9a-f]{
4}-[
0-
9a-f]{
4}-[
0-
9a-f]{
4}-[
0-
9a-f]{
12}$/i;
var gGlobalScope =
this;
const PingServer = {
_httpServer:
null,
_started:
false,
_defers: [Promise.withResolvers()],
_currentDeferred:
0,
_logger:
null,
get port() {
return this._httpServer.identity.primaryPort;
},
get host() {
return this._httpServer.identity.primaryHost;
},
// The server's URL using the IPv4 loopback address explicitly rather than
// "localhost". Tests that point Telemetry at a stopped server should use this
// so the connection is refused immediately: "localhost" may resolve to ::1
// first and stall on the connection timeout (notably on Windows), making each
// failing ping send take several seconds.
get ipv4URL() {
return "http://127.0.0.1:" + this.port;
},
get started() {
return this._started;
},
get _log() {
if (!
this._logger) {
this._logger = Log.repository.getLoggerWithMessagePrefix(
"Toolkit.Telemetry",
"PingServer::"
);
}
return this._logger;
},
registerPingHandler(handler) {
const wrapped = wrapWithExceptionHandler(handler);
this._httpServer.registerPrefixHandler(
"/submit/telemetry/", wrapped);
},
resetPingHandler() {
this.registerPingHandler(request => {
let r = request;
this._log.trace(
`defaultPingHandler() - ${r.method} ${r.scheme}:
//${r.host}:${r.port}${r.path}`
);
let deferred =
this._defers[
this._defers.length -
1];
this._defers.push(Promise.withResolvers());
deferred.resolve(request);
});
},
start() {
this._httpServer =
new HttpServer();
this._httpServer.start(-
1);
this._started =
true;
this.clearRequests();
this.resetPingHandler();
},
stop() {
return new Promise(resolve => {
this._httpServer.stop(resolve);
this._started =
false;
});
},
clearRequests() {
this._defers = [Promise.withResolvers()];
this._currentDeferred =
0;
},
promiseNextRequest() {
const deferred =
this._defers[
this._currentDeferred++];
// Send the ping to the consumer on the next tick, so that the completion gets
// signaled to Telemetry.
return new Promise(r =>
Services.tm.dispatchToMainThread(() => r(deferred.promise))
);
},
promiseNextPing() {
return this.promiseNextRequest().then(request =>
decodeRequestPayload(request)
);
},
async promiseNextRequests(count) {
let results = [];
for (let i =
0; i < count; ++i) {
results.push(await
this.promiseNextRequest());
}
return results;
},
promiseNextPings(count) {
return this.promiseNextRequests(count).then(requests => {
return Array.from(requests, decodeRequestPayload);
});
},
};
/**
* Decode the payload of an HTTP request into a ping.
*
* @param {object} request The data representing an HTTP request (nsIHttpRequest).
* @return {object} The decoded ping payload.
*/
function decodeRequestPayload(request) {
let s = request.bodyInputStream;
let payload =
null;
if (
request.hasHeader(
"content-encoding") &&
request.getHeader(
"content-encoding") ==
"gzip"
) {
let observer = {
buffer:
"",
onStreamComplete(loader, context, status, length, result) {
// String.fromCharCode can only deal with 500,000 characters
// at a time, so chunk the result into parts of that size.
const chunkSize =
500000;
for (let offset =
0; offset < result.length; offset += chunkSize) {
this.buffer += String.fromCharCode.apply(
String,
result.slice(offset, offset + chunkSize)
);
}
},
};
let scs = Cc[
"@mozilla.org/streamConverters;1"].getService(
Ci.nsIStreamConverterService
);
let listener = Cc[
"@mozilla.org/network/stream-loader;1"].createInstance(
Ci.nsIStreamLoader
);
listener.init(observer);
let converter = scs.asyncConvertData(
"gzip",
"uncompressed",
listener,
null
);
converter.onStartRequest(
null,
null);
converter.onDataAvailable(
null, s,
0, s.available());
converter.onStopRequest(
null,
null,
null);
let unicodeConverter = Cc[
"@mozilla.org/intl/scriptableunicodeconverter"
].createInstance(Ci.nsIScriptableUnicodeConverter);
unicodeConverter.charset =
"UTF-8";
let utf8string = unicodeConverter.ConvertToUnicode(observer.buffer);
utf8string += unicodeConverter.Finish();
payload = JSON.parse(utf8string);
}
else {
let bytes = NetUtil.readInputStream(s, s.available());
payload = JSON.parse(
new TextDecoder().decode(bytes));
}
if (payload &&
"clientId" in payload) {
// Check for canary value
Assert.notEqual(
TelemetryUtils.knownClientID,
payload.clientId,
`Known clientId shouldn
't appear in a "${payload.type}" ping on the server.`
);
Assert.ok(
"profileGroupId" in payload,
"Pings with a clientId must also contain a profileGroupId"
);
}
return payload;
}
function checkPingFormat(aPing, aType, aHasClientId, aHasEnvironment) {
const PING_FORMAT_VERSION =
4;
const MANDATORY_PING_FIELDS = [
"type",
"id",
"creationDate",
"version",
"application",
"payload",
];
const APPLICATION_TEST_DATA = {
buildId: gAppInfo.appBuildID,
name: APP_NAME,
version: APP_VERSION,
displayVersion: AppConstants.MOZ_APP_VERSION_DISPLAY,
vendor:
"Mozilla",
platformVersion: PLATFORM_VERSION,
xpcomAbi:
"noarch-spidermonkey",
};
// Check that the ping contains all the mandatory fields.
for (let f of MANDATORY_PING_FIELDS) {
Assert.ok(f in aPing, f +
" must be available.");
}
Assert.equal(aPing.type, aType,
"The ping must have the correct type.");
Assert.equal(
aPing.version,
PING_FORMAT_VERSION,
"The ping must have the correct version."
);
// Test the application section.
for (let f in APPLICATION_TEST_DATA) {
Assert.equal(
aPing.application[f],
APPLICATION_TEST_DATA[f],
f +
" must have the correct value."
);
}
// We can't check the values for channel and architecture. Just make
// sure they are in.
Assert.ok(
"architecture" in aPing.application,
"The application section must have an architecture field."
);
Assert.ok(
"channel" in aPing.application,
"The application section must have a channel field."
);
// Check the clientId and environment fields, as needed.
Assert.equal(
"clientId" in aPing, aHasClientId);
Assert.equal(
"environment" in aPing, aHasEnvironment);
}
function wrapWithExceptionHandler(f) {
function wrapper(...args) {
try {
f(...args);
}
catch (ex) {
if (
typeof ex !=
"object") {
throw ex;
}
dump(
"Caught exception: " + ex.message +
"\n");
dump(ex.stack);
do_test_finished();
}
}
return wrapper;
}
async
function loadAddonManager(...args) {
AddonTestUtils.init(gGlobalScope);
AddonTestUtils.overrideCertDB();
createAppInfo(...args);
// As we're not running in application, we need to setup the built-in
// add-ons to reseamble a setup similar to a Firefox Desktop instance.
// Enable SCOPE_APPLICATION for builtin testing. Default in tests is only SCOPE_PROFILE.
let scopes = AddonManager.SCOPE_PROFILE | AddonManager.SCOPE_APPLICATION;
Services.prefs.setIntPref(
"extensions.enabledScopes", scopes);
// Disable XPIProvider auto-installed default theme logic
// for the unit tests using this helper.
Services.prefs.setBoolPref(
"extensions.skipInstallDefaultThemeForTests",
true
);
// NOTE: keep the addon id and version in sync with the content of
// toolkit/components/telemetry/tests/addons/system/manifest.json
const addon_id =
"tel-system-xpi@tests.mozilla.org";
const addon_version =
"1.0";
const addon_res_url_path =
"telemetry-test-builtin-addon";
// The built-in location requires a resource: URL that maps to a
// jar: or file: URL. This would typically be something bundled
// into omni.ja but for testing we just use a temp file.
const xpi = do_get_file(
"system.xpi");
let base = Services.io.newURI(`jar:file:${xpi.path}!/`);
let resProto = Services.io
.getProtocolHandler(
"resource")
.QueryInterface(Ci.nsIResProtocolHandler);
resProto.setSubstitution(addon_res_url_path, base);
let builtins = [
{
addon_id,
addon_version,
res_url: `resource:
//${addon_res_url_path}/`,
},
];
await AddonTestUtils.overrideBuiltIns({ builtins });
await AddonTestUtils.promiseStartupManager();
return { builtins };
}
function finishAddonManagerStartup() {
Services.obs.notifyObservers(
null,
"test-load-xpi-database");
}
var gAppInfo =
null;
function createAppInfo(
ID = APP_ID,
name = APP_NAME,
version = APP_VERSION,
platformVersion = PLATFORM_VERSION
) {
AddonTestUtils.createAppInfo(ID, name, version, platformVersion);
gAppInfo = AddonTestUtils.appInfo;
}
// Fake the timeout functions for the TelemetryScheduler.
function fakeSchedulerTimer(set, clear) {
const { Policy } = ChromeUtils.importESModule(
"resource://gre/modules/TelemetryScheduler.sys.mjs"
);
Policy.setSchedulerTickTimeout = set;
Policy.clearSchedulerTickTimeout = clear;
}
/* global TelemetrySession:false, TelemetryEnvironment:false, TelemetryController:false,
TelemetryStorage:false, TelemetrySend:false, TelemetryReportingPolicy:false
*/
/**
* Fake the current date.
* This passes all received arguments to a new Date constructor and
* uses the resulting date to fake the time in Telemetry modules.
*
* @return Date The new faked date.
*/
function fakeNow(...args) {
const date =
new Date(...args);
const modules = [
ChromeUtils.importESModule(
"resource://gre/modules/TelemetrySession.sys.mjs"
),
ChromeUtils.importESModule(
"resource://gre/modules/TelemetryEnvironment.sys.mjs"
),
ChromeUtils.importESModule(
"resource://gre/modules/TelemetryControllerParent.sys.mjs"
),
ChromeUtils.importESModule(
"resource://gre/modules/TelemetryStorage.sys.mjs"
),
ChromeUtils.importESModule(
"resource://gre/modules/TelemetrySend.sys.mjs"),
ChromeUtils.importESModule(
"resource://gre/modules/TelemetryReportingPolicy.sys.mjs"
),
ChromeUtils.importESModule(
"resource://gre/modules/TelemetryScheduler.sys.mjs"
),
];
for (let m of modules) {
m.Policy.now = () => date;
}
return new Date(date);
}
function fakeMonotonicNow(ms) {
const { Policy } = ChromeUtils.importESModule(
"resource://gre/modules/TelemetrySession.sys.mjs"
);
Policy.monotonicNow = () => ms;
return ms;
}
// Fake the timeout functions for TelemetryController sending.
function fakePingSendTimer(set, clear) {
const { Policy } = ChromeUtils.importESModule(
"resource://gre/modules/TelemetrySend.sys.mjs"
);
let obj = Cu.cloneInto({ set, clear }, TelemetrySend, {
cloneFunctions:
true,
});
Policy.setSchedulerTickTimeout = obj.set;
Policy.clearSchedulerTickTimeout = obj.clear;
}
function fakeMidnightPingFuzzingDelay(delayMs) {
const { Policy } = ChromeUtils.importESModule(
"resource://gre/modules/TelemetrySend.sys.mjs"
);
Policy.midnightPingFuzzingDelay = () => delayMs;
}
function fakeGeneratePingId(func) {
const { Policy } = ChromeUtils.importESModule(
"resource://gre/modules/TelemetryControllerParent.sys.mjs"
);
Policy.generatePingId = func;
}
function fakeCachedClientId(uuid) {
const { Policy } = ChromeUtils.importESModule(
"resource://gre/modules/TelemetryControllerParent.sys.mjs"
);
Policy.getCachedClientID = () => uuid;
}
// Fake the gzip compression for the next ping to be sent out
// and immediately reset to the original function.
function fakeGzipCompressStringForNextPing(length) {
const { Policy, gzipCompressString } = ChromeUtils.importESModule(
"resource://gre/modules/TelemetrySend.sys.mjs"
);
let largePayload = generateString(length);
Policy.gzipCompressString = () => {
Policy.gzipCompressString = gzipCompressString;
return largePayload;
};
}
function fakeIntlReady() {
const { Policy } = ChromeUtils.importESModule(
"resource://gre/modules/TelemetryEnvironment.sys.mjs"
);
Policy._intlLoaded =
true;
// Dispatch the observer event in case the promise has been registered already.
Services.obs.notifyObservers(
null,
"browser-delayed-startup-finished");
}
// Override the uninstall ping file names
function fakeUninstallPingPath(aPathFcn) {
const { Policy } = ChromeUtils.importESModule(
"resource://gre/modules/TelemetryStorage.sys.mjs"
);
Policy.getUninstallPingPath =
aPathFcn ||
(id => ({
directory:
new FileUtils.File(PathUtils.profileDir),
file: `uninstall_ping_0123456789ABCDEF_${id}.json`,
}));
}
// Return a date that is |offset| ms in the future from |date|.
function futureDate(date, offset) {
return new Date(date.getTime() + offset);
}
function truncateToDays(aMsec) {
return Math.floor(aMsec / MILLISECONDS_PER_DAY);
}
// Returns a promise that resolves to true when the passed promise rejects,
// false otherwise.
function promiseRejects(promise) {
return promise.then(
() =>
false,
() =>
true
);
}
// Generates a random string of at least a specific length.
function generateRandomString(length) {
let string =
"";
while (string.length < length) {
string += Math.random().toString(
36);
}
return string.substring(
0, length);
}
function generateString(length) {
return new Array(length +
1).join(
"a");
}
// Short-hand for retrieving the histogram with that id.
function getHistogram(histogramId) {
return Telemetry.getHistogramById(histogramId);
}
// Short-hand for retrieving the snapshot of the Histogram with that id.
function getSnapshot(histogramId) {
return Telemetry.getHistogramById(histogramId).snapshot();
}
// Helper for setting an empty list of Environment preferences to watch.
function setEmptyPrefWatchlist() {
const { TelemetryEnvironment } = ChromeUtils.importESModule(
"resource://gre/modules/TelemetryEnvironment.sys.mjs"
);
return TelemetryEnvironment.onInitialized().then(() =>
TelemetryEnvironment.testWatchPreferences(
new Map())
);
}
// macOS has the app.update.channel pref locked. Check if it needs to be
// unlocked before proceeding with the test.
function maybeUnlockAppUpdateChannelPref() {
if (Services.prefs.getDefaultBranch(
"").prefIsLocked(
"app.update.channel")) {
Services.prefs.getDefaultBranch(
"").unlockPref(
"app.update.channel");
registerCleanupFunction(() => {
Services.prefs.getDefaultBranch(
"").lockPref(
"app.update.channel");
});
}
}
function getDateInSeconds(date) {
const MS_IN_SEC =
1000;
return Math.floor(date / MS_IN_SEC);
}
if (runningInParent) {
// Set logging preferences for all the tests.
Services.prefs.setCharPref(
"toolkit.telemetry.log.level",
"Trace");
// Telemetry archiving should be on.
Services.prefs.setBoolPref(TelemetryUtils.Preferences.ArchiveEnabled,
true);
// Telemetry xpcshell tests cannot show the infobar.
Services.prefs.setBoolPref(
TelemetryUtils.Preferences.BypassNotification,
true
);
// FHR uploads should be enabled.
Services.prefs.setBoolPref(TelemetryUtils.Preferences.FhrUploadEnabled,
true);
// Many tests expect the shutdown and the new-profile to not be sent on shutdown
// and will fail if receive an unexpected ping. Let's globally disable these features:
// the relevant tests will enable these prefs when needed.
Services.prefs.setBoolPref(
TelemetryUtils.Preferences.ShutdownPingSender,
false
);
Services.prefs.setBoolPref(
TelemetryUtils.Preferences.ShutdownPingSenderFirstSession,
false
);
Services.prefs.setBoolPref(
"toolkit.telemetry.newProfilePing.enabled",
false);
Services.prefs.setBoolPref(
TelemetryUtils.Preferences.FirstShutdownPingEnabled,
false
);
// Turn off Health Ping submission.
Services.prefs.setBoolPref(
TelemetryUtils.Preferences.HealthPingEnabled,
false
);
// Speed up child process accumulations
Services.prefs.setIntPref(TelemetryUtils.Preferences.IPCBatchTimeout,
10);
// Non-unified Telemetry (e.g. Fennec on Android) needs the preference to be set
// in order to enable Telemetry.
if (Services.prefs.getBoolPref(TelemetryUtils.Preferences.Unified,
false)) {
Services.prefs.setBoolPref(
TelemetryUtils.Preferences.OverridePreRelease,
true
);
}
else {
Services.prefs.setBoolPref(
TelemetryUtils.Preferences.TelemetryEnabled,
true
);
}
// Disable TOU pre-onboarding in xpcshell so Telemetry isn't gated on Browser
// UI.
const TOS_ENABLED_PREF =
"browser.preonboarding.enabled";
const previous = Services.prefs.getBoolPref(TOS_ENABLED_PREF,
false);
Services.prefs.setBoolPref(TOS_ENABLED_PREF,
false);
registerCleanupFunction(() => {
Services.prefs.setBoolPref(TOS_ENABLED_PREF, previous);
});
fakePingSendTimer(
callback => {
Services.tm.dispatchToMainThread(() => callback());
},
() => {}
);
// This gets imported via fakeNow();
registerCleanupFunction(() => TelemetrySend.shutdown());
}
TelemetryController.testInitLogging();
// Avoid timers interrupting test behavior.
fakeSchedulerTimer(
() => {},
() => {}
);
// Make pind sending predictable.
fakeMidnightPingFuzzingDelay(
0);
// Avoid using the directory service, which is not registered in some tests.
fakeUninstallPingPath();
const PLATFORM_VERSION =
"1.9.2";
const APP_VERSION =
"1";
const APP_ID =
"xpcshell@tests.mozilla.org";
const APP_NAME =
"XPCShell";
const DISTRIBUTION_CUSTOMIZATION_COMPLETE_TOPIC =
"distribution-customization-complete";
const PLUGIN2_NAME =
"Quicktime";
const PLUGIN2_DESC =
"A mock Quicktime plugin";
const PLUGIN2_VERSION =
"2.3";