diff options
Diffstat (limited to 'toolkit/components/telemetry/tests/unit/test_TelemetrySession.js')
-rw-r--r-- | toolkit/components/telemetry/tests/unit/test_TelemetrySession.js | 2357 |
1 files changed, 2357 insertions, 0 deletions
diff --git a/toolkit/components/telemetry/tests/unit/test_TelemetrySession.js b/toolkit/components/telemetry/tests/unit/test_TelemetrySession.js new file mode 100644 index 0000000000..bae3d63942 --- /dev/null +++ b/toolkit/components/telemetry/tests/unit/test_TelemetrySession.js @@ -0,0 +1,2357 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ +*/ +/* This testcase triggers two telemetry pings. + * + * Telemetry code keeps histograms of past telemetry pings. The first + * ping populates these histograms. One of those histograms is then + * checked in the second request. + */ + +const { ClientID } = ChromeUtils.importESModule( + "resource://gre/modules/ClientID.sys.mjs" +); +const { TelemetrySession } = ChromeUtils.importESModule( + "resource://gre/modules/TelemetrySession.sys.mjs" +); +const { TelemetryEnvironment } = ChromeUtils.importESModule( + "resource://gre/modules/TelemetryEnvironment.sys.mjs" +); +const { TelemetryReportingPolicy } = ChromeUtils.importESModule( + "resource://gre/modules/TelemetryReportingPolicy.sys.mjs" +); +const { Preferences } = ChromeUtils.importESModule( + "resource://gre/modules/Preferences.sys.mjs" +); + +const PING_FORMAT_VERSION = 4; +const PING_TYPE_MAIN = "main"; +const PING_TYPE_SAVED_SESSION = "saved-session"; + +const REASON_ABORTED_SESSION = "aborted-session"; +const REASON_SAVED_SESSION = "saved-session"; +const REASON_SHUTDOWN = "shutdown"; +const REASON_TEST_PING = "test-ping"; +const REASON_DAILY = "daily"; +const REASON_ENVIRONMENT_CHANGE = "environment-change"; + +const IGNORE_CLONED_HISTOGRAM = "test::ignore_me_also"; +// Add some unicode characters here to ensure that sending them works correctly. +const SHUTDOWN_TIME = 10000; +const FAILED_PROFILE_LOCK_ATTEMPTS = 2; + +// Constants from prio.h for nsIFileOutputStream.init +const PR_WRONLY = 0x2; +const PR_CREATE_FILE = 0x8; +const PR_TRUNCATE = 0x20; +const RW_OWNER = parseInt("0600", 8); + +const MS_IN_ONE_HOUR = 60 * 60 * 1000; +const MS_IN_ONE_DAY = 24 * MS_IN_ONE_HOUR; + +const DATAREPORTING_DIR = "datareporting"; +const ABORTED_PING_FILE_NAME = "aborted-session-ping"; +const ABORTED_SESSION_UPDATE_INTERVAL_MS = 5 * 60 * 1000; + +XPCOMUtils.defineLazyGetter(this, "DATAREPORTING_PATH", function () { + return PathUtils.join(PathUtils.profileDir, DATAREPORTING_DIR); +}); + +var gClientID = null; +var gMonotonicNow = 0; + +function sendPing() { + TelemetrySession.gatherStartup(); + if (PingServer.started) { + TelemetrySend.setServer("http://localhost:" + PingServer.port); + return TelemetrySession.testPing(); + } + TelemetrySend.setServer("http://doesnotexist"); + return TelemetrySession.testPing(); +} + +function fakeGenerateUUID(sessionFunc, subsessionFunc) { + const { Policy } = ChromeUtils.importESModule( + "resource://gre/modules/TelemetrySession.sys.mjs" + ); + Policy.generateSessionUUID = sessionFunc; + Policy.generateSubsessionUUID = subsessionFunc; +} + +function fakeIdleNotification(topic) { + return TelemetryScheduler.observe(null, topic, null); +} + +function setupTestData() { + Services.startup.interrupted = true; + let h2 = Telemetry.getHistogramById("TELEMETRY_TEST_COUNT"); + h2.add(); + + let k1 = Telemetry.getKeyedHistogramById("TELEMETRY_TEST_KEYED_COUNT"); + k1.add("a"); + k1.add("a"); + k1.add("b"); +} + +function checkPingFormat(aPing, aType, aHasClientId, aHasEnvironment) { + const MANDATORY_PING_FIELDS = [ + "type", + "id", + "creationDate", + "version", + "application", + "payload", + ]; + + const APPLICATION_TEST_DATA = { + buildId: gAppInfo.appBuildID, + name: APP_NAME, + version: APP_VERSION, + 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 checkPayloadInfo(data, reason) { + const ALLOWED_REASONS = [ + "environment-change", + "shutdown", + "daily", + "saved-session", + "test-ping", + ]; + let numberCheck = arg => { + return typeof arg == "number"; + }; + let positiveNumberCheck = arg => { + return numberCheck(arg) && arg >= 0; + }; + let stringCheck = arg => { + return typeof arg == "string" && arg != ""; + }; + let revisionCheck = arg => { + return AppConstants.MOZILLA_OFFICIAL + ? stringCheck(arg) + : typeof arg == "string"; + }; + let uuidCheck = arg => { + return UUID_REGEX.test(arg); + }; + let isoDateCheck = arg => { + // We expect use of this version of the ISO format: + // 2015-04-12T18:51:19.1+00:00 + const isoDateRegEx = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+[+-]\d{2}:\d{2}$/; + return ( + stringCheck(arg) && + !Number.isNaN(Date.parse(arg)) && + isoDateRegEx.test(arg) + ); + }; + + const EXPECTED_INFO_FIELDS_TYPES = { + reason: stringCheck, + revision: revisionCheck, + timezoneOffset: numberCheck, + sessionId: uuidCheck, + subsessionId: uuidCheck, + // Special cases: previousSessionId and previousSubsessionId are null on first run. + previousSessionId: arg => { + return arg ? uuidCheck(arg) : true; + }, + previousSubsessionId: arg => { + return arg ? uuidCheck(arg) : true; + }, + subsessionCounter: positiveNumberCheck, + profileSubsessionCounter: positiveNumberCheck, + sessionStartDate: isoDateCheck, + subsessionStartDate: isoDateCheck, + subsessionLength: positiveNumberCheck, + }; + + for (let f in EXPECTED_INFO_FIELDS_TYPES) { + Assert.ok(f in data, f + " must be available."); + + let checkFunc = EXPECTED_INFO_FIELDS_TYPES[f]; + Assert.ok( + checkFunc(data[f]), + f + " must have the correct type and valid data " + data[f] + ); + } + + // Check for a valid revision. + if (data.revision != "") { + const revisionUrlRegEx = + /^http[s]?:\/\/hg.mozilla.org(\/[a-z\S]+)+(\/rev\/[0-9a-z]+)$/g; + Assert.ok(revisionUrlRegEx.test(data.revision)); + } + + // Previous buildId is not mandatory. + if (data.previousBuildId) { + Assert.ok(stringCheck(data.previousBuildId)); + } + + Assert.ok( + ALLOWED_REASONS.find(r => r == data.reason), + "Payload must contain an allowed reason." + ); + Assert.equal(data.reason, reason, "Payload reason must match expected."); + + Assert.ok( + Date.parse(data.subsessionStartDate) >= Date.parse(data.sessionStartDate) + ); + Assert.ok(data.profileSubsessionCounter >= data.subsessionCounter); + + // According to https://en.wikipedia.org/wiki/List_of_UTC_time_offsets, + // UTC offsets range from -12 to +14 hours. + // Don't think the extremes of the range are affected by further + // daylight-savings adjustments, but it is possible. + Assert.ok( + data.timezoneOffset >= -12 * 60, + "The timezone must be in a valid range." + ); + Assert.ok( + data.timezoneOffset <= 14 * 60, + "The timezone must be in a valid range." + ); +} + +function checkScalars(processes) { + // Check that the scalars section is available in the ping payload. + const parentProcess = processes.parent; + Assert.ok( + "scalars" in parentProcess, + "The scalars section must be available in the parent process." + ); + Assert.ok( + "keyedScalars" in parentProcess, + "The keyedScalars section must be available in the parent process." + ); + Assert.equal( + typeof parentProcess.scalars, + "object", + "The scalars entry must be an object." + ); + Assert.equal( + typeof parentProcess.keyedScalars, + "object", + "The keyedScalars entry must be an object." + ); + + let checkScalar = function (scalar, name) { + // Check if the value is of a supported type. + const valueType = typeof scalar; + switch (valueType) { + case "string": + Assert.ok( + scalar.length <= 50, + "String values can't have more than 50 characters" + ); + break; + case "number": + Assert.ok( + scalar >= 0, + "We only support unsigned integer values in scalars." + ); + break; + case "boolean": + Assert.ok(true, "Boolean scalar found."); + break; + default: + Assert.ok( + false, + name + " contains an unsupported value type (" + valueType + ")" + ); + } + }; + + // Check that we have valid scalar entries. + const scalars = parentProcess.scalars; + for (let name in scalars) { + Assert.equal(typeof name, "string", "Scalar names must be strings."); + checkScalar(scalars[name], name); + } + + // Check that we have valid keyed scalar entries. + const keyedScalars = parentProcess.keyedScalars; + for (let name in keyedScalars) { + Assert.equal(typeof name, "string", "Scalar names must be strings."); + Assert.ok( + Object.keys(keyedScalars[name]).length, + "The reported keyed scalars must contain at least 1 key." + ); + for (let key in keyedScalars[name]) { + Assert.equal(typeof key, "string", "Keyed scalar keys must be strings."); + Assert.ok( + key.length <= 70, + "Keyed scalar keys can't have more than 70 characters." + ); + checkScalar(scalars[name][key], name); + } + } +} + +function checkPayload(payload, reason, successfulPings) { + Assert.ok("info" in payload, "Payload must contain an info section."); + checkPayloadInfo(payload.info, reason); + + Assert.ok(payload.simpleMeasurements.totalTime >= 0); + Assert.equal(payload.simpleMeasurements.startupInterrupted, 1); + Assert.equal(payload.simpleMeasurements.shutdownDuration, SHUTDOWN_TIME); + + let activeTicks = payload.simpleMeasurements.activeTicks; + Assert.ok(activeTicks >= 0); + + if ("browser.timings.last_shutdown" in payload.processes.parent.scalars) { + Assert.equal( + payload.processes.parent.scalars["browser.timings.last_shutdown"], + SHUTDOWN_TIME + ); + } + + Assert.equal( + payload.simpleMeasurements.failedProfileLockCount, + FAILED_PROFILE_LOCK_ATTEMPTS + ); + let profileDirectory = Services.dirsvc.get("ProfD", Ci.nsIFile); + let failedProfileLocksFile = profileDirectory.clone(); + failedProfileLocksFile.append("Telemetry.FailedProfileLocks.txt"); + Assert.ok(!failedProfileLocksFile.exists()); + + let isWindows = "@mozilla.org/windows-registry-key;1" in Cc; + if (isWindows) { + Assert.ok(payload.simpleMeasurements.startupSessionRestoreReadBytes > 0); + Assert.ok(payload.simpleMeasurements.startupSessionRestoreWriteBytes > 0); + } + + const TELEMETRY_SEND_SUCCESS = "TELEMETRY_SEND_SUCCESS"; + const TELEMETRY_SUCCESS = "TELEMETRY_SUCCESS"; + const TELEMETRY_TEST_FLAG = "TELEMETRY_TEST_FLAG"; + const TELEMETRY_TEST_COUNT = "TELEMETRY_TEST_COUNT"; + const TELEMETRY_TEST_KEYED_FLAG = "TELEMETRY_TEST_KEYED_FLAG"; + const TELEMETRY_TEST_KEYED_COUNT = "TELEMETRY_TEST_KEYED_COUNT"; + + if (successfulPings > 0) { + Assert.ok(TELEMETRY_SEND_SUCCESS in payload.histograms); + } + Assert.ok(TELEMETRY_TEST_FLAG in payload.histograms); + Assert.ok(TELEMETRY_TEST_COUNT in payload.histograms); + + Assert.ok(!(IGNORE_CLONED_HISTOGRAM in payload.histograms)); + + // Flag histograms should automagically spring to life. + const expected_flag = { + range: [1, 2], + bucket_count: 3, + histogram_type: 3, + values: { 0: 1, 1: 0 }, + sum: 0, + }; + let flag = payload.histograms[TELEMETRY_TEST_FLAG]; + Assert.deepEqual(flag, expected_flag); + + // We should have a test count. + const expected_count = { + range: [1, 2], + bucket_count: 3, + histogram_type: 4, + values: { 0: 1, 1: 0 }, + sum: 1, + }; + let count = payload.histograms[TELEMETRY_TEST_COUNT]; + Assert.deepEqual(count, expected_count); + + // There should be one successful report from the previous telemetry ping. + if (successfulPings > 0) { + const expected_tc = { + range: [1, 2], + bucket_count: 3, + histogram_type: 2, + values: { 0: 2, 1: successfulPings, 2: 0 }, + sum: successfulPings, + }; + let tc = payload.histograms[TELEMETRY_SUCCESS]; + Assert.deepEqual(tc, expected_tc); + } + + // The ping should include data from memory reporters. We can't check that + // this data is correct, because we can't control the values returned by the + // memory reporters. But we can at least check that the data is there. + // + // It's important to check for the presence of reporters with a mix of units, + // because MemoryTelemetry has separate logic for each one. But we can't + // currently check UNITS_COUNT_CUMULATIVE or UNITS_PERCENTAGE because + // Telemetry doesn't touch a memory reporter with these units that's + // available on all platforms. + + Assert.ok("MEMORY_TOTAL" in payload.histograms); // UNITS_BYTES + Assert.ok("MEMORY_JS_GC_HEAP" in payload.histograms); // UNITS_BYTES + Assert.ok("MEMORY_JS_COMPARTMENTS_SYSTEM" in payload.histograms); // UNITS_COUNT + + Assert.ok( + "mainThread" in payload.slowSQL && "otherThreads" in payload.slowSQL + ); + + // Check keyed histogram payload. + + Assert.ok("keyedHistograms" in payload); + let keyedHistograms = payload.keyedHistograms; + Assert.ok(!(TELEMETRY_TEST_KEYED_FLAG in keyedHistograms)); + Assert.ok(TELEMETRY_TEST_KEYED_COUNT in keyedHistograms); + + const expected_keyed_count = { + a: { + range: [1, 2], + bucket_count: 3, + histogram_type: 4, + values: { 0: 2, 1: 0 }, + sum: 2, + }, + b: { + range: [1, 2], + bucket_count: 3, + histogram_type: 4, + values: { 0: 1, 1: 0 }, + sum: 1, + }, + }; + Assert.deepEqual( + expected_keyed_count, + keyedHistograms[TELEMETRY_TEST_KEYED_COUNT] + ); + + Assert.ok( + "processes" in payload, + "The payload must have a processes section." + ); + Assert.ok( + "parent" in payload.processes, + "There must be at least a parent process." + ); + + checkScalars(payload.processes); +} + +function writeStringToFile(file, contents) { + let ostream = Cc[ + "@mozilla.org/network/safe-file-output-stream;1" + ].createInstance(Ci.nsIFileOutputStream); + ostream.init( + file, + PR_WRONLY | PR_CREATE_FILE | PR_TRUNCATE, + RW_OWNER, + ostream.DEFER_OPEN + ); + ostream.write(contents, contents.length); + ostream.QueryInterface(Ci.nsISafeOutputStream).finish(); + ostream.close(); +} + +function write_fake_shutdown_file() { + let profileDirectory = Services.dirsvc.get("ProfD", Ci.nsIFile); + let file = profileDirectory.clone(); + file.append("Telemetry.ShutdownTime.txt"); + let contents = "" + SHUTDOWN_TIME; + writeStringToFile(file, contents); +} + +function write_fake_failedprofilelocks_file() { + let profileDirectory = Services.dirsvc.get("ProfD", Ci.nsIFile); + let file = profileDirectory.clone(); + file.append("Telemetry.FailedProfileLocks.txt"); + let contents = "" + FAILED_PROFILE_LOCK_ATTEMPTS; + writeStringToFile(file, contents); +} + +add_task(async function test_setup() { + // Addon manager needs a profile directory + do_get_profile(); + await loadAddonManager(APP_ID, APP_NAME, APP_VERSION, PLATFORM_VERSION); + finishAddonManagerStartup(); + fakeIntlReady(); + // Make sure we don't generate unexpected pings due to pref changes. + await setEmptyPrefWatchlist(); + + Services.prefs.setBoolPref(TelemetryUtils.Preferences.FhrUploadEnabled, true); + + // Make it look like we've previously failed to lock a profile a couple times. + write_fake_failedprofilelocks_file(); + + // Make it look like we've shutdown before. + write_fake_shutdown_file(); + + await new Promise(resolve => + Telemetry.asyncFetchTelemetryData(wrapWithExceptionHandler(resolve)) + ); +}); + +add_task(async function asyncSetup() { + await TelemetryController.testSetup(); + // Load the client ID from the client ID provider to check for pings sanity. + gClientID = await ClientID.getClientID(); +}); + +// Ensures that expired histograms are not part of the payload. +add_task(async function test_expiredHistogram() { + let dummy = Telemetry.getHistogramById("TELEMETRY_TEST_EXPIRED"); + + dummy.add(1); + + Assert.equal( + TelemetrySession.getPayload().histograms.TELEMETRY_TEST_EXPIRED, + undefined + ); +}); + +add_task(async function sessionTimeExcludingAndIncludingSuspend() { + if (gIsAndroid) { + // We don't support this new probe on android at the moment. + return; + } + Preferences.set("toolkit.telemetry.testing.overrideProductsCheck", true); + await TelemetryController.testReset(); + let subsession = TelemetrySession.getPayload("environment-change", true); + let parentScalars = subsession.processes.parent.scalars; + + let withSuspend = + parentScalars["browser.engagement.session_time_including_suspend"]; + let withoutSuspend = + parentScalars["browser.engagement.session_time_excluding_suspend"]; + + Assert.ok( + withSuspend > 0, + "The session time including suspend should be positive" + ); + + Assert.ok( + withoutSuspend > 0, + "The session time excluding suspend should be positive" + ); + + // Two things about the next assertion: + // 1. The two calls to get the two different uptime values are made + // separately, so we can't guarantee equality, even if we know the machine + // has not been suspended (for example because it's running in infra and + // was just booted). In this case the value should be close to each other. + // 2. This test will fail if the device running this has been suspended in + // between booting the Firefox process running this test, and doing the + // following assertion test, but that's unlikely in practice. + const max_delta_ms = 100; + + Assert.ok( + withSuspend - withoutSuspend <= max_delta_ms, + "In test condition, the two uptimes should be close to each other" + ); + + // This however should always hold, except on Windows < 10, where the two + // clocks are from different system calls, and it can fail in test condition + // because the machine has not been suspended. + if ( + AppConstants.platform != "win" || + AppConstants.isPlatformAndVersionAtLeast("win", "10.0") + ) { + Assert.greaterOrEqual( + withSuspend, + withoutSuspend, + `The uptime with suspend must always been greater or equal to the uptime + without suspend` + ); + } + + Preferences.set("toolkit.telemetry.testing.overrideProductsCheck", false); +}); + +// Sends a ping to a non existing server. If we remove this test, we won't get +// all the histograms we need in the main ping. +add_task(async function test_noServerPing() { + await sendPing(); + // We need two pings in order to make sure STARTUP_MEMORY_STORAGE_SQLIE histograms + // are initialised. See bug 1131585. + await sendPing(); + // Allowing Telemetry to persist unsent pings as pending. If omitted may cause + // problems to the consequent tests. + await TelemetryController.testShutdown(); +}); + +// Checks that a sent ping is correctly received by a dummy http server. +add_task(async function test_simplePing() { + await TelemetryStorage.testClearPendingPings(); + PingServer.start(); + Preferences.set( + TelemetryUtils.Preferences.Server, + "http://localhost:" + PingServer.port + ); + + let now = new Date(2020, 1, 1, 12, 5, 6); + let expectedDate = new Date(2020, 1, 1, 12, 0, 0); + fakeNow(now); + gMonotonicNow = fakeMonotonicNow(gMonotonicNow + 5000); + + const expectedSessionUUID = "bd314d15-95bf-4356-b682-b6c4a8942202"; + const expectedSubsessionUUID = "3e2e5f6c-74ba-4e4d-a93f-a48af238a8c7"; + fakeGenerateUUID( + () => expectedSessionUUID, + () => expectedSubsessionUUID + ); + await TelemetryController.testReset(); + + // Session and subsession start dates are faked during TelemetrySession setup. We can + // now fake the session duration. + const SESSION_DURATION_IN_MINUTES = 15; + fakeNow(new Date(2020, 1, 1, 12, SESSION_DURATION_IN_MINUTES, 0)); + gMonotonicNow = fakeMonotonicNow( + gMonotonicNow + SESSION_DURATION_IN_MINUTES * 60 * 1000 + ); + + await sendPing(); + let ping = await PingServer.promiseNextPing(); + + checkPingFormat(ping, PING_TYPE_MAIN, true, true); + + // Check that we get the data we expect. + let payload = ping.payload; + Assert.equal(payload.info.sessionId, expectedSessionUUID); + Assert.equal(payload.info.subsessionId, expectedSubsessionUUID); + let sessionStartDate = new Date(payload.info.sessionStartDate); + Assert.equal(sessionStartDate.toISOString(), expectedDate.toISOString()); + let subsessionStartDate = new Date(payload.info.subsessionStartDate); + Assert.equal(subsessionStartDate.toISOString(), expectedDate.toISOString()); + Assert.equal(payload.info.subsessionLength, SESSION_DURATION_IN_MINUTES * 60); + + // Restore the UUID generator so we don't mess with other tests. + fakeGenerateUUID(TelemetryUtils.generateUUID, TelemetryUtils.generateUUID); + + await TelemetryController.testShutdown(); +}); + +// Saves the current session histograms, reloads them, performs a ping +// and checks that the dummy http server received both the previously +// saved ping and the new one. +add_task(async function test_saveLoadPing() { + // Let's start out with a defined state. + await TelemetryStorage.testClearPendingPings(); + await TelemetryController.testReset(); + PingServer.clearRequests(); + + // Setup test data and trigger pings. + setupTestData(); + await TelemetrySession.testSavePendingPing(); + await sendPing(); + + // Get requests received by dummy server. + const requests = await PingServer.promiseNextRequests(2); + + for (let req of requests) { + Assert.equal( + req.getHeader("content-type"), + "application/json; charset=UTF-8", + "The request must have the correct content-type." + ); + } + + // We decode both requests to check for the |reason|. + let pings = Array.from(requests, decodeRequestPayload); + + // Check we have the correct two requests. Ordering is not guaranteed. The ping type + // is encoded in the URL. + if (pings[0].type != PING_TYPE_MAIN) { + pings.reverse(); + } + + checkPingFormat(pings[0], PING_TYPE_MAIN, true, true); + checkPayload(pings[0].payload, REASON_TEST_PING, 0); + checkPingFormat(pings[1], PING_TYPE_SAVED_SESSION, true, true); + checkPayload(pings[1].payload, REASON_SAVED_SESSION, 0); + + await TelemetryController.testShutdown(); +}); + +add_task(async function test_checkSubsessionScalars() { + if (gIsAndroid) { + // We don't support subsessions yet on Android. + return; + } + + // Clear the scalars. + Telemetry.clearScalars(); + await TelemetryController.testReset(); + + // Set some scalars. + const UINT_SCALAR = "telemetry.test.unsigned_int_kind"; + const STRING_SCALAR = "telemetry.test.string_kind"; + let expectedUint = 37; + let expectedString = "Test value. Yay."; + Telemetry.scalarSet(UINT_SCALAR, expectedUint); + Telemetry.scalarSet(STRING_SCALAR, expectedString); + + // Check that scalars are not available in classic pings but are in subsession + // pings. Also clear the subsession. + let classic = TelemetrySession.getPayload(); + let subsession = TelemetrySession.getPayload("environment-change", true); + + const TEST_SCALARS = [UINT_SCALAR, STRING_SCALAR]; + for (let name of TEST_SCALARS) { + // Scalar must be reported in subsession pings (e.g. main). + Assert.ok( + name in subsession.processes.parent.scalars, + name + " must be reported in a subsession ping." + ); + } + // No scalar must be reported in classic pings (e.g. saved-session). + Assert.ok( + !Object.keys(classic.processes.parent.scalars).length, + "Scalars must not be reported in a classic ping." + ); + + // And make sure that we're getting the right values in the + // subsession ping. + Assert.equal( + subsession.processes.parent.scalars[UINT_SCALAR], + expectedUint, + UINT_SCALAR + " must contain the expected value." + ); + Assert.equal( + subsession.processes.parent.scalars[STRING_SCALAR], + expectedString, + STRING_SCALAR + " must contain the expected value." + ); + + // Since we cleared the subsession in the last getPayload(), check that + // breaking subsessions clears the scalars. + subsession = TelemetrySession.getPayload("environment-change"); + for (let name of TEST_SCALARS) { + Assert.ok( + !(name in subsession.processes.parent.scalars), + name + " must be cleared with the new subsession." + ); + } + + // Check if setting the scalars again works as expected. + expectedUint = 85; + expectedString = "A creative different value"; + Telemetry.scalarSet(UINT_SCALAR, expectedUint); + Telemetry.scalarSet(STRING_SCALAR, expectedString); + subsession = TelemetrySession.getPayload("environment-change"); + Assert.equal( + subsession.processes.parent.scalars[UINT_SCALAR], + expectedUint, + UINT_SCALAR + " must contain the expected value." + ); + Assert.equal( + subsession.processes.parent.scalars[STRING_SCALAR], + expectedString, + STRING_SCALAR + " must contain the expected value." + ); + + await TelemetryController.testShutdown(); +}); + +add_task(async function test_dailyCollection() { + if (gIsAndroid) { + // We don't do daily collections yet on Android. + return; + } + + let now = new Date(2030, 1, 1, 12, 0, 0); + let nowHour = new Date(2030, 1, 1, 12, 0, 0); + let schedulerTickCallback = null; + + PingServer.clearRequests(); + + fakeNow(now); + + // Fake scheduler functions to control daily collection flow in tests. + fakeSchedulerTimer( + callback => (schedulerTickCallback = callback), + () => {} + ); + + // Init and check timer. + await TelemetryStorage.testClearPendingPings(); + await TelemetryController.testReset(); + TelemetrySend.setServer("http://localhost:" + PingServer.port); + + // Set histograms to expected state. + const COUNT_ID = "TELEMETRY_TEST_COUNT"; + const KEYED_ID = "TELEMETRY_TEST_KEYED_COUNT"; + const count = Telemetry.getHistogramById(COUNT_ID); + const keyed = Telemetry.getKeyedHistogramById(KEYED_ID); + + count.clear(); + keyed.clear(); + count.add(1); + keyed.add("a", 1); + keyed.add("b", 1); + keyed.add("b", 1); + + // Make sure the daily ping gets triggered. + let expectedDate = nowHour; + now = futureDate(nowHour, MS_IN_ONE_DAY); + fakeNow(now); + + Assert.ok(!!schedulerTickCallback); + // Run a scheduler tick: it should trigger the daily ping. + await schedulerTickCallback(); + + // Collect the daily ping. + let ping = await PingServer.promiseNextPing(); + Assert.ok(!!ping); + + Assert.equal(ping.type, PING_TYPE_MAIN); + Assert.equal(ping.payload.info.reason, REASON_DAILY); + let subsessionStartDate = new Date(ping.payload.info.subsessionStartDate); + Assert.equal(subsessionStartDate.toISOString(), expectedDate.toISOString()); + + Assert.equal(ping.payload.histograms[COUNT_ID].sum, 1); + Assert.equal(ping.payload.keyedHistograms[KEYED_ID].a.sum, 1); + Assert.equal(ping.payload.keyedHistograms[KEYED_ID].b.sum, 2); + + // The daily ping is rescheduled for "tomorrow". + expectedDate = futureDate(expectedDate, MS_IN_ONE_DAY); + now = futureDate(now, MS_IN_ONE_DAY); + fakeNow(now); + + // Run a scheduler tick. Trigger and collect another ping. The histograms should be reset. + await schedulerTickCallback(); + + ping = await PingServer.promiseNextPing(); + Assert.ok(!!ping); + + Assert.equal(ping.type, PING_TYPE_MAIN); + Assert.equal(ping.payload.info.reason, REASON_DAILY); + subsessionStartDate = new Date(ping.payload.info.subsessionStartDate); + Assert.equal(subsessionStartDate.toISOString(), expectedDate.toISOString()); + + Assert.ok(!(COUNT_ID in ping.payload.histograms)); + Assert.ok(!(KEYED_ID in ping.payload.keyedHistograms)); + + // Trigger and collect another daily ping, with the histograms being set again. + count.add(1); + keyed.add("a", 1); + keyed.add("b", 1); + + // The daily ping is rescheduled for "tomorrow". + expectedDate = futureDate(expectedDate, MS_IN_ONE_DAY); + now = futureDate(now, MS_IN_ONE_DAY); + fakeNow(now); + + await schedulerTickCallback(); + ping = await PingServer.promiseNextPing(); + Assert.ok(!!ping); + + Assert.equal(ping.type, PING_TYPE_MAIN); + Assert.equal(ping.payload.info.reason, REASON_DAILY); + subsessionStartDate = new Date(ping.payload.info.subsessionStartDate); + Assert.equal(subsessionStartDate.toISOString(), expectedDate.toISOString()); + + Assert.equal(ping.payload.histograms[COUNT_ID].sum, 1); + Assert.equal(ping.payload.keyedHistograms[KEYED_ID].a.sum, 1); + Assert.equal(ping.payload.keyedHistograms[KEYED_ID].b.sum, 1); + + // Shutdown to cleanup the aborted-session if it gets created. + await TelemetryController.testShutdown(); +}); + +add_task(async function test_dailyDuplication() { + if (gIsAndroid) { + // We don't do daily collections yet on Android. + return; + } + + await TelemetrySend.reset(); + await TelemetryStorage.testClearPendingPings(); + PingServer.clearRequests(); + + let schedulerTickCallback = null; + let now = new Date(2030, 1, 1, 0, 0, 0); + fakeNow(now); + // Fake scheduler functions to control daily collection flow in tests. + fakeSchedulerTimer( + callback => (schedulerTickCallback = callback), + () => {} + ); + await TelemetryController.testReset(); + + // Make sure the daily ping gets triggered at midnight. + // We need to make sure that we trigger this after the period where we wait for + // the user to become idle. + let firstDailyDue = new Date(2030, 1, 2, 0, 0, 0); + fakeNow(firstDailyDue); + + // Run a scheduler tick: it should trigger the daily ping. + Assert.ok(!!schedulerTickCallback); + await schedulerTickCallback(); + + // Get the first daily ping. + let ping = await PingServer.promiseNextPing(); + Assert.ok(!!ping); + + Assert.equal(ping.type, PING_TYPE_MAIN); + Assert.equal(ping.payload.info.reason, REASON_DAILY); + + // We don't expect to receive any other daily ping in this test, so assert if we do. + PingServer.registerPingHandler((req, res) => { + Assert.ok( + false, + "No more daily pings should be sent/received in this test." + ); + }); + + // Set the current time to a bit after midnight. + let secondDailyDue = new Date(firstDailyDue); + secondDailyDue.setHours(0); + secondDailyDue.setMinutes(15); + fakeNow(secondDailyDue); + + // Run a scheduler tick: it should NOT trigger the daily ping. + Assert.ok(!!schedulerTickCallback); + await schedulerTickCallback(); + + // Shutdown to cleanup the aborted-session if it gets created. + PingServer.resetPingHandler(); + await TelemetryController.testShutdown(); +}); + +add_task(async function test_dailyOverdue() { + if (gIsAndroid) { + // We don't do daily collections yet on Android. + return; + } + + let schedulerTickCallback = null; + let now = new Date(2030, 1, 1, 11, 0, 0); + fakeNow(now); + // Fake scheduler functions to control daily collection flow in tests. + fakeSchedulerTimer( + callback => (schedulerTickCallback = callback), + () => {} + ); + await TelemetryStorage.testClearPendingPings(); + await TelemetryController.testReset(); + + // Skip one hour ahead: nothing should be due. + now.setHours(now.getHours() + 1); + fakeNow(now); + + // Assert if we receive something! + PingServer.registerPingHandler((req, res) => { + Assert.ok(false, "No daily ping should be received if not overdue!."); + }); + + // This tick should not trigger any daily ping. + Assert.ok(!!schedulerTickCallback); + await schedulerTickCallback(); + + // Restore the non asserting ping handler. + PingServer.resetPingHandler(); + PingServer.clearRequests(); + + // Simulate an overdue ping: we're not close to midnight, but the last daily ping + // time is too long ago. + let dailyOverdue = new Date(2030, 1, 2, 13, 0, 0); + fakeNow(dailyOverdue); + + // Run a scheduler tick: it should trigger the daily ping. + Assert.ok(!!schedulerTickCallback); + await schedulerTickCallback(); + + // Get the first daily ping. + let ping = await PingServer.promiseNextPing(); + Assert.ok(!!ping); + + Assert.equal(ping.type, PING_TYPE_MAIN); + Assert.equal(ping.payload.info.reason, REASON_DAILY); + + // Shutdown to cleanup the aborted-session if it gets created. + await TelemetryController.testShutdown(); +}); + +add_task(async function test_environmentChange() { + if (gIsAndroid) { + // We don't split subsessions on environment changes yet on Android. + return; + } + + await TelemetryStorage.testClearPendingPings(); + PingServer.clearRequests(); + + let now = fakeNow(2040, 1, 1, 12, 0, 0); + gMonotonicNow = fakeMonotonicNow( + gMonotonicNow + 10 * MILLISECONDS_PER_MINUTE + ); + + const PREF_TEST = "toolkit.telemetry.test.pref1"; + Preferences.reset(PREF_TEST); + + const PREFS_TO_WATCH = new Map([ + [PREF_TEST, { what: TelemetryEnvironment.RECORD_PREF_VALUE }], + ]); + + // Setup. + await TelemetryController.testReset(); + TelemetrySend.setServer("http://localhost:" + PingServer.port); + await TelemetryEnvironment.testWatchPreferences(PREFS_TO_WATCH); + + // Set histograms to expected state. + const COUNT_ID = "TELEMETRY_TEST_COUNT"; + const KEYED_ID = "TELEMETRY_TEST_KEYED_COUNT"; + const count = Telemetry.getHistogramById(COUNT_ID); + const keyed = Telemetry.getKeyedHistogramById(KEYED_ID); + + count.clear(); + keyed.clear(); + count.add(1); + keyed.add("a", 1); + keyed.add("b", 1); + + // Trigger and collect environment-change ping. + gMonotonicNow = fakeMonotonicNow( + gMonotonicNow + 10 * MILLISECONDS_PER_MINUTE + ); + let startHour = TelemetryUtils.truncateToHours(now); + now = fakeNow(futureDate(now, 10 * MILLISECONDS_PER_MINUTE)); + + Preferences.set(PREF_TEST, 1); + let ping = await PingServer.promiseNextPing(); + Assert.ok(!!ping); + + Assert.equal(ping.type, PING_TYPE_MAIN); + Assert.equal(ping.environment.settings.userPrefs[PREF_TEST], undefined); + Assert.equal(ping.payload.info.reason, REASON_ENVIRONMENT_CHANGE); + let subsessionStartDate = new Date(ping.payload.info.subsessionStartDate); + Assert.equal(subsessionStartDate.toISOString(), startHour.toISOString()); + + Assert.equal(ping.payload.histograms[COUNT_ID].sum, 1); + Assert.equal(ping.payload.keyedHistograms[KEYED_ID].a.sum, 1); + + // Trigger and collect another ping. The histograms should be reset. + startHour = TelemetryUtils.truncateToHours(now); + gMonotonicNow = fakeMonotonicNow( + gMonotonicNow + 10 * MILLISECONDS_PER_MINUTE + ); + now = fakeNow(futureDate(now, 10 * MILLISECONDS_PER_MINUTE)); + + Preferences.set(PREF_TEST, 2); + ping = await PingServer.promiseNextPing(); + Assert.ok(!!ping); + + Assert.equal(ping.type, PING_TYPE_MAIN); + Assert.equal(ping.environment.settings.userPrefs[PREF_TEST], 1); + Assert.equal(ping.payload.info.reason, REASON_ENVIRONMENT_CHANGE); + subsessionStartDate = new Date(ping.payload.info.subsessionStartDate); + Assert.equal(subsessionStartDate.toISOString(), startHour.toISOString()); + + Assert.ok(!(COUNT_ID in ping.payload.histograms)); + Assert.ok(!(KEYED_ID in ping.payload.keyedHistograms)); + + // Trigger and collect another ping. The histograms should be reset. + startHour = TelemetryUtils.truncateToHours(now); + gMonotonicNow = fakeMonotonicNow( + gMonotonicNow + 10 * MILLISECONDS_PER_MINUTE + ); + now = fakeNow(futureDate(now, 10 * MILLISECONDS_PER_MINUTE)); + + await TelemetryController.testShutdown(); +}); + +add_task(async function test_experimentAnnotations_subsession() { + if (gIsAndroid) { + // We don't split subsessions on environment changes yet on Android. + return; + } + + const EXPERIMENT1 = "experiment-1"; + const EXPERIMENT1_BRANCH = "nice-branch"; + const EXPERIMENT2 = "experiment-2"; + const EXPERIMENT2_BRANCH = "other-branch"; + + await TelemetryStorage.testClearPendingPings(); + PingServer.clearRequests(); + + let now = fakeNow(2040, 1, 1, 12, 0, 0); + gMonotonicNow = fakeMonotonicNow( + gMonotonicNow + 10 * MILLISECONDS_PER_MINUTE + ); + + // Setup. + await TelemetryController.testReset(); + TelemetrySend.setServer("http://localhost:" + PingServer.port); + Assert.equal(TelemetrySession.getPayload().info.subsessionCounter, 1); + + // Trigger a subsession split with a telemetry annotation. + gMonotonicNow = fakeMonotonicNow( + gMonotonicNow + 10 * MILLISECONDS_PER_MINUTE + ); + let futureTestDate = futureDate(now, 10 * MILLISECONDS_PER_MINUTE); + now = fakeNow(futureTestDate); + TelemetryEnvironment.setExperimentActive(EXPERIMENT1, EXPERIMENT1_BRANCH); + + let ping = await PingServer.promiseNextPing(); + Assert.ok(!!ping, "A ping must be received."); + + Assert.equal( + ping.type, + PING_TYPE_MAIN, + "The received ping must be a 'main' ping." + ); + Assert.equal( + ping.payload.info.reason, + REASON_ENVIRONMENT_CHANGE, + "The 'main' ping must be triggered by a change in the environment." + ); + // We expect the current experiments to be reported in the next ping, not this + // one. + Assert.ok( + !("experiments" in ping.environment), + "The old environment must contain no active experiments." + ); + // Since this change wasn't throttled, the subsession counter must increase. + Assert.equal( + TelemetrySession.getPayload().info.subsessionCounter, + 2, + "The experiment annotation must trigger a new subsession." + ); + + // Add another annotation to the environment. We're not advancing the fake + // timer, so no subsession split should happen due to throttling. + TelemetryEnvironment.setExperimentActive(EXPERIMENT2, EXPERIMENT2_BRANCH); + Assert.equal( + TelemetrySession.getPayload().info.subsessionCounter, + 2, + "The experiment annotation must not trigger a new subsession " + + "if throttling happens." + ); + let oldExperiments = TelemetryEnvironment.getActiveExperiments(); + + // Fake the timer and remove an annotation, we expect a new subsession split. + gMonotonicNow = fakeMonotonicNow( + gMonotonicNow + 10 * MILLISECONDS_PER_MINUTE + ); + now = fakeNow(futureDate(now, 10 * MILLISECONDS_PER_MINUTE)); + TelemetryEnvironment.setExperimentInactive(EXPERIMENT1, EXPERIMENT1_BRANCH); + + ping = await PingServer.promiseNextPing(); + Assert.ok(!!ping, "A ping must be received."); + + Assert.equal( + ping.type, + PING_TYPE_MAIN, + "The received ping must be a 'main' ping." + ); + Assert.equal( + ping.payload.info.reason, + REASON_ENVIRONMENT_CHANGE, + "The 'main' ping must be triggered by a change in the environment." + ); + // We expect both experiments to be in this environment. + Assert.deepEqual( + ping.environment.experiments, + oldExperiments, + "The environment must contain both the experiments." + ); + Assert.equal( + TelemetrySession.getPayload().info.subsessionCounter, + 3, + "The removing an experiment annotation must trigger a new subsession." + ); + + await TelemetryController.testShutdown(); +}); + +add_task(async function test_savedPingsOnShutdown() { + await TelemetryController.testReset(); + + // Assure that we store the ping properly when saving sessions on shutdown. + // We make the TelemetryController shutdown to trigger a session save. + const dir = TelemetryStorage.pingDirectoryPath; + await IOUtils.remove(dir, { ignoreAbsent: true, recursive: true }); + await IOUtils.makeDirectory(dir); + await TelemetryController.testShutdown(); + + PingServer.clearRequests(); + await TelemetryController.testReset(); + + const ping = await PingServer.promiseNextPing(); + + let expectedType = gIsAndroid ? PING_TYPE_SAVED_SESSION : PING_TYPE_MAIN; + let expectedReason = gIsAndroid ? REASON_SAVED_SESSION : REASON_SHUTDOWN; + + checkPingFormat(ping, expectedType, true, true); + Assert.equal(ping.payload.info.reason, expectedReason); + Assert.equal(ping.clientId, gClientID); +}); + +add_task(async function test_sendShutdownPing() { + if ( + gIsAndroid || + (AppConstants.platform == "linux" && !Services.appinfo.is64Bit) + ) { + // We don't support the pingsender on Android, yet, see bug 1335917. + // We also don't suppor the pingsender testing on Treeherder for + // Linux 32 bit (due to missing libraries). So skip it there too. + // See bug 1310703 comment 78. + return; + } + + let checkPendingShutdownPing = async function () { + let pendingPings = await TelemetryStorage.loadPendingPingList(); + Assert.equal(pendingPings.length, 1, "We expect 1 pending ping: shutdown."); + // Load the pings off the disk. + const shutdownPing = await TelemetryStorage.loadPendingPing( + pendingPings[0].id + ); + Assert.ok(shutdownPing, "The 'shutdown' ping must be saved to disk."); + Assert.equal( + "shutdown", + shutdownPing.payload.info.reason, + "The 'shutdown' ping must be saved to disk." + ); + }; + + Preferences.set(TelemetryUtils.Preferences.ShutdownPingSender, true); + Preferences.set(TelemetryUtils.Preferences.FirstRun, false); + // Make sure the reporting policy picks up the updated pref. + TelemetryReportingPolicy.testUpdateFirstRun(); + PingServer.clearRequests(); + Telemetry.clearScalars(); + + // Shutdown telemetry and wait for an incoming ping. + let nextPing = PingServer.promiseNextPing(); + await TelemetryController.testShutdown(); + let ping = await nextPing; + + // Check that we received a shutdown ping. + checkPingFormat(ping, ping.type, true, true); + Assert.equal(ping.payload.info.reason, REASON_SHUTDOWN); + Assert.equal(ping.clientId, gClientID); + // Try again, this time disable ping upload. The PingSender + // should not be sending any ping! + PingServer.registerPingHandler(() => + Assert.ok(false, "Telemetry must not send pings if not allowed to.") + ); + Preferences.set(TelemetryUtils.Preferences.FhrUploadEnabled, false); + await TelemetryController.testReset(); + await TelemetryController.testShutdown(); + + // Make sure we have no pending pings between the runs. + await TelemetryStorage.testClearPendingPings(); + + // Enable ping upload and signal an OS shutdown. The pingsender + // will not be spawned and no ping will be sent. + Preferences.set(TelemetryUtils.Preferences.FhrUploadEnabled, true); + await TelemetryController.testReset(); + Services.obs.notifyObservers(null, "quit-application-forced"); + await TelemetryController.testShutdown(); + // After re-enabling FHR, wait for the new client ID + gClientID = await ClientID.getClientID(); + + // Check that the "shutdown" ping was correctly saved to disk. + await checkPendingShutdownPing(); + + // Make sure we have no pending pings between the runs. + await TelemetryStorage.testClearPendingPings(); + Telemetry.clearScalars(); + + await TelemetryController.testReset(); + Services.obs.notifyObservers( + null, + "quit-application-granted", + "syncShutdown" + ); + await TelemetryController.testShutdown(); + await checkPendingShutdownPing(); + + // Make sure we have no pending pings between the runs. + await TelemetryStorage.testClearPendingPings(); + + // Disable the "submission policy". The shutdown ping must not be sent. + Preferences.set(TelemetryUtils.Preferences.BypassNotification, false); + await TelemetryController.testReset(); + await TelemetryController.testShutdown(); + + // Make sure we have no pending pings between the runs. + await TelemetryStorage.testClearPendingPings(); + + // We cannot reset the BypassNotification pref, as we need it to be + // |true| in tests. + Preferences.set(TelemetryUtils.Preferences.BypassNotification, true); + + // With both upload enabled and the policy shown, make sure we don't + // send the shutdown ping using the pingsender on the first + // subsession. + Preferences.set(TelemetryUtils.Preferences.FirstRun, true); + // Make sure the reporting policy picks up the updated pref. + TelemetryReportingPolicy.testUpdateFirstRun(); + + await TelemetryController.testReset(); + await TelemetryController.testShutdown(); + + // Clear the state and prepare for the next test. + await TelemetryStorage.testClearPendingPings(); + PingServer.clearRequests(); + PingServer.resetPingHandler(); + + // Check that we're able to send the shutdown ping using the pingsender + // from the first session if the related pref is on. + Preferences.set( + TelemetryUtils.Preferences.ShutdownPingSenderFirstSession, + true + ); + Preferences.set(TelemetryUtils.Preferences.FirstRun, true); + TelemetryReportingPolicy.testUpdateFirstRun(); + + // Restart/shutdown telemetry and wait for an incoming ping. + await TelemetryController.testReset(); + await TelemetryController.testShutdown(); + ping = await PingServer.promiseNextPing(); + + // Check that we received a shutdown ping. + checkPingFormat(ping, ping.type, true, true); + Assert.equal(ping.payload.info.reason, REASON_SHUTDOWN); + Assert.equal(ping.clientId, gClientID); + + // Reset the pref and restart Telemetry. + Preferences.set(TelemetryUtils.Preferences.ShutdownPingSender, false); + Preferences.set( + TelemetryUtils.Preferences.ShutdownPingSenderFirstSession, + false + ); + Preferences.reset(TelemetryUtils.Preferences.FirstRun); + PingServer.resetPingHandler(); +}); + +add_task(async function test_sendFirstShutdownPing() { + if ( + gIsAndroid || + (AppConstants.platform == "linux" && !Services.appinfo.is64Bit) + ) { + // We don't support the pingsender on Android, yet, see bug 1335917. + // We also don't suppor the pingsender testing on Treeherder for + // Linux 32 bit (due to missing libraries). So skip it there too. + // See bug 1310703 comment 78. + return; + } + + let storageContainsFirstShutdown = async function () { + let pendingPings = await TelemetryStorage.loadPendingPingList(); + let pings = await Promise.all( + pendingPings.map(async p => { + return TelemetryStorage.loadPendingPing(p.id); + }) + ); + return pings.find(p => p.type == "first-shutdown"); + }; + + let checkShutdownNotSent = async function () { + // The failure-mode of the ping-sender is used to check that a ping was + // *not* sent. This can be combined with the state of the storage to infer + // the appropriate behavior from the preference flags. + + // Assert failure if we recive a ping. + PingServer.registerPingHandler((req, res) => { + const receivedPing = decodeRequestPayload(req); + Assert.ok( + false, + `No ping should be received in this test (got ${receivedPing.id}).` + ); + }); + + // Assert that pings are sent on first run, forcing a forced application + // quit. This should be equivalent to the first test in this suite. + Preferences.set(TelemetryUtils.Preferences.FirstRun, true); + TelemetryReportingPolicy.testUpdateFirstRun(); + + await TelemetryController.testReset(); + Services.obs.notifyObservers(null, "quit-application-forced"); + await TelemetryController.testShutdown(); + Assert.ok( + await storageContainsFirstShutdown(), + "The 'first-shutdown' ping must be saved to disk." + ); + + await TelemetryStorage.testClearPendingPings(); + + // Assert that it's not sent during subsequent runs + Preferences.set(TelemetryUtils.Preferences.FirstRun, false); + TelemetryReportingPolicy.testUpdateFirstRun(); + + await TelemetryController.testReset(); + Services.obs.notifyObservers(null, "quit-application-forced"); + await TelemetryController.testShutdown(); + Assert.ok( + !(await storageContainsFirstShutdown()), + "The 'first-shutdown' ping should only be written during first run." + ); + + await TelemetryStorage.testClearPendingPings(); + + // Assert that the the ping is only sent if the flag is enabled. + Preferences.set(TelemetryUtils.Preferences.FirstRun, true); + Preferences.set(TelemetryUtils.Preferences.FirstShutdownPingEnabled, false); + TelemetryReportingPolicy.testUpdateFirstRun(); + + await TelemetryController.testReset(); + await TelemetryController.testShutdown(); + Assert.ok( + !(await storageContainsFirstShutdown()), + "The 'first-shutdown' ping should only be written if enabled" + ); + + await TelemetryStorage.testClearPendingPings(); + + // Assert that the the ping is not collected when the ping-sender is disabled. + // The information would be made irrelevant by the main-ping in the second session. + Preferences.set(TelemetryUtils.Preferences.FirstShutdownPingEnabled, true); + Preferences.set(TelemetryUtils.Preferences.ShutdownPingSender, false); + TelemetryReportingPolicy.testUpdateFirstRun(); + + await TelemetryController.testReset(); + await TelemetryController.testShutdown(); + Assert.ok( + !(await storageContainsFirstShutdown()), + "The 'first-shutdown' ping should only be written if ping-sender is enabled" + ); + + // Clear the state and prepare for the next test. + await TelemetryStorage.testClearPendingPings(); + PingServer.clearRequests(); + PingServer.resetPingHandler(); + }; + + // Remove leftover pending pings from other tests + await TelemetryStorage.testClearPendingPings(); + PingServer.clearRequests(); + Telemetry.clearScalars(); + + // Set testing invariants for FirstShutdownPingEnabled + Preferences.set(TelemetryUtils.Preferences.ShutdownPingSender, true); + Preferences.set( + TelemetryUtils.Preferences.ShutdownPingSenderFirstSession, + false + ); + + // Set primary conditions of the 'first-shutdown' ping + Preferences.set(TelemetryUtils.Preferences.FirstShutdownPingEnabled, true); + Preferences.set(TelemetryUtils.Preferences.FirstRun, true); + TelemetryReportingPolicy.testUpdateFirstRun(); + + // Assert general 'first-shutdown' use-case. + await TelemetryController.testReset(); + await TelemetryController.testShutdown(); + let ping = await PingServer.promiseNextPing(); + checkPingFormat(ping, "first-shutdown", true, true); + Assert.equal(ping.payload.info.reason, REASON_SHUTDOWN); + Assert.equal(ping.clientId, gClientID); + + await TelemetryStorage.testClearPendingPings(); + + // Assert that the shutdown is not sent under various conditions + await checkShutdownNotSent(); + + // Reset the pref and restart Telemetry. + Preferences.set(TelemetryUtils.Preferences.ShutdownPingSender, false); + Preferences.set( + TelemetryUtils.Preferences.ShutdownPingSenderFirstSession, + false + ); + Preferences.set(TelemetryUtils.Preferences.FirstShutdownPingEnabled, false); + Preferences.reset(TelemetryUtils.Preferences.FirstRun); + PingServer.resetPingHandler(); +}); + +add_task(async function test_savedSessionData() { + // Create the directory which will contain the data file, if it doesn't already + // exist. + await IOUtils.makeDirectory(DATAREPORTING_PATH); + getHistogram("TELEMETRY_SESSIONDATA_FAILED_LOAD").clear(); + getHistogram("TELEMETRY_SESSIONDATA_FAILED_PARSE").clear(); + getHistogram("TELEMETRY_SESSIONDATA_FAILED_VALIDATION").clear(); + + // Write test data to the session data file. + const dataFilePath = PathUtils.join(DATAREPORTING_PATH, "session-state.json"); + const sessionState = { + sessionId: null, + subsessionId: null, + profileSubsessionCounter: 3785, + }; + await IOUtils.writeJSON(dataFilePath, sessionState); + + const PREF_TEST = "toolkit.telemetry.test.pref1"; + Preferences.reset(PREF_TEST); + const PREFS_TO_WATCH = new Map([ + [PREF_TEST, { what: TelemetryEnvironment.RECORD_PREF_VALUE }], + ]); + + // We expect one new subsession when starting TelemetrySession and one after triggering + // an environment change. + const expectedSubsessions = sessionState.profileSubsessionCounter + 2; + const expectedSessionUUID = "ff602e52-47a1-b7e8-4c1a-ffffffffc87a"; + const expectedSubsessionUUID = "009fd1ad-b85e-4817-b3e5-000000003785"; + fakeGenerateUUID( + () => expectedSessionUUID, + () => expectedSubsessionUUID + ); + + if (gIsAndroid) { + // We don't support subsessions yet on Android, so skip the next checks. + return; + } + + // Start TelemetrySession so that it loads the session data file. + await TelemetryController.testReset(); + Assert.equal(0, getSnapshot("TELEMETRY_SESSIONDATA_FAILED_LOAD").sum); + Assert.equal(0, getSnapshot("TELEMETRY_SESSIONDATA_FAILED_PARSE").sum); + Assert.equal(0, getSnapshot("TELEMETRY_SESSIONDATA_FAILED_VALIDATION").sum); + + // Watch a test preference, trigger and environment change and wait for it to propagate. + // _watchPreferences triggers a subsession notification + gMonotonicNow = fakeMonotonicNow( + gMonotonicNow + 10 * MILLISECONDS_PER_MINUTE + ); + fakeNow(new Date(2050, 1, 1, 12, 0, 0)); + await TelemetryEnvironment.testWatchPreferences(PREFS_TO_WATCH); + let changePromise = new Promise(resolve => + TelemetryEnvironment.registerChangeListener("test_fake_change", resolve) + ); + Preferences.set(PREF_TEST, 1); + await changePromise; + TelemetryEnvironment.unregisterChangeListener("test_fake_change"); + + let payload = TelemetrySession.getPayload(); + Assert.equal(payload.info.profileSubsessionCounter, expectedSubsessions); + await TelemetryController.testShutdown(); + + // Restore the UUID generator so we don't mess with other tests. + fakeGenerateUUID(TelemetryUtils.generateUUID, TelemetryUtils.generateUUID); + + // Load back the serialised session data. + let data = await IOUtils.readJSON(dataFilePath); + Assert.equal(data.profileSubsessionCounter, expectedSubsessions); + Assert.equal(data.sessionId, expectedSessionUUID); + Assert.equal(data.subsessionId, expectedSubsessionUUID); +}); + +add_task(async function test_sessionData_ShortSession() { + if (gIsAndroid) { + // We don't support subsessions yet on Android, so skip the next checks. + return; + } + + const SESSION_STATE_PATH = PathUtils.join( + DATAREPORTING_PATH, + "session-state.json" + ); + + // Remove the session state file. + await IOUtils.remove(SESSION_STATE_PATH, { ignoreAbsent: true }); + getHistogram("TELEMETRY_SESSIONDATA_FAILED_LOAD").clear(); + getHistogram("TELEMETRY_SESSIONDATA_FAILED_PARSE").clear(); + getHistogram("TELEMETRY_SESSIONDATA_FAILED_VALIDATION").clear(); + + const expectedSessionUUID = "ff602e52-47a1-b7e8-4c1a-ffffffffc87a"; + const expectedSubsessionUUID = "009fd1ad-b85e-4817-b3e5-000000003785"; + fakeGenerateUUID( + () => expectedSessionUUID, + () => expectedSubsessionUUID + ); + + // We intentionally don't wait for the setup to complete and shut down to simulate + // short sessions. We expect the profile subsession counter to be 1. + TelemetryController.testReset(); + await TelemetryController.testShutdown(); + + Assert.equal(1, getSnapshot("TELEMETRY_SESSIONDATA_FAILED_LOAD").sum); + Assert.equal(0, getSnapshot("TELEMETRY_SESSIONDATA_FAILED_PARSE").sum); + Assert.equal(0, getSnapshot("TELEMETRY_SESSIONDATA_FAILED_VALIDATION").sum); + + // Restore the UUID generation functions. + fakeGenerateUUID(TelemetryUtils.generateUUID, TelemetryUtils.generateUUID); + + // Start TelemetryController so that it loads the session data file. We expect the profile + // subsession counter to be incremented by 1 again. + await TelemetryController.testReset(); + + // We expect 2 profile subsession counter updates. + let payload = TelemetrySession.getPayload(); + Assert.equal(payload.info.profileSubsessionCounter, 2); + Assert.equal(payload.info.previousSessionId, expectedSessionUUID); + Assert.equal(payload.info.previousSubsessionId, expectedSubsessionUUID); + Assert.equal(1, getSnapshot("TELEMETRY_SESSIONDATA_FAILED_LOAD").sum); + Assert.equal(0, getSnapshot("TELEMETRY_SESSIONDATA_FAILED_PARSE").sum); + Assert.equal(0, getSnapshot("TELEMETRY_SESSIONDATA_FAILED_VALIDATION").sum); + + await TelemetryController.testShutdown(); +}); + +add_task(async function test_invalidSessionData() { + // Create the directory which will contain the data file, if it doesn't already + // exist. + await IOUtils.makeDirectory(DATAREPORTING_PATH); + getHistogram("TELEMETRY_SESSIONDATA_FAILED_LOAD").clear(); + getHistogram("TELEMETRY_SESSIONDATA_FAILED_PARSE").clear(); + getHistogram("TELEMETRY_SESSIONDATA_FAILED_VALIDATION").clear(); + + // Write test data to the session data file. This should fail to parse. + const dataFilePath = PathUtils.join(DATAREPORTING_PATH, "session-state.json"); + const unparseableData = "{asdf:@äü"; + await IOUtils.writeUTF8(dataFilePath, unparseableData, { + tmpPath: `${dataFilePath}.tmp`, + }); + + // Start TelemetryController so that it loads the session data file. + await TelemetryController.testReset(); + + // The session data file should not load. Only expect the current subsession. + Assert.equal(0, getSnapshot("TELEMETRY_SESSIONDATA_FAILED_LOAD").sum); + Assert.equal(1, getSnapshot("TELEMETRY_SESSIONDATA_FAILED_PARSE").sum); + Assert.equal(0, getSnapshot("TELEMETRY_SESSIONDATA_FAILED_VALIDATION").sum); + + // Write test data to the session data file. This should fail validation. + const sessionState = { + profileSubsessionCounter: "not-a-number?", + someOtherField: 12, + }; + await IOUtils.writeJSON(dataFilePath, sessionState); + + // The session data file should not load. Only expect the current subsession. + const expectedSubsessions = 1; + const expectedSessionUUID = "ff602e52-47a1-b7e8-4c1a-ffffffffc87a"; + const expectedSubsessionUUID = "009fd1ad-b85e-4817-b3e5-000000003785"; + fakeGenerateUUID( + () => expectedSessionUUID, + () => expectedSubsessionUUID + ); + + // Start TelemetryController so that it loads the session data file. + await TelemetryController.testShutdown(); + await TelemetryController.testReset(); + + let payload = TelemetrySession.getPayload(); + Assert.equal(payload.info.profileSubsessionCounter, expectedSubsessions); + Assert.equal(0, getSnapshot("TELEMETRY_SESSIONDATA_FAILED_LOAD").sum); + Assert.equal(1, getSnapshot("TELEMETRY_SESSIONDATA_FAILED_PARSE").sum); + Assert.equal(1, getSnapshot("TELEMETRY_SESSIONDATA_FAILED_VALIDATION").sum); + + await TelemetryController.testShutdown(); + + // Restore the UUID generator so we don't mess with other tests. + fakeGenerateUUID(TelemetryUtils.generateUUID, TelemetryUtils.generateUUID); + + // Load back the serialised session data. + let data = await IOUtils.readJSON(dataFilePath); + Assert.equal(data.profileSubsessionCounter, expectedSubsessions); + Assert.equal(data.sessionId, expectedSessionUUID); + Assert.equal(data.subsessionId, expectedSubsessionUUID); +}); + +add_task(async function test_abortedSession() { + if (gIsAndroid) { + // We don't have the aborted session ping here. + return; + } + + const ABORTED_FILE = PathUtils.join( + DATAREPORTING_PATH, + ABORTED_PING_FILE_NAME + ); + + // Make sure the aborted sessions directory does not exist to test its creation. + await IOUtils.remove(DATAREPORTING_PATH, { + ignoreAbsent: true, + recursive: true, + }); + + let schedulerTickCallback = null; + let now = new Date(2040, 1, 1, 0, 0, 0); + fakeNow(now); + // Fake scheduler functions to control aborted-session flow in tests. + fakeSchedulerTimer( + callback => (schedulerTickCallback = callback), + () => {} + ); + await TelemetryController.testReset(); + + Assert.ok( + await IOUtils.exists(DATAREPORTING_PATH), + "Telemetry must create the aborted session directory when starting." + ); + + // Fake now again so that the scheduled aborted-session save takes place. + now = futureDate(now, ABORTED_SESSION_UPDATE_INTERVAL_MS); + fakeNow(now); + // The first aborted session checkpoint must take place right after the initialisation. + Assert.ok(!!schedulerTickCallback); + // Execute one scheduler tick. + await schedulerTickCallback(); + // Check that the aborted session is due at the correct time. + Assert.ok( + await IOUtils.exists(ABORTED_FILE), + "There must be an aborted session ping." + ); + + // This ping is not yet in the pending pings folder, so we can't access it using + // TelemetryStorage.popPendingPings(). + let abortedSessionPing = await IOUtils.readJSON(ABORTED_FILE); + + // Validate the ping. + checkPingFormat(abortedSessionPing, PING_TYPE_MAIN, true, true); + Assert.equal(abortedSessionPing.payload.info.reason, REASON_ABORTED_SESSION); + + // Trigger a another aborted-session ping and check that it overwrites the previous one. + now = futureDate(now, ABORTED_SESSION_UPDATE_INTERVAL_MS); + fakeNow(now); + await schedulerTickCallback(); + + let updatedAbortedSessionPing = await IOUtils.readJSON(ABORTED_FILE); + checkPingFormat(updatedAbortedSessionPing, PING_TYPE_MAIN, true, true); + Assert.equal( + updatedAbortedSessionPing.payload.info.reason, + REASON_ABORTED_SESSION + ); + Assert.notEqual(abortedSessionPing.id, updatedAbortedSessionPing.id); + Assert.notEqual( + abortedSessionPing.creationDate, + updatedAbortedSessionPing.creationDate + ); + + await TelemetryController.testShutdown(); + Assert.ok( + !(await IOUtils.exists(ABORTED_FILE)), + "No aborted session ping must be available after a shutdown." + ); +}); + +add_task(async function test_abortedSession_Shutdown() { + if (gIsAndroid) { + // We don't have the aborted session ping here. + return; + } + + const ABORTED_FILE = PathUtils.join( + DATAREPORTING_PATH, + ABORTED_PING_FILE_NAME + ); + + let schedulerTickCallback = null; + let now = fakeNow(2040, 1, 1, 0, 0, 0); + // Fake scheduler functions to control aborted-session flow in tests. + fakeSchedulerTimer( + callback => (schedulerTickCallback = callback), + () => {} + ); + await TelemetryController.testReset(); + + Assert.ok( + await IOUtils.exists(DATAREPORTING_PATH), + "Telemetry must create the aborted session directory when starting." + ); + + // Fake now again so that the scheduled aborted-session save takes place. + fakeNow(futureDate(now, ABORTED_SESSION_UPDATE_INTERVAL_MS)); + // The first aborted session checkpoint must take place right after the initialisation. + Assert.ok(!!schedulerTickCallback); + // Execute one scheduler tick. + await schedulerTickCallback(); + // Check that the aborted session is due at the correct time. + Assert.ok( + await IOUtils.exists(ABORTED_FILE), + "There must be an aborted session ping." + ); + + // Remove the aborted session file and then shut down to make sure exceptions (e.g file + // not found) do not compromise the shutdown. + await IOUtils.remove(ABORTED_FILE); + + await TelemetryController.testShutdown(); +}); + +add_task(async function test_abortedDailyCoalescing() { + if (gIsAndroid) { + // We don't have the aborted session or the daily ping here. + return; + } + + const ABORTED_FILE = PathUtils.join( + DATAREPORTING_PATH, + ABORTED_PING_FILE_NAME + ); + + // Make sure the aborted sessions directory does not exist to test its creation. + await IOUtils.remove(DATAREPORTING_PATH, { + ignoreAbsent: true, + recursive: true, + }); + + let schedulerTickCallback = null; + PingServer.clearRequests(); + + let nowDate = new Date(2009, 10, 18, 0, 0, 0); + fakeNow(nowDate); + + // Fake scheduler functions to control aborted-session flow in tests. + fakeSchedulerTimer( + callback => (schedulerTickCallback = callback), + () => {} + ); + await TelemetryStorage.testClearPendingPings(); + PingServer.clearRequests(); + await TelemetryController.testReset(); + + Assert.ok( + await IOUtils.exists(DATAREPORTING_PATH), + "Telemetry must create the aborted session directory when starting." + ); + + // Delay the callback around midnight so that the aborted-session ping gets merged with the + // daily ping. + let dailyDueDate = futureDate(nowDate, MS_IN_ONE_DAY); + fakeNow(dailyDueDate); + // Trigger both the daily ping and the saved-session. + Assert.ok(!!schedulerTickCallback); + // Execute one scheduler tick. + await schedulerTickCallback(); + + // Wait for the daily ping. + let dailyPing = await PingServer.promiseNextPing(); + Assert.equal(dailyPing.payload.info.reason, REASON_DAILY); + + // Check that an aborted session ping was also written to disk. + Assert.ok( + await IOUtils.exists(ABORTED_FILE), + "There must be an aborted session ping." + ); + + // Read aborted session ping and check that the session/subsession ids equal the + // ones in the daily ping. + let abortedSessionPing = await IOUtils.readJSON(ABORTED_FILE); + Assert.equal( + abortedSessionPing.payload.info.sessionId, + dailyPing.payload.info.sessionId + ); + Assert.equal( + abortedSessionPing.payload.info.subsessionId, + dailyPing.payload.info.subsessionId + ); + + await TelemetryController.testShutdown(); +}); + +add_task(async function test_schedulerComputerSleep() { + if (gIsAndroid) { + // We don't have the aborted session or the daily ping here. + return; + } + + const ABORTED_FILE = PathUtils.join( + DATAREPORTING_PATH, + ABORTED_PING_FILE_NAME + ); + + await TelemetryController.testReset(); + await TelemetryController.testShutdown(); + await TelemetryStorage.testClearPendingPings(); + PingServer.clearRequests(); + + // Remove any aborted-session ping from the previous tests. + await IOUtils.remove(DATAREPORTING_PATH, { + ignoreAbsent: true, + recursive: true, + }); + + // Set a fake current date and start Telemetry. + let nowDate = fakeNow(2009, 10, 18, 0, 0, 0); + let schedulerTickCallback = null; + fakeSchedulerTimer( + callback => (schedulerTickCallback = callback), + () => {} + ); + await TelemetryController.testReset(); + + // Set the current time 3 days in the future at midnight, before running the callback. + nowDate = fakeNow(futureDate(nowDate, 3 * MS_IN_ONE_DAY)); + Assert.ok(!!schedulerTickCallback); + // Execute one scheduler tick. + await schedulerTickCallback(); + + let dailyPing = await PingServer.promiseNextPing(); + Assert.equal( + dailyPing.payload.info.reason, + REASON_DAILY, + "The wake notification should have triggered a daily ping." + ); + Assert.equal( + dailyPing.creationDate, + nowDate.toISOString(), + "The daily ping date should be correct." + ); + + Assert.ok( + await IOUtils.exists(ABORTED_FILE), + "There must be an aborted session ping." + ); + + // Now also test if we are sending a daily ping if we wake up on the next + // day even when the timer doesn't trigger. + // This can happen due to timeouts not running out during sleep times, + // see bug 1262386, bug 1204823 et al. + // Note that we don't get wake notifications on Linux due to bug 758848. + nowDate = fakeNow(futureDate(nowDate, 1 * MS_IN_ONE_DAY)); + + // We emulate the mentioned timeout behavior by sending the wake notification + // instead of triggering the timeout callback. + // This should trigger a daily ping, because we passed midnight. + Services.obs.notifyObservers(null, "wake_notification"); + + dailyPing = await PingServer.promiseNextPing(); + Assert.equal( + dailyPing.payload.info.reason, + REASON_DAILY, + "The wake notification should have triggered a daily ping." + ); + Assert.equal( + dailyPing.creationDate, + nowDate.toISOString(), + "The daily ping date should be correct." + ); + + await TelemetryController.testShutdown(); +}); + +add_task(async function test_schedulerEnvironmentReschedules() { + if (gIsAndroid) { + // We don't have the aborted session or the daily ping here. + return; + } + + // Reset the test preference. + const PREF_TEST = "toolkit.telemetry.test.pref1"; + Preferences.reset(PREF_TEST); + const PREFS_TO_WATCH = new Map([ + [PREF_TEST, { what: TelemetryEnvironment.RECORD_PREF_VALUE }], + ]); + + await TelemetryController.testReset(); + await TelemetryController.testShutdown(); + await TelemetryStorage.testClearPendingPings(); + // bug 1829855 - Sometimes the idle dispatch from a previous test interferes. + TelemetryScheduler.testReset(); + PingServer.clearRequests(); + + // Set a fake current date and start Telemetry. + let nowDate = fakeNow(2060, 10, 18, 0, 0, 0); + gMonotonicNow = fakeMonotonicNow( + gMonotonicNow + 10 * MILLISECONDS_PER_MINUTE + ); + let schedulerTickCallback = null; + fakeSchedulerTimer( + callback => (schedulerTickCallback = callback), + () => {} + ); + await TelemetryController.testReset(); + await TelemetryEnvironment.testWatchPreferences(PREFS_TO_WATCH); + + // Set the current time at midnight. + fakeNow(futureDate(nowDate, MS_IN_ONE_DAY)); + gMonotonicNow = fakeMonotonicNow( + gMonotonicNow + 10 * MILLISECONDS_PER_MINUTE + ); + + // Trigger the environment change. + Preferences.set(PREF_TEST, 1); + + // Wait for the environment-changed ping. + let ping = await PingServer.promiseNextPing(); + Assert.equal(ping.type, "main", `Expected 'main' ping on ${ping.id}`); + Assert.equal( + ping.payload.info.reason, + "environment-change", + `Expected 'environment-change' reason on ${ping.id}` + ); + + // We don't expect to receive any daily ping in this test, so assert if we do. + PingServer.registerPingHandler((req, res) => { + const receivedPing = decodeRequestPayload(req); + Assert.ok( + false, + `No ping should be received in this test (got ${receivedPing.id} type: ${receivedPing.type} reason: ${receivedPing.payload.info.reason}).` + ); + }); + + // Execute one scheduler tick. It should not trigger a daily ping. + Assert.ok(!!schedulerTickCallback); + await schedulerTickCallback(); + await TelemetryController.testShutdown(); +}); + +add_task(async function test_schedulerNothingDue() { + if (gIsAndroid) { + // We don't have the aborted session or the daily ping here. + return; + } + + const ABORTED_FILE = PathUtils.join( + DATAREPORTING_PATH, + ABORTED_PING_FILE_NAME + ); + + // Remove any aborted-session ping from the previous tests. + await IOUtils.remove(DATAREPORTING_PATH, { + ignoreAbsent: true, + recursive: true, + }); + await TelemetryStorage.testClearPendingPings(); + await TelemetryController.testReset(); + + // We don't expect to receive any ping in this test, so assert if we do. + PingServer.registerPingHandler((req, res) => { + const receivedPing = decodeRequestPayload(req); + Assert.ok( + false, + `No ping should be received in this test (got ${receivedPing.id}).` + ); + }); + + // Set a current date/time away from midnight, so that the daily ping doesn't get + // sent. + let nowDate = new Date(2009, 10, 18, 11, 0, 0); + fakeNow(nowDate); + let schedulerTickCallback = null; + fakeSchedulerTimer( + callback => (schedulerTickCallback = callback), + () => {} + ); + await TelemetryController.testReset(); + + // Delay the callback execution to a time when no ping should be due. + let nothingDueDate = futureDate( + nowDate, + ABORTED_SESSION_UPDATE_INTERVAL_MS / 2 + ); + fakeNow(nothingDueDate); + Assert.ok(!!schedulerTickCallback); + // Execute one scheduler tick. + await schedulerTickCallback(); + + // Check that no aborted session ping was written to disk. + Assert.ok(!(await IOUtils.exists(ABORTED_FILE))); + + await TelemetryController.testShutdown(); + PingServer.resetPingHandler(); +}); + +add_task(async function test_pingExtendedStats() { + const EXTENDED_PAYLOAD_FIELDS = [ + "log", + "slowSQL", + "fileIOReports", + "lateWrites", + "addonDetails", + ]; + + // Reset telemetry and disable sending extended statistics. + await TelemetryStorage.testClearPendingPings(); + PingServer.clearRequests(); + await TelemetryController.testReset(); + Telemetry.canRecordExtended = false; + + await sendPing(); + + let ping = await PingServer.promiseNextPing(); + checkPingFormat(ping, PING_TYPE_MAIN, true, true); + + // Check that the payload does not contain extended statistics fields. + for (let f in EXTENDED_PAYLOAD_FIELDS) { + Assert.ok( + !(EXTENDED_PAYLOAD_FIELDS[f] in ping.payload), + EXTENDED_PAYLOAD_FIELDS[f] + + " must not be in the payload if the extended set is off." + ); + } + + // We check this one separately so that we can reuse EXTENDED_PAYLOAD_FIELDS below, since + // slowSQLStartup might not be there. + Assert.ok( + !("slowSQLStartup" in ping.payload), + "slowSQLStartup must not be sent if the extended set is off" + ); + + Assert.ok( + !("addonManager" in ping.payload.simpleMeasurements), + "addonManager must not be sent if the extended set is off." + ); + Assert.ok( + !("UITelemetry" in ping.payload.simpleMeasurements), + "UITelemetry must not be sent." + ); + + // Restore the preference. + Telemetry.canRecordExtended = true; + + // Send a new ping that should contain the extended data. + await sendPing(); + ping = await PingServer.promiseNextPing(); + checkPingFormat(ping, PING_TYPE_MAIN, true, true); + + // Check that the payload now contains extended statistics fields. + for (let f in EXTENDED_PAYLOAD_FIELDS) { + Assert.ok( + EXTENDED_PAYLOAD_FIELDS[f] in ping.payload, + EXTENDED_PAYLOAD_FIELDS[f] + + " must be in the payload if the extended set is on." + ); + } + + Assert.ok( + "addonManager" in ping.payload.simpleMeasurements, + "addonManager must be sent if the extended set is on." + ); + Assert.ok( + !("UITelemetry" in ping.payload.simpleMeasurements), + "UITelemetry must not be sent." + ); + + await TelemetryController.testShutdown(); +}); + +add_task(async function test_schedulerUserIdle() { + if (gIsAndroid) { + // We don't have the aborted session or the daily ping here. + return; + } + + const SCHEDULER_TICK_INTERVAL_MS = 5 * 60 * 1000; + const SCHEDULER_TICK_IDLE_INTERVAL_MS = 60 * 60 * 1000; + + let now = new Date(2010, 1, 1, 11, 0, 0); + fakeNow(now); + + let schedulerTimeout = 0; + fakeSchedulerTimer( + (callback, timeout) => { + schedulerTimeout = timeout; + }, + () => {} + ); + await TelemetryController.testReset(); + await TelemetryStorage.testClearPendingPings(); + PingServer.clearRequests(); + + // When not idle, the scheduler should have a 5 minutes tick interval. + Assert.equal(schedulerTimeout, SCHEDULER_TICK_INTERVAL_MS); + + // Send an "idle" notification to the scheduler. + fakeIdleNotification("idle"); + + // When idle, the scheduler should have a 1hr tick interval. + Assert.equal(schedulerTimeout, SCHEDULER_TICK_IDLE_INTERVAL_MS); + + // Send an "active" notification to the scheduler. + await fakeIdleNotification("active"); + + // When user is back active, the scheduler tick should be 5 minutes again. + Assert.equal(schedulerTimeout, SCHEDULER_TICK_INTERVAL_MS); + + // We should not miss midnight when going to idle. + now.setHours(23); + now.setMinutes(50); + fakeNow(now); + fakeIdleNotification("idle"); + Assert.equal(schedulerTimeout, 10 * 60 * 1000); + + await TelemetryController.testShutdown(); +}); + +add_task(async function test_DailyDueAndIdle() { + if (gIsAndroid) { + // We don't have the aborted session or the daily ping here. + return; + } + + await TelemetryStorage.testClearPendingPings(); + PingServer.clearRequests(); + + let receivedPingRequest = null; + // Register a ping handler that will assert when receiving multiple daily pings. + PingServer.registerPingHandler(req => { + Assert.ok(!receivedPingRequest, "Telemetry must only send one daily ping."); + receivedPingRequest = req; + }); + + // Faking scheduler timer has to happen before resetting TelemetryController + // to be effective. + let schedulerTickCallback = null; + let now = new Date(2030, 1, 1, 0, 0, 0); + fakeNow(now); + // Fake scheduler functions to control daily collection flow in tests. + fakeSchedulerTimer( + callback => (schedulerTickCallback = callback), + () => {} + ); + await TelemetryController.testReset(); + + // Trigger the daily ping. + let firstDailyDue = new Date(2030, 1, 2, 0, 0, 0); + fakeNow(firstDailyDue); + + // Run a scheduler tick: it should trigger the daily ping. + Assert.ok(!!schedulerTickCallback); + let tickPromise = schedulerTickCallback(); + + // Send an idle and then an active user notification. + fakeIdleNotification("idle"); + fakeIdleNotification("active"); + + // Wait on the tick promise. + await tickPromise; + + await TelemetrySend.testWaitOnOutgoingPings(); + + // Decode the ping contained in the request and check that's a daily ping. + Assert.ok(receivedPingRequest, "Telemetry must send one daily ping."); + const receivedPing = decodeRequestPayload(receivedPingRequest); + checkPingFormat(receivedPing, PING_TYPE_MAIN, true, true); + Assert.equal(receivedPing.payload.info.reason, REASON_DAILY); + + await TelemetryController.testShutdown(); +}); + +add_task(async function test_userIdleAndSchedlerTick() { + if (gIsAndroid) { + // We don't have the aborted session or the daily ping here. + return; + } + + let receivedPingRequest = null; + // Register a ping handler that will assert when receiving multiple daily pings. + PingServer.registerPingHandler(req => { + Assert.ok(!receivedPingRequest, "Telemetry must only send one daily ping."); + receivedPingRequest = req; + }); + + let schedulerTickCallback = null; + let now = new Date(2030, 1, 1, 0, 0, 0); + fakeNow(now); + // Fake scheduler functions to control daily collection flow in tests. + fakeSchedulerTimer( + callback => (schedulerTickCallback = callback), + () => {} + ); + await TelemetryStorage.testClearPendingPings(); + await TelemetryController.testReset(); + PingServer.clearRequests(); + + // Move the current date/time to midnight. + let firstDailyDue = new Date(2030, 1, 2, 0, 0, 0); + fakeNow(firstDailyDue); + + // The active notification should trigger a scheduler tick. The latter will send the + // due daily ping. + fakeIdleNotification("active"); + + // Immediately running another tick should not send a daily ping again. + Assert.ok(!!schedulerTickCallback); + await schedulerTickCallback(); + + // A new "idle" notification should not send a new daily ping. + fakeIdleNotification("idle"); + + await TelemetrySend.testWaitOnOutgoingPings(); + + // Decode the ping contained in the request and check that's a daily ping. + Assert.ok(receivedPingRequest, "Telemetry must send one daily ping."); + const receivedPing = decodeRequestPayload(receivedPingRequest); + checkPingFormat(receivedPing, PING_TYPE_MAIN, true, true); + Assert.equal(receivedPing.payload.info.reason, REASON_DAILY); + + PingServer.resetPingHandler(); + await TelemetryController.testShutdown(); +}); + +add_task(async function test_changeThrottling() { + if (gIsAndroid) { + // We don't support subsessions yet on Android. + return; + } + + let getSubsessionCount = () => { + return TelemetrySession.getPayload().info.subsessionCounter; + }; + + let now = fakeNow(2050, 1, 2, 0, 0, 0); + gMonotonicNow = fakeMonotonicNow( + gMonotonicNow + 10 * MILLISECONDS_PER_MINUTE + ); + await TelemetryController.testReset(); + Assert.equal(getSubsessionCount(), 1); + + // The first pref change should not trigger a notification. + TelemetrySession.testOnEnvironmentChange("test", {}); + Assert.equal(getSubsessionCount(), 1); + + // We should get a change notification after the 5min throttling interval. + fakeNow(futureDate(now, 5 * MILLISECONDS_PER_MINUTE + 1)); + gMonotonicNow = fakeMonotonicNow( + gMonotonicNow + 5 * MILLISECONDS_PER_MINUTE + 1 + ); + TelemetrySession.testOnEnvironmentChange("test", {}); + Assert.equal(getSubsessionCount(), 2); + + // After that, changes should be throttled again. + now = fakeNow(futureDate(now, 1 * MILLISECONDS_PER_MINUTE)); + gMonotonicNow = fakeMonotonicNow(gMonotonicNow + 1 * MILLISECONDS_PER_MINUTE); + TelemetrySession.testOnEnvironmentChange("test", {}); + Assert.equal(getSubsessionCount(), 2); + + // ... for 5min. + now = fakeNow(futureDate(now, 4 * MILLISECONDS_PER_MINUTE + 1)); + gMonotonicNow = fakeMonotonicNow( + gMonotonicNow + 4 * MILLISECONDS_PER_MINUTE + 1 + ); + TelemetrySession.testOnEnvironmentChange("test", {}); + Assert.equal(getSubsessionCount(), 3); +}); + +add_task(async function stopServer() { + // It is important to shut down the TelemetryController first as, due to test + // environment changes, failure to upload pings here during shutdown results + // in an infinite loop of send failures and retries. + await TelemetryController.testShutdown(); + await PingServer.stop(); +}); |