head_helpers.js (21730B)
1 /* Any copyright is dedicated to the Public Domain. 2 http://creativecommons.org/publicdomain/zero/1.0/ */ 3 4 /* import-globals-from head_appinfo.js */ 5 /* import-globals-from ../../../common/tests/unit/head_helpers.js */ 6 /* import-globals-from head_errorhandler_common.js */ 7 /* import-globals-from head_http_server.js */ 8 9 // This file expects Service to be defined in the global scope when EHTestsCommon 10 // is used (from service.js). 11 /* global Service */ 12 13 var { AddonTestUtils, MockAsyncShutdown } = ChromeUtils.importESModule( 14 "resource://testing-common/AddonTestUtils.sys.mjs" 15 ); 16 var { Async } = ChromeUtils.importESModule( 17 "resource://services-common/async.sys.mjs" 18 ); 19 var { CommonUtils } = ChromeUtils.importESModule( 20 "resource://services-common/utils.sys.mjs" 21 ); 22 var { PlacesTestUtils } = ChromeUtils.importESModule( 23 "resource://testing-common/PlacesTestUtils.sys.mjs" 24 ); 25 var { sinon } = ChromeUtils.importESModule( 26 "resource://testing-common/Sinon.sys.mjs" 27 ); 28 var { SerializableSet, Svc, Utils, getChromeWindow } = 29 ChromeUtils.importESModule("resource://services-sync/util.sys.mjs"); 30 var { XPCOMUtils } = ChromeUtils.importESModule( 31 "resource://gre/modules/XPCOMUtils.sys.mjs" 32 ); 33 var { PlacesUtils } = ChromeUtils.importESModule( 34 "resource://gre/modules/PlacesUtils.sys.mjs" 35 ); 36 var { PlacesSyncUtils } = ChromeUtils.importESModule( 37 "resource://gre/modules/PlacesSyncUtils.sys.mjs" 38 ); 39 var { ObjectUtils } = ChromeUtils.importESModule( 40 "resource://gre/modules/ObjectUtils.sys.mjs" 41 ); 42 var { 43 MockFxaStorageManager, 44 SyncTestingInfrastructure, 45 configureFxAccountIdentity, 46 configureIdentity, 47 encryptPayload, 48 getLoginTelemetryScalar, 49 makeFxAccountsInternalMock, 50 makeIdentityConfig, 51 promiseNamedTimer, 52 promiseZeroTimer, 53 sumHistogram, 54 syncTestLogging, 55 waitForZeroTimer, 56 } = ChromeUtils.importESModule( 57 "resource://testing-common/services/sync/utils.sys.mjs" 58 ); 59 ChromeUtils.defineESModuleGetters(this, { 60 AddonManager: "resource://gre/modules/AddonManager.sys.mjs", 61 }); 62 63 add_setup(async function head_setup() { 64 // Initialize logging. This will sometimes be reset by a pref reset, 65 // so it's also called as part of SyncTestingInfrastructure(). 66 syncTestLogging(); 67 // If a test imports Service, make sure it is initialized first. 68 if (typeof Service !== "undefined") { 69 await Service.promiseInitialized; 70 } 71 }); 72 73 ChromeUtils.defineLazyGetter(this, "SyncPingSchema", function () { 74 let { FileUtils } = ChromeUtils.importESModule( 75 "resource://gre/modules/FileUtils.sys.mjs" 76 ); 77 let { NetUtil } = ChromeUtils.importESModule( 78 "resource://gre/modules/NetUtil.sys.mjs" 79 ); 80 let stream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance( 81 Ci.nsIFileInputStream 82 ); 83 let schema; 84 try { 85 let schemaFile = do_get_file("sync_ping_schema.json"); 86 stream.init(schemaFile, FileUtils.MODE_RDONLY, FileUtils.PERMS_FILE, 0); 87 88 let bytes = NetUtil.readInputStream(stream, stream.available()); 89 schema = JSON.parse(new TextDecoder().decode(bytes)); 90 } finally { 91 stream.close(); 92 } 93 94 // Allow tests to make whatever engines they want, this shouldn't cause 95 // validation failure. 96 schema.definitions.engine.properties.name = { type: "string" }; 97 return schema; 98 }); 99 100 ChromeUtils.defineLazyGetter(this, "SyncPingValidator", function () { 101 const { JsonSchema } = ChromeUtils.importESModule( 102 "resource://gre/modules/JsonSchema.sys.mjs" 103 ); 104 return new JsonSchema.Validator(SyncPingSchema); 105 }); 106 107 // This is needed for loadAddonTestFunctions(). 108 var gGlobalScope = this; 109 110 function ExtensionsTestPath(path) { 111 if (path[0] != "/") { 112 throw Error("Path must begin with '/': " + path); 113 } 114 115 return "../../../../toolkit/mozapps/extensions/test/xpcshell" + path; 116 } 117 118 function webExtensionsTestPath(path) { 119 if (path[0] != "/") { 120 throw Error("Path must begin with '/': " + path); 121 } 122 123 return "../../../../toolkit/components/extensions/test/xpcshell" + path; 124 } 125 126 /** 127 * Loads the WebExtension test functions by importing its test file. 128 */ 129 function loadWebExtensionTestFunctions() { 130 /* import-globals-from ../../../../toolkit/components/extensions/test/xpcshell/head_sync.js */ 131 const path = webExtensionsTestPath("/head_sync.js"); 132 let file = do_get_file(path); 133 let uri = Services.io.newFileURI(file); 134 Services.scriptloader.loadSubScript(uri.spec, gGlobalScope); 135 } 136 137 /** 138 * Installs an add-on from an addonInstall 139 * 140 * @param install addonInstall instance to install 141 */ 142 async function installAddonFromInstall(install) { 143 await install.install(); 144 145 Assert.notEqual(null, install.addon); 146 Assert.notEqual(null, install.addon.syncGUID); 147 148 return install.addon; 149 } 150 151 /** 152 * Convenience function to install an add-on from the extensions unit tests. 153 * 154 * @param file 155 * Add-on file to install. 156 * @param reconciler 157 * addons reconciler, if passed we will wait on the events to be 158 * processed before resolving 159 * @return addon object that was installed 160 */ 161 async function installAddon(file, reconciler = null) { 162 let install = await AddonManager.getInstallForFile(file); 163 Assert.notEqual(null, install); 164 const addon = await installAddonFromInstall(install); 165 if (reconciler) { 166 await reconciler.queueCaller.promiseCallsComplete(); 167 } 168 return addon; 169 } 170 171 /** 172 * Convenience function to uninstall an add-on. 173 * 174 * @param addon 175 * Addon instance to uninstall 176 * @param reconciler 177 * addons reconciler, if passed we will wait on the events to be 178 * processed before resolving 179 */ 180 async function uninstallAddon(addon, reconciler = null) { 181 const uninstallPromise = new Promise(res => { 182 let listener = { 183 onUninstalled(uninstalled) { 184 if (uninstalled.id == addon.id) { 185 AddonManager.removeAddonListener(listener); 186 res(uninstalled); 187 } 188 }, 189 }; 190 AddonManager.addAddonListener(listener); 191 }); 192 addon.uninstall(); 193 await uninstallPromise; 194 if (reconciler) { 195 await reconciler.queueCaller.promiseCallsComplete(); 196 } 197 } 198 199 async function generateNewKeys(collectionKeys, collections = null) { 200 let wbo = await collectionKeys.generateNewKeysWBO(collections); 201 let modified = new_timestamp(); 202 collectionKeys.setContents(wbo.cleartext, modified); 203 } 204 205 // Helpers for testing open tabs. 206 // These reflect part of the internal structure of TabEngine, 207 // and stub part of Service.wm. 208 209 function mockShouldSkipWindow(win) { 210 return win.closed || win.mockIsPrivate; 211 } 212 213 function mockGetTabState(tab) { 214 return tab; 215 } 216 217 function mockGetWindowEnumerator(urls) { 218 let elements = []; 219 220 const numWindows = 1; 221 for (let w = 0; w < numWindows; ++w) { 222 let tabs = []; 223 let win = { 224 closed: false, 225 mockIsPrivate: false, 226 gBrowser: { 227 tabs, 228 }, 229 }; 230 elements.push(win); 231 232 let lastAccessed = 2000; 233 for (let url of urls) { 234 tabs.push({ 235 linkedBrowser: { 236 currentURI: Services.io.newURI(url), 237 contentTitle: "title", 238 }, 239 lastAccessed, 240 }); 241 lastAccessed += 1000; 242 } 243 } 244 245 // Always include a closed window and a private window. 246 elements.push({ 247 closed: true, 248 mockIsPrivate: false, 249 gBrowser: { 250 tabs: [], 251 }, 252 }); 253 254 elements.push({ 255 closed: false, 256 mockIsPrivate: true, 257 gBrowser: { 258 tabs: [], 259 }, 260 }); 261 262 return elements.values(); 263 } 264 265 // Helper function to get the sync telemetry and add the typically used test 266 // engine names to its list of allowed engines. 267 function get_sync_test_telemetry() { 268 let { SyncTelemetry } = ChromeUtils.importESModule( 269 "resource://services-sync/telemetry.sys.mjs" 270 ); 271 SyncTelemetry.tryRefreshDevices = function () {}; 272 let testEngines = ["rotary", "steam", "sterling", "catapult", "nineties"]; 273 for (let engineName of testEngines) { 274 SyncTelemetry.allowedEngines.add(engineName); 275 } 276 SyncTelemetry.submissionInterval = -1; 277 return SyncTelemetry; 278 } 279 280 function assert_valid_ping(record) { 281 // Our JSON validator does not like `undefined` values, even though they will 282 // be skipped when we serialize to JSON. 283 record = JSON.parse(JSON.stringify(record)); 284 285 // This is called as the test harness tears down due to shutdown. This 286 // will typically have no recorded syncs, and the validator complains about 287 // it. So ignore such records (but only ignore when *both* shutdown and 288 // no Syncs - either of them not being true might be an actual problem) 289 if (record && (record.why != "shutdown" || !!record.syncs.length)) { 290 const result = SyncPingValidator.validate(record); 291 if (!result.valid) { 292 if (result.errors.length) { 293 // validation failed - using a simple |deepEqual([], errors)| tends to 294 // truncate the validation errors in the output and doesn't show that 295 // the ping actually was - so be helpful. 296 info("telemetry ping validation failed"); 297 info("the ping data is: " + JSON.stringify(record, undefined, 2)); 298 info( 299 "the validation failures: " + 300 JSON.stringify(result.errors, undefined, 2) 301 ); 302 ok( 303 false, 304 "Sync telemetry ping validation failed - see output above for details" 305 ); 306 } 307 } 308 equal(record.version, 1); 309 record.syncs.forEach(p => { 310 lessOrEqual(p.when, Date.now()); 311 }); 312 } 313 } 314 315 function assert_success_sync(record) { 316 ok(!record.failureReason, JSON.stringify(record.failureReason)); 317 equal(undefined, record.status); 318 greater(record.engines.length, 0); 319 for (let e of record.engines) { 320 ok(!e.failureReason); 321 equal(undefined, e.status); 322 if (e.validation) { 323 equal(undefined, e.validation.problems); 324 equal(undefined, e.validation.failureReason); 325 } 326 if (e.outgoing) { 327 for (let o of e.outgoing) { 328 equal(undefined, o.failed); 329 notEqual(undefined, o.sent); 330 } 331 } 332 if (e.incoming) { 333 equal(undefined, e.incoming.failed); 334 equal(undefined, e.incoming.newFailed); 335 notEqual(undefined, e.incoming.applied || e.incoming.reconciled); 336 } 337 } 338 } 339 340 // Asserts that `ping` is a ping that doesn't contain any failure information 341 function assert_success_ping(ping) { 342 ok(!!ping); 343 assert_valid_ping(ping); 344 ping.syncs.forEach(assert_success_sync); 345 } 346 347 // Hooks into telemetry to validate all pings after calling. 348 function validate_all_future_pings() { 349 let telem = get_sync_test_telemetry(); 350 telem.submit = assert_valid_ping; 351 } 352 353 function wait_for_pings(expectedPings) { 354 return new Promise(resolve => { 355 let telem = get_sync_test_telemetry(); 356 let oldSubmit = telem.submit; 357 let pings = []; 358 telem.submit = function (record) { 359 pings.push(record); 360 if (pings.length == expectedPings) { 361 telem.submit = oldSubmit; 362 resolve(pings); 363 } 364 }; 365 }); 366 } 367 368 async function wait_for_ping(callback, allowErrorPings, getFullPing = false) { 369 let pingsPromise = wait_for_pings(1); 370 await callback(); 371 let [record] = await pingsPromise; 372 if (allowErrorPings) { 373 assert_valid_ping(record); 374 } else { 375 assert_success_ping(record); 376 } 377 if (getFullPing) { 378 return record; 379 } 380 equal(record.syncs.length, 1); 381 return record.syncs[0]; 382 } 383 384 // Perform a sync and validate all telemetry caused by the sync. If fnValidate 385 // is null, we just check the ping records success. If fnValidate is specified, 386 // then the sync must have recorded just a single sync, and that sync will be 387 // passed to the function to be checked. 388 async function sync_and_validate_telem( 389 fnValidate = null, 390 wantFullPing = false 391 ) { 392 let numErrors = 0; 393 let telem = get_sync_test_telemetry(); 394 let oldSubmit = telem.submit; 395 try { 396 telem.submit = function (record) { 397 // This is called via an observer, so failures here don't cause the test 398 // to fail :( 399 try { 400 // All pings must be valid. 401 assert_valid_ping(record); 402 if (fnValidate) { 403 // for historical reasons most of these callbacks expect a "sync" 404 // record, not the entire ping. 405 if (wantFullPing) { 406 fnValidate(record); 407 } else { 408 Assert.equal(record.syncs.length, 1); 409 fnValidate(record.syncs[0]); 410 } 411 } else { 412 // no validation function means it must be a "success" ping. 413 assert_success_ping(record); 414 } 415 } catch (ex) { 416 print("Failure in ping validation callback", ex, "\n", ex.stack); 417 numErrors += 1; 418 } 419 }; 420 await Service.sync(); 421 Assert.equal(numErrors, 0, "There were telemetry validation errors"); 422 } finally { 423 telem.submit = oldSubmit; 424 } 425 } 426 427 // Used for the (many) cases where we do a 'partial' sync, where only a single 428 // engine is actually synced, but we still want to ensure we're generating a 429 // valid ping. Returns a promise that resolves to the ping, or rejects with the 430 // thrown error after calling an optional callback. 431 async function sync_engine_and_validate_telem( 432 engine, 433 allowErrorPings, 434 onError, 435 wantFullPing = false 436 ) { 437 let telem = get_sync_test_telemetry(); 438 let caughtError = null; 439 // Clear out status, so failures from previous syncs won't show up in the 440 // telemetry ping. 441 let { Status } = ChromeUtils.importESModule( 442 "resource://services-sync/status.sys.mjs" 443 ); 444 Status._engines = {}; 445 Status.partial = false; 446 // Ideally we'd clear these out like we do with engines, (probably via 447 // Status.resetSync()), but this causes *numerous* tests to fail, so we just 448 // assume that if no failureReason or engine failures are set, and the 449 // status properties are the same as they were initially, that it's just 450 // a leftover. 451 // This is only an issue since we're triggering the sync of just one engine, 452 // without doing any other parts of the sync. 453 let initialServiceStatus = Status._service; 454 let initialSyncStatus = Status._sync; 455 456 let oldSubmit = telem.submit; 457 let submitPromise = new Promise((resolve, reject) => { 458 telem.submit = function (ping) { 459 telem.submit = oldSubmit; 460 ping.syncs.forEach(record => { 461 if (record && record.status) { 462 // did we see anything to lead us to believe that something bad actually happened 463 let realProblem = 464 record.failureReason || 465 record.engines.some(e => { 466 if (e.failureReason || e.status) { 467 return true; 468 } 469 if (e.outgoing && e.outgoing.some(o => o.failed > 0)) { 470 return true; 471 } 472 return e.incoming && e.incoming.failed; 473 }); 474 if (!realProblem) { 475 // no, so if the status is the same as it was initially, just assume 476 // that its leftover and that we can ignore it. 477 if (record.status.sync && record.status.sync == initialSyncStatus) { 478 delete record.status.sync; 479 } 480 if ( 481 record.status.service && 482 record.status.service == initialServiceStatus 483 ) { 484 delete record.status.service; 485 } 486 if (!record.status.sync && !record.status.service) { 487 delete record.status; 488 } 489 } 490 } 491 }); 492 if (allowErrorPings) { 493 assert_valid_ping(ping); 494 } else { 495 assert_success_ping(ping); 496 } 497 equal(ping.syncs.length, 1); 498 if (caughtError) { 499 if (onError) { 500 onError(ping.syncs[0], ping); 501 } 502 reject(caughtError); 503 } else if (wantFullPing) { 504 resolve(ping); 505 } else { 506 resolve(ping.syncs[0]); 507 } 508 }; 509 }); 510 // neuter the scheduler as it interacts badly with some of the tests - the 511 // engine being synced usually isn't the registered engine, so we see 512 // scored incremented and not removed, which schedules unexpected syncs. 513 let oldObserve = Service.scheduler.observe; 514 Service.scheduler.observe = () => {}; 515 try { 516 Svc.Obs.notify("weave:service:sync:start"); 517 try { 518 await engine.sync(); 519 } catch (e) { 520 caughtError = e; 521 } 522 if (caughtError) { 523 Svc.Obs.notify("weave:service:sync:error", caughtError); 524 } else { 525 Svc.Obs.notify("weave:service:sync:finish"); 526 } 527 } finally { 528 Service.scheduler.observe = oldObserve; 529 } 530 return submitPromise; 531 } 532 533 // Returns a promise that resolves once the specified observer notification 534 // has fired. 535 function promiseOneObserver(topic) { 536 return new Promise(resolve => { 537 let observer = function (subject, data) { 538 Svc.Obs.remove(topic, observer); 539 resolve({ subject, data }); 540 }; 541 Svc.Obs.add(topic, observer); 542 }); 543 } 544 545 async function registerRotaryEngine() { 546 let { RotaryEngine } = ChromeUtils.importESModule( 547 "resource://testing-common/services/sync/rotaryengine.sys.mjs" 548 ); 549 await Service.engineManager.clear(); 550 551 await Service.engineManager.register(RotaryEngine); 552 let engine = Service.engineManager.get("rotary"); 553 let syncID = await engine.resetLocalSyncID(); 554 engine.enabled = true; 555 556 return { engine, syncID, tracker: engine._tracker }; 557 } 558 559 // Set the validation prefs to attempt validation every time to avoid non-determinism. 560 function enableValidationPrefs(engines = ["bookmarks"]) { 561 for (let engine of engines) { 562 Svc.PrefBranch.setIntPref(`engine.${engine}.validation.interval`, 0); 563 Svc.PrefBranch.setIntPref( 564 `engine.${engine}.validation.percentageChance`, 565 100 566 ); 567 Svc.PrefBranch.setIntPref(`engine.${engine}.validation.maxRecords`, -1); 568 Svc.PrefBranch.setBoolPref(`engine.${engine}.validation.enabled`, true); 569 } 570 } 571 572 async function serverForEnginesWithKeys(users, engines, callback) { 573 // Generate and store a fake default key bundle to avoid resetting the client 574 // before the first sync. 575 let wbo = await Service.collectionKeys.generateNewKeysWBO(); 576 let modified = new_timestamp(); 577 Service.collectionKeys.setContents(wbo.cleartext, modified); 578 579 let allEngines = [Service.clientsEngine].concat(engines); 580 581 let globalEngines = {}; 582 for (let engine of allEngines) { 583 let syncID = await engine.resetLocalSyncID(); 584 globalEngines[engine.name] = { version: engine.version, syncID }; 585 } 586 587 let contents = { 588 meta: { 589 global: { 590 syncID: Service.syncID, 591 storageVersion: STORAGE_VERSION, 592 engines: globalEngines, 593 }, 594 }, 595 crypto: { 596 keys: encryptPayload(wbo.cleartext), 597 }, 598 }; 599 for (let engine of allEngines) { 600 contents[engine.name] = {}; 601 } 602 603 return serverForUsers(users, contents, callback); 604 } 605 606 async function serverForFoo(engine, callback) { 607 // The bookmarks engine *always* tracks changes, meaning we might try 608 // and sync due to the bookmarks we ourselves create! Worse, because we 609 // do an engine sync only, there's no locking - so we end up with multiple 610 // syncs running. Neuter that by making the threshold very large. 611 Service.scheduler.syncThreshold = 10000000; 612 return serverForEnginesWithKeys({ foo: "password" }, engine, callback); 613 } 614 615 // Places notifies history observers asynchronously, so `addVisits` might return 616 // before the tracker receives the notification. This helper registers an 617 // observer that resolves once the expected notification fires. 618 async function promiseVisit(expectedType, expectedURI) { 619 return new Promise(resolve => { 620 function done(type, uri) { 621 if (uri == expectedURI.spec && type == expectedType) { 622 PlacesObservers.removeListener( 623 ["page-visited", "page-removed"], 624 observer.handlePlacesEvents 625 ); 626 resolve(); 627 } 628 } 629 let observer = { 630 handlePlacesEvents(events) { 631 Assert.equal(events.length, 1); 632 633 if (events[0].type === "page-visited") { 634 done("added", events[0].url); 635 } else if (events[0].type === "page-removed") { 636 Assert.ok(events[0].isRemovedFromStore); 637 done("removed", events[0].url); 638 } 639 }, 640 }; 641 PlacesObservers.addListener( 642 ["page-visited", "page-removed"], 643 observer.handlePlacesEvents 644 ); 645 }); 646 } 647 648 async function addVisit( 649 suffix, 650 referrer = null, 651 transition = PlacesUtils.history.TRANSITION_LINK 652 ) { 653 let uriString = "http://getfirefox.com/" + suffix; 654 let uri = CommonUtils.makeURI(uriString); 655 _("Adding visit for URI " + uriString); 656 657 let visitAddedPromise = promiseVisit("added", uri); 658 await PlacesTestUtils.addVisits({ 659 uri, 660 visitDate: Date.now() * 1000, 661 transition, 662 referrer, 663 }); 664 await visitAddedPromise; 665 666 return uri; 667 } 668 669 function bookmarkNodesToInfos(nodes) { 670 return nodes.map(node => { 671 let info = { 672 guid: node.guid, 673 index: node.index, 674 }; 675 if (node.children) { 676 info.children = bookmarkNodesToInfos(node.children); 677 } 678 return info; 679 }); 680 } 681 682 async function assertBookmarksTreeMatches(rootGuid, expected, message) { 683 let root = await PlacesUtils.promiseBookmarksTree(rootGuid, { 684 includeItemIds: true, 685 }); 686 let actual = bookmarkNodesToInfos(root.children); 687 688 if (!ObjectUtils.deepEqual(actual, expected)) { 689 _(`Expected structure for ${rootGuid}`, JSON.stringify(expected)); 690 _(`Actual structure for ${rootGuid}`, JSON.stringify(actual)); 691 throw new Assert.constructor.AssertionError({ actual, expected, message }); 692 } 693 } 694 695 function add_bookmark_test(task) { 696 const { BookmarksEngine } = ChromeUtils.importESModule( 697 "resource://services-sync/engines/bookmarks.sys.mjs" 698 ); 699 700 add_task(async function () { 701 _(`Running bookmarks test ${task.name}`); 702 let engine = new BookmarksEngine(Service); 703 await engine.initialize(); 704 await engine._resetClient(); 705 try { 706 await task(engine); 707 } finally { 708 await engine.finalize(); 709 } 710 }); 711 }