formautofill_common.js (17679B)
1 /* import-globals-from ../../../../../testing/mochitest/tests/SimpleTest/SimpleTest.js */ 2 /* import-globals-from ../../../../../testing/mochitest/tests/SimpleTest/EventUtils.js */ 3 /* import-globals-from ../../../../../toolkit/components/satchel/test/satchel_common.js */ 4 /* eslint-disable no-unused-vars */ 5 // Despite a use of `spawnChrome` and thus ChromeUtils, we can't use isInstance 6 // here as it gets used in plain mochitests which don't have the ChromeOnly 7 // APIs for it. 8 /* eslint-disable mozilla/use-isInstance */ 9 10 "use strict"; 11 12 let formFillChromeScript; 13 let defaultTextColor; 14 let defaultDisabledTextColor; 15 let expectingPopup = null; 16 17 const { FormAutofillUtils } = SpecialPowers.ChromeUtils.importESModule( 18 "resource://gre/modules/shared/FormAutofillUtils.sys.mjs" 19 ); 20 21 const { OSKeyStore } = SpecialPowers.ChromeUtils.importESModule( 22 "resource://gre/modules/OSKeyStore.sys.mjs" 23 ); 24 25 async function sleep(ms = 500, reason = "Intentionally wait for UI ready") { 26 SimpleTest.requestFlakyTimeout(reason); 27 await new Promise(resolve => setTimeout(resolve, ms)); 28 } 29 30 async function focusAndWaitForFieldsIdentified( 31 input, 32 mustBeIdentified = false 33 ) { 34 info("expecting the target input being focused and indentified"); 35 if (typeof input === "string") { 36 input = document.querySelector(input); 37 } 38 const rootElement = input.form || input.ownerDocument.documentElement; 39 const previouslyFocused = input != document.activeElement; 40 41 input.focus(); 42 43 if (mustBeIdentified) { 44 rootElement.removeAttribute("test-formautofill-identified"); 45 } 46 if (rootElement.hasAttribute("test-formautofill-identified")) { 47 return; 48 } 49 if (!previouslyFocused) { 50 await new Promise(resolve => { 51 formFillChromeScript.addMessageListener( 52 "FormAutofillTest:FieldsIdentified", 53 function onIdentified() { 54 formFillChromeScript.removeMessageListener( 55 "FormAutofillTest:FieldsIdentified", 56 onIdentified 57 ); 58 resolve(); 59 } 60 ); 61 }); 62 } 63 // In order to ensure that "markAsAutofillField" is fully executed, a short period 64 // of timeout is still required. 65 await sleep(300, "Guarantee asynchronous identifyAutofillFields is invoked"); 66 rootElement.setAttribute("test-formautofill-identified", "true"); 67 } 68 69 async function setInput(selector, value, userInput = false) { 70 const input = document.querySelector("input" + selector); 71 if (userInput) { 72 SpecialPowers.wrap(input).setUserInput(value); 73 } else { 74 input.value = value; 75 } 76 await focusAndWaitForFieldsIdentified(input); 77 78 return input; 79 } 80 81 function clickOnElement(selector) { 82 let element = document.querySelector(selector); 83 84 if (!element) { 85 throw new Error("Can not find the element"); 86 } 87 88 SimpleTest.executeSoon(() => element.click()); 89 } 90 91 // The equivalent helper function to getAdaptedProfiles in 92 // FormAutofillSection.sys.mjs that transforms the given profile to expected 93 // filled profile. 94 function _getAdaptedProfile(profile) { 95 const adaptedProfile = Object.assign({}, profile); 96 97 if (profile["street-address"]) { 98 adaptedProfile["street-address"] = FormAutofillUtils.toOneLineAddress( 99 profile["street-address"] 100 ); 101 } 102 103 return adaptedProfile; 104 } 105 106 async function checkFieldHighlighted(elem, expectedValue) { 107 let isHighlightApplied; 108 await SimpleTest.promiseWaitForCondition(function checkHighlight() { 109 isHighlightApplied = elem.matches(":autofill"); 110 return isHighlightApplied === expectedValue; 111 }, `Checking #${elem.id} highlight style`); 112 113 is(isHighlightApplied, expectedValue, `Checking #${elem.id} highlight style`); 114 } 115 116 async function checkFormFieldsStyle(profile, isPreviewing = true) { 117 const elems = document.querySelectorAll("input, select"); 118 119 for (const elem of elems) { 120 let fillableValue; 121 let previewValue; 122 let isElementEligible = 123 FormAutofillUtils.isCreditCardOrAddressFieldType(elem) && 124 FormAutofillUtils.isFieldAutofillable(elem); 125 if (!isElementEligible) { 126 fillableValue = ""; 127 previewValue = ""; 128 } else { 129 fillableValue = profile && profile[elem.id]; 130 previewValue = 131 (isPreviewing && fillableValue?.toString().replaceAll("*", "•")) || ""; 132 } 133 await checkFieldHighlighted(elem, !!fillableValue); 134 } 135 } 136 137 function checkFieldValue(elem, expectedValue) { 138 if (typeof elem === "string") { 139 elem = document.querySelector(elem); 140 } 141 is(elem.value, String(expectedValue), "Checking " + elem.id + " field"); 142 } 143 144 async function triggerAutofillAndCheckProfile(profile) { 145 let adaptedProfile = _getAdaptedProfile(profile); 146 const promises = []; 147 for (const [fieldName, value] of Object.entries(adaptedProfile)) { 148 info(`triggerAutofillAndCheckProfile: ${fieldName}`); 149 const element = document.getElementById(fieldName); 150 const expectingEvent = 151 document.activeElement == element ? "input" : "change"; 152 const checkFieldAutofilled = Promise.all([ 153 new Promise(resolve => { 154 let beforeInputFired = false; 155 let hadEditor = SpecialPowers.wrap(element).hasEditor; 156 element.addEventListener( 157 "beforeinput", 158 event => { 159 beforeInputFired = true; 160 is( 161 event.inputType, 162 "insertReplacementText", 163 'inputType value should be "insertReplacementText"' 164 ); 165 is( 166 event.data, 167 String(value), 168 `data value of "beforeinput" should be "${value}"` 169 ); 170 is( 171 event.dataTransfer, 172 null, 173 'dataTransfer of "beforeinput" should be null' 174 ); 175 is( 176 event.getTargetRanges().length, 177 0, 178 'getTargetRanges() of "beforeinput" should return empty array' 179 ); 180 is( 181 event.cancelable, 182 SpecialPowers.getBoolPref( 183 "dom.input_event.allow_to_cancel_set_user_input" 184 ), 185 `"beforeinput" event should be cancelable on ${element.tagName} unless it's suppressed by the pref` 186 ); 187 is( 188 event.bubbles, 189 true, 190 `"beforeinput" event should always bubble on ${element.tagName}` 191 ); 192 resolve(); 193 }, 194 { once: true } 195 ); 196 element.addEventListener( 197 "input", 198 event => { 199 if ( 200 (element.tagName == "INPUT" && element.type == "text") || 201 element.tagName == "TEXTAREA" 202 ) { 203 if (hadEditor) { 204 ok( 205 beforeInputFired, 206 `"beforeinput" event should've been fired before "input" event on ${element.tagName}` 207 ); 208 } else { 209 ok( 210 beforeInputFired, 211 `"beforeinput" event should've been fired before "input" event on ${element.tagName}` 212 ); 213 } 214 ok( 215 event instanceof InputEvent, 216 `"input" event should be dispatched with InputEvent interface on ${element.tagName}` 217 ); 218 is( 219 event.inputType, 220 "insertReplacementText", 221 'inputType value should be "insertReplacementText"' 222 ); 223 is(event.data, String(value), `data value should be "${value}"`); 224 is(event.dataTransfer, null, "dataTransfer should be null"); 225 is( 226 event.getTargetRanges().length, 227 0, 228 "getTargetRanges() should return empty array" 229 ); 230 } else { 231 ok( 232 !beforeInputFired, 233 `"beforeinput" event shouldn't be fired on ${element.tagName}` 234 ); 235 ok( 236 event instanceof Event && !(event instanceof UIEvent), 237 `"input" event should be dispatched with Event interface on ${element.tagName}` 238 ); 239 } 240 is( 241 event.cancelable, 242 false, 243 `"input" event should be never cancelable on ${element.tagName}` 244 ); 245 is( 246 event.bubbles, 247 true, 248 `"input" event should always bubble on ${element.tagName}` 249 ); 250 resolve(); 251 }, 252 { once: true } 253 ); 254 }), 255 new Promise(resolve => 256 element.addEventListener(expectingEvent, resolve, { once: true }) 257 ), 258 ]).then(() => checkFieldValue(element, value)); 259 260 promises.push(checkFieldAutofilled); 261 } 262 // Press Enter key and trigger form autofill. 263 synthesizeKey("KEY_Enter"); 264 265 return Promise.all(promises); 266 } 267 268 async function onStorageChanged(type) { 269 info(`expecting the storage changed: ${type}`); 270 return new Promise(resolve => { 271 formFillChromeScript.addMessageListener( 272 "formautofill-storage-changed", 273 function onChanged(data) { 274 formFillChromeScript.removeMessageListener( 275 "formautofill-storage-changed", 276 onChanged 277 ); 278 is(data.data, type, `Receive ${type} storage changed event`); 279 resolve(); 280 } 281 ); 282 }); 283 } 284 285 function makeAddressComment({ primary, secondary, status }) { 286 return JSON.stringify({ 287 primary, 288 secondary, 289 status, 290 ariaLabel: primary + " " + secondary + " " + status, 291 }); 292 } 293 294 // Compare the labels on the autocomplete menu items to the expected labels. 295 function checkMenuEntries(expectedValues, extraRows = 1) { 296 let actualValues = getMenuEntries().labels; 297 let expectedLength = expectedValues.length + extraRows; 298 299 is(actualValues.length, expectedLength, " Checking length of expected menu"); 300 for (let i = 0; i < expectedValues.length; i++) { 301 is(actualValues[i], expectedValues[i], " Checking menu entry #" + i); 302 } 303 } 304 305 // Compare the comment on the autocomplete menu items to the expected comment. 306 // The profile field is not compared. 307 function checkMenuEntriesComment(expectedValues, extraRows = 1) { 308 let actualValues = getMenuEntries().comments; 309 let expectedLength = expectedValues.length + extraRows; 310 311 is(actualValues.length, expectedLength, " Checking length of expected menu"); 312 for (let i = 0; i < expectedValues.length; i++) { 313 const expectedValue = JSON.parse(expectedValues[i]); 314 const actualValue = JSON.parse(actualValues[i]); 315 for (const [key, value] of Object.entries(expectedValue)) { 316 is( 317 actualValue[key], 318 value, 319 ` Checking menu entry #${i}, ${key} should be the same` 320 ); 321 } 322 } 323 } 324 325 function invokeAsyncChromeTask(message, payload = {}) { 326 info(`expecting the chrome task finished: ${message}`); 327 return formFillChromeScript.sendQuery(message, payload); 328 } 329 330 async function addAddress(address) { 331 await invokeAsyncChromeTask("FormAutofillTest:AddAddress", { address }); 332 await sleep(); 333 } 334 335 async function removeAddress(guid) { 336 return invokeAsyncChromeTask("FormAutofillTest:RemoveAddress", { guid }); 337 } 338 339 async function updateAddress(guid, address) { 340 return invokeAsyncChromeTask("FormAutofillTest:UpdateAddress", { 341 address, 342 guid, 343 }); 344 } 345 346 async function checkAddresses(expectedAddresses) { 347 return invokeAsyncChromeTask("FormAutofillTest:CheckAddresses", { 348 expectedAddresses, 349 }); 350 } 351 352 async function cleanUpAddresses() { 353 return invokeAsyncChromeTask("FormAutofillTest:CleanUpAddresses"); 354 } 355 356 async function addCreditCard(creditcard) { 357 await invokeAsyncChromeTask("FormAutofillTest:AddCreditCard", { creditcard }); 358 await sleep(); 359 } 360 361 async function removeCreditCard(guid) { 362 return invokeAsyncChromeTask("FormAutofillTest:RemoveCreditCard", { guid }); 363 } 364 365 async function checkCreditCards(expectedCreditCards) { 366 return invokeAsyncChromeTask("FormAutofillTest:CheckCreditCards", { 367 expectedCreditCards, 368 }); 369 } 370 371 async function cleanUpCreditCards() { 372 return invokeAsyncChromeTask("FormAutofillTest:CleanUpCreditCards"); 373 } 374 375 async function cleanUpStorage() { 376 await cleanUpAddresses(); 377 await cleanUpCreditCards(); 378 } 379 380 async function canTestOSKeyStoreLogin() { 381 let { canTest } = await invokeAsyncChromeTask( 382 "FormAutofillTest:CanTestOSKeyStoreLogin" 383 ); 384 return canTest; 385 } 386 387 /** 388 * This function should be used along with the `waitForOSKeyStoreLogin` API. 389 * See the comment in `waitForOSKeyStoreLogin` for more details. 390 */ 391 async function waitForOSKeyStoreLoginTestSetupComplete() { 392 if ( 393 !(await SpecialPowers.spawnChrome([], () => { 394 // Need to re-import this because we're running in the parent. 395 // eslint-disable-next-line no-shadow 396 const { FormAutofillUtils } = ChromeUtils.importESModule( 397 "resource://gre/modules/shared/FormAutofillUtils.sys.mjs" 398 ); 399 400 return FormAutofillUtils.getOSAuthEnabled(); 401 })) 402 ) { 403 return; 404 } 405 406 await SimpleTest.promiseWaitForCondition(async () => { 407 return await SpecialPowers.spawnChrome([], () => { 408 const { OSKeyStoreTestUtils } = ChromeUtils.importESModule( 409 "resource://testing-common/OSKeyStoreTestUtils.sys.mjs" 410 ); 411 412 return Services.prefs.getStringPref( 413 OSKeyStoreTestUtils.TEST_ONLY_REAUTH, 414 "" 415 ); 416 }); 417 }); 418 } 419 420 /** 421 * This API returns a promise that will be resolved when 422 * `waitForOSKeyStoreLogin` in `OSKeyStoreTestUtils.sys.mjs` completes. 423 * It is common to use it as follows: 424 * const promise = waitForOSKeyStoreLogin(); 425 * triggerOSReauth(); // Code that triggers OS re-authentication 426 * await promise; 427 * 428 * However, the timing to switch to using test OS re-auth after calling this 429 * function is asynchronous, which means triggering OS re-auth right after 430 * this API may still activate the real OS re-auth popup. To avoid that, you 431 * need to call `await waitForOSKeyStoreLoginTestSetupComplete()` before 432 * triggering OS re-auth. 433 */ 434 async function waitForOSKeyStoreLogin(login = false) { 435 // Need to fetch this from the parent in order for it to be correct. 436 if ( 437 !(await SpecialPowers.spawnChrome([], () => { 438 // Need to re-import this because we're running in the parent. 439 // eslint-disable-next-line no-shadow 440 const { FormAutofillUtils } = ChromeUtils.importESModule( 441 "resource://gre/modules/shared/FormAutofillUtils.sys.mjs" 442 ); 443 444 return FormAutofillUtils.getOSAuthEnabled(); 445 })) 446 ) { 447 return; 448 } 449 450 await invokeAsyncChromeTask("FormAutofillTest:OSKeyStoreLogin", { login }); 451 } 452 453 function patchRecordCCNumber(record) { 454 const ccNumberFmt = "****" + record.cc["cc-number"].substr(-4); 455 456 return { 457 cc: Object.assign({}, record.cc, { ccNumberFmt }), 458 expected: record.expected, 459 }; 460 } 461 462 // Utils for registerPopupShownListener(in satchel_common.js) that handles dropdown popup 463 // Please call "initPopupListener()" in your test and "await expectPopup()" 464 // if you want to wait for dropdown menu displayed. 465 function expectPopup() { 466 info("expecting a popup"); 467 return new Promise(resolve => { 468 expectingPopup = resolve; 469 }); 470 } 471 472 function notExpectPopup(ms = 500) { 473 info("not expecting a popup"); 474 return new Promise((resolve, reject) => { 475 expectingPopup = reject.bind(this, "Unexpected Popup"); 476 // TODO: We don't have an event to notify no popup showing, so wait for 500 477 // ms (in default) to predict any unexpected popup showing. 478 setTimeout(resolve, ms); 479 }); 480 } 481 482 function popupShownListener() { 483 info("popup shown for test "); 484 if (expectingPopup) { 485 expectingPopup(); 486 expectingPopup = null; 487 } 488 } 489 490 function initPopupListener() { 491 registerPopupShownListener(popupShownListener); 492 } 493 494 async function triggerPopupAndHoverItem(fieldSelector, selectIndex) { 495 const promise = expectPopup(); 496 await focusAndWaitForFieldsIdentified(fieldSelector); 497 synthesizeKey("KEY_ArrowDown"); 498 await promise; 499 for (let i = 0; i <= selectIndex; i++) { 500 synthesizeKey("KEY_ArrowDown"); 501 } 502 await notifySelectedIndex(selectIndex); 503 } 504 505 function formAutoFillCommonSetup() { 506 // Remove the /creditCard path segement when referenced from the 'creditCard' subdirectory. 507 let chromeURL = SimpleTest.getTestFileURL( 508 "formautofill_parent_utils.js" 509 ).replace(/\/creditCard/, ""); 510 formFillChromeScript = SpecialPowers.loadChromeScript(chromeURL); 511 formFillChromeScript.addMessageListener("onpopupshown", ({ results }) => { 512 gLastAutoCompleteResults = results; 513 if (gPopupShownListener) { 514 gPopupShownListener({ results }); 515 } 516 }); 517 518 add_setup(async () => { 519 info(`expecting the storage setup`); 520 await formFillChromeScript.sendQuery("setup"); 521 }); 522 523 SimpleTest.registerCleanupFunction(async () => { 524 info(`expecting the storage cleanup`); 525 await formFillChromeScript.sendQuery("cleanup"); 526 527 formFillChromeScript.destroy(); 528 expectingPopup = null; 529 }); 530 531 document.addEventListener( 532 "DOMContentLoaded", 533 function () { 534 defaultTextColor = window 535 .getComputedStyle(document.querySelector("input")) 536 .getPropertyValue("color"); 537 538 // This is needed for test_formautofill_preview_highlight.html to work properly 539 let disabledInput = document.querySelector(`input[disabled]`); 540 if (disabledInput) { 541 defaultDisabledTextColor = window 542 .getComputedStyle(disabledInput) 543 .getPropertyValue("color"); 544 } 545 }, 546 { once: true } 547 ); 548 } 549 550 /* 551 * Extremely over-simplified detection of card type from card number just for 552 * our tests. This is needed to test the aria-label of credit card menu entries. 553 */ 554 function getCCTypeName(creditCard) { 555 return creditCard["cc-number"][0] == "4" ? "Visa" : "MasterCard"; 556 } 557 558 formAutoFillCommonSetup();