summaryrefslogtreecommitdiffstats
path: root/browser/extensions
diff options
context:
space:
mode:
Diffstat (limited to 'browser/extensions')
-rw-r--r--browser/extensions/formautofill/.eslintrc.js82
-rw-r--r--browser/extensions/formautofill/api.js222
-rw-r--r--browser/extensions/formautofill/background.js15
-rw-r--r--browser/extensions/formautofill/content/autofillEditForms.js644
-rw-r--r--browser/extensions/formautofill/content/customElements.js410
-rw-r--r--browser/extensions/formautofill/content/editAddress.xhtml134
-rw-r--r--browser/extensions/formautofill/content/editCreditCard.xhtml122
-rw-r--r--browser/extensions/formautofill/content/editDialog.js239
-rw-r--r--browser/extensions/formautofill/content/formautofill.css54
-rw-r--r--browser/extensions/formautofill/content/formfill-anchor.svg8
-rw-r--r--browser/extensions/formautofill/content/icon-address-save.svg6
-rw-r--r--browser/extensions/formautofill/content/icon-address-update.svg6
-rw-r--r--browser/extensions/formautofill/content/icon-credit-card-generic.svg8
-rw-r--r--browser/extensions/formautofill/content/icon-credit-card.svg8
-rw-r--r--browser/extensions/formautofill/content/manageAddresses.xhtml54
-rw-r--r--browser/extensions/formautofill/content/manageCreditCards.xhtml55
-rw-r--r--browser/extensions/formautofill/content/manageDialog.css125
-rw-r--r--browser/extensions/formautofill/content/manageDialog.js464
-rw-r--r--browser/extensions/formautofill/content/third-party/cc-logo-amex.pngbin0 -> 1306 bytes
-rw-r--r--browser/extensions/formautofill/content/third-party/cc-logo-amex@2x.pngbin0 -> 2311 bytes
-rw-r--r--browser/extensions/formautofill/content/third-party/cc-logo-cartebancaire.pngbin0 -> 1240 bytes
-rw-r--r--browser/extensions/formautofill/content/third-party/cc-logo-cartebancaire@2x.pngbin0 -> 3111 bytes
-rw-r--r--browser/extensions/formautofill/content/third-party/cc-logo-diners.svg1
-rw-r--r--browser/extensions/formautofill/content/third-party/cc-logo-discover.pngbin0 -> 1117 bytes
-rw-r--r--browser/extensions/formautofill/content/third-party/cc-logo-discover@2x.pngbin0 -> 2471 bytes
-rw-r--r--browser/extensions/formautofill/content/third-party/cc-logo-jcb.svg1
-rw-r--r--browser/extensions/formautofill/content/third-party/cc-logo-mastercard.svg1
-rw-r--r--browser/extensions/formautofill/content/third-party/cc-logo-mir.svg1
-rw-r--r--browser/extensions/formautofill/content/third-party/cc-logo-unionpay.svg1
-rw-r--r--browser/extensions/formautofill/content/third-party/cc-logo-visa.svg1
-rw-r--r--browser/extensions/formautofill/docs/heuristics.rst36
-rw-r--r--browser/extensions/formautofill/docs/index.rst30
-rw-r--r--browser/extensions/formautofill/jar.mn7
-rw-r--r--browser/extensions/formautofill/locales/en-US/formautofill.properties127
-rw-r--r--browser/extensions/formautofill/locales/jar.mn8
-rw-r--r--browser/extensions/formautofill/locales/moz.build7
-rw-r--r--browser/extensions/formautofill/manifest.json26
-rw-r--r--browser/extensions/formautofill/moz.build58
-rw-r--r--browser/extensions/formautofill/schema.json1
-rw-r--r--browser/extensions/formautofill/skin/linux/autocomplete-item.css10
-rw-r--r--browser/extensions/formautofill/skin/linux/editDialog.css8
-rw-r--r--browser/extensions/formautofill/skin/osx/autocomplete-item.css18
-rw-r--r--browser/extensions/formautofill/skin/osx/editDialog.css5
-rw-r--r--browser/extensions/formautofill/skin/shared/autocomplete-item-shared.css188
-rw-r--r--browser/extensions/formautofill/skin/shared/editAddress.css134
-rw-r--r--browser/extensions/formautofill/skin/shared/editCreditCard.css53
-rw-r--r--browser/extensions/formautofill/skin/shared/editDialog-shared.css110
-rw-r--r--browser/extensions/formautofill/skin/windows/autocomplete-item.css25
-rw-r--r--browser/extensions/formautofill/skin/windows/editDialog.css12
-rw-r--r--browser/extensions/formautofill/test/browser/address/browser.ini13
-rw-r--r--browser/extensions/formautofill/test/browser/address/browser_address_doorhanger_display.js240
-rw-r--r--browser/extensions/formautofill/test/browser/address/browser_address_telemetry.js691
-rw-r--r--browser/extensions/formautofill/test/browser/address/head_address.js1
-rw-r--r--browser/extensions/formautofill/test/browser/browser.ini35
-rw-r--r--browser/extensions/formautofill/test/browser/browser_autocomplete_footer.js137
-rw-r--r--browser/extensions/formautofill/test/browser/browser_autocomplete_marked_back_forward.js66
-rw-r--r--browser/extensions/formautofill/test/browser/browser_autocomplete_marked_detached_tab.js58
-rw-r--r--browser/extensions/formautofill/test/browser/browser_autofill_address_select.js64
-rw-r--r--browser/extensions/formautofill/test/browser/browser_autofill_duplicate_fields.js95
-rw-r--r--browser/extensions/formautofill/test/browser/browser_check_installed.js12
-rw-r--r--browser/extensions/formautofill/test/browser/browser_dropdown_layout.js53
-rw-r--r--browser/extensions/formautofill/test/browser/browser_editAddressDialog.js951
-rw-r--r--browser/extensions/formautofill/test/browser/browser_fathom_cc.js204
-rw-r--r--browser/extensions/formautofill/test/browser/browser_first_time_use_doorhanger.js142
-rw-r--r--browser/extensions/formautofill/test/browser/browser_manageAddressesDialog.js105
-rw-r--r--browser/extensions/formautofill/test/browser/browser_privacyPreferences.js439
-rw-r--r--browser/extensions/formautofill/test/browser/browser_remoteiframe.js127
-rw-r--r--browser/extensions/formautofill/test/browser/browser_submission_in_private_mode.js37
-rw-r--r--browser/extensions/formautofill/test/browser/browser_update_doorhanger.js189
-rw-r--r--browser/extensions/formautofill/test/browser/creditCard/browser.ini51
-rw-r--r--browser/extensions/formautofill/test/browser/creditCard/browser_anti_clickjacking.js123
-rw-r--r--browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger_action.js170
-rw-r--r--browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger_display.js311
-rw-r--r--browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger_fields.js198
-rw-r--r--browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger_iframe.js103
-rw-r--r--browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger_logo.js238
-rw-r--r--browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger_sync.js117
-rw-r--r--browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_dropdown_layout.js57
-rw-r--r--browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_fill_cancel_login.js37
-rw-r--r--browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_heuristics.js165
-rw-r--r--browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_heuristics_cc_type.js77
-rw-r--r--browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_submission_autodetect_type.js104
-rw-r--r--browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_submission_normalized.js109
-rw-r--r--browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_telemetry.js872
-rw-r--r--browser/extensions/formautofill/test/browser/creditCard/browser_editCreditCardDialog.js422
-rw-r--r--browser/extensions/formautofill/test/browser/creditCard/browser_insecure_form.js145
-rw-r--r--browser/extensions/formautofill/test/browser/creditCard/browser_manageCreditCardsDialog.js290
-rw-r--r--browser/extensions/formautofill/test/browser/creditCard/head_cc.js1
-rw-r--r--browser/extensions/formautofill/test/browser/empty.html8
-rwxr-xr-xbrowser/extensions/formautofill/test/browser/fathom/test-setup.sh39
-rw-r--r--browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/1.svg3
-rw-r--r--browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/10.svg1
-rw-r--r--browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/11.pngbin0 -> 4968 bytes
-rw-r--r--browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/12.gifbin0 -> 37 bytes
-rw-r--r--browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/13.svg16
-rw-r--r--browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/14.svg14
-rw-r--r--browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/15.svg1
-rw-r--r--browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/16.svg11
-rw-r--r--browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/17.binbin0 -> 9594 bytes
-rw-r--r--browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/18.svg1
-rw-r--r--browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/2.svg8
-rw-r--r--browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/3.svg1
-rw-r--r--browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/4.svg6
-rw-r--r--browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/5.svg6
-rw-r--r--browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/6.svg8
-rw-r--r--browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/7.woff2bin0 -> 15480 bytes
-rw-r--r--browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/8.woff2bin0 -> 15784 bytes
-rw-r--r--browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/9.woff2bin0 -> 15908 bytes
-rw-r--r--browser/extensions/formautofill/test/browser/fathom/testing/sample.html20
-rw-r--r--browser/extensions/formautofill/test/browser/focus-leak/browser.ini12
-rw-r--r--browser/extensions/formautofill/test/browser/focus-leak/browser_iframe_typecontent_input_focus.js56
-rw-r--r--browser/extensions/formautofill/test/browser/focus-leak/doc_iframe_typecontent_input_focus.xhtml7
-rw-r--r--browser/extensions/formautofill/test/browser/focus-leak/doc_iframe_typecontent_input_focus_frame.html6
-rw-r--r--browser/extensions/formautofill/test/browser/head.js1094
-rw-r--r--browser/extensions/formautofill/test/browser/heuristics/browser.ini17
-rw-r--r--browser/extensions/formautofill/test/browser/heuristics/browser_autocomplete_off_on_form.js74
-rw-r--r--browser/extensions/formautofill/test/browser/heuristics/browser_autocomplete_off_on_inputs.js102
-rw-r--r--browser/extensions/formautofill/test/browser/heuristics/browser_basic.js69
-rw-r--r--browser/extensions/formautofill/test/browser/heuristics/browser_cc_exp.js56
-rw-r--r--browser/extensions/formautofill/test/browser/heuristics/browser_de_fields.js32
-rw-r--r--browser/extensions/formautofill/test/browser/heuristics/browser_fr_fields.js27
-rw-r--r--browser/extensions/formautofill/test/browser/heuristics/browser_ignore_invisible_fields.js115
-rw-r--r--browser/extensions/formautofill/test/browser/heuristics/browser_multiple_section.js118
-rw-r--r--browser/extensions/formautofill/test/browser/heuristics/browser_parseAddressFields.js138
-rw-r--r--browser/extensions/formautofill/test/browser/heuristics/browser_section_validation_address.js79
-rw-r--r--browser/extensions/formautofill/test/browser/heuristics/browser_sections_by_name.js318
-rw-r--r--browser/extensions/formautofill/test/browser/heuristics/third_party/browser.ini22
-rw-r--r--browser/extensions/formautofill/test/browser/heuristics/third_party/browser_BestBuy.js82
-rw-r--r--browser/extensions/formautofill/test/browser/heuristics/third_party/browser_CDW.js71
-rw-r--r--browser/extensions/formautofill/test/browser/heuristics/third_party/browser_CostCo.js170
-rw-r--r--browser/extensions/formautofill/test/browser/heuristics/third_party/browser_DirectAsda.js25
-rw-r--r--browser/extensions/formautofill/test/browser/heuristics/third_party/browser_Ebay.js25
-rw-r--r--browser/extensions/formautofill/test/browser/heuristics/third_party/browser_GlobalDirectAsda.js24
-rw-r--r--browser/extensions/formautofill/test/browser/heuristics/third_party/browser_HomeDepot.js77
-rw-r--r--browser/extensions/formautofill/test/browser/heuristics/third_party/browser_Lufthansa.js28
-rw-r--r--browser/extensions/formautofill/test/browser/heuristics/third_party/browser_Lush.js31
-rw-r--r--browser/extensions/formautofill/test/browser/heuristics/third_party/browser_Macys.js88
-rw-r--r--browser/extensions/formautofill/test/browser/heuristics/third_party/browser_NewEgg.js109
-rw-r--r--browser/extensions/formautofill/test/browser/heuristics/third_party/browser_OfficeDepot.js83
-rw-r--r--browser/extensions/formautofill/test/browser/heuristics/third_party/browser_QVC.js96
-rw-r--r--browser/extensions/formautofill/test/browser/heuristics/third_party/browser_Sears.js81
-rw-r--r--browser/extensions/formautofill/test/browser/heuristics/third_party/browser_Staples.js78
-rw-r--r--browser/extensions/formautofill/test/browser/heuristics/third_party/browser_Walmart.js93
-rw-r--r--browser/extensions/formautofill/test/fixtures/autocomplete_address_basic.html26
-rw-r--r--browser/extensions/formautofill/test/fixtures/autocomplete_basic.html52
-rw-r--r--browser/extensions/formautofill/test/fixtures/autocomplete_creditcard_basic.html29
-rw-r--r--browser/extensions/formautofill/test/fixtures/autocomplete_creditcard_cc_exp_field.html28
-rw-r--r--browser/extensions/formautofill/test/fixtures/autocomplete_creditcard_iframe.html12
-rw-r--r--browser/extensions/formautofill/test/fixtures/autocomplete_iframe.html13
-rw-r--r--browser/extensions/formautofill/test/fixtures/autocomplete_off_on_form.html52
-rw-r--r--browser/extensions/formautofill/test/fixtures/autocomplete_off_on_inputs.html77
-rw-r--r--browser/extensions/formautofill/test/fixtures/autocomplete_simple_basic.html19
-rw-r--r--browser/extensions/formautofill/test/fixtures/heuristics_cc_exp.html73
-rw-r--r--browser/extensions/formautofill/test/fixtures/heuristics_de_fields.html122
-rw-r--r--browser/extensions/formautofill/test/fixtures/heuristics_fr_fields.html34
-rw-r--r--browser/extensions/formautofill/test/fixtures/multiple_section.html84
-rw-r--r--browser/extensions/formautofill/test/fixtures/third_party/BestBuy/Checkout_Payment.html283
-rw-r--r--browser/extensions/formautofill/test/fixtures/third_party/BestBuy/Checkout_ShippingAddress.html326
-rw-r--r--browser/extensions/formautofill/test/fixtures/third_party/BestBuy/SignIn.html21
-rw-r--r--browser/extensions/formautofill/test/fixtures/third_party/CDW/Checkout_BillingPaymentInfo.html469
-rw-r--r--browser/extensions/formautofill/test/fixtures/third_party/CDW/Checkout_Logon.html118
-rw-r--r--browser/extensions/formautofill/test/fixtures/third_party/CDW/Checkout_ShippingInfo.html376
-rw-r--r--browser/extensions/formautofill/test/fixtures/third_party/CostCo/Payment.html892
-rw-r--r--browser/extensions/formautofill/test/fixtures/third_party/CostCo/ShippingAddress.html527
-rw-r--r--browser/extensions/formautofill/test/fixtures/third_party/CostCo/SignIn.html374
-rw-r--r--browser/extensions/formautofill/test/fixtures/third_party/DirectAsda/Payment.html90
-rw-r--r--browser/extensions/formautofill/test/fixtures/third_party/Ebay/Checkout_Payment_FR.html135
-rw-r--r--browser/extensions/formautofill/test/fixtures/third_party/GlobalDirectAsda/Payment.html154
-rw-r--r--browser/extensions/formautofill/test/fixtures/third_party/HomeDepot/Checkout_ShippingPayment.html381
-rw-r--r--browser/extensions/formautofill/test/fixtures/third_party/HomeDepot/SignIn.html83
-rw-r--r--browser/extensions/formautofill/test/fixtures/third_party/Lufthansa/Checkout_Payment.html23
-rw-r--r--browser/extensions/formautofill/test/fixtures/third_party/Lush/index.html421
-rw-r--r--browser/extensions/formautofill/test/fixtures/third_party/Macys/Checkout_Payment.html474
-rw-r--r--browser/extensions/formautofill/test/fixtures/third_party/Macys/Checkout_ShippingAddress.html439
-rw-r--r--browser/extensions/formautofill/test/fixtures/third_party/Macys/SignIn.html208
-rw-r--r--browser/extensions/formautofill/test/fixtures/third_party/NewEgg/BillingInfo.html1074
-rw-r--r--browser/extensions/formautofill/test/fixtures/third_party/NewEgg/Login.html156
-rw-r--r--browser/extensions/formautofill/test/fixtures/third_party/NewEgg/ShippingInfo.html270
-rw-r--r--browser/extensions/formautofill/test/fixtures/third_party/OfficeDepot/Payment.html672
-rw-r--r--browser/extensions/formautofill/test/fixtures/third_party/OfficeDepot/ShippingAddress.html347
-rw-r--r--browser/extensions/formautofill/test/fixtures/third_party/OfficeDepot/SignIn.html44
-rw-r--r--browser/extensions/formautofill/test/fixtures/third_party/QVC/PaymentMethod.html527
-rw-r--r--browser/extensions/formautofill/test/fixtures/third_party/QVC/SignIn.html80
-rw-r--r--browser/extensions/formautofill/test/fixtures/third_party/QVC/YourInformation.html522
-rw-r--r--browser/extensions/formautofill/test/fixtures/third_party/README4
-rw-r--r--browser/extensions/formautofill/test/fixtures/third_party/Sears/PaymentOptions.html566
-rw-r--r--browser/extensions/formautofill/test/fixtures/third_party/Sears/ShippingAddress.html447
-rw-r--r--browser/extensions/formautofill/test/fixtures/third_party/Staples/Basic.html117
-rw-r--r--browser/extensions/formautofill/test/fixtures/third_party/Staples/Basic_ac_on.html117
-rw-r--r--browser/extensions/formautofill/test/fixtures/third_party/Staples/PaymentBilling.html99
-rw-r--r--browser/extensions/formautofill/test/fixtures/third_party/Staples/PaymentBilling_ac_on.html98
-rw-r--r--browser/extensions/formautofill/test/fixtures/third_party/Walmart/Checkout.html243
-rw-r--r--browser/extensions/formautofill/test/fixtures/third_party/Walmart/Payment.html235
-rw-r--r--browser/extensions/formautofill/test/fixtures/third_party/Walmart/Shipping.html234
-rw-r--r--browser/extensions/formautofill/test/fixtures/without_autocomplete_address_basic.html26
-rw-r--r--browser/extensions/formautofill/test/fixtures/without_autocomplete_creditcard_basic.html53
-rw-r--r--browser/extensions/formautofill/test/mochitest/creditCard/mochitest.ini26
-rw-r--r--browser/extensions/formautofill/test/mochitest/creditCard/test_basic_creditcard_autocomplete_form.html251
-rw-r--r--browser/extensions/formautofill/test/mochitest/creditCard/test_clear_form.html205
-rw-r--r--browser/extensions/formautofill/test/mochitest/creditCard/test_clear_form_expiry_select_elements.html211
-rw-r--r--browser/extensions/formautofill/test/mochitest/creditCard/test_creditcard_autocomplete_off.html96
-rw-r--r--browser/extensions/formautofill/test/mochitest/creditCard/test_preview_highlight_with_multiple_cc_number_fields.html174
-rw-r--r--browser/extensions/formautofill/test/mochitest/creditCard/test_preview_highlight_with_site_prefill.html110
-rw-r--r--browser/extensions/formautofill/test/mochitest/formautofill_common.js478
-rw-r--r--browser/extensions/formautofill/test/mochitest/formautofill_parent_utils.js304
-rw-r--r--browser/extensions/formautofill/test/mochitest/mochitest.ini23
-rw-r--r--browser/extensions/formautofill/test/mochitest/test_address_level_1_submission.html102
-rw-r--r--browser/extensions/formautofill/test/mochitest/test_autofill_and_ordinal_forms.html116
-rw-r--r--browser/extensions/formautofill/test/mochitest/test_autofocus_form.html69
-rw-r--r--browser/extensions/formautofill/test/mochitest/test_basic_autocomplete_form.html220
-rw-r--r--browser/extensions/formautofill/test/mochitest/test_form_changes.html128
-rw-r--r--browser/extensions/formautofill/test/mochitest/test_formautofill_preview_highlight.html121
-rw-r--r--browser/extensions/formautofill/test/mochitest/test_multi_locale_CA_address_form.html273
-rw-r--r--browser/extensions/formautofill/test/mochitest/test_multiple_forms.html67
-rw-r--r--browser/extensions/formautofill/test/mochitest/test_on_address_submission.html121
-rw-r--r--browser/extensions/formautofill/test/unit/head.js357
-rw-r--r--browser/extensions/formautofill/test/unit/head_addressComponent.js69
-rw-r--r--browser/extensions/formautofill/test/unit/test_activeStatus.js176
-rw-r--r--browser/extensions/formautofill/test/unit/test_addressComponent_city.js27
-rw-r--r--browser/extensions/formautofill/test/unit/test_addressComponent_country.js47
-rw-r--r--browser/extensions/formautofill/test/unit/test_addressComponent_email.js74
-rw-r--r--browser/extensions/formautofill/test/unit/test_addressComponent_name.js101
-rw-r--r--browser/extensions/formautofill/test/unit/test_addressComponent_organization.js55
-rw-r--r--browser/extensions/formautofill/test/unit/test_addressComponent_postal_code.js57
-rw-r--r--browser/extensions/formautofill/test/unit/test_addressComponent_state.js32
-rw-r--r--browser/extensions/formautofill/test/unit/test_addressComponent_street_address.js56
-rw-r--r--browser/extensions/formautofill/test/unit/test_addressComponent_tel.js76
-rw-r--r--browser/extensions/formautofill/test/unit/test_addressDataLoader.js102
-rw-r--r--browser/extensions/formautofill/test/unit/test_addressRecords.js858
-rw-r--r--browser/extensions/formautofill/test/unit/test_autofillFormFields.js1078
-rw-r--r--browser/extensions/formautofill/test/unit/test_clearPopulatedForm.js116
-rw-r--r--browser/extensions/formautofill/test/unit/test_collectFormFields.js638
-rw-r--r--browser/extensions/formautofill/test/unit/test_createRecords.js525
-rw-r--r--browser/extensions/formautofill/test/unit/test_creditCardRecords.js926
-rw-r--r--browser/extensions/formautofill/test/unit/test_extractLabelStrings.js77
-rw-r--r--browser/extensions/formautofill/test/unit/test_findLabelElements.js100
-rw-r--r--browser/extensions/formautofill/test/unit/test_getAdaptedProfiles.js1300
-rw-r--r--browser/extensions/formautofill/test/unit/test_getAdaptedProfiles_locales.js272
-rw-r--r--browser/extensions/formautofill/test/unit/test_getCategoriesFromFieldNames.js95
-rw-r--r--browser/extensions/formautofill/test/unit/test_getCreditCardLogo.js25
-rw-r--r--browser/extensions/formautofill/test/unit/test_getFormInputDetails.js204
-rw-r--r--browser/extensions/formautofill/test/unit/test_getInfo.js363
-rw-r--r--browser/extensions/formautofill/test/unit/test_getRecords.js258
-rw-r--r--browser/extensions/formautofill/test/unit/test_isAddressAutofillAvailable.js74
-rw-r--r--browser/extensions/formautofill/test/unit/test_isCJKName.js80
-rw-r--r--browser/extensions/formautofill/test/unit/test_isCreditCardAutofillAvailable.js84
-rw-r--r--browser/extensions/formautofill/test/unit/test_isCreditCardOrAddressFieldType.js103
-rw-r--r--browser/extensions/formautofill/test/unit/test_known_strings.js148
-rw-r--r--browser/extensions/formautofill/test/unit/test_markAsAutofillField.js201
-rw-r--r--browser/extensions/formautofill/test/unit/test_migrateRecords.js382
-rw-r--r--browser/extensions/formautofill/test/unit/test_nameUtils.js289
-rw-r--r--browser/extensions/formautofill/test/unit/test_onFormSubmitted.js805
-rw-r--r--browser/extensions/formautofill/test/unit/test_parseAddressFormat.js66
-rw-r--r--browser/extensions/formautofill/test/unit/test_parseStreetAddress.js74
-rw-r--r--browser/extensions/formautofill/test/unit/test_phoneNumber.js399
-rw-r--r--browser/extensions/formautofill/test/unit/test_previewFormFields.js199
-rw-r--r--browser/extensions/formautofill/test/unit/test_profileAutocompleteResult.js450
-rw-r--r--browser/extensions/formautofill/test/unit/test_reconcile.js1173
-rw-r--r--browser/extensions/formautofill/test/unit/test_savedFieldNames.js106
-rw-r--r--browser/extensions/formautofill/test/unit/test_storage_remove.js88
-rw-r--r--browser/extensions/formautofill/test/unit/test_storage_syncfields.js498
-rw-r--r--browser/extensions/formautofill/test/unit/test_storage_tombstones.js190
-rw-r--r--browser/extensions/formautofill/test/unit/test_sync.js1017
-rw-r--r--browser/extensions/formautofill/test/unit/test_sync_deprecate_credit_card_v4.js248
-rw-r--r--browser/extensions/formautofill/test/unit/test_toOneLineAddress.js64
-rw-r--r--browser/extensions/formautofill/test/unit/test_transformFields.js972
-rw-r--r--browser/extensions/formautofill/test/unit/xpcshell.ini100
-rw-r--r--browser/extensions/moz.build14
-rw-r--r--browser/extensions/pictureinpicture/data/picture_in_picture_overrides.js304
-rw-r--r--browser/extensions/pictureinpicture/experiment-apis/aboutConfigPipPrefs.js65
-rw-r--r--browser/extensions/pictureinpicture/experiment-apis/aboutConfigPipPrefs.json59
-rw-r--r--browser/extensions/pictureinpicture/experiment-apis/pictureInPicture.js84
-rw-r--r--browser/extensions/pictureinpicture/experiment-apis/pictureInPicture.json51
-rw-r--r--browser/extensions/pictureinpicture/lib/picture_in_picture_overrides.js98
-rw-r--r--browser/extensions/pictureinpicture/manifest.json48
-rw-r--r--browser/extensions/pictureinpicture/moz.build61
-rw-r--r--browser/extensions/pictureinpicture/run.js9
-rw-r--r--browser/extensions/pictureinpicture/tests/browser/.eslintrc.js14
-rw-r--r--browser/extensions/pictureinpicture/tests/browser/browser.ini19
-rw-r--r--browser/extensions/pictureinpicture/tests/browser/browser_mock_wrapper.js205
-rw-r--r--browser/extensions/pictureinpicture/tests/browser/test-mock-wrapper.html22
-rw-r--r--browser/extensions/pictureinpicture/tests/browser/test-mock-wrapper.js31
-rw-r--r--browser/extensions/pictureinpicture/tests/browser/test-toggle-visibility.html22
-rw-r--r--browser/extensions/pictureinpicture/video-wrappers/airmozilla.js63
-rw-r--r--browser/extensions/pictureinpicture/video-wrappers/bbc.js31
-rw-r--r--browser/extensions/pictureinpicture/video-wrappers/cbc.js30
-rw-r--r--browser/extensions/pictureinpicture/video-wrappers/dailymotion.js42
-rw-r--r--browser/extensions/pictureinpicture/video-wrappers/disneyplus.js40
-rw-r--r--browser/extensions/pictureinpicture/video-wrappers/edx.js33
-rw-r--r--browser/extensions/pictureinpicture/video-wrappers/hbomax.js48
-rw-r--r--browser/extensions/pictureinpicture/video-wrappers/hotstar.js41
-rw-r--r--browser/extensions/pictureinpicture/video-wrappers/hulu.js71
-rw-r--r--browser/extensions/pictureinpicture/video-wrappers/mock-wrapper.js34
-rw-r--r--browser/extensions/pictureinpicture/video-wrappers/netflix.js79
-rw-r--r--browser/extensions/pictureinpicture/video-wrappers/nytimes.js37
-rw-r--r--browser/extensions/pictureinpicture/video-wrappers/piped.js41
-rw-r--r--browser/extensions/pictureinpicture/video-wrappers/primeVideo.js101
-rw-r--r--browser/extensions/pictureinpicture/video-wrappers/radiocanada.js36
-rw-r--r--browser/extensions/pictureinpicture/video-wrappers/sonyliv.js39
-rw-r--r--browser/extensions/pictureinpicture/video-wrappers/tubi.js35
-rw-r--r--browser/extensions/pictureinpicture/video-wrappers/tubilive.js35
-rw-r--r--browser/extensions/pictureinpicture/video-wrappers/twitch.js19
-rw-r--r--browser/extensions/pictureinpicture/video-wrappers/udemy.js41
-rw-r--r--browser/extensions/pictureinpicture/video-wrappers/videojsWrapper.js38
-rw-r--r--browser/extensions/pictureinpicture/video-wrappers/voot.js37
-rw-r--r--browser/extensions/pictureinpicture/video-wrappers/washingtonpost.js42
-rw-r--r--browser/extensions/pictureinpicture/video-wrappers/yahoo.js38
-rw-r--r--browser/extensions/pictureinpicture/video-wrappers/youtube.js65
-rw-r--r--browser/extensions/report-site-issue/.eslintrc.js58
-rw-r--r--browser/extensions/report-site-issue/background.js217
-rw-r--r--browser/extensions/report-site-issue/experimentalAPIs/aboutConfigPrefs.js39
-rw-r--r--browser/extensions/report-site-issue/experimentalAPIs/aboutConfigPrefs.json35
-rw-r--r--browser/extensions/report-site-issue/experimentalAPIs/actors/tabExtrasActor.jsm163
-rw-r--r--browser/extensions/report-site-issue/experimentalAPIs/browserInfo.js197
-rw-r--r--browser/extensions/report-site-issue/experimentalAPIs/browserInfo.json64
-rw-r--r--browser/extensions/report-site-issue/experimentalAPIs/helpMenu.js38
-rw-r--r--browser/extensions/report-site-issue/experimentalAPIs/helpMenu.json28
-rw-r--r--browser/extensions/report-site-issue/experimentalAPIs/l10n.js55
-rw-r--r--browser/extensions/report-site-issue/experimentalAPIs/l10n.json21
-rw-r--r--browser/extensions/report-site-issue/experimentalAPIs/tabExtras.js99
-rw-r--r--browser/extensions/report-site-issue/experimentalAPIs/tabExtras.json21
-rw-r--r--browser/extensions/report-site-issue/locales/en-US/webcompat.properties10
-rw-r--r--browser/extensions/report-site-issue/locales/jar.mn8
-rw-r--r--browser/extensions/report-site-issue/locales/moz.build7
-rw-r--r--browser/extensions/report-site-issue/manifest.json66
-rw-r--r--browser/extensions/report-site-issue/moz.build41
-rw-r--r--browser/extensions/report-site-issue/test/browser/browser.ini14
-rw-r--r--browser/extensions/report-site-issue/test/browser/browser_button_state.js52
-rw-r--r--browser/extensions/report-site-issue/test/browser/browser_disabled_cleanup.js41
-rw-r--r--browser/extensions/report-site-issue/test/browser/browser_report_site_issue.js300
-rw-r--r--browser/extensions/report-site-issue/test/browser/fastclick.html11
-rw-r--r--browser/extensions/report-site-issue/test/browser/frameworks.html8
-rw-r--r--browser/extensions/report-site-issue/test/browser/head.js119
-rw-r--r--browser/extensions/report-site-issue/test/browser/test.html40
-rw-r--r--browser/extensions/report-site-issue/test/browser/webcompat.html85
-rw-r--r--browser/extensions/screenshots/assertIsBlankDocument.js18
-rw-r--r--browser/extensions/screenshots/assertIsTrusted.js24
-rw-r--r--browser/extensions/screenshots/background/analytics.js55
-rw-r--r--browser/extensions/screenshots/background/communication.js69
-rw-r--r--browser/extensions/screenshots/background/deviceInfo.js39
-rw-r--r--browser/extensions/screenshots/background/main.js238
-rw-r--r--browser/extensions/screenshots/background/selectorLoader.js139
-rw-r--r--browser/extensions/screenshots/background/senderror.js144
-rw-r--r--browser/extensions/screenshots/background/startBackground.js123
-rw-r--r--browser/extensions/screenshots/background/takeshot.js85
-rw-r--r--browser/extensions/screenshots/blank.html7
-rw-r--r--browser/extensions/screenshots/blobConverters.js48
-rw-r--r--browser/extensions/screenshots/build/inlineSelectionCss.js667
-rw-r--r--browser/extensions/screenshots/build/selection.js126
-rw-r--r--browser/extensions/screenshots/build/shot.js888
-rw-r--r--browser/extensions/screenshots/build/thumbnailGenerator.js190
-rw-r--r--browser/extensions/screenshots/catcher.js101
-rw-r--r--browser/extensions/screenshots/clipboard.js64
-rw-r--r--browser/extensions/screenshots/domainFromUrl.js32
-rw-r--r--browser/extensions/screenshots/experiments/screenshots/api.js56
-rw-r--r--browser/extensions/screenshots/experiments/screenshots/schema.json35
-rw-r--r--browser/extensions/screenshots/log.js50
-rw-r--r--browser/extensions/screenshots/manifest.json56
-rw-r--r--browser/extensions/screenshots/moz.build60
-rw-r--r--browser/extensions/screenshots/randomString.js19
-rw-r--r--browser/extensions/screenshots/selector/callBackground.js35
-rw-r--r--browser/extensions/screenshots/selector/documentMetadata.js93
-rw-r--r--browser/extensions/screenshots/selector/shooter.js163
-rw-r--r--browser/extensions/screenshots/selector/ui.js904
-rw-r--r--browser/extensions/screenshots/selector/uicontrol.js1026
-rw-r--r--browser/extensions/screenshots/selector/util.js124
-rw-r--r--browser/extensions/screenshots/sitehelper.js63
-rw-r--r--browser/extensions/screenshots/test/browser/browser.ini22
-rw-r--r--browser/extensions/screenshots/test/browser/browser_screenshot_button.js75
-rw-r--r--browser/extensions/screenshots/test/browser/browser_screenshots_dimensions.js109
-rw-r--r--browser/extensions/screenshots/test/browser/browser_screenshots_download.js98
-rw-r--r--browser/extensions/screenshots/test/browser/browser_screenshots_injection.js82
-rw-r--r--browser/extensions/screenshots/test/browser/green2vh.html23
-rw-r--r--browser/extensions/screenshots/test/browser/head.js230
-rw-r--r--browser/extensions/screenshots/test/browser/injection-page.html23
-rw-r--r--browser/extensions/search-detection/extension/api.js264
-rw-r--r--browser/extensions/search-detection/extension/background.js178
-rw-r--r--browser/extensions/search-detection/extension/manifest.json32
-rw-r--r--browser/extensions/search-detection/extension/schema.json60
-rw-r--r--browser/extensions/search-detection/jar.mn7
-rw-r--r--browser/extensions/search-detection/moz.build10
-rw-r--r--browser/extensions/search-detection/tests/browser/.eslintrc.js7
-rw-r--r--browser/extensions/search-detection/tests/browser/browser.ini9
-rw-r--r--browser/extensions/search-detection/tests/browser/browser_client_side_redirection.js204
-rw-r--r--browser/extensions/search-detection/tests/browser/browser_extension_loaded.js15
-rw-r--r--browser/extensions/search-detection/tests/browser/browser_server_side_redirection.js260
-rw-r--r--browser/extensions/search-detection/tests/browser/redirect.sjs32
-rw-r--r--browser/extensions/webcompat/about-compat/AboutCompat.jsm42
-rw-r--r--browser/extensions/webcompat/about-compat/aboutCompat.css187
-rw-r--r--browser/extensions/webcompat/about-compat/aboutCompat.html51
-rw-r--r--browser/extensions/webcompat/about-compat/aboutCompat.js283
-rw-r--r--browser/extensions/webcompat/about-compat/aboutPage.js46
-rw-r--r--browser/extensions/webcompat/about-compat/aboutPage.json6
-rw-r--r--browser/extensions/webcompat/about-compat/aboutPageProcessScript.js34
-rw-r--r--browser/extensions/webcompat/components.conf17
-rw-r--r--browser/extensions/webcompat/data/injections.js1059
-rw-r--r--browser/extensions/webcompat/data/shims.js874
-rw-r--r--browser/extensions/webcompat/data/ua_overrides.js1371
-rw-r--r--browser/extensions/webcompat/experiment-apis/aboutConfigPrefs.js53
-rw-r--r--browser/extensions/webcompat/experiment-apis/aboutConfigPrefs.json72
-rw-r--r--browser/extensions/webcompat/experiment-apis/appConstants.js28
-rw-r--r--browser/extensions/webcompat/experiment-apis/appConstants.json15
-rw-r--r--browser/extensions/webcompat/experiment-apis/matchPatterns.js30
-rw-r--r--browser/extensions/webcompat/experiment-apis/matchPatterns.json29
-rw-r--r--browser/extensions/webcompat/experiment-apis/systemManufacturer.js23
-rw-r--r--browser/extensions/webcompat/experiment-apis/systemManufacturer.json20
-rw-r--r--browser/extensions/webcompat/experiment-apis/trackingProtection.js216
-rw-r--r--browser/extensions/webcompat/experiment-apis/trackingProtection.json102
-rw-r--r--browser/extensions/webcompat/injections/css/bug0000000-testbed-css-injection.css7
-rw-r--r--browser/extensions/webcompat/injections/css/bug1570328-developer-apple.com-transform-scale.css21
-rw-r--r--browser/extensions/webcompat/injections/css/bug1575000-apply.lloydsbank.co.uk-radio-buttons-fix.css15
-rw-r--r--browser/extensions/webcompat/injections/css/bug1605611-maps.google.com-directions-time.css16
-rw-r--r--browser/extensions/webcompat/injections/css/bug1610344-directv.com.co-hide-unsupported-message.css17
-rw-r--r--browser/extensions/webcompat/injections/css/bug1644830-missingmail.usps.com-checkboxes-not-visible.css17
-rw-r--r--browser/extensions/webcompat/injections/css/bug1651917-teletrader.com.body-transform-origin.css18
-rw-r--r--browser/extensions/webcompat/injections/css/bug1653075-livescience.com-scrollbar-width.css17
-rw-r--r--browser/extensions/webcompat/injections/css/bug1654877-preev.com-moz-appearance-fix.css19
-rw-r--r--browser/extensions/webcompat/injections/css/bug1654907-reactine.ca-hide-unsupported.css16
-rw-r--r--browser/extensions/webcompat/injections/css/bug1694470-myvidster.com-content-not-shown.css15
-rw-r--r--browser/extensions/webcompat/injections/css/bug1707795-office365-sheets-overscroll-disable.css12
-rw-r--r--browser/extensions/webcompat/injections/css/bug1712833-buskocchi.desuca.co.jp-fix-map-height.css13
-rw-r--r--browser/extensions/webcompat/injections/css/bug1741234-patient.alphalabs.ca-height-fix.css13
-rw-r--r--browser/extensions/webcompat/injections/css/bug1765947-veniceincoming.com-left-fix.css13
-rw-r--r--browser/extensions/webcompat/injections/css/bug1770962-coldwellbankerhomes.com-image-height.css18
-rw-r--r--browser/extensions/webcompat/injections/css/bug1774490-rainews.it-gallery-fix.css13
-rw-r--r--browser/extensions/webcompat/injections/css/bug1784141-aveeno.com-acuvue.com-unsupported.css17
-rw-r--r--browser/extensions/webcompat/injections/css/bug1784199-entrata-platform-unsupported.css18
-rw-r--r--browser/extensions/webcompat/injections/css/bug1799994-www.vivobarefoot.com-product-filters-fix.css17
-rw-r--r--browser/extensions/webcompat/injections/css/bug1800000-www.honda.co.uk-choose-dealer-button-fix.css17
-rw-r--r--browser/extensions/webcompat/injections/css/bug1819678-nppes.cms.hhs.gov-unsupported-banner.css15
-rw-r--r--browser/extensions/webcompat/injections/css/bug1829949-tomshardware.com-scrollbar-width.css18
-rw-r--r--browser/extensions/webcompat/injections/css/bug1829952-eventer.co.il-button-height.css18
-rw-r--r--browser/extensions/webcompat/injections/css/bug1830747-babbel.com-page-height.css17
-rw-r--r--browser/extensions/webcompat/injections/css/bug1830752-afisha.ru-slider-pointer-events.css23
-rw-r--r--browser/extensions/webcompat/injections/css/bug1830761-91mobiles.com-content-height.css18
-rw-r--r--browser/extensions/webcompat/injections/css/bug1830796-copyleaks.com-hide-unsupported.css13
-rw-r--r--browser/extensions/webcompat/injections/css/bug1830810-interceramic.com-hide-unsupported.css13
-rw-r--r--browser/extensions/webcompat/injections/css/bug1830813-page.onstove.com-hide-unsupported.css18
-rw-r--r--browser/extensions/webcompat/injections/css/bug1836103-autostar-novoross.ru-make-map-taller.css13
-rw-r--r--browser/extensions/webcompat/injections/css/bug1836105-cnn.com-fix-blank-pages-when-printing.css19
-rw-r--r--browser/extensions/webcompat/injections/css/bug1836177-clalit.co.il-hide-number-input-spinners.css13
-rw-r--r--browser/extensions/webcompat/injections/js/bug0000000-testbed-js-injection.js15
-rw-r--r--browser/extensions/webcompat/injections/js/bug1448747-fastclick-shim.js35
-rw-r--r--browser/extensions/webcompat/injections/js/bug1452707-window.controllers-shim-ib.absa.co.za.js33
-rw-r--r--browser/extensions/webcompat/injections/js/bug1457335-histography.io-ua-change.js38
-rw-r--r--browser/extensions/webcompat/injections/js/bug1472075-bankofamerica.com-ua-change.js52
-rw-r--r--browser/extensions/webcompat/injections/js/bug1579159-m.tailieu.vn-pdfjs-worker-disable.js32
-rw-r--r--browser/extensions/webcompat/injections/js/bug1605611-maps.google.com-directions-time.js38
-rw-r--r--browser/extensions/webcompat/injections/js/bug1631811-datastudio.google.com-indexedDB.js22
-rw-r--r--browser/extensions/webcompat/injections/js/bug1722955-frontgate.com-ua-override.js21
-rw-r--r--browser/extensions/webcompat/injections/js/bug1724764-window-print.js28
-rw-r--r--browser/extensions/webcompat/injections/js/bug1724868-news.yahoo.co.jp-ua-override.js29
-rw-r--r--browser/extensions/webcompat/injections/js/bug1731825-office365-email-handling-prompt-autohide.js36
-rw-r--r--browser/extensions/webcompat/injections/js/bug1739489-draftjs-beforeinput.js116
-rw-r--r--browser/extensions/webcompat/injections/js/bug1769762-tiktok.com-plugins-shim.js35
-rw-r--r--browser/extensions/webcompat/injections/js/bug1774005-installtrigger-shim.js26
-rw-r--r--browser/extensions/webcompat/injections/js/bug1784302-effectiveType-shim.js27
-rw-r--r--browser/extensions/webcompat/injections/js/bug1795490-www.china-airlines.com-undisable-date-fields-on-mobile.js40
-rw-r--r--browser/extensions/webcompat/injections/js/bug1799968-www.samsung.com-appVersion-linux-fix.js31
-rw-r--r--browser/extensions/webcompat/injections/js/bug1799980-healow.com-infinite-loop-fix.js37
-rw-r--r--browser/extensions/webcompat/injections/js/bug1818818-fastclick-legacy-shim.js24
-rw-r--r--browser/extensions/webcompat/injections/js/bug1819450-cmbchina.com-ua-change.js29
-rw-r--r--browser/extensions/webcompat/injections/js/bug1819476-axisbank.com-webkitSpeechRecognition-shim.js26
-rw-r--r--browser/extensions/webcompat/injections/js/bug1819678-cnki.net-undisable-search-field.js45
-rw-r--r--browser/extensions/webcompat/injections/js/bug1819678-free4talk.com-window-chrome-shim.js25
-rw-r--r--browser/extensions/webcompat/injections/js/bug1830776-blueshieldca.com-unsupported.js24
-rw-r--r--browser/extensions/webcompat/injections/js/bug1831007-nintendo-window-OnetrustActiveGroups.js27
-rw-r--r--browser/extensions/webcompat/injections/js/bug1836157-thai-masszazs-niceScroll-disable.js23
-rw-r--r--browser/extensions/webcompat/injections/js/bug1842437-www.youtube.com-performance-now-precision.js39
-rw-r--r--browser/extensions/webcompat/lib/about_compat_broker.js141
-rw-r--r--browser/extensions/webcompat/lib/custom_functions.js109
-rw-r--r--browser/extensions/webcompat/lib/injections.js165
-rw-r--r--browser/extensions/webcompat/lib/intervention_helpers.js233
-rw-r--r--browser/extensions/webcompat/lib/messaging_helper.js36
-rw-r--r--browser/extensions/webcompat/lib/module_shim.js24
-rw-r--r--browser/extensions/webcompat/lib/requestStorageAccess_helper.js30
-rw-r--r--browser/extensions/webcompat/lib/shim_messaging_helper.js65
-rw-r--r--browser/extensions/webcompat/lib/shims.js1044
-rw-r--r--browser/extensions/webcompat/lib/ua_helpers.js79
-rw-r--r--browser/extensions/webcompat/lib/ua_overrides.js210
-rw-r--r--browser/extensions/webcompat/manifest.json153
-rw-r--r--browser/extensions/webcompat/moz.build192
-rw-r--r--browser/extensions/webcompat/run.js45
-rw-r--r--browser/extensions/webcompat/shims/addthis-angular.js16
-rw-r--r--browser/extensions/webcompat/shims/adform.js30
-rw-r--r--browser/extensions/webcompat/shims/adnexus-ast.js210
-rw-r--r--browser/extensions/webcompat/shims/adnexus-prebid.js68
-rw-r--r--browser/extensions/webcompat/shims/adsafeprotected-ima.js19
-rw-r--r--browser/extensions/webcompat/shims/apstag.js73
-rw-r--r--browser/extensions/webcompat/shims/blogger.js39
-rw-r--r--browser/extensions/webcompat/shims/bloggerAccount.js68
-rw-r--r--browser/extensions/webcompat/shims/bmauth.js21
-rw-r--r--browser/extensions/webcompat/shims/branch.js84
-rw-r--r--browser/extensions/webcompat/shims/chartbeat.js18
-rw-r--r--browser/extensions/webcompat/shims/crave-ca.js56
-rw-r--r--browser/extensions/webcompat/shims/criteo.js64
-rw-r--r--browser/extensions/webcompat/shims/cxense.js593
-rw-r--r--browser/extensions/webcompat/shims/doubleverify.js36
-rw-r--r--browser/extensions/webcompat/shims/eluminate.js95
-rw-r--r--browser/extensions/webcompat/shims/empty-script.js5
-rw-r--r--browser/extensions/webcompat/shims/empty-shim.txt0
-rw-r--r--browser/extensions/webcompat/shims/everest.js171
-rw-r--r--browser/extensions/webcompat/shims/facebook-sdk.js554
-rw-r--r--browser/extensions/webcompat/shims/facebook.svg3
-rw-r--r--browser/extensions/webcompat/shims/fastclick.js75
-rw-r--r--browser/extensions/webcompat/shims/firebase.js95
-rw-r--r--browser/extensions/webcompat/shims/google-ads.js77
-rw-r--r--browser/extensions/webcompat/shims/google-analytics-and-tag-manager.js187
-rw-r--r--browser/extensions/webcompat/shims/google-analytics-ecommerce-plugin.js13
-rw-r--r--browser/extensions/webcompat/shims/google-analytics-legacy.js137
-rw-r--r--browser/extensions/webcompat/shims/google-ima.js620
-rw-r--r--browser/extensions/webcompat/shims/google-page-ad.js17
-rw-r--r--browser/extensions/webcompat/shims/google-publisher-tags.js509
-rw-r--r--browser/extensions/webcompat/shims/google-safeframe.html29
-rw-r--r--browser/extensions/webcompat/shims/history.js54
-rw-r--r--browser/extensions/webcompat/shims/iam.js39
-rw-r--r--browser/extensions/webcompat/shims/iaspet.js45
-rw-r--r--browser/extensions/webcompat/shims/instagram.js55
-rw-r--r--browser/extensions/webcompat/shims/kinja.js44
-rw-r--r--browser/extensions/webcompat/shims/live-test-shim.js82
-rw-r--r--browser/extensions/webcompat/shims/maxmind-geoip.js69
-rw-r--r--browser/extensions/webcompat/shims/microsoftLogin.js29
-rw-r--r--browser/extensions/webcompat/shims/microsoftVirtualAssistant.js46
-rw-r--r--browser/extensions/webcompat/shims/moat.js46
-rw-r--r--browser/extensions/webcompat/shims/mochitest-shim-1.js87
-rw-r--r--browser/extensions/webcompat/shims/mochitest-shim-2.js85
-rw-r--r--browser/extensions/webcompat/shims/mochitest-shim-3.js7
-rw-r--r--browser/extensions/webcompat/shims/nielsen.js111
-rw-r--r--browser/extensions/webcompat/shims/optimizely.js205
-rw-r--r--browser/extensions/webcompat/shims/play.svg7
-rw-r--r--browser/extensions/webcompat/shims/private-browsing-web-api-fixes.js17
-rw-r--r--browser/extensions/webcompat/shims/rambler-authenticator.js84
-rw-r--r--browser/extensions/webcompat/shims/rich-relevance.js288
-rw-r--r--browser/extensions/webcompat/shims/spotify-embed.js133
-rw-r--r--browser/extensions/webcompat/shims/tracking-pixel.pngbin0 -> 70 bytes
-rw-r--r--browser/extensions/webcompat/shims/vast2.xml12
-rw-r--r--browser/extensions/webcompat/shims/vast3.xml12
-rw-r--r--browser/extensions/webcompat/shims/vidible.js424
-rw-r--r--browser/extensions/webcompat/shims/vmad.xml12
-rw-r--r--browser/extensions/webcompat/shims/webtrends.js46
-rw-r--r--browser/extensions/webcompat/tests/browser/browser.ini15
-rw-r--r--browser/extensions/webcompat/tests/browser/browser_aboutcompat.js27
-rw-r--r--browser/extensions/webcompat/tests/browser/browser_shims.js73
-rw-r--r--browser/extensions/webcompat/tests/browser/head.js140
-rw-r--r--browser/extensions/webcompat/tests/browser/iframe_test.html19
-rw-r--r--browser/extensions/webcompat/tests/browser/shims_test.html21
-rw-r--r--browser/extensions/webcompat/tests/browser/shims_test.js11
-rw-r--r--browser/extensions/webcompat/tests/browser/shims_test_2.html21
-rw-r--r--browser/extensions/webcompat/tests/browser/shims_test_2.js11
-rw-r--r--browser/extensions/webcompat/tests/browser/shims_test_3.html21
-rw-r--r--browser/extensions/webcompat/tests/browser/shims_test_3.js7
551 files changed, 72226 insertions, 0 deletions
diff --git a/browser/extensions/formautofill/.eslintrc.js b/browser/extensions/formautofill/.eslintrc.js
new file mode 100644
index 0000000000..f290c1b3c1
--- /dev/null
+++ b/browser/extensions/formautofill/.eslintrc.js
@@ -0,0 +1,82 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+module.exports = {
+ rules: {
+ // Rules from the mozilla plugin
+ "mozilla/balanced-listeners": "error",
+ "mozilla/no-aArgs": "error",
+ "mozilla/var-only-at-top-level": "error",
+
+ // No expressions where a statement is expected
+ "no-unused-expressions": "error",
+
+ // No declaring variables that are never used
+ "no-unused-vars": [
+ "error",
+ {
+ args: "none",
+ vars: "all",
+ },
+ ],
+
+ // No using variables before defined
+ "no-use-before-define": "error",
+
+ // Disallow using variables outside the blocks they are defined (especially
+ // since only let and const are used, see "no-var").
+ "block-scoped-var": "error",
+
+ // Warn about cyclomatic complexity in functions.
+ complexity: ["error", { max: 26 }],
+
+ // Maximum depth callbacks can be nested.
+ "max-nested-callbacks": ["error", 4],
+
+ // Disallow using the console API, except for error statments.
+ "no-console": ["error", { allow: ["error"] }],
+
+ // Disallow fallthrough of case statements, except if there is a comment.
+ "no-fallthrough": "error",
+
+ // Disallow use of multiline strings (use template strings instead).
+ "no-multi-str": "error",
+
+ // Disallow usage of __proto__ property.
+ "no-proto": "error",
+
+ // Disallow use of assignment in return statement. It is preferable for a
+ // single line of code to have only one easily predictable effect.
+ "no-return-assign": "error",
+
+ // Require use of the second argument for parseInt().
+ radix: "error",
+
+ // Require "use strict" to be defined globally in the script.
+ strict: ["error", "global"],
+
+ // Disallow Yoda conditions (where literal value comes first).
+ yoda: "error",
+
+ // Disallow function or variable declarations in nested blocks
+ "no-inner-declarations": "error",
+ },
+
+ overrides: [
+ {
+ files: "**/head.js",
+ rules: {
+ "no-unused-vars": [
+ "error",
+ {
+ args: "none",
+ vars: "local",
+ },
+ ],
+ },
+ },
+ ],
+};
diff --git a/browser/extensions/formautofill/api.js b/browser/extensions/formautofill/api.js
new file mode 100644
index 0000000000..000c393e8e
--- /dev/null
+++ b/browser/extensions/formautofill/api.js
@@ -0,0 +1,222 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* globals ExtensionAPI, Services, XPCOMUtils */
+
+const CACHED_STYLESHEETS = new WeakMap();
+
+ChromeUtils.defineESModuleGetters(this, {
+ FormAutofill: "resource://autofill/FormAutofill.sys.mjs",
+ FormAutofillParent: "resource://autofill/FormAutofillParent.sys.mjs",
+ FormAutofillStatus: "resource://autofill/FormAutofillParent.sys.mjs",
+ AutoCompleteParent: "resource://gre/actors/AutoCompleteParent.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "resProto",
+ "@mozilla.org/network/protocol;1?name=resource",
+ "nsISubstitutingProtocolHandler"
+);
+
+const RESOURCE_HOST = "formautofill";
+
+function insertStyleSheet(domWindow, url) {
+ let doc = domWindow.document;
+ let styleSheetAttr = `href="${url}" type="text/css"`;
+ let styleSheet = doc.createProcessingInstruction(
+ "xml-stylesheet",
+ styleSheetAttr
+ );
+
+ doc.insertBefore(styleSheet, doc.documentElement);
+
+ if (CACHED_STYLESHEETS.has(domWindow)) {
+ CACHED_STYLESHEETS.get(domWindow).push(styleSheet);
+ } else {
+ CACHED_STYLESHEETS.set(domWindow, [styleSheet]);
+ }
+}
+
+function ensureCssLoaded(domWindow) {
+ if (CACHED_STYLESHEETS.has(domWindow)) {
+ // This window already has autofill stylesheets.
+ return;
+ }
+
+ insertStyleSheet(domWindow, "chrome://formautofill/content/formautofill.css");
+ insertStyleSheet(
+ domWindow,
+ "chrome://formautofill/content/skin/autocomplete-item-shared.css"
+ );
+ insertStyleSheet(
+ domWindow,
+ "chrome://formautofill/content/skin/autocomplete-item.css"
+ );
+}
+
+this.formautofill = class extends ExtensionAPI {
+ /**
+ * Adjusts and checks form autofill preferences during startup.
+ *
+ * @param {boolean} addressAutofillAvailable
+ * @param {boolean} creditCardAutofillAvailable
+ */
+ adjustAndCheckFormAutofillPrefs(
+ addressAutofillAvailable,
+ creditCardAutofillAvailable
+ ) {
+ // Reset the sync prefs in case the features were previously available
+ // but aren't now.
+ if (!creditCardAutofillAvailable) {
+ Services.prefs.clearUserPref(
+ "services.sync.engine.creditcards.available"
+ );
+ }
+ if (!addressAutofillAvailable) {
+ Services.prefs.clearUserPref("services.sync.engine.addresses.available");
+ }
+
+ if (!addressAutofillAvailable && !creditCardAutofillAvailable) {
+ Services.prefs.clearUserPref("dom.forms.autocomplete.formautofill");
+ Services.telemetry.scalarSet("formautofill.availability", false);
+ return;
+ }
+
+ // This pref is used for web contents to detect the autocomplete feature.
+ // When it's true, "element.autocomplete" will return tokens we currently
+ // support -- otherwise it'll return an empty string.
+ Services.prefs.setBoolPref("dom.forms.autocomplete.formautofill", true);
+ Services.telemetry.scalarSet("formautofill.availability", true);
+
+ // These "*.available" prefs determines whether the "addresses"/"creditcards" sync engine is
+ // available (ie, whether it is shown in any UI etc) - it *does not* determine
+ // whether the engine is actually enabled or not.
+ if (FormAutofill.isAutofillAddressesAvailable) {
+ Services.prefs.setBoolPref(
+ "services.sync.engine.addresses.available",
+ true
+ );
+ } else {
+ Services.prefs.clearUserPref("services.sync.engine.addresses.available");
+ }
+ if (FormAutofill.isAutofillCreditCardsAvailable) {
+ Services.prefs.setBoolPref(
+ "services.sync.engine.creditcards.available",
+ true
+ );
+ } else {
+ Services.prefs.clearUserPref(
+ "services.sync.engine.creditcards.available"
+ );
+ }
+ }
+ onStartup() {
+ // We have to do this before actually determining if we're enabled, since
+ // there are scripts inside of the core browser code that depend on the
+ // FormAutofill JSMs being registered.
+ let uri = Services.io.newURI("chrome/res/", null, this.extension.rootURI);
+ resProto.setSubstitution(RESOURCE_HOST, uri);
+
+ let aomStartup = Cc[
+ "@mozilla.org/addons/addon-manager-startup;1"
+ ].getService(Ci.amIAddonManagerStartup);
+ const manifestURI = Services.io.newURI(
+ "manifest.json",
+ null,
+ this.extension.rootURI
+ );
+ this.chromeHandle = aomStartup.registerChrome(manifestURI, [
+ ["content", "formautofill", "chrome/content/"],
+ ]);
+
+ // Until we move to fluent (bug 1446164), we're stuck with
+ // chrome.manifest for handling localization since its what the
+ // build system can handle for localized repacks.
+ if (this.extension.rootURI instanceof Ci.nsIJARURI) {
+ this.autofillManifest = this.extension.rootURI.JARFile.QueryInterface(
+ Ci.nsIFileURL
+ ).file;
+ } else if (this.extension.rootURI instanceof Ci.nsIFileURL) {
+ this.autofillManifest = this.extension.rootURI.file;
+ }
+
+ if (this.autofillManifest) {
+ Components.manager.addBootstrappedManifestLocation(this.autofillManifest);
+ } else {
+ console.error(
+ "Cannot find formautofill chrome.manifest for registring translated strings"
+ );
+ }
+ let addressAutofillAvailable = FormAutofill.isAutofillAddressesAvailable;
+ let creditCardAutofillAvailable =
+ FormAutofill.isAutofillCreditCardsAvailable;
+ this.adjustAndCheckFormAutofillPrefs(
+ addressAutofillAvailable,
+ creditCardAutofillAvailable
+ );
+ if (!creditCardAutofillAvailable && !addressAutofillAvailable) {
+ return;
+ }
+ // Listen for the autocomplete popup message
+ // or the form submitted message (which may trigger a
+ // doorhanger) to lazily append our stylesheets related
+ // to the autocomplete feature.
+ AutoCompleteParent.addPopupStateListener(ensureCssLoaded);
+ FormAutofillParent.addMessageObserver(this);
+ this.onFormSubmitted = (data, window) => ensureCssLoaded(window);
+
+ FormAutofillStatus.init();
+
+ ChromeUtils.registerWindowActor("FormAutofill", {
+ parent: {
+ esModuleURI: "resource://autofill/FormAutofillParent.sys.mjs",
+ },
+ child: {
+ esModuleURI: "resource://autofill/FormAutofillChild.sys.mjs",
+ events: {
+ focusin: {},
+ DOMFormBeforeSubmit: {},
+ },
+ },
+ allFrames: true,
+ });
+ }
+
+ onShutdown(isAppShutdown) {
+ if (isAppShutdown) {
+ return;
+ }
+
+ resProto.setSubstitution(RESOURCE_HOST, null);
+
+ this.chromeHandle.destruct();
+ this.chromeHandle = null;
+
+ if (this.autofillManifest) {
+ Components.manager.removeBootstrappedManifestLocation(
+ this.autofillManifest
+ );
+ }
+
+ ChromeUtils.unregisterWindowActor("FormAutofill");
+
+ AutoCompleteParent.removePopupStateListener(ensureCssLoaded);
+ FormAutofillParent.removeMessageObserver(this);
+
+ for (let win of Services.wm.getEnumerator("navigator:browser")) {
+ let cachedStyleSheets = CACHED_STYLESHEETS.get(win);
+
+ if (!cachedStyleSheets) {
+ continue;
+ }
+
+ while (cachedStyleSheets.length !== 0) {
+ cachedStyleSheets.pop().remove();
+ }
+ }
+ }
+};
diff --git a/browser/extensions/formautofill/background.js b/browser/extensions/formautofill/background.js
new file mode 100644
index 0000000000..fe6265415f
--- /dev/null
+++ b/browser/extensions/formautofill/background.js
@@ -0,0 +1,15 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* eslint-env webextensions */
+
+"use strict";
+
+browser.runtime.onUpdateAvailable.addListener(details => {
+ // By listening to but ignoring this event, any updates will
+ // be delayed until the next browser restart.
+ // Note that if we ever wanted to change this, we should make
+ // sure we manually invalidate the startup cache using the
+ // startupcache-invalidate notification.
+});
diff --git a/browser/extensions/formautofill/content/autofillEditForms.js b/browser/extensions/formautofill/content/autofillEditForms.js
new file mode 100644
index 0000000000..3ed64a098a
--- /dev/null
+++ b/browser/extensions/formautofill/content/autofillEditForms.js
@@ -0,0 +1,644 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* exported EditAddress, EditCreditCard */
+/* eslint-disable mozilla/balanced-listeners */ // Not relevant since the document gets unloaded.
+
+"use strict";
+
+const { FormAutofill } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofill.sys.mjs"
+);
+const { FormAutofillUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/FormAutofillUtils.sys.mjs"
+);
+
+class EditAutofillForm {
+ constructor(elements) {
+ this._elements = elements;
+ }
+
+ /**
+ * Fill the form with a record object.
+ *
+ * @param {object} [record = {}]
+ */
+ loadRecord(record = {}) {
+ for (let field of this._elements.form.elements) {
+ let value = record[field.id];
+ value = typeof value == "undefined" ? "" : value;
+
+ if (record.guid) {
+ field.value = value;
+ } else if (field.localName == "select") {
+ this.setDefaultSelectedOptionByValue(field, value);
+ } else {
+ // Use .defaultValue instead of .value to avoid setting the `dirty` flag
+ // which triggers form validation UI.
+ field.defaultValue = value;
+ }
+ }
+ if (!record.guid) {
+ // Reset the dirty value flag and validity state.
+ this._elements.form.reset();
+ } else {
+ for (let field of this._elements.form.elements) {
+ this.updatePopulatedState(field);
+ this.updateCustomValidity(field);
+ }
+ }
+ }
+
+ setDefaultSelectedOptionByValue(select, value) {
+ for (let option of select.options) {
+ option.defaultSelected = option.value == value;
+ }
+ }
+
+ /**
+ * Get a record from the form suitable for a save/update in storage.
+ *
+ * @returns {object}
+ */
+ buildFormObject() {
+ let initialObject = {};
+ if (this.hasMailingAddressFields) {
+ // Start with an empty string for each mailing-address field so that any
+ // fields hidden for the current country are blanked in the return value.
+ initialObject = {
+ "street-address": "",
+ "address-level3": "",
+ "address-level2": "",
+ "address-level1": "",
+ "postal-code": "",
+ };
+ }
+
+ return Array.from(this._elements.form.elements).reduce((obj, input) => {
+ if (!input.disabled) {
+ obj[input.id] = input.value;
+ }
+ return obj;
+ }, initialObject);
+ }
+
+ /**
+ * Handle events
+ *
+ * @param {DOMEvent} event
+ */
+ handleEvent(event) {
+ switch (event.type) {
+ case "change": {
+ this.handleChange(event);
+ break;
+ }
+ case "input": {
+ this.handleInput(event);
+ break;
+ }
+ }
+ }
+
+ /**
+ * Handle change events
+ *
+ * @param {DOMEvent} event
+ */
+ handleChange(event) {
+ this.updatePopulatedState(event.target);
+ }
+
+ /**
+ * Handle input events
+ *
+ * @param {DOMEvent} event
+ */
+ handleInput(event) {}
+
+ /**
+ * Attach event listener
+ */
+ attachEventListeners() {
+ this._elements.form.addEventListener("input", this);
+ }
+
+ /**
+ * Set the field-populated attribute if the field has a value.
+ *
+ * @param {DOMElement} field The field that will be checked for a value.
+ */
+ updatePopulatedState(field) {
+ let span = field.parentNode.querySelector(".label-text");
+ if (!span) {
+ return;
+ }
+ span.toggleAttribute("field-populated", !!field.value.trim());
+ }
+
+ /**
+ * Run custom validity routines specific to the field and type of form.
+ *
+ * @param {DOMElement} field The field that will be validated.
+ */
+ updateCustomValidity(field) {}
+}
+
+class EditAddress extends EditAutofillForm {
+ /**
+ * @param {HTMLElement[]} elements
+ * @param {object} record
+ * @param {object} config
+ * @param {boolean} [config.noValidate=undefined] Whether to validate the form
+ */
+ constructor(elements, record, config) {
+ super(elements);
+
+ Object.assign(this, config);
+ let { form } = this._elements;
+ Object.assign(this._elements, {
+ addressLevel3Label: form.querySelector(
+ "#address-level3-container > .label-text"
+ ),
+ addressLevel2Label: form.querySelector(
+ "#address-level2-container > .label-text"
+ ),
+ addressLevel1Label: form.querySelector(
+ "#address-level1-container > .label-text"
+ ),
+ postalCodeLabel: form.querySelector(
+ "#postal-code-container > .label-text"
+ ),
+ country: form.querySelector("#country"),
+ });
+
+ this.populateCountries();
+ // Need to populate the countries before trying to set the initial country.
+ // Also need to use this._record so it has the default country selected.
+ this.loadRecord(record);
+ this.attachEventListeners();
+
+ form.noValidate = !!config.noValidate;
+ }
+
+ loadRecord(record) {
+ this._record = record;
+ if (!record) {
+ record = {
+ country: FormAutofill.DEFAULT_REGION,
+ };
+ }
+
+ let { addressLevel1Options } = FormAutofillUtils.getFormFormat(
+ record.country
+ );
+ this.populateAddressLevel1(addressLevel1Options, record.country);
+
+ super.loadRecord(record);
+ this.loadAddressLevel1(record["address-level1"], record.country);
+ this.formatForm(record.country);
+ }
+
+ get hasMailingAddressFields() {
+ let { addressFields } = this._elements.form.dataset;
+ return (
+ !addressFields ||
+ addressFields.trim().split(/\s+/).includes("mailing-address")
+ );
+ }
+
+ /**
+ * `mailing-address` is a special attribute token to indicate mailing fields + country.
+ *
+ * @param {object[]} mailingFieldsOrder - `fieldsOrder` from `getFormFormat`
+ * @param {string} addressFields - white-space-separated string of requested address fields to show
+ * @returns {object[]} in the same structure as `mailingFieldsOrder` but including non-mail fields
+ */
+ static computeVisibleFields(mailingFieldsOrder, addressFields) {
+ if (addressFields) {
+ let requestedFieldClasses = addressFields.trim().split(/\s+/);
+ let fieldClasses = [];
+ if (requestedFieldClasses.includes("mailing-address")) {
+ fieldClasses = fieldClasses.concat(mailingFieldsOrder);
+ // `country` isn't part of the `mailingFieldsOrder` so add it when filling a mailing-address
+ requestedFieldClasses.splice(
+ requestedFieldClasses.indexOf("mailing-address"),
+ 1,
+ "country"
+ );
+ }
+
+ for (let fieldClassName of requestedFieldClasses) {
+ fieldClasses.push({
+ fieldId: fieldClassName,
+ newLine: fieldClassName == "name",
+ });
+ }
+ return fieldClasses;
+ }
+
+ // This is the default which is shown in the management interface and includes all fields.
+ return mailingFieldsOrder.concat([
+ {
+ fieldId: "country",
+ },
+ {
+ fieldId: "tel",
+ },
+ {
+ fieldId: "email",
+ newLine: true,
+ },
+ ]);
+ }
+
+ /**
+ * Format the form based on country. The address-level1 and postal-code labels
+ * should be specific to the given country.
+ *
+ * @param {string} country
+ */
+ formatForm(country) {
+ const {
+ addressLevel3L10nId,
+ addressLevel2L10nId,
+ addressLevel1L10nId,
+ addressLevel1Options,
+ postalCodeL10nId,
+ fieldsOrder: mailingFieldsOrder,
+ postalCodePattern,
+ countryRequiredFields,
+ } = FormAutofillUtils.getFormFormat(country);
+
+ document.l10n.setAttributes(
+ this._elements.addressLevel3Label,
+ addressLevel3L10nId
+ );
+ document.l10n.setAttributes(
+ this._elements.addressLevel2Label,
+ addressLevel2L10nId
+ );
+ document.l10n.setAttributes(
+ this._elements.addressLevel1Label,
+ addressLevel1L10nId
+ );
+ document.l10n.setAttributes(
+ this._elements.postalCodeLabel,
+ postalCodeL10nId
+ );
+ let addressFields = this._elements.form.dataset.addressFields;
+ let extraRequiredFields = this._elements.form.dataset.extraRequiredFields;
+ let fieldClasses = EditAddress.computeVisibleFields(
+ mailingFieldsOrder,
+ addressFields
+ );
+ let requiredFields = new Set(countryRequiredFields);
+ if (extraRequiredFields) {
+ for (let extraRequiredField of extraRequiredFields.trim().split(/\s+/)) {
+ requiredFields.add(extraRequiredField);
+ }
+ }
+ this.arrangeFields(fieldClasses, requiredFields);
+ this.updatePostalCodeValidation(postalCodePattern);
+ this.populateAddressLevel1(addressLevel1Options, country);
+ }
+
+ /**
+ * Update address field visibility and order based on libaddressinput data.
+ *
+ * @param {object[]} fieldsOrder array of objects with `fieldId` and optional `newLine` properties
+ * @param {Set} requiredFields Set of `fieldId` strings that mark which fields are required
+ */
+ arrangeFields(fieldsOrder, requiredFields) {
+ /**
+ * @see FormAutofillStorage.VALID_ADDRESS_FIELDS
+ */
+ let fields = [
+ // `name` is a wrapper for the 3 name fields.
+ "name",
+ "organization",
+ "street-address",
+ "address-level3",
+ "address-level2",
+ "address-level1",
+ "postal-code",
+ "country",
+ "tel",
+ "email",
+ ];
+ let inputs = [];
+ for (let i = 0; i < fieldsOrder.length; i++) {
+ let { fieldId, newLine } = fieldsOrder[i];
+
+ let container = this._elements.form.querySelector(
+ `#${fieldId}-container`
+ );
+ let containerInputs = [
+ ...container.querySelectorAll("input, textarea, select"),
+ ];
+ containerInputs.forEach(function (input) {
+ input.disabled = false;
+ // libaddressinput doesn't list 'country' or 'name' as required.
+ // The additional-name field should never get marked as required.
+ input.required =
+ (fieldId == "country" ||
+ fieldId == "name" ||
+ requiredFields.has(fieldId)) &&
+ input.id != "additional-name";
+ });
+ inputs.push(...containerInputs);
+ container.style.display = "flex";
+ container.style.order = i;
+ container.style.pageBreakAfter = newLine ? "always" : "auto";
+ // Remove the field from the list of fields
+ fields.splice(fields.indexOf(fieldId), 1);
+ }
+ for (let i = 0; i < inputs.length; i++) {
+ // Assign tabIndex starting from 1
+ inputs[i].tabIndex = i + 1;
+ }
+ // Hide the remaining fields
+ for (let field of fields) {
+ let container = this._elements.form.querySelector(`#${field}-container`);
+ container.style.display = "none";
+ for (let input of [
+ ...container.querySelectorAll("input, textarea, select"),
+ ]) {
+ input.disabled = true;
+ }
+ }
+ }
+
+ updatePostalCodeValidation(postalCodePattern) {
+ let postalCodeInput = this._elements.form.querySelector("#postal-code");
+ if (postalCodePattern && postalCodeInput.style.display != "none") {
+ postalCodeInput.setAttribute("pattern", postalCodePattern);
+ } else {
+ postalCodeInput.removeAttribute("pattern");
+ }
+ }
+
+ /**
+ * Set the address-level1 value on the form field (input or select, whichever is present).
+ *
+ * @param {string} addressLevel1Value Value of the address-level1 from the autofill record
+ * @param {string} country The corresponding country
+ */
+ loadAddressLevel1(addressLevel1Value, country) {
+ let field = this._elements.form.querySelector("#address-level1");
+
+ if (field.localName == "input") {
+ field.value = addressLevel1Value || "";
+ return;
+ }
+
+ let matchedSelectOption = FormAutofillUtils.findAddressSelectOption(
+ field,
+ {
+ country,
+ "address-level1": addressLevel1Value,
+ },
+ "address-level1"
+ );
+ if (matchedSelectOption && !matchedSelectOption.selected) {
+ field.value = matchedSelectOption.value;
+ field.dispatchEvent(new Event("input", { bubbles: true }));
+ field.dispatchEvent(new Event("change", { bubbles: true }));
+ } else if (addressLevel1Value) {
+ // If the option wasn't found, insert an option at the beginning of
+ // the select that matches the stored value.
+ field.insertBefore(
+ new Option(addressLevel1Value, addressLevel1Value, true, true),
+ field.firstChild
+ );
+ }
+ }
+
+ /**
+ * Replace the text input for address-level1 with a select dropdown if
+ * a fixed set of names exists. Otherwise show a text input.
+ *
+ * @param {Map?} options Map of options with regionCode -> name mappings
+ * @param {string} country The corresponding country
+ */
+ populateAddressLevel1(options, country) {
+ let field = this._elements.form.querySelector("#address-level1");
+
+ if (field.dataset.country == country) {
+ return;
+ }
+
+ if (!options) {
+ if (field.localName == "input") {
+ return;
+ }
+
+ let input = document.createElement("input");
+ input.setAttribute("type", "text");
+ input.id = "address-level1";
+ input.required = field.required;
+ input.disabled = field.disabled;
+ input.tabIndex = field.tabIndex;
+ field.replaceWith(input);
+ return;
+ }
+
+ if (field.localName == "input") {
+ let select = document.createElement("select");
+ select.id = "address-level1";
+ select.required = field.required;
+ select.disabled = field.disabled;
+ select.tabIndex = field.tabIndex;
+ field.replaceWith(select);
+ field = select;
+ }
+
+ field.textContent = "";
+ field.dataset.country = country;
+ let fragment = document.createDocumentFragment();
+ fragment.appendChild(new Option(undefined, undefined, true, true));
+ for (let [regionCode, regionName] of options) {
+ let option = new Option(regionName, regionCode);
+ fragment.appendChild(option);
+ }
+ field.appendChild(fragment);
+ }
+
+ populateCountries() {
+ let fragment = document.createDocumentFragment();
+ // Sort countries by their visible names.
+ let countries = [...FormAutofill.countries.entries()].sort((e1, e2) =>
+ e1[1].localeCompare(e2[1])
+ );
+ for (let [country] of countries) {
+ const countryName = Services.intl.getRegionDisplayNames(undefined, [
+ country.toLowerCase(),
+ ]);
+ const option = new Option(countryName, country);
+ fragment.appendChild(option);
+ }
+ this._elements.country.appendChild(fragment);
+ }
+
+ handleChange(event) {
+ if (event.target == this._elements.country) {
+ this.formatForm(event.target.value);
+ }
+ super.handleChange(event);
+ }
+
+ attachEventListeners() {
+ this._elements.form.addEventListener("change", this);
+ super.attachEventListeners();
+ }
+}
+
+class EditCreditCard extends EditAutofillForm {
+ /**
+ * @param {HTMLElement[]} elements
+ * @param {object} record with a decrypted cc-number
+ * @param {object} addresses in an object with guid keys for the billing address picker.
+ */
+ constructor(elements, record, addresses) {
+ super(elements);
+
+ this._addresses = addresses;
+ Object.assign(this._elements, {
+ ccNumber: this._elements.form.querySelector("#cc-number"),
+ invalidCardNumberStringElement: this._elements.form.querySelector(
+ "#invalidCardNumberString"
+ ),
+ month: this._elements.form.querySelector("#cc-exp-month"),
+ year: this._elements.form.querySelector("#cc-exp-year"),
+ billingAddress: this._elements.form.querySelector("#billingAddressGUID"),
+ billingAddressRow:
+ this._elements.form.querySelector(".billingAddressRow"),
+ });
+
+ this.attachEventListeners();
+ this.loadRecord(record, addresses);
+ }
+
+ loadRecord(record, addresses, preserveFieldValues) {
+ // _record must be updated before generateYears and generateBillingAddressOptions are called.
+ this._record = record;
+ this._addresses = addresses;
+ this.generateBillingAddressOptions(preserveFieldValues);
+ if (!preserveFieldValues) {
+ // Re-generating the months will reset the selected option.
+ this.generateMonths();
+ // Re-generating the years will reset the selected option.
+ this.generateYears();
+ super.loadRecord(record);
+ }
+ }
+
+ generateMonths() {
+ const count = 12;
+
+ // Clear the list
+ this._elements.month.textContent = "";
+
+ // Empty month option
+ this._elements.month.appendChild(new Option());
+
+ // Populate month list. Format: "month number - month name"
+ let dateFormat = new Intl.DateTimeFormat(navigator.language, {
+ month: "long",
+ }).format;
+ for (let i = 0; i < count; i++) {
+ let monthNumber = (i + 1).toString();
+ let monthName = dateFormat(new Date(1970, i));
+ let option = new Option();
+ option.value = monthNumber;
+ // XXX: Bug 1446164 - Localize this string.
+ option.textContent = `${monthNumber.padStart(2, "0")} - ${monthName}`;
+ this._elements.month.appendChild(option);
+ }
+ }
+
+ generateYears() {
+ const count = 11;
+ const currentYear = new Date().getFullYear();
+ const ccExpYear = this._record && this._record["cc-exp-year"];
+
+ // Clear the list
+ this._elements.year.textContent = "";
+
+ // Provide an empty year option
+ this._elements.year.appendChild(new Option());
+
+ if (ccExpYear && ccExpYear < currentYear) {
+ this._elements.year.appendChild(new Option(ccExpYear));
+ }
+
+ for (let i = 0; i < count; i++) {
+ let year = currentYear + i;
+ let option = new Option(year);
+ this._elements.year.appendChild(option);
+ }
+
+ if (ccExpYear && ccExpYear > currentYear + count) {
+ this._elements.year.appendChild(new Option(ccExpYear));
+ }
+ }
+
+ generateBillingAddressOptions(preserveFieldValues) {
+ let billingAddressGUID;
+ if (preserveFieldValues && this._elements.billingAddress.value) {
+ billingAddressGUID = this._elements.billingAddress.value;
+ } else if (this._record) {
+ billingAddressGUID = this._record.billingAddressGUID;
+ }
+
+ this._elements.billingAddress.textContent = "";
+
+ this._elements.billingAddress.appendChild(new Option("", ""));
+
+ let hasAddresses = false;
+ for (let [guid, address] of Object.entries(this._addresses)) {
+ hasAddresses = true;
+ let selected = guid == billingAddressGUID;
+ let option = new Option(
+ FormAutofillUtils.getAddressLabel(address),
+ guid,
+ selected,
+ selected
+ );
+ this._elements.billingAddress.appendChild(option);
+ }
+
+ this._elements.billingAddressRow.hidden = !hasAddresses;
+ }
+
+ attachEventListeners() {
+ this._elements.form.addEventListener("change", this);
+ super.attachEventListeners();
+ }
+
+ handleInput(event) {
+ // Clear the error message if cc-number is valid
+ if (
+ event.target == this._elements.ccNumber &&
+ FormAutofillUtils.isCCNumber(this._elements.ccNumber.value)
+ ) {
+ this._elements.ccNumber.setCustomValidity("");
+ }
+ super.handleInput(event);
+ }
+
+ updateCustomValidity(field) {
+ super.updateCustomValidity(field);
+
+ // Mark the cc-number field as invalid if the number is empty or invalid.
+ if (
+ field == this._elements.ccNumber &&
+ !FormAutofillUtils.isCCNumber(field.value)
+ ) {
+ let invalidCardNumberString =
+ this._elements.invalidCardNumberStringElement.textContent;
+ field.setCustomValidity(invalidCardNumberString || " ");
+ }
+ }
+}
diff --git a/browser/extensions/formautofill/content/customElements.js b/browser/extensions/formautofill/content/customElements.js
new file mode 100644
index 0000000000..0b3d761817
--- /dev/null
+++ b/browser/extensions/formautofill/content/customElements.js
@@ -0,0 +1,410 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// This file is loaded into the browser window scope.
+/* eslint-env mozilla/browser-window */
+/* eslint-disable mozilla/balanced-listeners */ // Not relevant since the document gets unloaded.
+
+"use strict";
+
+// Wrap in a block to prevent leaking to window scope.
+(() => {
+ function sendMessageToBrowser(msgName, data) {
+ let { AutoCompleteParent } = ChromeUtils.importESModule(
+ "resource://gre/actors/AutoCompleteParent.sys.mjs"
+ );
+
+ let actor = AutoCompleteParent.getCurrentActor();
+ if (!actor) {
+ return;
+ }
+
+ actor.manager.getActor("FormAutofill").sendAsyncMessage(msgName, data);
+ }
+
+ class MozAutocompleteProfileListitemBase extends MozElements.MozRichlistitem {
+ constructor() {
+ super();
+
+ /**
+ * For form autofill, we want to unify the selection no matter by
+ * keyboard navigation or mouseover in order not to confuse user which
+ * profile preview is being shown. This field is set to true to indicate
+ * that selectedIndex of popup should be changed while mouseover item
+ */
+ this.selectedByMouseOver = true;
+ }
+
+ get _stringBundle() {
+ if (!this.__stringBundle) {
+ this.__stringBundle = Services.strings.createBundle(
+ "chrome://formautofill/locale/formautofill.properties"
+ );
+ }
+ return this.__stringBundle;
+ }
+
+ _cleanup() {
+ this.removeAttribute("formautofillattached");
+ if (this._itemBox) {
+ this._itemBox.removeAttribute("size");
+ }
+ }
+
+ _onOverflow() {}
+
+ _onUnderflow() {}
+
+ handleOverUnderflow() {}
+
+ _adjustAutofillItemLayout() {
+ let outerBoxRect = this.parentNode.getBoundingClientRect();
+
+ // Make item fit in popup as XUL box could not constrain
+ // item's width
+ this._itemBox.style.width = outerBoxRect.width + "px";
+ // Use two-lines layout when width is smaller than 150px or
+ // 185px if an image precedes the label.
+ let oneLineMinRequiredWidth = this.getAttribute("ac-image") ? 185 : 150;
+
+ if (outerBoxRect.width <= oneLineMinRequiredWidth) {
+ this._itemBox.setAttribute("size", "small");
+ } else {
+ this._itemBox.removeAttribute("size");
+ }
+ }
+ }
+
+ MozElements.MozAutocompleteProfileListitem = class MozAutocompleteProfileListitem extends (
+ MozAutocompleteProfileListitemBase
+ ) {
+ static get markup() {
+ return `
+ <div xmlns="http://www.w3.org/1999/xhtml" class="autofill-item-box">
+ <div class="profile-label-col profile-item-col">
+ <span class="profile-label-affix"></span>
+ <span class="profile-label"></span>
+ </div>
+ <div class="profile-comment-col profile-item-col">
+ <span class="profile-comment"></span>
+ </div>
+ </div>
+ `;
+ }
+
+ connectedCallback() {
+ if (this.delayConnectedCallback()) {
+ return;
+ }
+
+ this.textContent = "";
+
+ this.appendChild(this.constructor.fragment);
+
+ this._itemBox = this.querySelector(".autofill-item-box");
+ this._labelAffix = this.querySelector(".profile-label-affix");
+ this._label = this.querySelector(".profile-label");
+ this._comment = this.querySelector(".profile-comment");
+
+ this.initializeAttributeInheritance();
+ this._adjustAcItem();
+ }
+
+ static get inheritedAttributes() {
+ return {
+ ".autofill-item-box": "ac-image",
+ };
+ }
+
+ set selected(val) {
+ if (val) {
+ this.setAttribute("selected", "true");
+ } else {
+ this.removeAttribute("selected");
+ }
+
+ sendMessageToBrowser("FormAutofill:PreviewProfile");
+ }
+
+ get selected() {
+ return this.getAttribute("selected") == "true";
+ }
+
+ _adjustAcItem() {
+ this._adjustAutofillItemLayout();
+ this.setAttribute("formautofillattached", "true");
+ this._itemBox.style.setProperty(
+ "--primary-icon",
+ `url(${this.getAttribute("ac-image")})`
+ );
+
+ let { primaryAffix, primary, secondary, ariaLabel } = JSON.parse(
+ this.getAttribute("ac-value")
+ );
+
+ this._labelAffix.textContent = primaryAffix;
+ this._label.textContent = primary;
+ this._comment.textContent = secondary;
+ if (ariaLabel) {
+ this.setAttribute("aria-label", ariaLabel);
+ }
+ }
+ };
+
+ customElements.define(
+ "autocomplete-profile-listitem",
+ MozElements.MozAutocompleteProfileListitem,
+ { extends: "richlistitem" }
+ );
+
+ class MozAutocompleteProfileListitemFooter extends MozAutocompleteProfileListitemBase {
+ static get markup() {
+ return `
+ <div xmlns="http://www.w3.org/1999/xhtml" class="autofill-item-box autofill-footer">
+ <div class="autofill-footer-row autofill-warning"></div>
+ <div class="autofill-footer-row autofill-button"></div>
+ </div>
+ `;
+ }
+
+ constructor() {
+ super();
+
+ this.addEventListener("click", event => {
+ if (event.button != 0) {
+ return;
+ }
+
+ if (this._warningTextBox.contains(event.originalTarget)) {
+ return;
+ }
+
+ window.openPreferences("privacy-form-autofill");
+ });
+ }
+
+ connectedCallback() {
+ if (this.delayConnectedCallback()) {
+ return;
+ }
+
+ this.textContent = "";
+ this.appendChild(this.constructor.fragment);
+
+ this._itemBox = this.querySelector(".autofill-footer");
+ this._optionButton = this.querySelector(".autofill-button");
+ this._warningTextBox = this.querySelector(".autofill-warning");
+
+ /**
+ * A handler for updating warning message once selectedIndex has been changed.
+ *
+ * There're three different states of warning message:
+ * 1. None of addresses were selected: We show all the categories intersection of fields in the
+ * form and fields in the results.
+ * 2. An address was selested: Show the additional categories that will also be filled.
+ * 3. An address was selected, but the focused category is the same as the only one category: Only show
+ * the exact category that we're going to fill in.
+ *
+ * @private
+ * @param {object} data
+ * Message data
+ * @param {string[]} data.categories
+ * The categories of all the fields contained in the selected address.
+ */
+ this.updateWarningNote = data => {
+ let categories =
+ data && data.categories ? data.categories : this._allFieldCategories;
+ // If the length of categories is 1, that means all the fillable fields are in the same
+ // category. We will change the way to inform user according to this flag. When the value
+ // is true, we show "Also autofills ...", otherwise, show "Autofills ..." only.
+ let hasExtraCategories = categories.length > 1;
+ // Show the categories in certain order to conform with the spec.
+ let orderedCategoryList = [
+ { id: "address", l10nId: "category.address" },
+ { id: "name", l10nId: "category.name" },
+ { id: "organization", l10nId: "category.organization2" },
+ { id: "tel", l10nId: "category.tel" },
+ { id: "email", l10nId: "category.email" },
+ ];
+ let showCategories = hasExtraCategories
+ ? orderedCategoryList.filter(
+ category =>
+ categories.includes(category.id) &&
+ category.id != this._focusedCategory
+ )
+ : [
+ orderedCategoryList.find(
+ category => category.id == this._focusedCategory
+ ),
+ ];
+
+ let separator =
+ this._stringBundle.GetStringFromName("fieldNameSeparator");
+ let warningTextTmplKey = hasExtraCategories
+ ? "phishingWarningMessage"
+ : "phishingWarningMessage2";
+ let categoriesText = showCategories
+ .map(category =>
+ this._stringBundle.GetStringFromName(category.l10nId)
+ )
+ .join(separator);
+
+ this._warningTextBox.textContent =
+ this._stringBundle.formatStringFromName(warningTextTmplKey, [
+ categoriesText,
+ ]);
+ this.parentNode.parentNode.adjustHeight();
+ };
+
+ this._adjustAcItem();
+ }
+
+ _onCollapse() {
+ if (this.showWarningText) {
+ let { FormAutofillParent } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillParent.sys.mjs"
+ );
+ FormAutofillParent.removeMessageObserver(this);
+ }
+ this._itemBox.removeAttribute("no-warning");
+ }
+
+ _adjustAcItem() {
+ this._adjustAutofillItemLayout();
+ this.setAttribute("formautofillattached", "true");
+
+ let buttonTextBundleKey;
+ if (this._itemBox.getAttribute("size") == "small") {
+ buttonTextBundleKey =
+ AppConstants.platform == "macosx"
+ ? "autocompleteFooterOptionOSXShort2"
+ : "autocompleteFooterOptionShort2";
+ } else {
+ buttonTextBundleKey =
+ AppConstants.platform == "macosx"
+ ? "autocompleteFooterOptionOSX2"
+ : "autocompleteFooterOption2";
+ }
+
+ let buttonText =
+ this._stringBundle.GetStringFromName(buttonTextBundleKey);
+ this._optionButton.textContent = buttonText;
+
+ let value = JSON.parse(this.getAttribute("ac-value"));
+
+ this._allFieldCategories = value.categories;
+ this._focusedCategory = value.focusedCategory;
+ this.showWarningText = this._allFieldCategories && this._focusedCategory;
+
+ if (this.showWarningText) {
+ let { FormAutofillParent } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillParent.sys.mjs"
+ );
+ FormAutofillParent.addMessageObserver(this);
+ this.updateWarningNote();
+ } else {
+ this._itemBox.setAttribute("no-warning", "true");
+ }
+ }
+ }
+
+ customElements.define(
+ "autocomplete-profile-listitem-footer",
+ MozAutocompleteProfileListitemFooter,
+ { extends: "richlistitem" }
+ );
+
+ class MozAutocompleteCreditcardInsecureField extends MozAutocompleteProfileListitemBase {
+ static get markup() {
+ return `
+ <div xmlns="http://www.w3.org/1999/xhtml" class="autofill-insecure-item"></div>
+ `;
+ }
+
+ connectedCallback() {
+ if (this.delayConnectedCallback()) {
+ return;
+ }
+ this.textContent = "";
+ this.appendChild(this.constructor.fragment);
+
+ this._itemBox = this.querySelector(".autofill-insecure-item");
+
+ this._adjustAcItem();
+ }
+
+ set selected(val) {
+ // This item is unselectable since we see this item as a pure message.
+ }
+
+ get selected() {
+ return this.getAttribute("selected") == "true";
+ }
+
+ _adjustAcItem() {
+ this._adjustAutofillItemLayout();
+ this.setAttribute("formautofillattached", "true");
+
+ let value = this.getAttribute("ac-value");
+ this._itemBox.textContent = value;
+ }
+ }
+
+ customElements.define(
+ "autocomplete-creditcard-insecure-field",
+ MozAutocompleteCreditcardInsecureField,
+ { extends: "richlistitem" }
+ );
+
+ class MozAutocompleteProfileListitemClearButton extends MozAutocompleteProfileListitemBase {
+ static get markup() {
+ return `
+ <div xmlns="http://www.w3.org/1999/xhtml" class="autofill-item-box autofill-footer">
+ <div class="autofill-footer-row autofill-button"></div>
+ </div>
+ `;
+ }
+
+ constructor() {
+ super();
+
+ this.addEventListener("click", event => {
+ if (event.button != 0) {
+ return;
+ }
+
+ sendMessageToBrowser("FormAutofill:ClearForm");
+ });
+ }
+
+ connectedCallback() {
+ if (this.delayConnectedCallback()) {
+ return;
+ }
+
+ this.textContent = "";
+ this.appendChild(this.constructor.fragment);
+
+ this._itemBox = this.querySelector(".autofill-item-box");
+ this._clearBtn = this.querySelector(".autofill-button");
+
+ this._adjustAcItem();
+ }
+
+ _adjustAcItem() {
+ this._adjustAutofillItemLayout();
+ this.setAttribute("formautofillattached", "true");
+
+ let clearFormBtnLabel =
+ this._stringBundle.GetStringFromName("clearFormBtnLabel2");
+ this._clearBtn.textContent = clearFormBtnLabel;
+ }
+ }
+
+ customElements.define(
+ "autocomplete-profile-listitem-clear-button",
+ MozAutocompleteProfileListitemClearButton,
+ { extends: "richlistitem" }
+ );
+})();
diff --git a/browser/extensions/formautofill/content/editAddress.xhtml b/browser/extensions/formautofill/content/editAddress.xhtml
new file mode 100644
index 0000000000..8972e75c47
--- /dev/null
+++ b/browser/extensions/formautofill/content/editAddress.xhtml
@@ -0,0 +1,134 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml">
+ <head>
+ <title data-l10n-id="autofill-add-new-address-title"></title>
+ <link rel="localization" href="browser/preferences/formAutofill.ftl" />
+ <link
+ rel="stylesheet"
+ href="chrome://formautofill/content/skin/editDialog-shared.css"
+ />
+ <link
+ rel="stylesheet"
+ href="chrome://formautofill/content/skin/editAddress.css"
+ />
+ <link
+ rel="stylesheet"
+ href="chrome://formautofill/content/skin/editDialog.css"
+ />
+ <script src="chrome://formautofill/content/editDialog.js"></script>
+ <script src="chrome://formautofill/content/autofillEditForms.js"></script>
+ <script
+ type="module"
+ src="chrome://global/content/elements/moz-button-group.mjs"
+ ></script>
+ </head>
+ <body>
+ <form id="form" class="editAddressForm" autocomplete="off">
+ <!--
+ The <span class="label-text" …/> needs to be after the form field in the same element in
+ order to get proper label styling with :focus and :moz-ui-invalid.
+ -->
+ <div id="name-container" class="container">
+ <label id="given-name-container">
+ <input id="given-name" type="text" required="required" />
+ <span data-l10n-id="autofill-address-given-name" class="label-text" />
+ </label>
+ <label id="additional-name-container">
+ <input id="additional-name" type="text" />
+ <span
+ data-l10n-id="autofill-address-additional-name"
+ class="label-text"
+ />
+ </label>
+ <label id="family-name-container">
+ <input id="family-name" type="text" required="required" />
+ <span
+ data-l10n-id="autofill-address-family-name"
+ class="label-text"
+ />
+ </label>
+ </div>
+ <label id="organization-container" class="container">
+ <input id="organization" type="text" />
+ <span data-l10n-id="autofill-address-organization" class="label-text" />
+ </label>
+ <label id="street-address-container" class="container">
+ <textarea id="street-address" rows="3" />
+ <span data-l10n-id="autofill-address-street" class="label-text" />
+ </label>
+ <label id="address-level3-container" class="container">
+ <input id="address-level3" type="text" />
+ <span class="label-text" />
+ </label>
+ <label id="address-level2-container" class="container">
+ <input id="address-level2" type="text" />
+ <span class="label-text" />
+ </label>
+ <label id="address-level1-container" class="container">
+ <!-- The address-level1 input will get replaced by a select dropdown
+ by autofillEditForms.js when the selected country has provided
+ specific options. -->
+ <input id="address-level1" type="text" />
+ <span class="label-text" />
+ </label>
+ <label id="postal-code-container" class="container">
+ <input id="postal-code" type="text" />
+ <span class="label-text" />
+ </label>
+ <label id="country-container" class="container">
+ <select id="country" required="required">
+ <option />
+ </select>
+ <span data-l10n-id="autofill-address-country" class="label-text" />
+ </label>
+ <label id="tel-container" class="container">
+ <input id="tel" type="tel" dir="auto" />
+ <span data-l10n-id="autofill-address-tel" class="label-text" />
+ </label>
+ <label id="email-container" class="container">
+ <input id="email" type="email" required="required" />
+ <span data-l10n-id="autofill-address-email" class="label-text" />
+ </label>
+ </form>
+ <div id="controls-container">
+ <span
+ id="country-warning-message"
+ data-l10n-id="autofill-country-warning-message"
+ />
+ <moz-button-group>
+ <button id="cancel" data-l10n-id="autofill-cancel-button" />
+ <button id="save" class="primary" data-l10n-id="autofill-save-button" />
+ </moz-button-group>
+ </div>
+ <script>
+ <![CDATA[
+ "use strict";
+
+ const {
+ record,
+ noValidate,
+ } = window.arguments?.[0] ?? {};
+
+ /* import-globals-from autofillEditForms.js */
+ const fieldContainer = new EditAddress({
+ form: document.getElementById("form"),
+ }, record, {
+ noValidate,
+ });
+
+ /* import-globals-from editDialog.js */
+ new EditAddressDialog({
+ title: document.querySelector("title"),
+ fieldContainer,
+ controlsContainer: document.getElementById("controls-container"),
+ cancel: document.getElementById("cancel"),
+ save: document.getElementById("save"),
+ }, record);
+ ]]>
+ </script>
+ </body>
+</html>
diff --git a/browser/extensions/formautofill/content/editCreditCard.xhtml b/browser/extensions/formautofill/content/editCreditCard.xhtml
new file mode 100644
index 0000000000..c8315540c6
--- /dev/null
+++ b/browser/extensions/formautofill/content/editCreditCard.xhtml
@@ -0,0 +1,122 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml">
+ <head>
+ <title data-l10n-id="autofill-add-new-card-title"></title>
+ <link rel="localization" href="browser/preferences/formAutofill.ftl" />
+ <link
+ rel="stylesheet"
+ href="chrome://formautofill/content/skin/editDialog-shared.css"
+ />
+ <link
+ rel="stylesheet"
+ href="chrome://formautofill/content/skin/editCreditCard.css"
+ />
+ <link
+ rel="stylesheet"
+ href="chrome://formautofill/content/skin/editDialog.css"
+ />
+ <script src="chrome://formautofill/content/editDialog.js"></script>
+ <script src="chrome://formautofill/content/autofillEditForms.js"></script>
+ </head>
+ <body>
+ <form id="form" class="editCreditCardForm contentPane" autocomplete="off">
+ <!--
+ The <span class="label-text" …/> needs to be after the form field in the same element in
+ order to get proper label styling with :focus and :moz-ui-invalid.
+ -->
+ <label id="cc-number-container" class="container" role="none">
+ <span
+ id="invalidCardNumberString"
+ hidden="hidden"
+ data-l10n-id="autofill-card-invalid-number"
+ ></span>
+ <!-- Because there is text both before and after the input, a11y will
+ include the value of the input in the label. Therefore, we override
+ with aria-labelledby.
+ -->
+ <input
+ id="cc-number"
+ type="text"
+ required="required"
+ minlength="14"
+ pattern="[- 0-9]+"
+ aria-labelledby="cc-number-label"
+ />
+ <span
+ id="cc-number-label"
+ data-l10n-id="autofill-card-number"
+ class="label-text"
+ />
+ </label>
+ <label id="cc-exp-month-container" class="container">
+ <select id="cc-exp-month" required="required">
+ <option />
+ </select>
+ <span data-l10n-id="autofill-card-expires-month" class="label-text" />
+ </label>
+ <label id="cc-exp-year-container" class="container">
+ <select id="cc-exp-year" required="required">
+ <option />
+ </select>
+ <span data-l10n-id="autofill-card-expires-year" class="label-text" />
+ </label>
+ <label id="cc-name-container" class="container">
+ <input id="cc-name" type="text" required="required" />
+ <span data-l10n-id="autofill-card-name-on-card" class="label-text" />
+ </label>
+ <label id="cc-csc-container" class="container" hidden="hidden">
+ <!-- The CSC container will get filled in by forms that need a CSC (using csc-input.js) -->
+ </label>
+ <div
+ id="billingAddressGUID-container"
+ class="billingAddressRow container rich-picker"
+ >
+ <select id="billingAddressGUID" required="required"></select>
+ <label
+ for="billingAddressGUID"
+ data-l10n-id="autofill-card-billing-address"
+ class="label-text"
+ />
+ </div>
+ </form>
+ <div id="controls-container">
+ <button id="cancel" data-l10n-id="autofill-cancel-button" />
+ <button id="save" class="primary" data-l10n-id="autofill-save-button" />
+ </div>
+ <script>
+ <![CDATA[
+ "use strict";
+
+ /* import-globals-from editDialog.js */
+
+ (async () => {
+ const {
+ record,
+ } = window.arguments?.[0] ?? {};
+
+ const addresses = {};
+ for (let address of await formAutofillStorage.addresses.getAll()) {
+ addresses[address.guid] = address;
+ }
+
+ /* import-globals-from autofillEditForms.js */
+ const fieldContainer = new EditCreditCard({
+ form: document.getElementById("form"),
+ }, record, addresses);
+
+ new EditCreditCardDialog({
+ title: document.querySelector("title"),
+ fieldContainer,
+ controlsContainer: document.getElementById("controls-container"),
+ cancel: document.getElementById("cancel"),
+ save: document.getElementById("save"),
+ }, record);
+ })();
+ ]]>
+ </script>
+ </body>
+</html>
diff --git a/browser/extensions/formautofill/content/editDialog.js b/browser/extensions/formautofill/content/editDialog.js
new file mode 100644
index 0000000000..77dcbb2ae0
--- /dev/null
+++ b/browser/extensions/formautofill/content/editDialog.js
@@ -0,0 +1,239 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* exported EditAddressDialog, EditCreditCardDialog */
+/* eslint-disable mozilla/balanced-listeners */ // Not relevant since the document gets unloaded.
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ formAutofillStorage: "resource://autofill/FormAutofillStorage.sys.mjs",
+});
+ChromeUtils.defineModuleGetter(
+ this,
+ "AutofillTelemetry",
+ "resource://autofill/AutofillTelemetry.jsm"
+);
+
+class AutofillEditDialog {
+ constructor(subStorageName, elements, record) {
+ this._storageInitPromise = formAutofillStorage.initialize();
+ this._subStorageName = subStorageName;
+ this._elements = elements;
+ this._record = record;
+ this.localizeDocument();
+ window.addEventListener("DOMContentLoaded", this, { once: true });
+ }
+
+ async init() {
+ this.updateSaveButtonState();
+ this.attachEventListeners();
+ // For testing only: signal to tests that the dialog is ready for testing.
+ // This is likely no longer needed since retrieving from storage is fully
+ // handled in manageDialog.js now.
+ window.dispatchEvent(new CustomEvent("FormReady"));
+ }
+
+ /**
+ * Get storage and ensure it has been initialized.
+ *
+ * @returns {object}
+ */
+ async getStorage() {
+ await this._storageInitPromise;
+ return formAutofillStorage[this._subStorageName];
+ }
+
+ /**
+ * Asks FormAutofillParent to save or update an record.
+ *
+ * @param {object} record
+ * @param {string} guid [optional]
+ */
+ async saveRecord(record, guid) {
+ let storage = await this.getStorage();
+ if (guid) {
+ await storage.update(guid, record);
+ } else {
+ await storage.add(record);
+ }
+ }
+
+ /**
+ * Handle events
+ *
+ * @param {DOMEvent} event
+ */
+ handleEvent(event) {
+ switch (event.type) {
+ case "DOMContentLoaded": {
+ this.init();
+ break;
+ }
+ case "click": {
+ this.handleClick(event);
+ break;
+ }
+ case "input": {
+ this.handleInput(event);
+ break;
+ }
+ case "keypress": {
+ this.handleKeyPress(event);
+ break;
+ }
+ case "contextmenu": {
+ if (
+ !HTMLInputElement.isInstance(event.target) &&
+ !HTMLTextAreaElement.isInstance(event.target)
+ ) {
+ event.preventDefault();
+ }
+ break;
+ }
+ }
+ }
+
+ /**
+ * Handle click events
+ *
+ * @param {DOMEvent} event
+ */
+ handleClick(event) {
+ if (event.target == this._elements.cancel) {
+ window.close();
+ }
+ if (event.target == this._elements.save) {
+ this.handleSubmit();
+ }
+ }
+
+ /**
+ * Handle input events
+ *
+ * @param {DOMEvent} event
+ */
+ handleInput(event) {
+ this.updateSaveButtonState();
+ }
+
+ /**
+ * Handle key press events
+ *
+ * @param {DOMEvent} event
+ */
+ handleKeyPress(event) {
+ if (event.keyCode == KeyEvent.DOM_VK_ESCAPE) {
+ window.close();
+ }
+ }
+
+ updateSaveButtonState() {
+ // Toggle disabled attribute on the save button based on
+ // whether the form is filled or empty.
+ if (!Object.keys(this._elements.fieldContainer.buildFormObject()).length) {
+ this._elements.save.setAttribute("disabled", true);
+ } else {
+ this._elements.save.removeAttribute("disabled");
+ }
+ }
+
+ /**
+ * Attach event listener
+ */
+ attachEventListeners() {
+ window.addEventListener("keypress", this);
+ window.addEventListener("contextmenu", this);
+ this._elements.controlsContainer.addEventListener("click", this);
+ document.addEventListener("input", this);
+ }
+
+ // An interface to be inherited.
+ localizeDocument() {}
+
+ recordFormSubmit() {
+ let method = this._record?.guid ? "edit" : "add";
+ AutofillTelemetry.recordManageEvent(this.telemetryType, method);
+ }
+}
+
+class EditAddressDialog extends AutofillEditDialog {
+ telemetryType = AutofillTelemetry.ADDRESS;
+
+ constructor(elements, record) {
+ super("addresses", elements, record);
+ if (record) {
+ AutofillTelemetry.recordManageEvent(this.telemetryType, "show_entry");
+ }
+ }
+
+ localizeDocument() {
+ if (this._record?.guid) {
+ document.l10n.setAttributes(
+ this._elements.title,
+ "autofill-edit-address-title"
+ );
+ }
+ }
+
+ async handleSubmit() {
+ await this.saveRecord(
+ this._elements.fieldContainer.buildFormObject(),
+ this._record ? this._record.guid : null
+ );
+ this.recordFormSubmit();
+
+ window.close();
+ }
+}
+
+class EditCreditCardDialog extends AutofillEditDialog {
+ telemetryType = AutofillTelemetry.CREDIT_CARD;
+
+ constructor(elements, record) {
+ elements.fieldContainer._elements.billingAddress.disabled = true;
+ super("creditCards", elements, record);
+ elements.fieldContainer._elements.ccNumber.addEventListener(
+ "blur",
+ this._onCCNumberFieldBlur.bind(this)
+ );
+ if (record) {
+ AutofillTelemetry.recordManageEvent(this.telemetryType, "show_entry");
+ }
+ }
+
+ _onCCNumberFieldBlur() {
+ let elem = this._elements.fieldContainer._elements.ccNumber;
+ this._elements.fieldContainer.updateCustomValidity(elem);
+ }
+
+ localizeDocument() {
+ if (this._record?.guid) {
+ document.l10n.setAttributes(
+ this._elements.title,
+ "autofill-edit-card-title"
+ );
+ }
+ }
+
+ async handleSubmit() {
+ let creditCard = this._elements.fieldContainer.buildFormObject();
+ if (!this._elements.fieldContainer._elements.form.reportValidity()) {
+ return;
+ }
+
+ try {
+ await this.saveRecord(
+ creditCard,
+ this._record ? this._record.guid : null
+ );
+
+ this.recordFormSubmit();
+
+ window.close();
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+}
diff --git a/browser/extensions/formautofill/content/formautofill.css b/browser/extensions/formautofill/content/formautofill.css
new file mode 100644
index 0000000000..fad9ee410a
--- /dev/null
+++ b/browser/extensions/formautofill/content/formautofill.css
@@ -0,0 +1,54 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#PopupAutoComplete > richlistbox > richlistitem[originaltype="autofill-profile"],
+#PopupAutoComplete > richlistbox > richlistitem[originaltype="autofill-footer"],
+#PopupAutoComplete > richlistbox > richlistitem[originaltype="autofill-insecureWarning"],
+#PopupAutoComplete > richlistbox > richlistitem[originaltype="autofill-clear-button"] {
+ display: block;
+ margin: 0;
+ padding: 0;
+ height: auto;
+ min-height: auto;
+}
+
+/* Treat @collpased="true" as display: none similar to how it is for XUL elements.
+ * https://developer.mozilla.org/en-US/docs/Web/CSS/visibility#Values */
+#PopupAutoComplete > richlistbox > richlistitem[originaltype="autofill-profile"][collapsed="true"],
+#PopupAutoComplete > richlistbox > richlistitem[originaltype="autofill-footer"][collapsed="true"],
+#PopupAutoComplete > richlistbox > richlistitem[originaltype="autofill-insecureWarning"][collapsed="true"],
+#PopupAutoComplete > richlistbox > richlistitem[originaltype="autofill-clear-button"][collapsed="true"] {
+ display: none;
+}
+
+#PopupAutoComplete[resultstyles~="autofill-profile"] {
+ min-width: 150px !important;
+}
+
+#PopupAutoComplete[resultstyles~="autofill-insecureWarning"] {
+ min-width: 200px !important;
+}
+
+#PopupAutoComplete > richlistbox > richlistitem[disabled="true"] {
+ opacity: 0.5;
+}
+
+/* Form Autofill Doorhanger */
+#autofill-address-notification popupnotificationcontent > .desc-message-box,
+#autofill-credit-card-notification popupnotificationcontent > .desc-message-box {
+ margin-block-end: 12px;
+}
+#autofill-credit-card-notification popupnotificationcontent > .desc-message-box > image {
+ -moz-context-properties: fill;
+ fill: currentColor;
+ width: auto;
+ height: auto;
+ list-style-image: url(chrome://formautofill/content/icon-credit-card-generic.svg);
+}
+#autofill-address-notification popupnotificationcontent > .desc-message-box > description,
+#autofill-address-notification popupnotificationcontent > .desc-message-box > additional-description,
+#autofill-credit-card-notification popupnotificationcontent > .desc-message-box > description {
+ font-style: italic;
+ margin-inline-start: 4px;
+}
diff --git a/browser/extensions/formautofill/content/formfill-anchor.svg b/browser/extensions/formautofill/content/formfill-anchor.svg
new file mode 100644
index 0000000000..0a9ef19add
--- /dev/null
+++ b/browser/extensions/formautofill/content/formfill-anchor.svg
@@ -0,0 +1,8 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity">
+ <path d="M7.3 6h1.5c.1 0 .2-.1.2-.3V2c0-.5-.4-1-1-1s-1 .4-1 1v3.8c0 .1.1.2.3.2z"/>
+ <path d="M13.5 3H11c-.6 0-1 .4-1 1s.4 1 1 1h2.5c.3 0 .5.2.5.5v7c0 .3-.2.5-.5.5h-11c-.3 0-.5-.3-.5-.5v-7c0-.3.2-.5.5-.5H5c.6 0 1-.4 1-1s-.4-1-1-1H2.5C1.1 3 0 4.1 0 5.5v7C0 13.8 1.1 15 2.5 15h11c1.4 0 2.5-1.1 2.5-2.5v-7C16 4.1 14.9 3 13.5 3z"/>
+ <path d="M3.6 7h2.8c.3 0 .6.2.6.5v2.8c0 .4-.3.7-.6.7H3.6c-.3 0-.6-.3-.6-.6V7.5c0-.3.3-.5.6-.5zM9.5 8h3c.3 0 .5-.3.5-.5s-.2-.5-.5-.5h-3c-.3 0-.5.2-.5.5s.2.5.5.5zM9.5 9c-.3 0-.5.2-.5.5s.2.5.5.5h2c.3 0 .5-.2.5-.5s-.2-.5-.5-.5h-2z"/>
+</svg>
diff --git a/browser/extensions/formautofill/content/icon-address-save.svg b/browser/extensions/formautofill/content/icon-address-save.svg
new file mode 100644
index 0000000000..8fdcf1cd5f
--- /dev/null
+++ b/browser/extensions/formautofill/content/icon-address-save.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 32 32">
+ <path d="M22 13.7H9.4c-.6 0-1.2.5-1.2 1.2 0 .6.5 1.2 1.2 1.2H22c.6 0 1.2-.5 1.2-1.2s-.6-1.2-1.2-1.2zM6.1 26.6V5.5c0-.8.7-1.5 1.5-1.5h16c.9 0 1.5.6 1.5 1.5V16h2V3.8c0-1-.7-1.8-1.8-1.8H5.9c-1 0-1.8.8-1.8 1.8v24.5c0 1 .8 1.7 1.8 1.7h9.3v-2H7.6c-.8 0-1.5-.6-1.5-1.4zm21.1-1.9h-2.5V20c0-.4-.3-.8-.8-.8h-3.1c-.4 0-.8.3-.8.8v4.6h-2.5c-.6 0-.8.4-.3.8l4.3 4.2c.2.2.5.3.8.3s.6-.1.8-.3l4.3-4.2c.6-.4.4-.7-.2-.7zm-11.3-5.6H9.4c-.6 0-1.2.5-1.2 1.2s.5 1.2 1.2 1.2h6.5c.6 0 1.2-.5 1.2-1.2s-.6-1.2-1.2-1.2zM22 7.8H9.4c-.6 0-1.2.5-1.2 1.2s.5 1.2 1.2 1.2H22c.6 0 1.2-.5 1.2-1.2s-.6-1.2-1.2-1.2z"/>
+</svg>
diff --git a/browser/extensions/formautofill/content/icon-address-update.svg b/browser/extensions/formautofill/content/icon-address-update.svg
new file mode 100644
index 0000000000..1455423fed
--- /dev/null
+++ b/browser/extensions/formautofill/content/icon-address-update.svg
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 32 32">
+ <path d="M22 13.7H9.4c-.6 0-1.2.5-1.2 1.2 0 .6.5 1.2 1.2 1.2H22c.6 0 1.2-.5 1.2-1.2s-.6-1.2-1.2-1.2zM6.1 26.6V5.5c0-.8.7-1.5 1.5-1.5h16c.9 0 1.5.6 1.5 1.5V16h2V3.8c0-1-.7-1.8-1.8-1.8H5.9c-1 0-1.8.8-1.8 1.8v24.5c0 1 .8 1.7 1.8 1.7h9.3v-2H7.6c-.8 0-1.5-.6-1.5-1.4zm9.8-7.5H9.4c-.6 0-1.2.5-1.2 1.2s.5 1.2 1.2 1.2h6.5c.6 0 1.2-.5 1.2-1.2s-.6-1.2-1.2-1.2zM22 7.8H9.4c-.6 0-1.2.5-1.2 1.2s.5 1.2 1.2 1.2H22c.6 0 1.2-.5 1.2-1.2s-.6-1.2-1.2-1.2zm-5.7 16l4.4-4.3c.2-.2.5-.3.8-.3s.6.1.8.3l4.4 4.3c.5.5.3.8-.3.8h-2.6v4.7c0 .4-.4.8-.8.8h-3c-.4 0-.8-.4-.8-.8v-4.7h-2.5c-.7 0-.8-.4-.4-.8z"/>
+</svg>
diff --git a/browser/extensions/formautofill/content/icon-credit-card-generic.svg b/browser/extensions/formautofill/content/icon-credit-card-generic.svg
new file mode 100644
index 0000000000..5d554fe7ce
--- /dev/null
+++ b/browser/extensions/formautofill/content/icon-credit-card-generic.svg
@@ -0,0 +1,8 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg height="16" width="16" xmlns="http://www.w3.org/2000/svg" fill="context-fill" viewBox="0 0 16 16">
+ <path d="M4.5,9.4H3.2c-0.3,0-0.5,0.2-0.5,0.5s0.2,0.5,0.5,0.5h1.3c0.3,0,0.5-0.2,0.5-0.5S4.8,9.4,4.5,9.4z"/>
+ <path d="M9.3,9.4H6.2c-0.3,0-0.5,0.2-0.5,0.5s0.2,0.5,0.5,0.5h3.2c0.3,0,0.5-0.2,0.5-0.5S9.6,9.4,9.3,9.4z"/>
+ <path d="M14,2H2C0.9,2,0,2.9,0,4v8c0,1.1,0.9,2,2,2h12c1.1,0,2-0.9,2-2V4C16,2.9,15.1,2,14,2z M14,12H2V7.7h12V12z M14,6H2V4h12V6z"/>
+</svg>
diff --git a/browser/extensions/formautofill/content/icon-credit-card.svg b/browser/extensions/formautofill/content/icon-credit-card.svg
new file mode 100644
index 0000000000..7ec782f880
--- /dev/null
+++ b/browser/extensions/formautofill/content/icon-credit-card.svg
@@ -0,0 +1,8 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" fill="context-fill" fill-opacity="context-fill-opacity" viewBox="0 0 32 32">
+ <path d="M9 22.2H6.4c-.6 0-1 .4-1 1s.4 1 1 1H9c.6 0 1-.4 1-1s-.4-1-1-1z"/>
+ <path d="M28 7.6v8H4v-4h10v-4H4c-2.2 0-4 1.8-4 4v16c0 2.2 1.8 4 4 4h24c2.2 0 4-1.8 4-4v-16c0-2.2-1.8-4-4-4zm-24 20V19h24v8.6H4z"/>
+ <path d="M19.2 22.2h-6.3c-.6 0-1 .4-1 1s.4 1 1 1h6.3c.6 0 1-.4 1-1s-.5-1-1-1zM16.3 7.9c-.4.4-.4 1 0 1.4l4 4c.4.4 1 .4 1.4 0l4-4c.4-.4.4-1 0-1.4s-1-.4-1.4 0L22 10.2v-9c0-.5-.4-1-1-1-.5 0-1 .4-1 1v9l-2.3-2.3c-.4-.4-1-.4-1.4 0z"/>
+</svg>
diff --git a/browser/extensions/formautofill/content/manageAddresses.xhtml b/browser/extensions/formautofill/content/manageAddresses.xhtml
new file mode 100644
index 0000000000..68e810179e
--- /dev/null
+++ b/browser/extensions/formautofill/content/manageAddresses.xhtml
@@ -0,0 +1,54 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<!DOCTYPE html>
+<html
+ xmlns="http://www.w3.org/1999/xhtml"
+ data-l10n-id="autofill-manage-dialog"
+ data-l10n-attrs="style"
+>
+ <head>
+ <title data-l10n-id="autofill-manage-addresses-title"></title>
+ <link rel="localization" href="browser/preferences/formAutofill.ftl" />
+ <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" />
+ <link
+ rel="stylesheet"
+ href="chrome://formautofill/content/manageDialog.css"
+ />
+ <script src="chrome://formautofill/content/manageDialog.js"></script>
+ </head>
+ <body>
+ <fieldset>
+ <legend data-l10n-id="autofill-manage-addresses-list-header" />
+ <select id="addresses" size="9" multiple="multiple" />
+ </fieldset>
+ <div id="controls-container">
+ <button
+ id="remove"
+ disabled="disabled"
+ data-l10n-id="autofill-manage-remove-button"
+ />
+ <!-- Wrapper is used to properly compute the search tooltip position -->
+ <div>
+ <button id="add" data-l10n-id="autofill-manage-add-button" />
+ </div>
+ <button
+ id="edit"
+ disabled="disabled"
+ data-l10n-id="autofill-manage-edit-button"
+ />
+ </div>
+ <script>
+ "use strict";
+ /* global ManageAddresses */
+ new ManageAddresses({
+ records: document.getElementById("addresses"),
+ controlsContainer: document.getElementById("controls-container"),
+ remove: document.getElementById("remove"),
+ add: document.getElementById("add"),
+ edit: document.getElementById("edit"),
+ });
+ </script>
+ </body>
+</html>
diff --git a/browser/extensions/formautofill/content/manageCreditCards.xhtml b/browser/extensions/formautofill/content/manageCreditCards.xhtml
new file mode 100644
index 0000000000..3e5bdcfbf5
--- /dev/null
+++ b/browser/extensions/formautofill/content/manageCreditCards.xhtml
@@ -0,0 +1,55 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<!DOCTYPE html>
+<html
+ xmlns="http://www.w3.org/1999/xhtml"
+ data-l10n-id="autofill-manage-dialog"
+ data-l10n-attrs="style"
+>
+ <head>
+ <title data-l10n-id="autofill-manage-credit-cards-title"></title>
+ <link rel="localization" href="browser/preferences/formAutofill.ftl" />
+ <link rel="localization" href="toolkit/payments/payments.ftl" />
+ <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" />
+ <link
+ rel="stylesheet"
+ href="chrome://formautofill/content/manageDialog.css"
+ />
+ <script src="chrome://formautofill/content/manageDialog.js"></script>
+ </head>
+ <body>
+ <fieldset>
+ <legend data-l10n-id="autofill-manage-credit-cards-list-header" />
+ <select id="credit-cards" size="9" multiple="multiple" />
+ </fieldset>
+ <div id="controls-container">
+ <button
+ id="remove"
+ disabled="disabled"
+ data-l10n-id="autofill-manage-remove-button"
+ />
+ <!-- Wrapper is used to properly compute the search tooltip position -->
+ <div>
+ <button id="add" data-l10n-id="autofill-manage-add-button" />
+ </div>
+ <button
+ id="edit"
+ disabled="disabled"
+ data-l10n-id="autofill-manage-edit-button"
+ />
+ </div>
+ <script>
+ "use strict";
+ /* global ManageCreditCards */
+ new ManageCreditCards({
+ records: document.getElementById("credit-cards"),
+ controlsContainer: document.getElementById("controls-container"),
+ remove: document.getElementById("remove"),
+ add: document.getElementById("add"),
+ edit: document.getElementById("edit"),
+ });
+ </script>
+ </body>
+</html>
diff --git a/browser/extensions/formautofill/content/manageDialog.css b/browser/extensions/formautofill/content/manageDialog.css
new file mode 100644
index 0000000000..f347c79118
--- /dev/null
+++ b/browser/extensions/formautofill/content/manageDialog.css
@@ -0,0 +1,125 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+html {
+ /* Prevent unnecessary horizontal scroll bar from showing */
+ overflow-x: hidden;
+}
+
+div {
+ display: flex;
+}
+
+button {
+ padding-inline: 10px;
+}
+
+fieldset {
+ margin: 0 4px;
+ padding: 0;
+ border: none;
+}
+
+fieldset > legend {
+ box-sizing: border-box;
+ width: 100%;
+ padding: 0.4em 0.7em;
+ background-color: var(--in-content-box-background);
+ border: 1px solid var(--in-content-box-border-color);
+ border-radius: 2px 2px 0 0;
+ user-select: none;
+}
+
+option:nth-child(even) {
+ background-color: var(--in-content-box-background-odd);
+}
+
+#addresses,
+#credit-cards {
+ width: 100%;
+ height: 16.6em;
+ margin: 0;
+ padding-inline: 0;
+ border-top: none;
+ border-radius: 0 0 2px 2px;
+}
+
+#addresses > option,
+#credit-cards > option {
+ display: flex;
+ align-items: center;
+ height: 1.6em;
+ padding-inline-start: 0.6em;
+}
+
+#controls-container {
+ margin-top: 1em;
+}
+
+#remove {
+ margin-inline-end: auto;
+}
+
+#credit-cards > option::before {
+ content: "";
+ background: url("icon-credit-card-generic.svg") no-repeat;
+ background-size: contain;
+ float: inline-start;
+ width: 16px;
+ height: 16px;
+ padding-inline-end: 10px;
+ -moz-context-properties: fill;
+ fill: currentColor;
+}
+
+/*
+ We use .png / @2x.png images where we don't yet have a vector version of a logo
+*/
+#credit-cards.branded > option[cc-type="amex"]::before {
+ background-image: url("third-party/cc-logo-amex.png");
+}
+
+#credit-cards.branded > option[cc-type="cartebancaire"]::before {
+ background-image: url("third-party/cc-logo-cartebancaire.png");
+}
+
+#credit-cards.branded > option[cc-type="diners"]::before {
+ background-image: url("third-party/cc-logo-diners.svg");
+}
+
+#credit-cards.branded > option[cc-type="discover"]::before {
+ background-image: url("third-party/cc-logo-discover.png");
+}
+
+#credit-cards.branded > option[cc-type="jcb"]::before {
+ background-image: url("third-party/cc-logo-jcb.svg");
+}
+
+#credit-cards.branded > option[cc-type="mastercard"]::before {
+ background-image: url("third-party/cc-logo-mastercard.svg");
+}
+
+#credit-cards.branded > option[cc-type="mir"]::before {
+ background-image: url("third-party/cc-logo-mir.svg");
+}
+
+#credit-cards.branded > option[cc-type="unionpay"]::before {
+ background-image: url("third-party/cc-logo-unionpay.svg");
+}
+
+#credit-cards.branded > option[cc-type="visa"]::before {
+ background-image: url("third-party/cc-logo-visa.svg");
+}
+
+@media (min-resolution: 1.1dppx) {
+ #credit-cards.branded > option[cc-type="amex"]::before {
+ background-image: url("third-party/cc-logo-amex@2x.png");
+ }
+ #credit-cards.branded > option[cc-type="cartebancaire"]::before {
+ background-image: url("third-party/cc-logo-cartebancaire@2x.png");
+ }
+ #credit-cards.branded > option[cc-type="discover"]::before {
+ background-image: url("third-party/cc-logo-discover@2x.png");
+ }
+}
diff --git a/browser/extensions/formautofill/content/manageDialog.js b/browser/extensions/formautofill/content/manageDialog.js
new file mode 100644
index 0000000000..25498fbcaf
--- /dev/null
+++ b/browser/extensions/formautofill/content/manageDialog.js
@@ -0,0 +1,464 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* exported ManageAddresses, ManageCreditCards */
+
+"use strict";
+
+const EDIT_ADDRESS_URL = "chrome://formautofill/content/editAddress.xhtml";
+const EDIT_CREDIT_CARD_URL =
+ "chrome://formautofill/content/editCreditCard.xhtml";
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+const { FormAutofill } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofill.sys.mjs"
+);
+const { AutofillTelemetry } = ChromeUtils.import(
+ "resource://autofill/AutofillTelemetry.jsm"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ CreditCard: "resource://gre/modules/CreditCard.sys.mjs",
+ FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs",
+ OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs",
+ formAutofillStorage: "resource://autofill/FormAutofillStorage.sys.mjs",
+});
+
+const lazy = {};
+XPCOMUtils.defineLazyGetter(
+ lazy,
+ "l10n",
+ () =>
+ new Localization([
+ "browser/preferences/formAutofill.ftl",
+ "branding/brand.ftl",
+ ])
+);
+
+this.log = null;
+XPCOMUtils.defineLazyGetter(this, "log", () =>
+ FormAutofill.defineLogGetter(this, "manageAddresses")
+);
+
+class ManageRecords {
+ constructor(subStorageName, elements) {
+ this._storageInitPromise = formAutofillStorage.initialize();
+ this._subStorageName = subStorageName;
+ this._elements = elements;
+ this._newRequest = false;
+ this._isLoadingRecords = false;
+ this.prefWin = window.opener;
+ window.addEventListener("DOMContentLoaded", this, { once: true });
+ }
+
+ async init() {
+ await this.loadRecords();
+ this.attachEventListeners();
+ // For testing only: Notify when the dialog is ready for interaction
+ window.dispatchEvent(new CustomEvent("FormReady"));
+ }
+
+ uninit() {
+ log.debug("uninit");
+ this.detachEventListeners();
+ this._elements = null;
+ }
+
+ /**
+ * Get the selected options on the addresses element.
+ *
+ * @returns {Array<DOMElement>}
+ */
+ get _selectedOptions() {
+ return Array.from(this._elements.records.selectedOptions);
+ }
+
+ /**
+ * Get storage and ensure it has been initialized.
+ *
+ * @returns {object}
+ */
+ async getStorage() {
+ await this._storageInitPromise;
+ return formAutofillStorage[this._subStorageName];
+ }
+
+ /**
+ * Load records and render them. This function is a wrapper for _loadRecords
+ * to ensure any reentrant will be handled well.
+ */
+ async loadRecords() {
+ // This function can be early returned when there is any reentrant happends.
+ // "_newRequest" needs to be set to ensure all changes will be applied.
+ if (this._isLoadingRecords) {
+ this._newRequest = true;
+ return;
+ }
+ this._isLoadingRecords = true;
+
+ await this._loadRecords();
+
+ // _loadRecords should be invoked again if there is any multiple entrant
+ // during running _loadRecords(). This step ensures that the latest request
+ // still is applied.
+ while (this._newRequest) {
+ this._newRequest = false;
+ await this._loadRecords();
+ }
+ this._isLoadingRecords = false;
+
+ // For testing only: Notify when records are loaded
+ this._elements.records.dispatchEvent(new CustomEvent("RecordsLoaded"));
+ }
+
+ async _loadRecords() {
+ let storage = await this.getStorage();
+ let records = await storage.getAll();
+ // Sort by last used time starting with most recent
+ records.sort((a, b) => {
+ let aLastUsed = a.timeLastUsed || a.timeLastModified;
+ let bLastUsed = b.timeLastUsed || b.timeLastModified;
+ return bLastUsed - aLastUsed;
+ });
+ await this.renderRecordElements(records);
+ this.updateButtonsStates(this._selectedOptions.length);
+ }
+
+ /**
+ * Render the records onto the page while maintaining selected options if
+ * they still exist.
+ *
+ * @param {Array<object>} records
+ */
+ async renderRecordElements(records) {
+ let selectedGuids = this._selectedOptions.map(option => option.value);
+ this.clearRecordElements();
+ for (let record of records) {
+ let { id, args, raw } = await this.getLabelInfo(record);
+ let option = new Option(
+ raw ?? "",
+ record.guid,
+ false,
+ selectedGuids.includes(record.guid)
+ );
+ if (id) {
+ document.l10n.setAttributes(option, id, args);
+ }
+
+ option.record = record;
+ this._elements.records.appendChild(option);
+ }
+ }
+
+ /**
+ * Remove all existing record elements.
+ */
+ clearRecordElements() {
+ let parent = this._elements.records;
+ while (parent.lastChild) {
+ parent.removeChild(parent.lastChild);
+ }
+ }
+
+ /**
+ * Remove records by selected options.
+ *
+ * @param {Array<DOMElement>} options
+ */
+ async removeRecords(options) {
+ let storage = await this.getStorage();
+ // Pause listening to storage change event to avoid triggering `loadRecords`
+ // when removing records
+ Services.obs.removeObserver(this, "formautofill-storage-changed");
+
+ for (let option of options) {
+ storage.remove(option.value);
+ option.remove();
+ }
+ this.updateButtonsStates(this._selectedOptions);
+
+ // Resume listening to storage change event
+ Services.obs.addObserver(this, "formautofill-storage-changed");
+ // For testing only: notify record(s) has been removed
+ this._elements.records.dispatchEvent(new CustomEvent("RecordsRemoved"));
+
+ for (let i = 0; i < options.length; i++) {
+ AutofillTelemetry.recordManageEvent(this.telemetryType, "delete");
+ }
+ }
+
+ /**
+ * Enable/disable the Edit and Remove buttons based on number of selected
+ * options.
+ *
+ * @param {number} selectedCount
+ */
+ updateButtonsStates(selectedCount) {
+ log.debug("updateButtonsStates:", selectedCount);
+ if (selectedCount == 0) {
+ this._elements.edit.setAttribute("disabled", "disabled");
+ this._elements.remove.setAttribute("disabled", "disabled");
+ } else if (selectedCount == 1) {
+ this._elements.edit.removeAttribute("disabled");
+ this._elements.remove.removeAttribute("disabled");
+ } else if (selectedCount > 1) {
+ this._elements.edit.setAttribute("disabled", "disabled");
+ this._elements.remove.removeAttribute("disabled");
+ }
+ }
+
+ /**
+ * Handle events
+ *
+ * @param {DOMEvent} event
+ */
+ handleEvent(event) {
+ switch (event.type) {
+ case "DOMContentLoaded": {
+ this.init();
+ break;
+ }
+ case "click": {
+ this.handleClick(event);
+ break;
+ }
+ case "change": {
+ this.updateButtonsStates(this._selectedOptions.length);
+ break;
+ }
+ case "unload": {
+ this.uninit();
+ break;
+ }
+ case "keypress": {
+ this.handleKeyPress(event);
+ break;
+ }
+ case "contextmenu": {
+ event.preventDefault();
+ break;
+ }
+ }
+ }
+
+ /**
+ * Handle click events
+ *
+ * @param {DOMEvent} event
+ */
+ handleClick(event) {
+ if (event.target == this._elements.remove) {
+ this.removeRecords(this._selectedOptions);
+ } else if (event.target == this._elements.add) {
+ this.openEditDialog();
+ } else if (
+ event.target == this._elements.edit ||
+ (event.target.parentNode == this._elements.records && event.detail > 1)
+ ) {
+ this.openEditDialog(this._selectedOptions[0].record);
+ }
+ }
+
+ /**
+ * Handle key press events
+ *
+ * @param {DOMEvent} event
+ */
+ handleKeyPress(event) {
+ if (event.keyCode == KeyEvent.DOM_VK_ESCAPE) {
+ window.close();
+ }
+ if (event.keyCode == KeyEvent.DOM_VK_DELETE) {
+ this.removeRecords(this._selectedOptions);
+ }
+ }
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case "formautofill-storage-changed": {
+ this.loadRecords();
+ }
+ }
+ }
+
+ /**
+ * Attach event listener
+ */
+ attachEventListeners() {
+ window.addEventListener("unload", this, { once: true });
+ window.addEventListener("keypress", this);
+ window.addEventListener("contextmenu", this);
+ this._elements.records.addEventListener("change", this);
+ this._elements.records.addEventListener("click", this);
+ this._elements.controlsContainer.addEventListener("click", this);
+ Services.obs.addObserver(this, "formautofill-storage-changed");
+ }
+
+ /**
+ * Remove event listener
+ */
+ detachEventListeners() {
+ window.removeEventListener("keypress", this);
+ window.removeEventListener("contextmenu", this);
+ this._elements.records.removeEventListener("change", this);
+ this._elements.records.removeEventListener("click", this);
+ this._elements.controlsContainer.removeEventListener("click", this);
+ Services.obs.removeObserver(this, "formautofill-storage-changed");
+ }
+}
+
+class ManageAddresses extends ManageRecords {
+ telemetryType = AutofillTelemetry.ADDRESS;
+
+ constructor(elements) {
+ super("addresses", elements);
+ elements.add.setAttribute(
+ "search-l10n-ids",
+ FormAutofillUtils.EDIT_ADDRESS_L10N_IDS.join(",")
+ );
+ AutofillTelemetry.recordManageEvent(this.telemetryType, "show");
+ }
+
+ /**
+ * Open the edit address dialog to create/edit an address.
+ *
+ * @param {object} address [optional]
+ */
+ openEditDialog(address) {
+ this.prefWin.gSubDialog.open(EDIT_ADDRESS_URL, undefined, {
+ record: address,
+ // Don't validate in preferences since it's fine for fields to be missing
+ // for autofill purposes. For PaymentRequest addresses get more validation.
+ noValidate: true,
+ });
+ }
+
+ getLabelInfo(address) {
+ return { raw: FormAutofillUtils.getAddressLabel(address) };
+ }
+}
+
+class ManageCreditCards extends ManageRecords {
+ telemetryType = AutofillTelemetry.CREDIT_CARD;
+
+ constructor(elements) {
+ super("creditCards", elements);
+ elements.add.setAttribute(
+ "search-l10n-ids",
+ FormAutofillUtils.EDIT_CREDITCARD_L10N_IDS.join(",")
+ );
+
+ this._isDecrypted = false;
+ AutofillTelemetry.recordManageEvent(this.telemetryType, "show");
+ }
+
+ /**
+ * Open the edit address dialog to create/edit a credit card.
+ *
+ * @param {object} creditCard [optional]
+ */
+ async openEditDialog(creditCard) {
+ // Ask for reauth if user is trying to edit an existing credit card.
+ if (creditCard) {
+ const reauthPasswordPromptMessage = await lazy.l10n.formatValue(
+ "autofill-edit-card-password-prompt"
+ );
+ const loggedIn = await FormAutofillUtils.ensureLoggedIn(
+ reauthPasswordPromptMessage
+ );
+ if (!loggedIn.authenticated) {
+ return;
+ }
+ }
+
+ let decryptedCCNumObj = {};
+ if (creditCard && creditCard["cc-number-encrypted"]) {
+ try {
+ decryptedCCNumObj["cc-number"] = await OSKeyStore.decrypt(
+ creditCard["cc-number-encrypted"]
+ );
+ } catch (ex) {
+ if (ex.result == Cr.NS_ERROR_ABORT) {
+ // User shouldn't be ask to reauth here, but it could happen.
+ // Return here and skip opening the dialog.
+ return;
+ }
+ // We've got ourselves a real error.
+ // Recover from encryption error so the user gets a chance to re-enter
+ // unencrypted credit card number.
+ decryptedCCNumObj["cc-number"] = "";
+ console.error(ex);
+ }
+ }
+ let decryptedCreditCard = Object.assign({}, creditCard, decryptedCCNumObj);
+ this.prefWin.gSubDialog.open(
+ EDIT_CREDIT_CARD_URL,
+ { features: "resizable=no" },
+ {
+ record: decryptedCreditCard,
+ }
+ );
+ }
+
+ /**
+ * Get credit card display label. It should display masked numbers and the
+ * cardholder's name, separated by a comma.
+ *
+ * @param {object} creditCard
+ * @returns {Promise<string>}
+ */
+ async getLabelInfo(creditCard) {
+ // The card type is displayed visually using an image. For a11y, we need
+ // to expose it as text. We do this using aria-label. However,
+ // aria-label overrides the text content, so we must include that also.
+ // Since the text content is generated by Fluent, aria-label must be
+ // generated by Fluent also.
+ const type = creditCard["cc-type"];
+ const typeL10nId = CreditCard.getNetworkL10nId(type);
+ const typeName = typeL10nId
+ ? await document.l10n.formatValue(typeL10nId)
+ : type ?? ""; // Unknown card type
+ return CreditCard.getLabelInfo({
+ name: creditCard["cc-name"],
+ number: creditCard["cc-number"],
+ month: creditCard["cc-exp-month"],
+ year: creditCard["cc-exp-year"],
+ type: typeName,
+ });
+ }
+
+ async renderRecordElements(records) {
+ // Revert back to encrypted form when re-rendering happens
+ this._isDecrypted = false;
+ // Display third-party card icons when possible
+ this._elements.records.classList.toggle(
+ "branded",
+ AppConstants.MOZILLA_OFFICIAL
+ );
+ await super.renderRecordElements(records);
+
+ let options = this._elements.records.options;
+ for (let option of options) {
+ let record = option.record;
+ if (record && record["cc-type"]) {
+ option.setAttribute("cc-type", record["cc-type"]);
+ } else {
+ option.removeAttribute("cc-type");
+ }
+ }
+ }
+
+ updateButtonsStates(selectedCount) {
+ super.updateButtonsStates(selectedCount);
+ }
+
+ handleClick(event) {
+ super.handleClick(event);
+ }
+}
diff --git a/browser/extensions/formautofill/content/third-party/cc-logo-amex.png b/browser/extensions/formautofill/content/third-party/cc-logo-amex.png
new file mode 100644
index 0000000000..c51a5be4a0
--- /dev/null
+++ b/browser/extensions/formautofill/content/third-party/cc-logo-amex.png
Binary files differ
diff --git a/browser/extensions/formautofill/content/third-party/cc-logo-amex@2x.png b/browser/extensions/formautofill/content/third-party/cc-logo-amex@2x.png
new file mode 100644
index 0000000000..f794641f3e
--- /dev/null
+++ b/browser/extensions/formautofill/content/third-party/cc-logo-amex@2x.png
Binary files differ
diff --git a/browser/extensions/formautofill/content/third-party/cc-logo-cartebancaire.png b/browser/extensions/formautofill/content/third-party/cc-logo-cartebancaire.png
new file mode 100644
index 0000000000..781c6e4958
--- /dev/null
+++ b/browser/extensions/formautofill/content/third-party/cc-logo-cartebancaire.png
Binary files differ
diff --git a/browser/extensions/formautofill/content/third-party/cc-logo-cartebancaire@2x.png b/browser/extensions/formautofill/content/third-party/cc-logo-cartebancaire@2x.png
new file mode 100644
index 0000000000..38158846dd
--- /dev/null
+++ b/browser/extensions/formautofill/content/third-party/cc-logo-cartebancaire@2x.png
Binary files differ
diff --git a/browser/extensions/formautofill/content/third-party/cc-logo-diners.svg b/browser/extensions/formautofill/content/third-party/cc-logo-diners.svg
new file mode 100644
index 0000000000..9cc4d8b9ff
--- /dev/null
+++ b/browser/extensions/formautofill/content/third-party/cc-logo-diners.svg
@@ -0,0 +1 @@
+<svg width="36" height="30" xmlns="http://www.w3.org/2000/svg"><g fill-rule="nonzero" fill="none"><path d="M19.863 20.068c4.698.022 8.987-3.839 8.987-8.536 0-5.137-4.289-8.688-8.987-8.686h-4.044c-4.755-.002-8.669 3.55-8.669 8.686 0 4.698 3.914 8.559 8.669 8.536h4.044z" fill="#4186CD"/><path d="M15.76 3.535a7.923 7.923 0 0 0 0 15.844 7.923 7.923 0 0 0 0-15.844zm-4.821 7.75c.004-2.122 1.288-3.931 3.1-4.65v9.3c-1.812-.719-3.096-2.527-3.1-4.65zm6.544 4.65v-9.3c1.811.717 3.097 2.527 3.1 4.65-.003 2.123-1.289 3.931-3.1 4.65z" fill="#FFF"/><g fill="#211E1F"><path d="M.65 22.925c0-.71-.375-.663-.733-.671v-.205c.31.015.63.015.94.015.336 0 .79-.015 1.381-.015 2.065 0 3.19 1.365 3.19 2.763 0 .782-.462 2.748-3.286 2.748-.407 0-.782-.016-1.157-.016-.358 0-.71.008-1.068.016v-.205c.478-.048.71-.064.733-.6v-3.83zm.644 3.636c0 .586.437.654.825.654 1.713 0 2.275-1.24 2.275-2.373 0-1.422-.951-2.449-2.48-2.449-.326 0-.476.022-.62.03v4.138zM5.428 27.364h.152c.225 0 .387 0 .387-.25v-2.041c0-.332-.121-.378-.419-.528v-.12c.378-.107.83-.249.861-.272a.301.301 0 0 1 .145-.038c.04 0 .057.046.057.106v2.893c0 .25.177.25.402.25h.137v.196c-.274 0-.556-.015-.845-.015-.29 0-.58.007-.877.015v-.196zm.689-4.627a.36.36 0 0 1-.345-.35c0-.177.169-.338.345-.338.182 0 .344.148.344.337 0 .19-.155.351-.344.351zM7.993 25.117c0-.278-.084-.353-.438-.496v-.143c.325-.106.634-.204.996-.363.022 0 .045.016.045.076v.49c.43-.309.8-.566 1.307-.566.64 0 .867.468.867 1.055v1.944c0 .25.166.25.377.25h.136v.196c-.265 0-.528-.015-.8-.015s-.544.007-.815.015v-.196h.136c.211 0 .362 0 .362-.25v-1.95c0-.43-.263-.642-.694-.642-.241 0-.626.196-.876.362v2.23c0 .25.166.25.378.25h.136v.196c-.264 0-.529-.015-.8-.015-.272 0-.544.007-.816.015v-.196h.137c.21 0 .362 0 .362-.25v-1.997zM11.943 25.569c-.017.072-.017.192 0 .465.049.762.553 1.388 1.212 1.388.453 0 .809-.24 1.113-.537l.115.113c-.38.489-.849.906-1.525.906-1.31 0-1.575-1.236-1.575-1.75 0-1.573 1.089-2.039 1.665-2.039.668 0 1.386.41 1.394 1.26 0 .05 0 .097-.008.145l-.074.049h-2.317zm1.514-.42c.212 0 .237-.077.237-.147 0-.3-.264-.542-.742-.542-.52 0-.877.264-.98.689h1.485zM14.383 27.364h.191c.198 0 .34 0 .34-.25v-2.117c0-.233-.262-.279-.368-.339v-.113c.516-.234.799-.43.863-.43.042 0 .063.023.063.099v.678h.015c.176-.294.474-.777.905-.777.176 0 .402.128.402.4 0 .203-.133.385-.331.385-.22 0-.22-.182-.468-.182-.12 0-.516.174-.516.626v1.77c0 .25.142.25.34.25h.395v.196c-.389-.008-.684-.015-.99-.015-.289 0-.586.007-.84.015v-.196zM17.282 26.668c.102.53.418.98.996.98.465 0 .64-.29.64-.57 0-.948-1.724-.643-1.724-1.935 0-.45.357-1.028 1.226-1.028.252 0 .592.073.9.234l.056.818h-.182c-.079-.505-.355-.795-.862-.795-.316 0-.616.185-.616.53 0 .94 1.834.65 1.834 1.91 0 .53-.42 1.092-1.36 1.092a2.06 2.06 0 0 1-.964-.272l-.087-.924.143-.04zM26.431 23.625h-.192c-.147-.94-.786-1.318-1.649-1.318-.886 0-2.173.618-2.173 2.548 0 1.626 1.11 2.792 2.296 2.792.763 0 1.395-.547 1.55-1.392l.176.048-.177 1.175c-.323.21-1.194.426-1.703.426-1.802 0-2.942-1.214-2.942-3.024 0-1.649 1.41-2.831 2.92-2.831.623 0 1.224.21 1.817.427l.077 1.15zM26.783 27.36h.153c.226 0 .387 0 .387-.253v-4.268c0-.498-.12-.514-.427-.598v-.123c.322-.099.66-.237.83-.33.087-.045.152-.084.176-.084.05 0 .065.046.065.108v5.295c0 .254.177.254.403.254h.136v.199c-.273 0-.555-.016-.845-.016-.29 0-.58.008-.878.016v-.2zM31.775 27.032c0 .136.084.143.214.143.092 0 .206-.007.305-.007v.159c-.328.03-.955.188-1.1.233l-.038-.023v-.61c-.458.37-.81.633-1.353.633-.412 0-.84-.264-.84-.897v-1.93c0-.196-.03-.384-.457-.422v-.143c.275-.007.885-.053.984-.053.085 0 .085.053.085.219v1.944c0 .226 0 .874.664.874.26 0 .604-.195.924-.458v-2.029c0-.15-.366-.233-.64-.309v-.135c.686-.046 1.115-.106 1.19-.106.062 0 .062.053.062.136v2.78zM33.372 24.72c.323-.27.76-.572 1.206-.572.94 0 1.505.804 1.505 1.671 0 1.042-.776 2.085-1.935 2.085-.599 0-.914-.191-1.125-.278l-.243.182-.169-.087a9.26 9.26 0 0 0 .113-1.416v-3.422c0-.518-.122-.534-.43-.621v-.128c.325-.103.664-.246.834-.342.09-.048.154-.088.18-.088.047 0 .064.048.064.112v2.905zm-.044 2.032c0 .301.291.808.834.808.868 0 1.232-.831 1.232-1.535 0-.854-.664-1.565-1.296-1.565-.3 0-.552.19-.77.372v1.92z"/></g></g></svg> \ No newline at end of file
diff --git a/browser/extensions/formautofill/content/third-party/cc-logo-discover.png b/browser/extensions/formautofill/content/third-party/cc-logo-discover.png
new file mode 100644
index 0000000000..104f9ee2d6
--- /dev/null
+++ b/browser/extensions/formautofill/content/third-party/cc-logo-discover.png
Binary files differ
diff --git a/browser/extensions/formautofill/content/third-party/cc-logo-discover@2x.png b/browser/extensions/formautofill/content/third-party/cc-logo-discover@2x.png
new file mode 100644
index 0000000000..1caaa01995
--- /dev/null
+++ b/browser/extensions/formautofill/content/third-party/cc-logo-discover@2x.png
Binary files differ
diff --git a/browser/extensions/formautofill/content/third-party/cc-logo-jcb.svg b/browser/extensions/formautofill/content/third-party/cc-logo-jcb.svg
new file mode 100644
index 0000000000..5cdbb027f8
--- /dev/null
+++ b/browser/extensions/formautofill/content/third-party/cc-logo-jcb.svg
@@ -0,0 +1 @@
+<svg width="30" height="30" xmlns="http://www.w3.org/2000/svg"><defs><path d="M3.622.575C1.734.575.009 2.278.009 4.188c0 1.051 0 5.212-.002 9.348.346.217 2.01.752 2.68.799 1.466.103 2.375-.381 2.515-1.603l-.007-4.538h3.243v4.434c-.17 2.54-2.399 3.057-5.79 2.887-.877-.046-2.07-.27-2.64-.42L.004 22.94H5.65c1.54 0 3.544-1.439 3.544-3.627V.575H3.622z" id="a"/><linearGradient x1="-.003%" y1="49.999%" x2="100.002%" y2="49.999%" id="b"><stop stop-color="#313477" offset="0%"/><stop stop-color="#0077BC" offset="100%"/></linearGradient><path d="M0 1.564l.007.001V.007L0 .002v1.562z" id="d"/><linearGradient x1="0%" y1="50.019%" x2="1.21%" y2="50.019%" id="e"><stop stop-color="#313477" offset="0%"/><stop stop-color="#0077BC" offset="100%"/></linearGradient><path d="M3.976.575C2.088.575.363 2.278.363 4.188v4.945c1.132-.885 2.958-1.319 5.281-1.14 1.322.102 2.3.286 2.834.445v1.57c-.588-.294-1.748-.73-2.715-.8-2.191-.158-3.342.868-3.342 2.528 0 1.494.888 2.773 3.331 2.602.806-.056 2.148-.525 2.719-.797l.007 1.523c-.492.155-2.02.488-3.458.5-2.165.017-3.694-.443-4.659-1.189L.36 22.941h5.643c1.54 0 3.546-1.439 3.546-3.627V.575H3.976z" id="g"/><linearGradient x1=".004%" y1="49.999%" x2="99.996%" y2="49.999%" id="h"><stop stop-color="#753136" offset="0%"/><stop stop-color="#ED1746" offset="100%"/></linearGradient><path d="M.123.448L.119 2.424l2.21.007c.43 0 .97-.368.97-1.01a.97.97 0 0 0-.967-.973c-.308.003-.8.001-1.245 0L.375.446C.26.446.17.446.123.448" id="j"/><linearGradient x1="0%" y1="50.008%" x2="99.996%" y2="50.008%" id="k"><stop stop-color="#008049" offset="0%"/><stop stop-color="#62BA44" offset="100%"/></linearGradient><path d="M.115.473l-.008 1.8 2.089.014c.346-.007.834-.325.834-.882 0-.567-.426-.95-.88-.939-.296.008-.702.005-1.078.002L.52.465C.333.465.187.467.115.473" id="m"/><linearGradient x1=".022%" y1="49.994%" x2="100.012%" y2="49.994%" id="n"><stop stop-color="#008049" offset="0%"/><stop stop-color="#62BA44" offset="100%"/></linearGradient><path d="M3.694.575C1.806.575.08 2.278.08 4.188L.08 8.164h5.365c1.067 0 2.324.457 2.324 1.754 0 .696-.37 1.485-1.706 1.74v.03c.78 0 2.132.457 2.132 1.833 0 1.423-1.46 1.817-2.243 1.817l-5.873.006-.001 7.597H5.72c1.54 0 3.544-1.439 3.544-3.627V.575H3.694z" id="p"/><linearGradient x1="-.007%" y1="49.999%" x2="100.004%" y2="49.999%" id="q"><stop stop-color="#008049" offset="0%"/><stop stop-color="#62BA44" offset="100%"/></linearGradient></defs><g fill="none" fill-rule="evenodd"><g transform="translate(0 3.013)"><mask id="c" fill="#fff"><use href="#a"/></mask><path d="M3.622.575C1.734.575.009 2.278.009 4.188c0 1.051 0 5.212-.002 9.348.346.217 2.01.752 2.68.799 1.466.103 2.375-.381 2.515-1.603l-.007-4.538h3.243v4.434c-.17 2.54-2.399 3.057-5.79 2.887-.877-.046-2.07-.27-2.64-.42L.004 22.94H5.65c1.54 0 3.544-1.439 3.544-3.627V.575H3.622z" fill="url(#b)" mask="url(#c)"/></g><g transform="translate(0 16.543)"><mask id="f" fill="#fff"><use href="#d"/></mask><path d="M0 1.564l.007.001V.007L0 .002v1.562z" fill="url(#e)" mask="url(#f)"/></g><g transform="translate(10 3.013)"><mask id="i" fill="#fff"><use href="#g"/></mask><path d="M3.976.575C2.088.575.363 2.278.363 4.188v4.945c1.132-.885 2.958-1.319 5.281-1.14 1.322.102 2.3.286 2.834.445v1.57c-.588-.294-1.748-.73-2.715-.8-2.191-.158-3.342.868-3.342 2.528 0 1.494.888 2.773 3.331 2.602.806-.056 2.148-.525 2.719-.797l.007 1.523c-.492.155-2.02.488-3.458.5-2.165.017-3.694-.443-4.659-1.189L.36 22.941h5.643c1.54 0 3.546-1.439 3.546-3.627V.575H3.976z" fill="url(#h)" mask="url(#i)"/></g><g transform="translate(22.353 14.778)"><mask id="l" fill="#fff"><use href="#j"/></mask><path d="M.123.448L.119 2.424l2.21.007c.43 0 .97-.368.97-1.01a.97.97 0 0 0-.967-.973c-.308.003-.8.001-1.245 0L.375.446C.26.446.17.446.123.448" fill="url(#k)" mask="url(#l)"/></g><g transform="translate(22.353 11.837)"><mask id="o" fill="#fff"><use href="#m"/></mask><path d="M.115.473l-.008 1.8 2.089.014c.346-.007.834-.325.834-.882 0-.567-.426-.95-.88-.939-.296.008-.702.005-1.078.002L.52.465C.333.465.187.467.115.473" fill="url(#n)" mask="url(#o)"/></g><g transform="translate(20.588 3.013)"><mask id="r" fill="#fff"><use href="#p"/></mask><path d="M3.694.575C1.806.575.08 2.278.08 4.188L.08 8.164h5.365c1.067 0 2.324.457 2.324 1.754 0 .696-.37 1.485-1.706 1.74v.03c.78 0 2.132.457 2.132 1.833 0 1.423-1.46 1.817-2.243 1.817l-5.873.006-.001 7.597H5.72c1.54 0 3.544-1.439 3.544-3.627V.575H3.694z" fill="url(#q)" mask="url(#r)"/></g></g></svg> \ No newline at end of file
diff --git a/browser/extensions/formautofill/content/third-party/cc-logo-mastercard.svg b/browser/extensions/formautofill/content/third-party/cc-logo-mastercard.svg
new file mode 100644
index 0000000000..3e0f21f9e3
--- /dev/null
+++ b/browser/extensions/formautofill/content/third-party/cc-logo-mastercard.svg
@@ -0,0 +1 @@
+<svg width="38" height="30" xmlns="http://www.w3.org/2000/svg"><g fill-rule="nonzero" fill="none"><path d="M7.485 29.258v-1.896a1.125 1.125 0 0 0-1.188-1.2 1.17 1.17 0 0 0-1.061.537 1.109 1.109 0 0 0-.999-.537.998.998 0 0 0-.885.448v-.373h-.657v3.021h.664v-1.662a.708.708 0 0 1 .74-.802c.435 0 .656.284.656.796v1.68h.664v-1.674a.71.71 0 0 1 .74-.802c.448 0 .663.284.663.796v1.68l.663-.012zm9.817-3.02h-1.08v-.917h-.664v.916h-.6v.6h.613v1.391c0 .701.271 1.119 1.049 1.119.29 0 .575-.08.821-.234l-.19-.563a1.213 1.213 0 0 1-.58.17c-.317 0-.437-.201-.437-.505v-1.377h1.074l-.006-.6zm5.605-.076a.891.891 0 0 0-.796.442v-.367h-.65v3.021h.656v-1.693c0-.5.215-.778.632-.778.14-.002.28.024.411.076l.202-.632a1.406 1.406 0 0 0-.467-.082l.012.013zm-8.474.316a2.26 2.26 0 0 0-1.232-.316c-.765 0-1.264.366-1.264.966 0 .493.367.797 1.043.891l.316.045c.36.05.53.145.53.316 0 .234-.24.366-.688.366-.361.01-.715-.1-1.005-.316l-.316.512a2.18 2.18 0 0 0 1.308.392c.872 0 1.378-.41 1.378-.986 0-.575-.398-.809-1.056-.904l-.316-.044c-.284-.038-.511-.095-.511-.297 0-.202.214-.354.575-.354.333.004.659.093.947.26l.291-.531zm17.602-.316a.891.891 0 0 0-.796.442v-.367h-.65v3.021h.656v-1.693c0-.5.215-.778.632-.778.14-.002.28.024.411.076l.202-.632a1.406 1.406 0 0 0-.467-.082l.012.013zm-8.467 1.58a1.526 1.526 0 0 0 1.611 1.58 1.58 1.58 0 0 0 1.087-.36l-.316-.532a1.327 1.327 0 0 1-.79.272.97.97 0 0 1 0-1.934c.286.003.563.099.79.272l.316-.53a1.58 1.58 0 0 0-1.087-.361 1.526 1.526 0 0 0-1.611 1.58v.012zm6.155 0v-1.505h-.658v.367a1.147 1.147 0 0 0-.948-.442 1.58 1.58 0 0 0 0 3.16c.37.013.722-.152.948-.443v.366h.658v-1.504zm-2.446 0a.913.913 0 1 1 .916.966.907.907 0 0 1-.916-.967zm-7.93-1.58a1.58 1.58 0 1 0 .044 3.16c.454.023.901-.124 1.254-.411l-.316-.487c-.25.2-.559.311-.878.316a.837.837 0 0 1-.904-.74h2.243v-.252c0-.948-.587-1.58-1.434-1.58l-.01-.006zm0 .587a.749.749 0 0 1 .764.733h-1.58a.777.777 0 0 1 .803-.733h.012zm16.464.999v-2.724h-.632v1.58a1.147 1.147 0 0 0-.948-.442 1.58 1.58 0 0 0 0 3.16c.369.013.722-.152.948-.443v.366h.632v-1.497zm1.096 1.07a.316.316 0 0 1 .218.086.294.294 0 0 1-.098.487.297.297 0 0 1-.12.025.316.316 0 0 1-.284-.183.297.297 0 0 1 .066-.329.316.316 0 0 1 .228-.085h-.01zm0 .535a.224.224 0 0 0 .165-.07.234.234 0 0 0 0-.316.234.234 0 0 0-.165-.07.237.237 0 0 0-.167.07.234.234 0 0 0 0 .316.234.234 0 0 0 .076.05c.032.015.066.021.101.02h-.01zm.02-.376a.126.126 0 0 1 .082.025c.02.016.03.041.028.066a.076.076 0 0 1-.022.057.11.11 0 0 1-.066.029l.091.104h-.072l-.086-.104h-.028v.104h-.06v-.278l.132-.003zm-.07.054v.075h.07a.066.066 0 0 0 .037 0 .032.032 0 0 0 0-.028.032.032 0 0 0 0-.028.066.066 0 0 0-.038 0l-.07-.02zm-3.476-1.283a.913.913 0 1 1 .917.967.907.907 0 0 1-.917-.967zm-22.19 0v-1.51h-.657v.366a1.147 1.147 0 0 0-.948-.442 1.58 1.58 0 1 0 0 3.16c.369.013.722-.152.948-.443v.366h.657v-1.497zm-2.445 0a.913.913 0 1 1 .916.967.907.907 0 0 1-.922-.967h.006z" fill="#231F20"/><path fill="#FF5F00" d="M14.215 3.22h9.953v17.886h-9.953z"/><path d="M14.847 12.165a11.356 11.356 0 0 1 4.345-8.945 11.375 11.375 0 1 0 0 17.886 11.356 11.356 0 0 1-4.345-8.941z" fill="#EB001B"/><path d="M37.596 12.165a11.375 11.375 0 0 1-18.404 8.941 11.375 11.375 0 0 0 0-17.886 11.375 11.375 0 0 1 18.404 8.941v.004zM36.51 19.265v-.412h.148v-.085h-.376v.085h.161v.412h.066zm.73 0v-.497h-.115l-.132.355-.133-.355h-.101v.497h.082v-.373l.123.323h.086l.123-.323v.376l.066-.003z" fill="#F79E1B"/></g></svg> \ No newline at end of file
diff --git a/browser/extensions/formautofill/content/third-party/cc-logo-mir.svg b/browser/extensions/formautofill/content/third-party/cc-logo-mir.svg
new file mode 100644
index 0000000000..26a24f985d
--- /dev/null
+++ b/browser/extensions/formautofill/content/third-party/cc-logo-mir.svg
@@ -0,0 +1 @@
+<svg width="36" height="30" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient x1="100%" y1="312.751%" x2=".612%" y2="312.751%" id="a"><stop stop-color="#1E5CD8" offset="0%"/><stop stop-color="#02AFFF" offset="100%"/></linearGradient></defs><g fill-rule="nonzero" fill="none"><path d="M7.812 11.313l-1.326 4.593h-.227l-1.326-4.594A1.823 1.823 0 0 0 3.18 10H0v10h3.184v-5.91h.227L5.234 20H7.51l1.819-5.91h.226V20h3.185V10H9.56c-.81 0-1.522.535-1.75 1.313zM25.442 20h3.204v-2.957h3.223c1.686 0 3.122-.953 3.677-2.293H25.442V20zm-5.676-8.945l-2.241 4.855h-.227V10h-3.184v10h2.703c.712 0 1.357-.414 1.654-1.055l2.242-4.851h.227V20h3.184V10H21.42c-.712 0-1.358.414-1.655 1.055z" fill="#006848"/><path d="M32.186 0c.92 0 1.752.352 2.382.93a3.49 3.49 0 0 1 1.146 2.59c0 .21-.023.417-.058.62H29.74a4.478 4.478 0 0 1-4.272-3.124c-.007-.02-.011-.043-.02-.067-.015-.054-.027-.113-.042-.168A4.642 4.642 0 0 1 25.293 0h6.893z" fill="url(#a)" transform="translate(0 10)"/></g></svg> \ No newline at end of file
diff --git a/browser/extensions/formautofill/content/third-party/cc-logo-unionpay.svg b/browser/extensions/formautofill/content/third-party/cc-logo-unionpay.svg
new file mode 100644
index 0000000000..99ef7e86b4
--- /dev/null
+++ b/browser/extensions/formautofill/content/third-party/cc-logo-unionpay.svg
@@ -0,0 +1 @@
+<svg width="36" height="30" xmlns="http://www.w3.org/2000/svg"><defs><path id="a" d="M0 .04h17.771v22.433H0z"/><path id="c" d="M.134.04h18.093v22.433H.134z"/><path id="e" d="M.202.04h17.77v22.433H.202z"/></defs><g fill="none" fill-rule="evenodd"><g transform="translate(0 3.179)"><mask id="b" fill="#fff"><use href="#a"/></mask><path d="M7.023.04h8.952C17.225.04 18 1.057 17.71 2.31l-4.168 17.893c-.294 1.25-1.545 2.269-2.795 2.269h-8.95c-1.248 0-2.027-1.02-1.736-2.269l4.17-17.893C4.52 1.058 5.771.04 7.022.04" fill="#E21837" mask="url(#b)"/></g><g transform="translate(8.073 3.179)"><mask id="d" fill="#fff"><use href="#c"/></mask><path d="M7.157.04h10.294c1.25 0 .686 1.018.392 2.271l-4.167 17.893c-.292 1.25-.201 2.269-1.453 2.269H1.93c-1.252 0-2.026-1.02-1.732-2.269L4.363 2.311C4.66 1.058 5.907.04 7.157.04" fill="#00457C" mask="url(#d)"/></g><g transform="translate(17.89 3.179)"><mask id="f" fill="#fff"><use href="#e"/></mask><path d="M7.224.04h8.952c1.251 0 2.028 1.018 1.734 2.271l-4.166 17.893c-.295 1.25-1.547 2.269-2.798 2.269H2c-1.252 0-2.028-1.02-1.735-2.269L4.432 2.311C4.723 1.058 5.972.04 7.224.04" fill="#007B84" mask="url(#f)"/></g><path d="M26.582 16.428L25.49 20.04h.295l-.228.746h-.292l-.069.23h-1.038l.07-.23H22.12l.21-.69h.215l1.106-3.667.22-.739h1.06l-.111.373s.282-.203.55-.272c.266-.07 1.801-.096 1.801-.096l-.227.734h-.362zm-1.866 0l-.28.923s.315-.142.484-.189c.174-.046.434-.061.434-.061l.203-.673h-.841zm-.42 1.38l-.29.96s.321-.163.492-.215c.174-.039.438-.072.438-.072l.205-.673h-.845zm-.675 2.24h.844l.242-.81h-.841l-.245.81z" fill="#FEFEFE"/><path d="M27.05 15.694h1.13l.012.42c-.008.072.054.106.186.106h.23l-.21.695h-.612c-.528.038-.73-.19-.715-.445l-.022-.776zM27.2 18.993H26.12l.185-.619h1.232l.175-.566h-1.216l.207-.698h3.384l-.21.698h-1.135l-.178.566h1.139l-.19.619h-1.229l-.219.26h.5l.121.78c.014.078.014.13.04.162.025.028.175.042.262.042h.152l-.231.759h-.385c-.058 0-.147-.005-.27-.01-.114-.01-.195-.077-.273-.116a.367.367 0 0 1-.202-.265l-.12-.778-.56.766c-.177.243-.417.428-.824.428h-.782l.205-.677h.3a.484.484 0 0 0 .218-.063.336.336 0 0 0 .166-.138l.816-1.15zM15.397 17.298h2.855l-.211.68H16.9l-.179.581h1.168l-.213.702h-1.167l-.284.945c-.034.104.278.117.39.117l.584-.08-.235.778H15.65c-.106 0-.185-.015-.299-.04a.312.312 0 0 1-.209-.153c-.048-.077-.122-.14-.071-.305l.378-1.25H14.8l.215-.714h.65l.173-.581h-.648l.207-.68zM17.317 16.074h1.171l-.212.712h-1.6l-.173.15c-.075.072-.1.042-.198.094-.09.045-.28.136-.525.136h-.513l.207-.684h.154c.13 0 .219-.012.264-.04a.617.617 0 0 0 .171-.222l.296-.535h1.163l-.205.389zM18.991 15.694h.997l-.146.502s.316-.252.536-.343c.22-.081.716-.154.716-.154l1.615-.01-.55 1.832a2.139 2.139 0 0 1-.269.608.7.7 0 0 1-.271.251 1.02 1.02 0 0 1-.375.126c-.106.008-.27.01-.496.014h-1.556l-.437 1.447c-.042.144-.061.213-.034.252a.18.18 0 0 0 .148.073l.686-.065-.235.794h-.766c-.245 0-.422-.006-.547-.015-.118-.01-.242 0-.325-.063-.07-.063-.18-.147-.177-.231.007-.078.04-.209.09-.389l1.396-4.63zm2.117 1.848h-1.634l-.1.33h1.414c.167-.02.202.004.216-.004l.104-.326zm-1.545-.297s.32-.292.867-.387c.124-.023.9-.015.9-.015l.119-.392h-1.647l-.24.794z" fill="#FEFEFE"/><path d="M21.899 18.648l-.093.44c-.04.137-.073.24-.177.328-.11.093-.237.19-.536.19l-.554.023-.005.497c-.005.14.032.126.054.149.026.025.049.035.073.045l.175-.01.529-.03-.22.726h-.606c-.425 0-.74-.01-.842-.091-.103-.065-.116-.146-.115-.286l.04-1.938h.968l-.014.397h.233c.08 0 .134-.008.167-.03a.175.175 0 0 0 .065-.1l.097-.31h.76zM8.082 8.932c-.033.158-.655 3.024-.656 3.026-.134.58-.231.993-.562 1.26a1 1 0 0 1-.66.23c-.409 0-.646-.203-.687-.587l-.007-.132.124-.781s.652-2.611.769-2.957l.01-.039c-1.27.011-1.495 0-1.51-.02-.009.028-.04.19-.04.19l-.666 2.943-.057.25-.11.816c0 .242.047.44.142.607.303.53 1.168.609 1.657.609.63 0 1.222-.134 1.622-.378.694-.41.875-1.051 1.037-1.62l.075-.293s.672-2.712.786-3.065c.004-.02.006-.03.012-.039-.92.01-1.192 0-1.28-.02M11.798 14.319c-.45-.008-.61-.008-1.135.02l-.02-.04c.045-.2.095-.398.14-.6l.065-.275c.097-.425.191-.92.202-1.072.01-.09.042-.317-.218-.317-.109 0-.223.053-.339.107-.063.226-.19.863-.252 1.153-.13.61-.138.681-.197.983l-.038.041a12.946 12.946 0 0 0-1.159.02l-.024-.046c.089-.362.178-.728.263-1.091.224-.986.278-1.362.338-1.863l.044-.03c.52-.073.647-.088 1.21-.202l.048.053-.087.313c.096-.057.187-.114.283-.163.266-.13.562-.17.724-.17.248 0 .518.069.63.355.107.254.036.567-.104 1.184l-.072.316c-.144.686-.168.812-.25 1.283l-.052.041zM13.627 14.319c-.272-.002-.448-.008-.617-.002-.17.002-.335.01-.588.022l-.013-.022-.016-.024c.069-.26.106-.35.14-.443a3.13 3.13 0 0 0 .128-.449c.08-.345.128-.586.16-.797.037-.204.057-.378.085-.58l.02-.015.02-.02c.27-.037.442-.062.618-.09.177-.023.355-.06.635-.113l.01.024.008.025c-.052.214-.105.427-.156.643-.05.217-.103.43-.15.643-.101.453-.142.623-.166.745-.024.115-.03.178-.069.412l-.025.021-.024.02zM17.67 12.768c.159-.692.036-1.015-.119-1.212-.234-.3-.648-.396-1.078-.396-.258 0-.873.025-1.354.468-.345.32-.505.754-.6 1.17-.098.423-.21 1.186.492 1.47.216.093.528.118.73.118.513 0 1.04-.141 1.436-.561.305-.341.445-.848.494-1.057m-1.18-.05c-.022.117-.124.551-.262.736-.097.136-.21.219-.337.219-.037 0-.26 0-.264-.332-.002-.163.031-.33.072-.512.119-.524.258-.964.616-.964.28 0 .3.328.175.853M28.677 14.365c-.544-.004-.7-.004-1.202.017l-.031-.04c.135-.517.272-1.032.393-1.554.158-.678.194-.966.245-1.363l.041-.033c.54-.077.69-.099 1.252-.203l.016.047c-.103.426-.203.85-.304 1.278-.206.893-.281 1.346-.36 1.813l-.05.038z" fill="#FEFEFE"/><path d="M28.935 12.83c.158-.688-.479-.062-.58-.289-.154-.354-.058-1.072-.683-1.312-.24-.095-.804.027-1.29.469-.34.315-.504.747-.597 1.161-.098.418-.21 1.18.488 1.452.222.095.422.123.624.113.702-.038 1.236-1.098 1.633-1.516.305-.333.358.124.405-.079m-1.074-.05c-.027.112-.13.549-.268.732-.092.13-.311.211-.437.211-.036 0-.257 0-.264-.325a2.225 2.225 0 0 1 .073-.512c.12-.515.258-.95.616-.95.28 0 .4.316.28.843M20.746 14.319a12.427 12.427 0 0 0-1.134.02l-.02-.04c.046-.2.097-.398.144-.6l.061-.275c.099-.425.194-.92.203-1.072.01-.09.042-.317-.216-.317-.113 0-.225.053-.341.107-.062.226-.192.863-.255 1.153-.126.61-.136.681-.193.983l-.04.041a12.904 12.904 0 0 0-1.156.02l-.024-.046c.088-.362.177-.728.262-1.091.224-.986.276-1.362.339-1.863l.04-.03c.52-.073.648-.088 1.212-.202l.043.053-.08.313a4.81 4.81 0 0 1 .281-.163c.264-.13.562-.17.724-.17.244 0 .516.069.632.355.105.254.033.567-.108 1.184l-.07.316c-.15.686-.17.812-.25 1.283l-.054.041zM25.133 10.61c-.079.359-.312.66-.61.806-.247.124-.549.134-.86.134h-.201l.015-.08.37-1.608.011-.082.005-.063.149.015.782.067c.302.117.426.418.34.81m-.487-1.68l-.375.003c-.974.012-1.364.008-1.524-.011l-.04.197-.348 1.618-.874 3.597c.85-.01 1.199-.01 1.345.006.034-.161.23-1.121.232-1.121 0 0 .168-.704.178-.73 0 0 .053-.073.106-.102h.078c.732 0 1.56 0 2.209-.477.441-.328.743-.81.877-1.398.035-.144.061-.315.061-.487 0-.225-.045-.447-.176-.62-.33-.464-.99-.472-1.75-.476M33.124 11.185l-.043-.05c-.556.113-.656.131-1.167.2l-.038.038-.005.024-.002-.009c-.38.877-.37.688-.679 1.378l-.003-.084-.077-1.497-.05-.05c-.581.113-.595.131-1.133.2l-.041.038c-.006.017-.006.037-.01.059l.004.007c.067.344.05.267.118.809.032.266.073.533.105.796.053.44.083.656.147 1.327-.363.6-.449.826-.798 1.352l.022.049c.524-.02.646-.02 1.035-.02l.084-.096c.294-.633 2.531-4.47 2.531-4.47M14.12 11.556c.298-.207.335-.493.085-.641-.254-.15-.7-.102-1 .105-.3.203-.333.49-.08.642.25.146.697.103.994-.106" fill="#FEFEFE"/><path d="M30.554 15.709l-.437.75c-.139.256-.395.447-.803.448l-.696-.012.203-.674h.137c.07 0 .121-.003.16-.023.036-.012.062-.04.09-.08l.258-.409h1.088z" fill="#FEFEFE"/></g></svg> \ No newline at end of file
diff --git a/browser/extensions/formautofill/content/third-party/cc-logo-visa.svg b/browser/extensions/formautofill/content/third-party/cc-logo-visa.svg
new file mode 100644
index 0000000000..57bcc144d1
--- /dev/null
+++ b/browser/extensions/formautofill/content/third-party/cc-logo-visa.svg
@@ -0,0 +1 @@
+<svg width="44" height="30" xmlns="http://www.w3.org/2000/svg"><defs><path d="M22.8 9.786c-.025-1.96 1.765-3.053 3.113-3.703 1.385-.667 1.85-1.095 1.845-1.691-.01-.913-1.105-1.316-2.13-1.332-1.787-.027-2.826.478-3.652.86L21.332.938c.83-.378 2.364-.708 3.956-.722 3.735 0 6.18 1.824 6.193 4.653.014 3.59-5.02 3.79-4.985 5.395.012.486.481 1.005 1.51 1.138.508.066 1.914.117 3.506-.609l.626 2.884a9.623 9.623 0 0 1-3.329.605c-3.516 0-5.99-1.85-6.01-4.497m15.347 4.248a1.621 1.621 0 0 1-1.514-.998L31.296.428h3.733l.743 2.032h4.561l.431-2.032h3.29l-2.87 13.606h-3.038m.522-3.675l1.077-5.11h-2.95l1.873 5.11m-20.394 3.675L15.33.428h3.557l2.942 13.606h-3.556m-8.965-9.26L7.81 12.648c-.176.879-.87 1.386-1.64 1.386H.116l-.084-.395c1.242-.267 2.654-.697 3.51-1.157.523-.282.672-.527.844-1.196L7.224.428h3.76l5.763 13.606H13.01L9.31 4.774z" id="a"/><linearGradient x1="16.148%" y1="34.401%" x2="85.832%" y2="66.349%" id="b"><stop stop-color="#222357" offset="0%"/><stop stop-color="#254AA5" offset="100%"/></linearGradient></defs><g transform="matrix(1 0 0 -1 0 22.674)" fill="none" fill-rule="evenodd"><mask id="c" fill="#fff"><use href="#a"/></mask><path fill="url(#b)" fill-rule="nonzero" mask="url(#c)" d="M-4.669 12.849l44.237 16.12L49.63 1.929 5.395-14.19"/></g></svg> \ No newline at end of file
diff --git a/browser/extensions/formautofill/docs/heuristics.rst b/browser/extensions/formautofill/docs/heuristics.rst
new file mode 100644
index 0000000000..cf6e49da39
--- /dev/null
+++ b/browser/extensions/formautofill/docs/heuristics.rst
@@ -0,0 +1,36 @@
+Form Autofill Heuristics
+========================
+
+Form Autofill Heuristics module is for detecting the field type based on `autocomplete attribute <https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofill>`_, `the regular expressions <http://searchfox.org/mozilla-central/source/browser/extensions/formautofill/content/heuristicsRegexp.js>`_ and the customized logic in each parser.
+
+Debugging
+---------
+
+The pref ``extensions.formautofill.heuristics.enabled`` is "true" in default. Set it to "false" could be useful to verify the result of autocomplete attribute.
+
+Dependent APIs
+--------------
+
+``element.getAutocompleteInfo()`` provides the parsed result of ``autocomplete`` attribute which includes the field name and section information defined in `autofill spec <https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofill>`_
+
+Regular Expressions
+-------------------
+
+This section is about how the regular expression is applied during parsing fields. All regular expressions are in `heuristicsRegexp.js <https://searchfox.org/mozilla-central/source/browser/extensions/formautofill/content/heuristicsRegexp.js>`_.
+
+Parser Implementations
+----------------------
+
+The parsers are for detecting the field type more accurately based on the near context of a field. Each parser uses ``FieldScanner`` to traverse the interested fields with the result from the regular expressions and adjust each field type when it matches to a grammar.
+
+* _parsePhoneFields
+
+ * related type: ``tel``, ``tel-*``
+
+* _parseAddressFields
+
+ * related type: ``address-line[1-3]``
+
+* _parseCreditCardExpirationDateFields
+
+ * related type: ``cc-exp``, ``cc-exp-month``, ``cc-exp-year``
diff --git a/browser/extensions/formautofill/docs/index.rst b/browser/extensions/formautofill/docs/index.rst
new file mode 100644
index 0000000000..37759ce602
--- /dev/null
+++ b/browser/extensions/formautofill/docs/index.rst
@@ -0,0 +1,30 @@
+Form Autofill
+=============
+
+`Wiki <https://wiki.mozilla.org/Firefox/Features/Form_Autofill>`_ |
+`IRC: #formfill <https://chat.mozilla.org/#/room/#form-autofill:mozilla.org>`_
+
+Introduction
+------------
+
+Form Autofill saves users time and effort when making online purchases by storing their personal information in a profile and automatically populating form fields when the user requires it.
+
+Our objective is to increase user engagement, satisfaction and retention for frequent online shoppers (those who make an online purchase at least once per month). We believe this can be achieved by enabling users to complete forms and “check out” in e-commerce flows as quickly and securely as possible.
+
+Debugging
+---------
+
+Set the pref ``extensions.formautofill.loglevel`` to "Debug".
+
+Contents
+--------
+
+.. toctree::
+ :maxdepth: 1
+
+ heuristics
+
+Report Issues
+-------------
+
+If you find any issues about filling a form with incorrect values, please file a `new bug <https://bugzilla.mozilla.org/enter_bug.cgi?product=Toolkit&component=Form%20Autofill>`_ to Toolkit::Form Autofill component or leave a comment in `bug 1405266 <https://bugzilla.mozilla.org/show_bug.cgi?id=1405266>`_.
diff --git a/browser/extensions/formautofill/jar.mn b/browser/extensions/formautofill/jar.mn
new file mode 100644
index 0000000000..4253184cf5
--- /dev/null
+++ b/browser/extensions/formautofill/jar.mn
@@ -0,0 +1,7 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+[features/formautofill@mozilla.org] chrome.jar:
+ content/ (content/*)
+ content/skin/ (skin/shared/*)
diff --git a/browser/extensions/formautofill/locales/en-US/formautofill.properties b/browser/extensions/formautofill/locales/en-US/formautofill.properties
new file mode 100644
index 0000000000..0a43a3c09b
--- /dev/null
+++ b/browser/extensions/formautofill/locales/en-US/formautofill.properties
@@ -0,0 +1,127 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# LOCALIZATION NOTE (autofillOptionsLink, autofillOptionsLinkOSX): These strings are used in the doorhanger for
+# updating addresses. The link leads users to Form Autofill browser preferences.
+autofillOptionsLink = Form Autofill Options
+autofillOptionsLinkOSX = Form Autofill Preferences
+# LOCALIZATION NOTE (changeAutofillOptions, changeAutofillOptionsOSX): These strings are used on the doorhanger
+# that notifies users that addresses are saved. The button leads users to Form Autofill browser preferences.
+changeAutofillOptions = Change Form Autofill Options
+changeAutofillOptionsOSX = Change Form Autofill Preferences
+changeAutofillOptionsAccessKey = C
+# LOCALIZATION NOTE (addressesSyncCheckbox): If Sync is enabled, this checkbox is displayed on the doorhanger
+# shown when saving addresses.
+addressesSyncCheckbox = Share addresses with synced devices
+# LOCALIZATION NOTE (creditCardsSyncCheckbox): If Sync is enabled and credit card sync is available,
+# this checkbox is displayed on the doorhanger shown when saving credit card.
+creditCardsSyncCheckbox = Share credit cards with synced devices
+
+# LOCALIZATION NOTE (saveAddressesMessage): %S is brandShortName. This string is used on the doorhanger to
+# notify users that addresses are saved.
+saveAddressesMessage = %S now saves addresses so you can fill out forms faster.
+saveAddressDescriptionLabel = Address to save:
+saveAddressLabel = Save Address
+saveAddressAccessKey = S
+# LOCALIZATION NOTE (updateAddressMessage, updateAddressDescriptionLabel, createAddressLabel, updateAddressLabel):
+# Used on the doorhanger when an address change is detected.
+updateAddressMessage = Would you like to update your address with this new information?
+updateAddressOldDescriptionLabel = Old Address:
+updateAddressNewDescriptionLabel = New Address:
+createAddressLabel = Create New Address
+createAddressAccessKey = C
+createAddressDescriptionLabel = Address to create:
+cancelAddressLabel = Don’t Save
+cancelAddressAccessKey = D
+updateAddressLabel = Update Address
+updateAddressAccessKey = U
+# LOCALIZATION NOTE (saveCreditCardMessage, saveCreditCardDescriptionLabel, saveCreditCardLabel, cancelCreditCardLabel, neverSaveCreditCardLabel):
+# Used on the doorhanger when users submit payment with credit card.
+# LOCALIZATION NOTE (saveCreditCardMessage): %S is brandShortName.
+saveCreditCardMessage = Would you like %S to save this credit card? (Security code will not be saved)
+saveCreditCardDescriptionLabel = Credit card to save:
+saveCreditCardLabel = Save Credit Card
+saveCreditCardAccessKey = S
+cancelCreditCardLabel = Don’t Save
+cancelCreditCardAccessKey = D
+neverSaveCreditCardLabel = Never Save Credit Cards
+neverSaveCreditCardAccessKey = N
+# LOCALIZATION NOTE (updateCreditCardMessage, updateCreditCardDescriptionLabel, createCreditCardLabel, updateCreditCardLabel):
+# Used on the doorhanger when an credit card change is detected.
+updateCreditCardMessage = Would you like to update your credit card with this new information?
+updateCreditCardDescriptionLabel = Credit card to update:
+createCreditCardLabel = Create New Credit Card
+createCreditCardAccessKey = C
+updateCreditCardLabel = Update Credit Card
+updateCreditCardAccessKey = U
+# LOCALIZATION NOTE (openAutofillMessagePanel): Tooltip label for Form Autofill doorhanger icon on address bar.
+openAutofillMessagePanel = Open Form Autofill message panel
+
+# LOCALIZATION NOTE (autocompleteFooterOption2):
+# Used as a label for the button, displayed at the bottom of the dropdown suggestion, to open Form Autofill browser preferences.
+autocompleteFooterOption2 = Form Autofill Options
+# LOCALIZATION NOTE (autocompleteFooterOptionOSX2):
+# Used as a label for the button, displayed at the bottom of the dropdown suggestion, to open Form Autofill browser preferences.
+autocompleteFooterOptionOSX2 = Form Autofill Preferences
+# LOCALIZATION NOTE (autocompleteFooterOptionShort2):
+# Used as a label for the button, displayed at the bottom of the dropdown suggestion, to open Form Autofill browser preferences.
+# The short version is used for inputs below a certain width (e.g. 150px).
+autocompleteFooterOptionShort2 = Autofill Options
+# LOCALIZATION NOTE (autocompleteFooterOptionOSXShort2):
+# Used as a label for the button, displayed at the bottom of the dropdown suggestion, to open Form Autofill browser preferences.
+# The short version is used for inputs below a certain width (e.g. 150px).
+autocompleteFooterOptionOSXShort2 = Autofill Preferences
+# LOCALIZATION NOTE (category.address, category.name, category.organization2, category.tel, category.email):
+# Used in autofill drop down suggestion to indicate what other categories Form Autofill will attempt to fill.
+category.address = address
+category.name = name
+category.organization2 = organization
+category.tel = phone
+category.email = email
+# LOCALIZATION NOTE (fieldNameSeparator): This is used as a separator between categories.
+fieldNameSeparator = ,\u0020
+# LOCALIZATION NOTE (phishingWarningMessage, phishingWarningMessage2): The warning
+# text that is displayed for informing users what categories are about to be filled.
+# "%S" will be replaced with a list generated from the pre-defined categories.
+# The text would be e.g. Also autofills organization, phone, email.
+phishingWarningMessage = Also autofills %S
+phishingWarningMessage2 = Autofills %S
+# LOCALIZATION NOTE (insecureFieldWarningDescription): %S is brandShortName. This string is used in drop down
+# suggestion when users try to autofill credit card on an insecure website (without https).
+insecureFieldWarningDescription = %S has detected an insecure site. Form Autofill is temporarily disabled.
+# LOCALIZATION NOTE (clearFormBtnLabel2): Label for the button in the dropdown menu that used to clear the populated
+# form.
+clearFormBtnLabel2 = Clear Autofill Form
+
+autofillHeader = Forms and Autofill
+# LOCALIZATION NOTE (autofillAddressesCheckbox): Label for the checkbox that enables autofilling addresses.
+autofillAddressesCheckbox = Autofill addresses
+# LOCALIZATION NOTE (learnMoreLabel): Label for the link that leads users to the Form Autofill SUMO page.
+learnMoreLabel = Learn more
+# LOCALIZATION NOTE (savedAddressesBtnLabel): Label for the button that opens a dialog that shows the
+# list of saved addresses.
+savedAddressesBtnLabel = Saved Addresses…
+# LOCALIZATION NOTE (autofillCreditCardsCheckbox): Label for the checkbox that enables autofilling credit cards.
+autofillCreditCardsCheckbox = Autofill credit cards
+# LOCALIZATION NOTE (savedCreditCardsBtnLabel): Label for the button that opens a dialog that shows the list
+# of saved credit cards.
+savedCreditCardsBtnLabel = Saved Credit Cards…
+
+autofillReauthCheckboxMac = Require macOS authentication to autofill, view, or edit stored credit cards.
+autofillReauthCheckboxWin = Require Windows authentication to autofill, view, or edit stored credit cards.
+autofillReauthCheckboxLin = Require Linux authentication to autofill, view, or edit stored credit cards.
+
+# LOCALIZATION NOTE (autofillReauthOSDialogMac): This string is
+# preceded by the operating system (macOS) with "Firefox is trying to ", and
+# has a period added to its end. Make sure to test in your locale.
+autofillReauthOSDialogMac = change the authentication settings
+autofillReauthOSDialogWin = To change the authentication settings, enter your Windows login credentials.
+autofillReauthOSDialogLin = To change the authentication settings, enter your Linux login credentials.
+
+useCreditCardPasswordPrompt.win = %S is trying to use stored credit card information. Confirm access to this Windows account below.
+# LOCALIZATION NOTE (useCreditCardPasswordPrompt.macos): This string is
+# preceded by the operating system (macOS) with "Firefox is trying to ", and
+# has a period added to its end. Make sure to test in your locale.
+useCreditCardPasswordPrompt.macos = use stored credit card information
+useCreditCardPasswordPrompt.linux = %S is trying to use stored credit card information.
diff --git a/browser/extensions/formautofill/locales/jar.mn b/browser/extensions/formautofill/locales/jar.mn
new file mode 100644
index 0000000000..58c364e55f
--- /dev/null
+++ b/browser/extensions/formautofill/locales/jar.mn
@@ -0,0 +1,8 @@
+#filter substitution
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+[features/formautofill@mozilla.org] @AB_CD@.jar:
+% locale formautofill @AB_CD@ %locale/@AB_CD@/
+ locale/@AB_CD@/formautofill.properties (%formautofill.properties)
diff --git a/browser/extensions/formautofill/locales/moz.build b/browser/extensions/formautofill/locales/moz.build
new file mode 100644
index 0000000000..d988c0ff9b
--- /dev/null
+++ b/browser/extensions/formautofill/locales/moz.build
@@ -0,0 +1,7 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+JAR_MANIFESTS += ["jar.mn"]
diff --git a/browser/extensions/formautofill/manifest.json b/browser/extensions/formautofill/manifest.json
new file mode 100644
index 0000000000..f79a3fdb76
--- /dev/null
+++ b/browser/extensions/formautofill/manifest.json
@@ -0,0 +1,26 @@
+{
+ "manifest_version": 2,
+ "name": "Form Autofill",
+ "version": "1.0.1",
+
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "formautofill@mozilla.org"
+ }
+ },
+
+ "background": {
+ "scripts": ["background.js"]
+ },
+
+ "experiment_apis": {
+ "formautofill": {
+ "schema": "schema.json",
+ "parent": {
+ "scopes": ["addon_parent"],
+ "script": "api.js",
+ "events": ["startup"]
+ }
+ }
+ }
+}
diff --git a/browser/extensions/formautofill/moz.build b/browser/extensions/formautofill/moz.build
new file mode 100644
index 0000000000..272319b131
--- /dev/null
+++ b/browser/extensions/formautofill/moz.build
@@ -0,0 +1,58 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DEFINES["MOZ_APP_VERSION"] = CONFIG["MOZ_APP_VERSION"]
+DEFINES["MOZ_APP_MAXVERSION"] = CONFIG["MOZ_APP_MAXVERSION"]
+
+DIRS += ["locales"]
+
+FINAL_TARGET_FILES.features["formautofill@mozilla.org"] += [
+ "api.js",
+ "background.js",
+ "manifest.json",
+ "schema.json",
+]
+
+if CONFIG["MOZ_WIDGET_TOOLKIT"] == "gtk":
+ FINAL_TARGET_FILES.features["formautofill@mozilla.org"].chrome.content.skin += [
+ "skin/linux/autocomplete-item.css",
+ "skin/linux/editDialog.css",
+ ]
+elif CONFIG["MOZ_WIDGET_TOOLKIT"] == "cocoa":
+ FINAL_TARGET_FILES.features["formautofill@mozilla.org"].chrome.content.skin += [
+ "skin/osx/autocomplete-item.css",
+ "skin/osx/editDialog.css",
+ ]
+elif CONFIG["MOZ_WIDGET_TOOLKIT"] == "windows":
+ FINAL_TARGET_FILES.features["formautofill@mozilla.org"].chrome.content.skin += [
+ "skin/windows/autocomplete-item.css",
+ "skin/windows/editDialog.css",
+ ]
+
+BROWSER_CHROME_MANIFESTS += [
+ "test/browser/address/browser.ini",
+ "test/browser/browser.ini",
+ "test/browser/creditCard/browser.ini",
+ "test/browser/focus-leak/browser.ini",
+ "test/browser/heuristics/browser.ini",
+ "test/browser/heuristics/third_party/browser.ini",
+]
+
+XPCSHELL_TESTS_MANIFESTS += [
+ "test/unit/xpcshell.ini",
+]
+
+MOCHITEST_MANIFESTS += [
+ "test/mochitest/creditCard/mochitest.ini",
+ "test/mochitest/mochitest.ini",
+]
+
+JAR_MANIFESTS += ["jar.mn"]
+
+SPHINX_TREES["docs"] = "docs"
+
+with Files("**"):
+ BUG_COMPONENT = ("Toolkit", "Form Autofill")
diff --git a/browser/extensions/formautofill/schema.json b/browser/extensions/formautofill/schema.json
new file mode 100644
index 0000000000..fe51488c70
--- /dev/null
+++ b/browser/extensions/formautofill/schema.json
@@ -0,0 +1 @@
+[]
diff --git a/browser/extensions/formautofill/skin/linux/autocomplete-item.css b/browser/extensions/formautofill/skin/linux/autocomplete-item.css
new file mode 100644
index 0000000000..8f782aaa2a
--- /dev/null
+++ b/browser/extensions/formautofill/skin/linux/autocomplete-item.css
@@ -0,0 +1,10 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@namespace url("http://www.w3.org/1999/xhtml");
+
+
+.autofill-item-box {
+ --default-font-size: 14.25;
+}
diff --git a/browser/extensions/formautofill/skin/linux/editDialog.css b/browser/extensions/formautofill/skin/linux/editDialog.css
new file mode 100644
index 0000000000..0f42d34b46
--- /dev/null
+++ b/browser/extensions/formautofill/skin/linux/editDialog.css
@@ -0,0 +1,8 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* Linux specific rules */
+dialog[subdialog] body {
+ font-size: 0.85rem;
+}
diff --git a/browser/extensions/formautofill/skin/osx/autocomplete-item.css b/browser/extensions/formautofill/skin/osx/autocomplete-item.css
new file mode 100644
index 0000000000..121c1139da
--- /dev/null
+++ b/browser/extensions/formautofill/skin/osx/autocomplete-item.css
@@ -0,0 +1,18 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@namespace url("http://www.w3.org/1999/xhtml");
+
+/* On Mac, the autocomplete panel changes color in system dark mode. We need
+ to change the contrast on warning-background-color accordingly. */
+@media (prefers-color-scheme: dark) {
+ .autofill-item-box {
+ --warning-background-color: rgba(248,232,28,.6);
+ }
+ }
+
+
+.autofill-item-box {
+ --default-font-size: 11;
+}
diff --git a/browser/extensions/formautofill/skin/osx/editDialog.css b/browser/extensions/formautofill/skin/osx/editDialog.css
new file mode 100644
index 0000000000..e22c07ec95
--- /dev/null
+++ b/browser/extensions/formautofill/skin/osx/editDialog.css
@@ -0,0 +1,5 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* OSX specific rules */
diff --git a/browser/extensions/formautofill/skin/shared/autocomplete-item-shared.css b/browser/extensions/formautofill/skin/shared/autocomplete-item-shared.css
new file mode 100644
index 0000000000..d9ec6bb665
--- /dev/null
+++ b/browser/extensions/formautofill/skin/shared/autocomplete-item-shared.css
@@ -0,0 +1,188 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@namespace url("http://www.w3.org/1999/xhtml");
+@namespace xul url("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul");
+
+
+xul|richlistitem[originaltype="autofill-profile"][selected="true"] > .autofill-item-box {
+ background-color: SelectedItem;
+ color: SelectedItemText;
+}
+
+xul|richlistitem[originaltype="autofill-footer"][selected="true"] > .autofill-item-box > .autofill-button,
+xul|richlistitem[originaltype="autofill-clear-button"][selected="true"] > .autofill-item-box > .autofill-button {
+ background-color: ButtonHighlight;
+}
+
+xul|richlistitem[originaltype="autofill-insecureWarning"] {
+ border-bottom: 1px solid var(--panel-separator-color);
+ background-color: var(--arrowpanel-dimmed);
+}
+
+.autofill-item-box {
+ --item-padding-vertical: 7px;
+ --item-padding-horizontal: 10px;
+ --col-spacer: 7px;
+ --item-width: calc(50% - (var(--col-spacer) / 2));
+ --comment-text-color: GreyText;
+ --warning-text-color: GreyText;
+ --warning-background-color: rgba(248, 232, 28, .2);
+
+ --default-font-size: 12;
+ --label-affix-font-size: 10;
+ --label-font-size: 12;
+ --comment-font-size: 10;
+ --warning-font-size: 10;
+ --btn-font-size: 11;
+}
+
+.autofill-item-box[size="small"] {
+ --item-padding-vertical: 7px;
+ --col-spacer: 0px;
+ --row-spacer: 3px;
+ --item-width: 100%;
+}
+
+.autofill-item-box:not([ac-image=""]) {
+ --item-padding-vertical: 6.5px;
+ --comment-font-size: 11;
+}
+
+.autofill-footer,
+.autofill-footer[size="small"] {
+ --item-width: 100%;
+ --item-padding-vertical: 0;
+ --item-padding-horizontal: 0;
+}
+
+.autofill-item-box {
+ box-sizing: border-box;
+ margin: 0;
+ border-bottom: 1px solid rgba(38,38,38,.15);
+ padding: var(--item-padding-vertical) 0;
+ padding-inline: var(--item-padding-horizontal);
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ align-items: center;
+ background-color: Field;
+ color: FieldText;
+}
+
+.autofill-item-box:last-child {
+ border-bottom: 0;
+}
+
+.autofill-item-box > .profile-item-col {
+ box-sizing: border-box;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ width: var(--item-width);
+}
+
+.autofill-item-box > .profile-label-col {
+ text-align: start;
+}
+
+.autofill-item-box:not([ac-image=""]) > .profile-label-col::before {
+ margin-inline-end: 5px;
+ float: inline-start;
+ content: "";
+ width: 16px;
+ height: 16px;
+ background-image: var(--primary-icon);
+ background-size: contain;
+ background-repeat: no-repeat;
+ background-position: center;
+ -moz-context-properties: fill;
+ fill: var(--comment-text-color)
+}
+
+.autofill-item-box > .profile-label-col > .profile-label {
+ font-size: calc(var(--label-font-size) / var(--default-font-size) * 1em);
+ unicode-bidi: plaintext;
+}
+
+.autofill-item-box > .profile-label-col > .profile-label-affix {
+ font-weight: lighter;
+ font-size: calc(var(--label-affix-font-size) / var(--default-font-size) * 1em);
+}
+
+.autofill-item-box > .profile-comment-col {
+ margin-inline-start: var(--col-spacer);
+ text-align: end;
+ color: var(--comment-text-color);
+}
+
+.autofill-item-box > .profile-comment-col > .profile-comment {
+ font-size: calc(var(--comment-font-size) / var(--default-font-size) * 1em);
+ unicode-bidi: plaintext;
+}
+
+.autofill-item-box[size="small"] {
+ flex-direction: column;
+}
+
+.autofill-item-box[size="small"] > .profile-comment-col {
+ margin-top: var(--row-spacer);
+ text-align: start;
+}
+
+.autofill-footer {
+ padding: 0;
+ flex-direction: column;
+}
+
+.autofill-footer > .autofill-footer-row {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: var(--item-width);
+}
+
+.autofill-footer > .autofill-warning {
+ padding: 2.5px 0;
+ color: var(--warning-text-color);
+ text-align: center;
+ background-color: var(--warning-background-color);
+ border-bottom: 1px solid rgba(38,38,38,.15);
+ font-size: calc(var(--warning-font-size) / var(--default-font-size) * 1em);
+}
+
+.autofill-footer > .autofill-button {
+ box-sizing: border-box;
+ padding: 0 10px;
+ min-height: 40px;
+ background-color: ButtonFace;
+ font-size: calc(var(--btn-font-size) / var(--default-font-size) * 1em);
+ color: ButtonText;
+ text-align: center;
+}
+
+.autofill-footer[no-warning="true"] > .autofill-warning {
+ display: none;
+}
+
+.autofill-insecure-item {
+ box-sizing: border-box;
+ padding: 4px 0;
+ display: flex;
+ flex-direction: row;
+ flex-wrap: nowrap;
+ align-items: center;
+ color: GrayText;
+}
+
+.autofill-insecure-item::before {
+ display: block;
+ margin-inline: 4px 8px;
+ content: "";
+ width: 16px;
+ height: 16px;
+ background-image: url(chrome://global/skin/icons/security-broken.svg);
+ -moz-context-properties: fill;
+ fill: GrayText;
+}
diff --git a/browser/extensions/formautofill/skin/shared/editAddress.css b/browser/extensions/formautofill/skin/shared/editAddress.css
new file mode 100644
index 0000000000..ea00f1a6a6
--- /dev/null
+++ b/browser/extensions/formautofill/skin/shared/editAddress.css
@@ -0,0 +1,134 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.editAddressForm {
+ display: flex;
+ flex-wrap: wrap;
+ /* Use space-between so --grid-column-row-gap is in between the elements on a row */
+ justify-content: space-between;
+}
+
+dialog:not([subdialog]) .editAddressForm {
+ margin-inline: calc(var(--grid-column-row-gap) / -2);
+}
+
+.editAddressForm .container {
+ /* !important is needed to override preferences.css's generic label rule. */
+ margin-top: var(--grid-column-row-gap) !important;
+ margin-inline: calc(var(--grid-column-row-gap) / 2);
+ flex-grow: 1;
+}
+
+#country-container {
+ /* The country dropdown has a different intrinsic (content) width than the
+ other fields which are <input>. */
+ flex-basis: calc(50% - var(--grid-column-row-gap));
+ flex-grow: 0;
+ /* Country names can be longer than 50% which ruins the symmetry in the grid. */
+ max-width: calc(50% - var(--grid-column-row-gap));
+}
+
+
+/* Begin name field rules */
+
+#name-container input {
+ /* Override the default @size="20" on <input>, which acts like a min-width, not
+ * allowing the fields to shrink with flexbox as small as they need to to match
+ * the other rows. This is noticeable on narrow viewports e.g. in the
+ * PaymentRequest dialog on Linux due to the larger font-size. */
+ width: 0;
+}
+
+/* When there is focus within any of the name fields, the border of the inputs
+ * should be the focused color, except for inner ones which get overriden below. */
+#name-container:focus-within input {
+ border-color: var(--in-content-focus-outline-color);
+}
+
+/* Invalid name fields should show the error outline instead of the focus border */
+#name-container:focus-within input:-moz-ui-invalid {
+ border-color: transparent;
+}
+
+#given-name-container,
+#additional-name-container,
+#family-name-container {
+ display: flex;
+ /* The 3 pieces inside the name container don't have the .container class so
+ need to set flex-grow themselves. See `.editAddressForm .container` */
+ flex-grow: 1;
+ /* Remove the bottom margin from the name containers so that the outer
+ #name-container provides the margin on the outside */
+ margin-bottom: 0 !important;
+ margin-inline: 0;
+}
+
+#given-name-container:focus-within,
+#additional-name-container:focus-within,
+#family-name-container:focus-within {
+ z-index: 1;
+}
+
+/* The name fields are placed adjacent to each other.
+ Remove the border-radius on adjacent fields. */
+#given-name:dir(ltr),
+#family-name:dir(rtl) {
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+ border-right-width: 0;
+}
+
+#given-name:dir(rtl),
+#family-name:dir(ltr) {
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+ border-left-width: 0;
+}
+
+#additional-name {
+ border-radius: 0;
+ /* This provides the inner separators between the fields and should never
+ * change to the focused color. */
+ border-inline-color: var(--in-content-box-border-color) !important;
+}
+
+/* Since the name fields are adjacent, there isn't room for the -moz-ui-invalid
+ box-shadow so raise invalid name fields and their labels above the siblings
+ so the shadow is shown around all 4 sides. */
+#name-container input:-moz-ui-invalid,
+#name-container input:-moz-ui-invalid ~ .label-text {
+ z-index: 1;
+}
+
+/* End name field rules */
+
+#name-container,
+#street-address-container {
+ /* Name and street address are always full-width */
+ flex: 0 1 100%;
+}
+
+#street-address {
+ resize: vertical;
+}
+
+#country-warning-message {
+ box-sizing: border-box;
+ font-size: 1rem;
+ display: flex;
+ align-items: center;
+ text-align: start;
+ opacity: .5;
+ padding-inline-start: 1em;
+ flex: 1;
+}
+
+dialog:not([subdialog]) #country-warning-message {
+ display: none;
+}
+
+moz-button-group{
+ margin-inline: 4px;
+ margin-block-end: 4px;
+}
diff --git a/browser/extensions/formautofill/skin/shared/editCreditCard.css b/browser/extensions/formautofill/skin/shared/editCreditCard.css
new file mode 100644
index 0000000000..00dde3b5b5
--- /dev/null
+++ b/browser/extensions/formautofill/skin/shared/editCreditCard.css
@@ -0,0 +1,53 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.editCreditCardForm {
+ display: grid;
+ grid-template-areas:
+ "cc-number cc-exp-month cc-exp-year"
+ "cc-name cc-csc ."
+ "billingAddressGUID billingAddressGUID billingAddressGUID";
+ grid-template-columns: 4fr 2fr 2fr;
+ grid-row-gap: var(--grid-column-row-gap);
+ grid-column-gap: var(--grid-column-row-gap);
+}
+
+.editCreditCardForm label {
+ /* Remove the margin on these labels since they are styled on top of
+ the input/select element. */
+ margin-inline-start: 0;
+ margin-inline-end: 0;
+}
+
+.editCreditCardForm .container {
+ display: flex;
+}
+
+#cc-number-container {
+ grid-area: cc-number;
+}
+
+#cc-exp-month-container {
+ grid-area: cc-exp-month;
+}
+
+#cc-exp-year-container {
+ grid-area: cc-exp-year;
+}
+
+#cc-name-container {
+ grid-area: cc-name;
+}
+
+#cc-csc-container {
+ grid-area: cc-csc;
+}
+
+#billingAddressGUID-container {
+ grid-area: billingAddressGUID;
+}
+
+#billingAddressGUID {
+ grid-area: dropdown;
+}
diff --git a/browser/extensions/formautofill/skin/shared/editDialog-shared.css b/browser/extensions/formautofill/skin/shared/editDialog-shared.css
new file mode 100644
index 0000000000..c2ddf71a60
--- /dev/null
+++ b/browser/extensions/formautofill/skin/shared/editDialog-shared.css
@@ -0,0 +1,110 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+:root {
+ --in-field-label-size: .8em;
+ --grid-column-row-gap: 8px;
+ /* Use the animation-easing-function that is defined in xul.css. */
+ --animation-easing-function: cubic-bezier(.07,.95,0,1);
+}
+
+dialog[subdialog] form {
+ /* Add extra space to ensure invalid input box is displayed properly */
+ padding: 2px;
+}
+
+/* The overly specific input attributes are required to override
+ padding from common.css */
+form input[type="email"],
+form input[type="tel"],
+form input[type="text"],
+form textarea,
+form select {
+ flex-grow: 1;
+ padding-top: calc(var(--in-field-label-size) + .4em);
+}
+
+form input[type="tel"] {
+ text-align: match-parent;
+}
+
+select {
+ margin: 0;
+ padding-bottom: 5px;
+}
+
+form label[class="container"] select {
+ min-width: 0;
+}
+
+form label,
+form div {
+ /* Positioned so that the .label-text and .error-text children will be
+ positioned relative to this. */
+ position: relative;
+ display: block;
+ line-height: 1em;
+}
+
+/* Reset margins for inputs and textareas, overriding in-content styles */
+#form textarea,
+#form input {
+ margin: 0;
+}
+
+form :is(label, div) .label-text {
+ position: absolute;
+ opacity: .5;
+ pointer-events: none;
+ inset-inline-start: 10px;
+ top: .2em;
+ transition: top .2s var(--animation-easing-function),
+ font-size .2s var(--animation-easing-function);
+}
+
+form :is(label, div):focus-within .label-text,
+form :is(label, div) .label-text[field-populated] {
+ top: 0;
+ font-size: var(--in-field-label-size);
+}
+
+form :is(input, select, textarea):focus ~ .label-text {
+ color: var(--in-content-item-selected);
+ opacity: 1;
+}
+
+/* Focused error fields should get a darker text but not the blue one since it
+ * doesn't look good with the red error outline. */
+form :is(input, select, textarea):focus:-moz-ui-invalid ~ .label-text {
+ color: var(--in-content-text-color);
+}
+
+form div[required] > label .label-text::after,
+form :is(label, div)[required] .label-text::after {
+ content: attr(fieldRequiredSymbol);
+}
+
+.persist-checkbox label {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ margin-block: var(--grid-column-row-gap);
+}
+
+dialog[subdialog] form {
+ /* Match the margin-inline-start of the #controls-container buttons
+ and provide enough padding at the top of the form so button outlines
+ don't get clipped. */
+ padding: 4px 4px 0;
+}
+
+#controls-container {
+ display: flex;
+ justify-content: end;
+ margin: 1em 0 0;
+}
+
+#billingAddressGUID-container {
+ display: none;
+}
diff --git a/browser/extensions/formautofill/skin/windows/autocomplete-item.css b/browser/extensions/formautofill/skin/windows/autocomplete-item.css
new file mode 100644
index 0000000000..ec5e87d925
--- /dev/null
+++ b/browser/extensions/formautofill/skin/windows/autocomplete-item.css
@@ -0,0 +1,25 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@namespace url("http://www.w3.org/1999/xhtml");
+@namespace xul url("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul");
+
+.autofill-item-box {
+ --default-font-size: 12;
+}
+
+xul|richlistitem[originaltype="autofill-footer"][selected="true"] > .autofill-item-box > .autofill-button,
+xul|richlistitem[originaltype="autofill-clear-button"][selected="true"] > .autofill-item-box > .autofill-button {
+ background-color: color-mix(in srgb, Field 90%, FieldText);
+}
+
+@media (-moz-windows-default-theme: 0) {
+ xul|richlistitem[originaltype="autofill-profile"][selected="true"] > .autofill-item-box {
+ background-color: SelectedItem;
+ }
+
+ .autofill-item-box {
+ --comment-text-color: GrayText;
+ }
+}
diff --git a/browser/extensions/formautofill/skin/windows/editDialog.css b/browser/extensions/formautofill/skin/windows/editDialog.css
new file mode 100644
index 0000000000..334e67cfc7
--- /dev/null
+++ b/browser/extensions/formautofill/skin/windows/editDialog.css
@@ -0,0 +1,12 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* The save button should be on the left and cancel on the right for Windows */
+#controlsContainer > #save {
+ order: -1;
+}
+
+#controlsContainer > #cancel {
+ order: 1;
+}
diff --git a/browser/extensions/formautofill/test/browser/address/browser.ini b/browser/extensions/formautofill/test/browser/address/browser.ini
new file mode 100644
index 0000000000..4eb6cc8927
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/address/browser.ini
@@ -0,0 +1,13 @@
+[DEFAULT]
+prefs =
+ extensions.formautofill.addresses.enabled=true
+ # lower the interval for event telemetry in the content process to update the parent process
+ toolkit.telemetry.ipcBatchTimeout=0
+support-files =
+ ../head.js
+ ../../fixtures/autocomplete_address_basic.html
+ ../../fixtures/without_autocomplete_address_basic.html
+ head_address.js
+
+[browser_address_doorhanger_display.js]
+[browser_address_telemetry.js]
diff --git a/browser/extensions/formautofill/test/browser/address/browser_address_doorhanger_display.js b/browser/extensions/formautofill/test/browser/address/browser_address_doorhanger_display.js
new file mode 100644
index 0000000000..970051667a
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/address/browser_address_doorhanger_display.js
@@ -0,0 +1,240 @@
+"use strict";
+
+async function expectSavedAddresses(expectedCount) {
+ const addresses = await getAddresses();
+ is(
+ addresses.length,
+ expectedCount,
+ `${addresses.length} address in the storage`
+ );
+ return addresses;
+}
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.formautofill.addresses.capture.v2.enabled", true]],
+ });
+});
+
+add_task(async function test_save_doorhanger_shown_no_profile() {
+ await expectSavedAddresses(0);
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: ADDRESS_FORM_URL },
+ async function (browser) {
+ let onPopupShown = waitForPopupShown();
+
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#given-name",
+ newValues: {
+ "#given-name": "Test User",
+ "#organization": "Sesame Street",
+ "#street-address": "123 Sesame Street",
+ "#tel": "1-345-345-3456",
+ },
+ });
+
+ await onPopupShown;
+ await clickDoorhangerButton(MAIN_BUTTON, 0);
+ }
+ );
+
+ await expectSavedAddresses(1);
+ await removeAllRecords();
+});
+
+add_task(async function test_save_doorhanger_shown_different_address() {
+ await setStorage(TEST_ADDRESS_1);
+ await expectSavedAddresses(1);
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: ADDRESS_FORM_URL },
+ async function (browser) {
+ let onPopupShown = waitForPopupShown();
+
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#given-name",
+ newValues: {
+ "#given-name": TEST_ADDRESS_2["give-name"],
+ "#street-address": TEST_ADDRESS_2["street-address"],
+ "#country": TEST_ADDRESS_2.country,
+ },
+ });
+
+ await onPopupShown;
+ await clickDoorhangerButton(MAIN_BUTTON, 0);
+ }
+ );
+
+ await expectSavedAddresses(2);
+ await removeAllRecords();
+});
+
+add_task(
+ async function test_update_doorhanger_shown_change_non_mergeable_given_name() {
+ await setStorage(TEST_ADDRESS_1);
+ await expectSavedAddresses(1);
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: ADDRESS_FORM_URL },
+ async function (browser) {
+ let onPopupShown = waitForPopupShown();
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#given-name",
+ newValues: {
+ "#given-name": "Cena",
+ "#street-address": TEST_ADDRESS_1["street-address"],
+ "#country": TEST_ADDRESS_1.country,
+ "#email": TEST_ADDRESS_1.email,
+ },
+ });
+
+ await onPopupShown;
+ await clickDoorhangerButton(MAIN_BUTTON, 0);
+ }
+ );
+
+ await expectSavedAddresses(2);
+ await removeAllRecords();
+ }
+);
+
+add_task(async function test_update_doorhanger_shown_add_email_field() {
+ // TEST_ADDRESS_2 doesn't contain email field
+ await setStorage(TEST_ADDRESS_2);
+ await expectSavedAddresses(1);
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: ADDRESS_FORM_URL },
+ async function (browser) {
+ let onPopupShown = waitForPopupShown();
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#given-name",
+ newValues: {
+ "#given-name": TEST_ADDRESS_2["given-name"],
+ "#street-address": TEST_ADDRESS_2["street-address"],
+ "#country": TEST_ADDRESS_2.country,
+ "#email": "test@mozilla.org",
+ },
+ });
+
+ await onPopupShown;
+ await clickDoorhangerButton(MAIN_BUTTON, 0);
+ }
+ );
+
+ const addresses = await expectSavedAddresses(1);
+ is(addresses[0].email, "test@mozilla.org", "Email field is saved");
+
+ await removeAllRecords();
+});
+
+add_task(async function test_doorhanger_not_shown_when_autofill_untouched() {
+ await setStorage(TEST_ADDRESS_2);
+ await expectSavedAddresses(1);
+
+ let onUsed = waitForStorageChangedEvents("notifyUsed");
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: ADDRESS_FORM_URL },
+ async function (browser) {
+ await sleep(1000);
+ await openPopupOn(browser, "form #given-name");
+ await sleep(1000);
+ await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser);
+ await sleep(1000);
+ await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser);
+ await waitForAutofill(
+ browser,
+ "#given-name",
+ TEST_ADDRESS_2["given-name"]
+ );
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ let form = content.document.getElementById("form");
+ form.querySelector("input[type=submit]").click();
+ });
+
+ await sleep(1000);
+ is(PopupNotifications.panel.state, "closed", "Doorhanger is hidden");
+ }
+ );
+ await onUsed;
+
+ const addresses = await expectSavedAddresses(1);
+ is(addresses[0].timesUsed, 1, "timesUsed field set to 1");
+ await removeAllRecords();
+});
+
+add_task(async function test_doorhanger_not_shown_when_fill_duplicate() {
+ await setStorage(TEST_ADDRESS_4);
+ await expectSavedAddresses(1);
+
+ let onUsed = waitForStorageChangedEvents("notifyUsed");
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: ADDRESS_FORM_URL },
+ async function (browser) {
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#given-name",
+ newValues: {
+ "#given-name": TEST_ADDRESS_4["given-name"],
+ "#family-name": TEST_ADDRESS_4["family-name"],
+ "#organization": TEST_ADDRESS_4.organization,
+ "#country": TEST_ADDRESS_4.country,
+ },
+ });
+
+ await sleep(1000);
+ is(PopupNotifications.panel.state, "closed", "Doorhanger is hidden");
+ }
+ );
+ await onUsed;
+
+ const addresses = await expectSavedAddresses(1);
+ is(
+ addresses[0]["given-name"],
+ TEST_ADDRESS_4["given-name"],
+ "Verify the name field"
+ );
+ is(addresses[0].timesUsed, 1, "timesUsed field set to 1");
+ await removeAllRecords();
+});
+
+add_task(
+ async function test_doorhanger_not_shown_when_autofill_then_fill_everything_duplicate() {
+ await setStorage(TEST_ADDRESS_2, TEST_ADDRESS_3);
+ await expectSavedAddresses(2);
+
+ let onUsed = waitForStorageChangedEvents("notifyUsed");
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: ADDRESS_FORM_URL },
+ async function (browser) {
+ await openPopupOn(browser, "form #given-name");
+ await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser);
+ await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser);
+ await waitForAutofill(
+ browser,
+ "#given-name",
+ TEST_ADDRESS_2["given-name"]
+ );
+
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#given-name",
+ newValues: {
+ // Change number to the second credit card number
+ "#given-name": TEST_ADDRESS_3["given-name"],
+ "#street-address": TEST_ADDRESS_3["street-address"],
+ "#postal-code": TEST_ADDRESS_3["postal-code"],
+ "#country": "",
+ },
+ });
+
+ await sleep(1000);
+ is(PopupNotifications.panel.state, "closed", "Doorhanger is hidden");
+ }
+ );
+ await onUsed;
+
+ await expectSavedAddresses(2);
+ await removeAllRecords();
+ }
+);
diff --git a/browser/extensions/formautofill/test/browser/address/browser_address_telemetry.js b/browser/extensions/formautofill/test/browser/address/browser_address_telemetry.js
new file mode 100644
index 0000000000..66dbc24951
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/address/browser_address_telemetry.js
@@ -0,0 +1,691 @@
+"use strict";
+
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+const { AddressTelemetry } = ChromeUtils.import(
+ "resource://autofill/AutofillTelemetry.jsm"
+);
+
+// Preference definitions
+const ENABLED_PREF = ENABLED_AUTOFILL_ADDRESSES_PREF;
+const AVAILABLE_PREF = AUTOFILL_ADDRESSES_AVAILABLE_PREF;
+const CAPTURE_ENABLE_PREF = ENABLED_AUTOFILL_ADDRESSES_CAPTURE_PREF;
+
+// Telemetry definitions
+const EVENT_CATEGORY = "address";
+
+const SCALAR_DETECTED_SECTION_COUNT =
+ "formautofill.addresses.detected_sections_count";
+const SCALAR_SUBMITTED_SECTION_COUNT =
+ "formautofill.addresses.submitted_sections_count";
+const SCALAR_AUTOFILL_PROFILE_COUNT =
+ "formautofill.addresses.autofill_profiles_count";
+
+const HISTOGRAM_PROFILE_NUM_USES = "AUTOFILL_PROFILE_NUM_USES";
+const HISTOGRAM_PROFILE_NUM_USES_KEY = "address";
+
+// Autofill UI
+const MANAGE_DIALOG_URL = MANAGE_ADDRESSES_DIALOG_URL;
+const EDIT_DIALOG_URL = EDIT_ADDRESS_DIALOG_URL;
+const DIALOG_SIZE = "width=600,height=400";
+const MANAGE_RECORD_SELECTOR = "#addresses";
+
+// Test specific definitions
+const TEST_PROFILE = TEST_ADDRESS_1;
+const TEST_PROFILE_1 = TEST_ADDRESS_1;
+const TEST_PROFILE_2 = TEST_ADDRESS_2;
+const TEST_PROFILE_3 = TEST_ADDRESS_3;
+
+const TEST_FOCUS_NAME_FIELD = "given-name";
+const TEST_FOCUS_NAME_FIELD_SELECTOR = "#" + TEST_FOCUS_NAME_FIELD;
+
+// Used for tests that update address fields after filling
+const TEST_NEW_VALUES = {
+ "#given-name": "Test User",
+ "#organization": "Sesame Street",
+ "#street-address": "123 Sesame Street",
+ "#tel": "1-345-345-3456",
+};
+
+const TEST_UPDATE_PROFILE_2_VALUES = {
+ "#email": "profile2@mozilla.org",
+};
+const TEST_BASIC_ADDRESS_FORM_URL = ADDRESS_FORM_URL;
+const TEST_BASIC_ADDRESS_FORM_WITHOUT_AC_URL =
+ ADDRESS_FORM_WITHOUT_AUTOCOMPLETE_URL;
+// This should be sync with the address fields that appear in TEST_BASIC_ADDRESS_FORM
+const TEST_BASIC_ADDRESS_FORM_FIELDS = [
+ "street_address",
+ "address_level1",
+ "address_level2",
+ "postal_code",
+ "country",
+ "given_name",
+ "family_name",
+ "organization",
+ "email",
+ "tel",
+];
+
+function buildFormExtra(list, fields, fieldValue, defaultValue, aExtra = {}) {
+ let extra = {};
+ for (const field of list) {
+ if (aExtra[field]) {
+ extra[field] = aExtra[field];
+ } else {
+ extra[field] = fields.includes(field) ? fieldValue : defaultValue;
+ }
+ }
+ return extra;
+}
+
+/**
+ * Utility function to generate expected value for `address_form` and `address_form_ext`
+ * telemetry event.
+ *
+ * @param {string} method see `methods` in `address_form` event telemetry
+ * @param {object} defaultExtra default extra object, this will not be overwritten
+ * @param {object} fields address fields that will be set to `value` param
+ * @param {string} value value to set for fields list in `fields` argument
+ * @param {string} defaultValue value to set for address fields that are not listed in `fields` argument`
+ */
+function formArgs(
+ method,
+ defaultExtra,
+ fields = [],
+ value = undefined,
+ defaultValue = null
+) {
+ if (["popup_shown", "filled_modified"].includes(method)) {
+ return [["address", method, "address_form", undefined, defaultExtra]];
+ }
+ let extra = buildFormExtra(
+ AddressTelemetry.SUPPORTED_FIELDS_IN_FORM,
+ fields,
+ value,
+ defaultValue,
+ defaultExtra
+ );
+
+ let extraExt = buildFormExtra(
+ AddressTelemetry.SUPPORTED_FIELDS_IN_FORM_EXT,
+ fields,
+ value,
+ defaultValue,
+ defaultExtra
+ );
+
+ // The order here should sync with AutofillTelemetry.
+ return [
+ ["address", method, "address_form", undefined, extra],
+ ["address", method, "address_form_ext", undefined, extraExt],
+ ];
+}
+
+function getProfiles() {
+ return getAddresses();
+}
+
+async function assertTelemetry(expected_content, expected_parent) {
+ let snapshots;
+
+ info(
+ `Waiting for ${expected_content?.length ?? 0} content events and ` +
+ `${expected_parent?.length ?? 0} parent events`
+ );
+
+ await TestUtils.waitForCondition(
+ () => {
+ snapshots = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ false
+ );
+
+ return (
+ (snapshots.parent?.length ?? 0) >= (expected_parent?.length ?? 0) &&
+ (snapshots.content?.length ?? 0) >= (expected_content?.length ?? 0)
+ );
+ },
+ "Wait for telemetry to be collected",
+ 100,
+ 100
+ );
+
+ info(JSON.stringify(snapshots, null, 2));
+
+ if (expected_content !== undefined) {
+ expected_content = expected_content.map(
+ ([category, method, object, value, extra]) => {
+ return { category, method, object, value, extra };
+ }
+ );
+
+ let clear = expected_parent === undefined;
+
+ TelemetryTestUtils.assertEvents(
+ expected_content,
+ {
+ category: EVENT_CATEGORY,
+ },
+ { clear, process: "content" }
+ );
+ }
+
+ if (expected_parent !== undefined) {
+ expected_parent = expected_parent.map(
+ ([category, method, object, value, extra]) => {
+ return { category, method, object, value, extra };
+ }
+ );
+ TelemetryTestUtils.assertEvents(
+ expected_parent,
+ {
+ category: EVENT_CATEGORY,
+ },
+ { process: "parent" }
+ );
+ }
+}
+
+function _assertHistogram(snapshot, expectedNonZeroRanges) {
+ // Compute the actual ranges in the format { range1: value1, range2: value2 }.
+ let actualNonZeroRanges = {};
+ for (let [range, value] of Object.entries(snapshot.values)) {
+ if (value > 0) {
+ actualNonZeroRanges[range] = value;
+ }
+ }
+
+ // These are stringified to visualize the differences between the values.
+ Assert.equal(
+ JSON.stringify(actualNonZeroRanges),
+ JSON.stringify(expectedNonZeroRanges)
+ );
+}
+
+function assertKeyedHistogram(histogramId, key, expected) {
+ let snapshot = Services.telemetry
+ .getKeyedHistogramById(histogramId)
+ .snapshot();
+
+ if (expected == undefined) {
+ Assert.deepEqual(snapshot, {});
+ } else {
+ _assertHistogram(snapshot[key], expected);
+ }
+}
+
+async function openTabAndUseAutofillProfile(
+ idx,
+ profile,
+ { closeTab = true, submitForm = true } = {}
+) {
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TEST_BASIC_ADDRESS_FORM_URL
+ );
+ let browser = tab.linkedBrowser;
+
+ await openPopupOn(browser, "form " + TEST_FOCUS_NAME_FIELD_SELECTOR);
+
+ for (let i = 0; i <= idx; i++) {
+ await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser);
+ }
+
+ await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser);
+ await waitForAutofill(
+ browser,
+ TEST_FOCUS_NAME_FIELD_SELECTOR,
+ profile[TEST_FOCUS_NAME_FIELD]
+ );
+ await focusUpdateSubmitForm(
+ browser,
+ {
+ focusSelector: TEST_FOCUS_NAME_FIELD_SELECTOR,
+ newValues: {},
+ },
+ submitForm
+ );
+
+ if (!closeTab) {
+ return tab;
+ }
+
+ await BrowserTestUtils.removeTab(tab);
+ return null;
+}
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [ENABLED_PREF, true],
+ [AVAILABLE_PREF, "on"],
+ [CAPTURE_ENABLE_PREF, true],
+ ],
+ });
+
+ Services.telemetry.setEventRecordingEnabled(EVENT_CATEGORY, true);
+
+ Services.telemetry.clearEvents();
+ Services.telemetry.clearScalars();
+
+ registerCleanupFunction(() => {
+ Services.telemetry.setEventRecordingEnabled(EVENT_CATEGORY, false);
+ });
+});
+
+add_task(async function test_popup_opened() {
+ await setStorage(TEST_PROFILE);
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: TEST_BASIC_ADDRESS_FORM_URL },
+ async function (browser) {
+ const focusInput = TEST_FOCUS_NAME_FIELD_SELECTOR;
+
+ await openPopupOn(browser, focusInput);
+
+ // Clean up
+ await closePopup(browser);
+ }
+ );
+
+ const fields = TEST_BASIC_ADDRESS_FORM_FIELDS;
+ await assertTelemetry([
+ ...formArgs("detected", {}, fields, "true", "false"),
+ ...formArgs("popup_shown", { field_name: TEST_FOCUS_NAME_FIELD }),
+ ]);
+
+ TelemetryTestUtils.assertScalar(
+ TelemetryTestUtils.getProcessScalars("content"),
+ SCALAR_DETECTED_SECTION_COUNT,
+ 1,
+ "There should be 1 section detected."
+ );
+ TelemetryTestUtils.assertScalarUnset(
+ TelemetryTestUtils.getProcessScalars("content"),
+ SCALAR_SUBMITTED_SECTION_COUNT,
+ 1
+ );
+
+ await removeAllRecords();
+ Services.telemetry.clearEvents();
+ Services.telemetry.clearScalars();
+});
+
+add_task(async function test_popup_opened_form_without_autocomplete() {
+ await setStorage(TEST_PROFILE);
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: TEST_BASIC_ADDRESS_FORM_WITHOUT_AC_URL },
+ async function (browser) {
+ const focusInput = TEST_FOCUS_NAME_FIELD_SELECTOR;
+ await openPopupOn(browser, focusInput);
+ await closePopup(browser);
+ }
+ );
+
+ const fields = TEST_BASIC_ADDRESS_FORM_FIELDS;
+ await assertTelemetry([
+ ...formArgs("detected", {}, fields, "0", "false"),
+ ...formArgs("popup_shown", { field_name: TEST_FOCUS_NAME_FIELD }),
+ ]);
+
+ TelemetryTestUtils.assertScalar(
+ TelemetryTestUtils.getProcessScalars("content"),
+ SCALAR_DETECTED_SECTION_COUNT,
+ 1,
+ "There should be 1 section detected."
+ );
+ TelemetryTestUtils.assertScalarUnset(
+ TelemetryTestUtils.getProcessScalars("content"),
+ SCALAR_SUBMITTED_SECTION_COUNT
+ );
+
+ await removeAllRecords();
+ Services.telemetry.clearEvents();
+ Services.telemetry.clearScalars();
+});
+
+add_task(async function test_submit_autofill_profile_new() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.formautofill.firstTimeUse", true]],
+ });
+ async function test_per_command(
+ command,
+ idx,
+ useCount = {},
+ expectChanged = undefined
+ ) {
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: TEST_BASIC_ADDRESS_FORM_URL },
+ async function (browser) {
+ let onPopupShown = waitForPopupShown();
+ let onChanged;
+ if (expectChanged !== undefined) {
+ onChanged = TestUtils.topicObserved("formautofill-storage-changed");
+ }
+
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: TEST_FOCUS_NAME_FIELD_SELECTOR,
+ newValues: TEST_NEW_VALUES,
+ });
+
+ await onPopupShown;
+ await clickDoorhangerButton(command, idx);
+ if (expectChanged !== undefined) {
+ await onChanged;
+ TelemetryTestUtils.assertScalar(
+ TelemetryTestUtils.getProcessScalars("parent"),
+ SCALAR_AUTOFILL_PROFILE_COUNT,
+ expectChanged,
+ "There should be ${expectChanged} profile(s) stored."
+ );
+ }
+ }
+ );
+
+ assertKeyedHistogram(
+ HISTOGRAM_PROFILE_NUM_USES,
+ HISTOGRAM_PROFILE_NUM_USES_KEY,
+ useCount
+ );
+
+ await removeAllRecords();
+ }
+
+ Services.telemetry.clearEvents();
+ Services.telemetry.clearScalars();
+ Services.telemetry.getKeyedHistogramById(HISTOGRAM_PROFILE_NUM_USES).clear();
+
+ const fields = TEST_BASIC_ADDRESS_FORM_FIELDS;
+ let expected_content = [
+ ...formArgs("detected", {}, fields, "true", "false"),
+ ...formArgs("submitted", {}, fields, "user_filled", "unavailable"),
+ ];
+
+ // FTU
+ await test_per_command(MAIN_BUTTON, undefined, { 1: 1 }, 1);
+ await assertTelemetry(expected_content, [
+ [EVENT_CATEGORY, "show", "capture_doorhanger"],
+ [EVENT_CATEGORY, "pref", "capture_doorhanger"],
+ ]);
+
+ // Need to close preference tab
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+
+ TelemetryTestUtils.assertScalar(
+ TelemetryTestUtils.getProcessScalars("content"),
+ SCALAR_DETECTED_SECTION_COUNT,
+ 1,
+ "There should be 1 sections detected."
+ );
+ TelemetryTestUtils.assertScalar(
+ TelemetryTestUtils.getProcessScalars("content"),
+ SCALAR_SUBMITTED_SECTION_COUNT,
+ 1,
+ "There should be 1 section submitted."
+ );
+
+ await removeAllRecords();
+ Services.telemetry.clearEvents();
+ Services.telemetry.clearScalars();
+});
+
+add_task(async function test_submit_autofill_profile_update() {
+ async function test_per_command(
+ command,
+ idx,
+ useCount = {},
+ expectChanged = undefined
+ ) {
+ await setStorage(TEST_PROFILE_2);
+ let profiles = await getProfiles();
+ Assert.equal(profiles.length, 1, "1 entry in storage");
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: TEST_BASIC_ADDRESS_FORM_URL },
+ async function (browser) {
+ let onPopupShown = waitForPopupShown();
+ let onChanged;
+ if (expectChanged !== undefined) {
+ onChanged = TestUtils.topicObserved("formautofill-storage-changed");
+ }
+
+ await openPopupOn(browser, TEST_FOCUS_NAME_FIELD_SELECTOR);
+ await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser);
+ await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser);
+
+ await waitForAutofill(
+ browser,
+ TEST_FOCUS_NAME_FIELD_SELECTOR,
+ TEST_PROFILE_2[TEST_FOCUS_NAME_FIELD]
+ );
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: TEST_FOCUS_NAME_FIELD_SELECTOR,
+ newValues: TEST_UPDATE_PROFILE_2_VALUES,
+ });
+ await onPopupShown;
+ await clickDoorhangerButton(command, idx);
+ if (expectChanged !== undefined) {
+ await onChanged;
+ TelemetryTestUtils.assertScalar(
+ TelemetryTestUtils.getProcessScalars("parent"),
+ SCALAR_AUTOFILL_PROFILE_COUNT,
+ expectChanged,
+ "There should be ${expectChanged} profile(s) stored."
+ );
+ }
+ }
+ );
+
+ assertKeyedHistogram(
+ HISTOGRAM_PROFILE_NUM_USES,
+ HISTOGRAM_PROFILE_NUM_USES_KEY,
+ useCount
+ );
+
+ SpecialPowers.clearUserPref(ENABLED_PREF);
+
+ await removeAllRecords();
+ }
+ Services.telemetry.clearEvents();
+ Services.telemetry.clearScalars();
+ Services.telemetry.getKeyedHistogramById(HISTOGRAM_PROFILE_NUM_USES).clear();
+
+ const fields = TEST_BASIC_ADDRESS_FORM_FIELDS;
+ let expected_content = [
+ ...formArgs("detected", {}, fields, "true", "false"),
+ ...formArgs("popup_shown", { field_name: TEST_FOCUS_NAME_FIELD }),
+ ...formArgs(
+ "filled",
+ {
+ given_name: "filled",
+ street_address: "filled",
+ country: "filled",
+ },
+ fields,
+ "not_filled",
+ "unavailable"
+ ),
+ ...formArgs(
+ "submitted",
+ {
+ given_name: "autofilled",
+ street_address: "autofilled",
+ country: "autofilled",
+ email: "user_filled",
+ },
+ fields,
+ "not_filled",
+ "unavailable"
+ ),
+ ];
+
+ await test_per_command(MAIN_BUTTON, undefined, { 1: 1 }, 1);
+ await assertTelemetry(expected_content, [
+ [EVENT_CATEGORY, "show", "update_doorhanger"],
+ [EVENT_CATEGORY, "update", "update_doorhanger"],
+ ]);
+
+ await test_per_command(SECONDARY_BUTTON, undefined, { 0: 1, 1: 1 }, 2);
+ await assertTelemetry(expected_content, [
+ [EVENT_CATEGORY, "show", "update_doorhanger"],
+ [EVENT_CATEGORY, "save", "update_doorhanger"],
+ ]);
+
+ await removeAllRecords();
+ Services.telemetry.clearEvents();
+ Services.telemetry.clearScalars();
+});
+
+add_task(async function test_removingAutofillProfilesViaKeyboardDelete() {
+ await setStorage(TEST_PROFILE);
+
+ let win = window.openDialog(MANAGE_DIALOG_URL, null, DIALOG_SIZE);
+ await waitForFocusAndFormReady(win);
+
+ let selRecords = win.document.querySelector(MANAGE_RECORD_SELECTOR);
+ Assert.equal(selRecords.length, 1, "One entry");
+
+ EventUtils.synthesizeMouseAtCenter(selRecords.children[0], {}, win);
+ EventUtils.synthesizeKey("VK_DELETE", {}, win);
+ await BrowserTestUtils.waitForEvent(selRecords, "RecordsRemoved");
+ Assert.equal(selRecords.length, 0, "No entry left");
+
+ win.close();
+
+ await assertTelemetry(undefined, [
+ [EVENT_CATEGORY, "show", "manage"],
+ [EVENT_CATEGORY, "delete", "manage"],
+ ]);
+
+ await removeAllRecords();
+ Services.telemetry.clearEvents();
+ Services.telemetry.clearScalars();
+});
+
+add_task(async function test_saveAutofillProfile() {
+ Services.telemetry.clearEvents();
+
+ await testDialog(EDIT_DIALOG_URL, win => {
+ // TODP: Default to US because the layout will be different
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey(TEST_PROFILE["given-name"], {}, win);
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey(TEST_PROFILE["family-name"], {}, win);
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey(TEST_PROFILE["street-address"], {}, win);
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey(TEST_PROFILE["address-level2"], {}, win);
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey(TEST_PROFILE["postal-code"], {}, win);
+ info("saving one entry");
+ win.document.querySelector("#save").click();
+ });
+
+ await assertTelemetry(undefined, [[EVENT_CATEGORY, "add", "manage"]]);
+
+ await removeAllRecords();
+ Services.telemetry.clearEvents();
+ Services.telemetry.clearScalars();
+});
+
+add_task(async function test_editAutofillProfile() {
+ Services.telemetry.clearEvents();
+
+ await setStorage(TEST_PROFILE);
+
+ let profiles = await getProfiles();
+ Assert.equal(profiles.length, 1, "1 entry in storage");
+ await testDialog(
+ EDIT_DIALOG_URL,
+ win => {
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey("VK_RIGHT", {}, win);
+ EventUtils.synthesizeKey("test", {}, win);
+ win.document.querySelector("#save").click();
+ },
+ {
+ record: profiles[0],
+ }
+ );
+
+ await assertTelemetry(undefined, [
+ [EVENT_CATEGORY, "show_entry", "manage"],
+ [EVENT_CATEGORY, "edit", "manage"],
+ ]);
+
+ await removeAllRecords();
+ Services.telemetry.clearEvents();
+ Services.telemetry.clearScalars();
+});
+
+add_task(async function test_histogram() {
+ Services.telemetry.getKeyedHistogramById(HISTOGRAM_PROFILE_NUM_USES).clear();
+
+ await setStorage(TEST_PROFILE_1, TEST_PROFILE_2, TEST_PROFILE_3);
+ let profiles = await getProfiles();
+ Assert.equal(profiles.length, 3, "3 entry in storage");
+
+ assertKeyedHistogram(
+ HISTOGRAM_PROFILE_NUM_USES,
+ HISTOGRAM_PROFILE_NUM_USES_KEY,
+ { 0: 3 }
+ );
+
+ await openTabAndUseAutofillProfile(0, TEST_PROFILE_1);
+ assertKeyedHistogram(
+ HISTOGRAM_PROFILE_NUM_USES,
+ HISTOGRAM_PROFILE_NUM_USES_KEY,
+ { 0: 2, 1: 1 }
+ );
+
+ await openTabAndUseAutofillProfile(1, TEST_PROFILE_2);
+ assertKeyedHistogram(
+ HISTOGRAM_PROFILE_NUM_USES,
+ HISTOGRAM_PROFILE_NUM_USES_KEY,
+ { 0: 1, 1: 2 }
+ );
+
+ await openTabAndUseAutofillProfile(0, TEST_PROFILE_2);
+ assertKeyedHistogram(
+ HISTOGRAM_PROFILE_NUM_USES,
+ HISTOGRAM_PROFILE_NUM_USES_KEY,
+ { 0: 1, 1: 1, 2: 1 }
+ );
+
+ await openTabAndUseAutofillProfile(1, TEST_PROFILE_1);
+ assertKeyedHistogram(
+ HISTOGRAM_PROFILE_NUM_USES,
+ HISTOGRAM_PROFILE_NUM_USES_KEY,
+ { 0: 1, 2: 2 }
+ );
+
+ await openTabAndUseAutofillProfile(2, TEST_PROFILE_3);
+ assertKeyedHistogram(
+ HISTOGRAM_PROFILE_NUM_USES,
+ HISTOGRAM_PROFILE_NUM_USES_KEY,
+ { 1: 1, 2: 2 }
+ );
+
+ await removeAllRecords();
+
+ assertKeyedHistogram(
+ HISTOGRAM_PROFILE_NUM_USES,
+ HISTOGRAM_PROFILE_NUM_USES_KEY,
+ undefined
+ );
+
+ Services.telemetry.clearEvents();
+ Services.telemetry.clearScalars();
+});
+
+add_task(async function test_clear_autofill_profile_autofill() {
+ // Address does not have clear pref. Keep the test so we know we should implement
+ // the test if we support clearing address via autocomplete.
+ Assert.ok(true);
+});
diff --git a/browser/extensions/formautofill/test/browser/address/head_address.js b/browser/extensions/formautofill/test/browser/address/head_address.js
new file mode 100644
index 0000000000..42196e8422
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/address/head_address.js
@@ -0,0 +1 @@
+/* import-globals-from ../head.js */
diff --git a/browser/extensions/formautofill/test/browser/browser.ini b/browser/extensions/formautofill/test/browser/browser.ini
new file mode 100644
index 0000000000..c9d100430b
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/browser.ini
@@ -0,0 +1,35 @@
+[DEFAULT]
+head = head.js
+support-files =
+ ./fathom/**
+ ../fixtures/autocomplete_basic.html
+ ../fixtures/autocomplete_iframe.html
+ ../fixtures/autocomplete_simple_basic.html
+ ./empty.html
+
+[browser_autocomplete_footer.js]
+skip-if = verify
+[browser_autocomplete_marked_back_forward.js]
+[browser_autocomplete_marked_detached_tab.js]
+skip-if =
+ verify && (os == "win")
+ os == "mac"
+[browser_autofill_address_select.js]
+[browser_autofill_duplicate_fields.js]
+[browser_check_installed.js]
+[browser_dropdown_layout.js]
+[browser_editAddressDialog.js]
+skip-if =
+ verify
+ win10_2004 # Bug 1723573
+ win11_2009 # Bug 1797751
+[browser_fathom_cc.js]
+[browser_first_time_use_doorhanger.js]
+skip-if = verify
+[browser_manageAddressesDialog.js]
+[browser_privacyPreferences.js]
+skip-if = (( os == "mac") || (os == 'linux') || (os == 'win')) # perma-fail see Bug 1600059
+[browser_remoteiframe.js]
+[browser_submission_in_private_mode.js]
+[browser_update_doorhanger.js]
+skip-if = true # bug 1426981 # Bug 1445538
diff --git a/browser/extensions/formautofill/test/browser/browser_autocomplete_footer.js b/browser/extensions/formautofill/test/browser/browser_autocomplete_footer.js
new file mode 100644
index 0000000000..d2f5c72f16
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/browser_autocomplete_footer.js
@@ -0,0 +1,137 @@
+"use strict";
+
+const URL = BASE_URL + "autocomplete_basic.html";
+
+add_task(async function setup_storage() {
+ await setStorage(
+ TEST_ADDRESS_2,
+ TEST_ADDRESS_3,
+ TEST_ADDRESS_4,
+ TEST_ADDRESS_5
+ );
+});
+
+add_task(async function test_press_enter_on_footer() {
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: URL },
+ async function (browser) {
+ const {
+ autoCompletePopup: { richlistbox: itemsBox },
+ } = browser;
+
+ await openPopupOn(browser, "#organization");
+ // Navigate to the footer and press enter.
+ const listItemElems = itemsBox.querySelectorAll(
+ ".autocomplete-richlistitem"
+ );
+ const prefTabPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ PRIVACY_PREF_URL,
+ true
+ );
+ for (let i = 0; i < listItemElems.length; i++) {
+ if (!listItemElems[i].collapsed) {
+ await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser);
+ }
+ }
+ await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser);
+ info(`expecting tab: about:preferences#privacy opened`);
+ const prefTab = await prefTabPromise;
+ info(`expecting tab: about:preferences#privacy removed`);
+ BrowserTestUtils.removeTab(prefTab);
+ ok(
+ true,
+ "Tab: preferences#privacy was successfully opened by pressing enter on the footer"
+ );
+
+ await closePopup(browser);
+ }
+ );
+});
+
+add_task(async function test_click_on_footer() {
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: URL },
+ async function (browser) {
+ const {
+ autoCompletePopup: { richlistbox: itemsBox },
+ } = browser;
+
+ await openPopupOn(browser, "#organization");
+ // Click on the footer
+ let optionButton = itemsBox.querySelector(
+ ".autocomplete-richlistitem:last-child"
+ );
+ while (optionButton.collapsed) {
+ optionButton = optionButton.previousElementSibling;
+ }
+ optionButton = optionButton._optionButton;
+
+ const prefTabPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ PRIVACY_PREF_URL,
+ true
+ );
+ // Make sure dropdown is visible before continuing mouse synthesizing.
+ await BrowserTestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_visible(optionButton)
+ );
+ await EventUtils.synthesizeMouseAtCenter(optionButton, {});
+ info(`expecting tab: about:preferences#privacy opened`);
+ const prefTab = await prefTabPromise;
+ info(`expecting tab: about:preferences#privacy removed`);
+ BrowserTestUtils.removeTab(prefTab);
+ ok(
+ true,
+ "Tab: preferences#privacy was successfully opened by clicking on the footer"
+ );
+
+ await closePopup(browser);
+ }
+ );
+});
+
+add_task(async function test_phishing_warning_single_category() {
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: URL },
+ async function (browser) {
+ const {
+ autoCompletePopup: { richlistbox: itemsBox },
+ } = browser;
+
+ await openPopupOn(browser, "#tel");
+ const warningBox = itemsBox.querySelector(
+ ".autocomplete-richlistitem:last-child"
+ )._warningTextBox;
+ ok(warningBox, "Got phishing warning box");
+ await expectWarningText(browser, "Autofills phone");
+ is(
+ warningBox.ownerGlobal.getComputedStyle(warningBox).backgroundColor,
+ "rgba(248, 232, 28, 0.2)",
+ "Check warning text background color"
+ );
+
+ await closePopup(browser);
+ }
+ );
+});
+
+add_task(async function test_phishing_warning_complex_categories() {
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: URL },
+ async function (browser) {
+ await openPopupOn(browser, "#street-address");
+
+ await expectWarningText(browser, "Also autofills organization, email");
+ await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser);
+ await expectWarningText(browser, "Autofills address");
+ await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser);
+ await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser);
+ await expectWarningText(browser, "Also autofills organization, email");
+ await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser);
+ await expectWarningText(browser, "Also autofills organization, email");
+
+ await closePopup(browser);
+ }
+ );
+});
diff --git a/browser/extensions/formautofill/test/browser/browser_autocomplete_marked_back_forward.js b/browser/extensions/formautofill/test/browser/browser_autocomplete_marked_back_forward.js
new file mode 100644
index 0000000000..ada61fa014
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/browser_autocomplete_marked_back_forward.js
@@ -0,0 +1,66 @@
+/**
+ * Test that autofill autocomplete works after back/forward navigation
+ */
+
+"use strict";
+
+const URL = BASE_URL + "autocomplete_basic.html";
+
+function checkPopup(autoCompletePopup) {
+ let first = autoCompletePopup.view.results[0];
+ const { primary, secondary } = JSON.parse(first.label);
+ ok(
+ primary.startsWith(TEST_ADDRESS_1["street-address"].split("\n")[0]),
+ "Check primary label is street address"
+ );
+ is(
+ secondary,
+ TEST_ADDRESS_1["address-level2"],
+ "Check secondary label is address-level2"
+ );
+}
+
+add_task(async function setup_storage() {
+ await setStorage(TEST_ADDRESS_1, TEST_ADDRESS_2, TEST_ADDRESS_3);
+});
+
+add_task(async function test_back_forward() {
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: URL },
+ async function (browser) {
+ const { autoCompletePopup } = browser;
+
+ // Check the page after the initial load
+ await openPopupOn(browser, "#street-address");
+ checkPopup(autoCompletePopup);
+
+ // Now navigate forward and make sure autofill autocomplete results are still attached
+ let loadPromise = BrowserTestUtils.browserLoaded(browser);
+ BrowserTestUtils.loadURIString(browser, `${URL}?load=2`);
+ info("expecting browser loaded");
+ await loadPromise;
+
+ // Check the second page
+ await openPopupOn(browser, "#street-address");
+ checkPopup(autoCompletePopup);
+
+ // Check after hitting back to the first page
+ let stoppedPromise = BrowserTestUtils.browserStopped(browser);
+ browser.goBack();
+ info("expecting browser stopped");
+ await stoppedPromise;
+ await openPopupOn(browser, "#street-address");
+ checkPopup(autoCompletePopup);
+
+ // Check after hitting forward to the second page
+ stoppedPromise = BrowserTestUtils.browserStopped(browser);
+ browser.goForward();
+ info("expecting browser stopped");
+ await stoppedPromise;
+ await openPopupOn(browser, "#street-address");
+ checkPopup(autoCompletePopup);
+
+ await closePopup(browser);
+ }
+ );
+});
diff --git a/browser/extensions/formautofill/test/browser/browser_autocomplete_marked_detached_tab.js b/browser/extensions/formautofill/test/browser/browser_autocomplete_marked_detached_tab.js
new file mode 100644
index 0000000000..399e7e16e5
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/browser_autocomplete_marked_detached_tab.js
@@ -0,0 +1,58 @@
+/**
+ * Test that autofill autocomplete works after detaching a tab
+ */
+
+"use strict";
+
+const URL = BASE_URL + "autocomplete_basic.html";
+
+function checkPopup(autoCompletePopup) {
+ let first = autoCompletePopup.view.results[0];
+ const { primary, secondary } = JSON.parse(first.label);
+ ok(
+ primary.startsWith(TEST_ADDRESS_1["street-address"].split("\n")[0]),
+ "Check primary label is street address"
+ );
+ is(
+ secondary,
+ TEST_ADDRESS_1["address-level2"],
+ "Check secondary label is address-level2"
+ );
+}
+
+add_task(async function setup_storage() {
+ await setStorage(TEST_ADDRESS_1, TEST_ADDRESS_2, TEST_ADDRESS_3);
+});
+
+add_task(async function test_detach_tab_marked() {
+ let tab = await BrowserTestUtils.openNewForegroundTab({ gBrowser, url: URL });
+ let browser = tab.linkedBrowser;
+ const { autoCompletePopup } = browser;
+
+ // Check the page after the initial load
+ await openPopupOn(browser, "#street-address");
+ checkPopup(autoCompletePopup);
+ await closePopup(browser);
+
+ // Detach the tab to a new window
+ info("expecting tab replaced with new window");
+ let windowLoadedPromise = BrowserTestUtils.waitForNewWindow();
+ let newWin = gBrowser.replaceTabWithWindow(
+ gBrowser.getTabForBrowser(browser)
+ );
+ await windowLoadedPromise;
+
+ info("tab was detached");
+ let newBrowser = newWin.gBrowser.selectedBrowser;
+ ok(newBrowser, "Found new <browser>");
+ let newAutoCompletePopup = newBrowser.autoCompletePopup;
+ ok(newAutoCompletePopup, "Found new autocomplete popup");
+
+ await openPopupOn(newBrowser, "#street-address");
+ checkPopup(newAutoCompletePopup);
+
+ await closePopup(newBrowser);
+ let windowRefocusedPromise = BrowserTestUtils.waitForEvent(window, "focus");
+ await BrowserTestUtils.closeWindow(newWin);
+ await windowRefocusedPromise;
+});
diff --git a/browser/extensions/formautofill/test/browser/browser_autofill_address_select.js b/browser/extensions/formautofill/test/browser/browser_autofill_address_select.js
new file mode 100644
index 0000000000..68e38ad10d
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/browser_autofill_address_select.js
@@ -0,0 +1,64 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PROFILE_US = {
+ email: "address_us@mozilla.org",
+ organization: "Mozilla",
+ country: "US",
+};
+
+const TEST_PROFILE_CA = {
+ email: "address_ca@mozilla.org",
+ organization: "Mozilla",
+ country: "CA",
+};
+
+const MARKUP_SELECT_COUNTRY = `
+ <html><body>
+ <input id="email" autocomplete="email">
+ <input id="organization" autocomplete="organization">
+ <select id="country""" autocomplete="country">
+ <option value="Germany">Germany</option>
+ <option value="Canada">Canada</option>
+ <option value="United States">United States</option>
+ <option value="France">France</option>
+ </select>
+ </body></html>
+`;
+
+add_autofill_heuristic_tests([
+ {
+ fixtureData: MARKUP_SELECT_COUNTRY,
+ profile: TEST_PROFILE_US,
+ expectedResult: [
+ {
+ default: {
+ reason: "autocomplete",
+ },
+ fields: [
+ { fieldName: "email", autofill: TEST_PROFILE_US.email },
+ { fieldName: "organization", autofill: TEST_PROFILE_US.organization },
+ { fieldName: "country", autofill: "United States" },
+ ],
+ },
+ ],
+ },
+ {
+ fixtureData: MARKUP_SELECT_COUNTRY,
+ profile: TEST_PROFILE_CA,
+ expectedResult: [
+ {
+ default: {
+ reason: "autocomplete",
+ },
+ fields: [
+ { fieldName: "email", autofill: TEST_PROFILE_CA.email },
+ { fieldName: "organization", autofill: TEST_PROFILE_CA.organization },
+ { fieldName: "country", autofill: "Canada" },
+ ],
+ },
+ ],
+ },
+]);
diff --git a/browser/extensions/formautofill/test/browser/browser_autofill_duplicate_fields.js b/browser/extensions/formautofill/test/browser/browser_autofill_duplicate_fields.js
new file mode 100644
index 0000000000..d7f80c2749
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/browser_autofill_duplicate_fields.js
@@ -0,0 +1,95 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PROFILE = {
+ "given-name": "Timothy",
+ "additional-name": "John",
+ "family-name": "Berners-Lee",
+ organization: "Mozilla",
+ "street-address": "331 E Evelyn Ave",
+ "address-level2": "Mountain View",
+ "address-level1": "CA",
+ "postal-code": "94041",
+ country: "US",
+ tel: "+16509030800",
+ email: "address@mozilla.org",
+};
+
+add_autofill_heuristic_tests([
+ {
+ fixtureData: `
+ <html><body>
+ <input id="email" autocomplete="email">
+ <input id="postal-code" autocomplete="postal-code">
+ <input id="country" autocomplete="country">
+ </body></html>
+ `,
+ profile: TEST_PROFILE,
+ expectedResult: [
+ {
+ default: {
+ reason: "autocomplete",
+ },
+ fields: [
+ { fieldName: "email", autofill: TEST_PROFILE.email },
+ { fieldName: "postal-code", autofill: TEST_PROFILE["postal-code"] },
+ { fieldName: "country", autofill: TEST_PROFILE.country },
+ ],
+ },
+ ],
+ },
+ {
+ description: "autofill multiple email fields(2)",
+ fixtureData: `
+ <html><body>
+ <input id="email" autocomplete="email">
+ <input id="email" autocomplete="email">
+ <input id="postal-code" autocomplete="postal-code">
+ <input id="country" autocomplete="country">
+ </body></html>
+ `,
+ profile: TEST_PROFILE,
+ expectedResult: [
+ {
+ default: {
+ reason: "autocomplete",
+ },
+ fields: [
+ { fieldName: "email", autofill: TEST_PROFILE.email },
+ { fieldName: "email", autofill: TEST_PROFILE.email },
+ { fieldName: "postal-code", autofill: TEST_PROFILE["postal-code"] },
+ { fieldName: "country", autofill: TEST_PROFILE.country },
+ ],
+ },
+ ],
+ },
+ {
+ description: "autofill multiple email fields(3)",
+ fixtureData: `
+ <html><body>
+ <input id="email" autocomplete="email">
+ <input id="email" autocomplete="email">
+ <input id="email" autocomplete="email">
+ <input id="postal-code" autocomplete="postal-code">
+ <input id="country" autocomplete="country">
+ </body></html>
+ `,
+ profile: TEST_PROFILE,
+ expectedResult: [
+ {
+ default: {
+ reason: "autocomplete",
+ },
+ fields: [
+ { fieldName: "email", autofill: TEST_PROFILE.email },
+ { fieldName: "email", autofill: TEST_PROFILE.email },
+ { fieldName: "email", autofill: TEST_PROFILE.email },
+ { fieldName: "postal-code", autofill: TEST_PROFILE["postal-code"] },
+ { fieldName: "country", autofill: TEST_PROFILE.country },
+ ],
+ },
+ ],
+ },
+]);
diff --git a/browser/extensions/formautofill/test/browser/browser_check_installed.js b/browser/extensions/formautofill/test/browser/browser_check_installed.js
new file mode 100644
index 0000000000..a93e64c209
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/browser_check_installed.js
@@ -0,0 +1,12 @@
+"use strict";
+
+add_task(async function test_enabled() {
+ let addon = await AddonManager.getAddonByID("formautofill@mozilla.org");
+ isnot(addon, null, "Check addon exists");
+ is(addon.version, "1.0.1", "Check version");
+ is(addon.name, "Form Autofill", "Check name");
+ ok(addon.isCompatible, "Check application compatibility");
+ ok(!addon.appDisabled, "Check not app disabled");
+ ok(addon.isActive, "Check addon is active");
+ is(addon.type, "extension", "Check type is 'extension'");
+});
diff --git a/browser/extensions/formautofill/test/browser/browser_dropdown_layout.js b/browser/extensions/formautofill/test/browser/browser_dropdown_layout.js
new file mode 100644
index 0000000000..bc1d2fccab
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/browser_dropdown_layout.js
@@ -0,0 +1,53 @@
+"use strict";
+
+const URL =
+ "http://example.org/browser/browser/extensions/formautofill/test/browser/autocomplete_basic.html";
+
+add_task(async function setup_storage() {
+ await setStorage(TEST_ADDRESS_1, TEST_ADDRESS_2, TEST_ADDRESS_3);
+});
+
+async function reopenPopupWithResizedInput(browser, selector, newSize) {
+ await closePopup(browser);
+ /* eslint no-shadow: ["error", { "allow": ["selector", "newSize"] }] */
+ await SpecialPowers.spawn(
+ browser,
+ [{ selector, newSize }],
+ async function ({ selector, newSize }) {
+ const input = content.document.querySelector(selector);
+
+ input.style.boxSizing = "border-box";
+ input.style.width = newSize + "px";
+ }
+ );
+ await openPopupOn(browser, selector);
+}
+
+add_task(async function test_address_dropdown() {
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: URL },
+ async function (browser) {
+ const focusInput = "#organization";
+ await openPopupOn(browser, focusInput);
+ const firstItem = getDisplayedPopupItems(browser)[0];
+
+ is(firstItem.getAttribute("ac-image"), "", "Should not show icon");
+
+ // The breakpoint of two-lines layout is 150px
+ await reopenPopupWithResizedInput(browser, focusInput, 140);
+ is(
+ firstItem._itemBox.getAttribute("size"),
+ "small",
+ "Show two-lines layout"
+ );
+ await reopenPopupWithResizedInput(browser, focusInput, 160);
+ is(
+ firstItem._itemBox.hasAttribute("size"),
+ false,
+ "Show one-line layout"
+ );
+
+ await closePopup(browser);
+ }
+ );
+});
diff --git a/browser/extensions/formautofill/test/browser/browser_editAddressDialog.js b/browser/extensions/formautofill/test/browser/browser_editAddressDialog.js
new file mode 100644
index 0000000000..0088f916bd
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/browser_editAddressDialog.js
@@ -0,0 +1,951 @@
+"use strict";
+
+const { FormAutofillUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/FormAutofillUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ Region: "resource://gre/modules/Region.sys.mjs",
+});
+
+requestLongerTimeout(6);
+
+add_task(async function setup_supportedCountries() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[SUPPORTED_COUNTRIES_PREF, "US,CA,DE"]],
+ });
+});
+
+add_task(async function test_cancelEditAddressDialog() {
+ await testDialog(EDIT_ADDRESS_DIALOG_URL, win => {
+ win.document.querySelector("#cancel").click();
+ });
+});
+
+add_task(async function test_cancelEditAddressDialogWithESC() {
+ await testDialog(EDIT_ADDRESS_DIALOG_URL, win => {
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, win);
+ });
+});
+
+add_task(async function test_defaultCountry() {
+ Region._setHomeRegion("CA", false);
+ await testDialog(EDIT_ADDRESS_DIALOG_URL, win => {
+ let doc = win.document;
+ is(
+ doc.querySelector("#country").value,
+ "CA",
+ "Default country set to Canada"
+ );
+ doc.querySelector("#cancel").click();
+ });
+ Region._setHomeRegion("DE", false);
+ await testDialog(EDIT_ADDRESS_DIALOG_URL, win => {
+ let doc = win.document;
+ is(
+ doc.querySelector("#country").value,
+ "DE",
+ "Default country set to Germany"
+ );
+ doc.querySelector("#cancel").click();
+ });
+ // Test unsupported country
+ Region._setHomeRegion("XX", false);
+ await testDialog(EDIT_ADDRESS_DIALOG_URL, win => {
+ let doc = win.document;
+ is(doc.querySelector("#country").value, "", "Default country set to empty");
+ doc.querySelector("#cancel").click();
+ });
+ Region._setHomeRegion("US", false);
+});
+
+add_task(async function test_saveAddress() {
+ await testDialog(EDIT_ADDRESS_DIALOG_URL, win => {
+ let doc = win.document;
+ // Verify labels
+ is(
+ doc.querySelector("#address-level1-container > .label-text").textContent,
+ "State",
+ "US address-level1 label should be 'State'"
+ );
+ is(
+ doc.querySelector("#postal-code-container > .label-text").textContent,
+ "ZIP Code",
+ "US postal-code label should be 'ZIP Code'"
+ );
+ // Input address info and verify move through form with tab keys
+ let keypresses = [
+ "VK_TAB",
+ TEST_ADDRESS_1["given-name"],
+ "VK_TAB",
+ TEST_ADDRESS_1["additional-name"],
+ "VK_TAB",
+ TEST_ADDRESS_1["family-name"],
+ "VK_TAB",
+ TEST_ADDRESS_1["street-address"],
+ "VK_TAB",
+ TEST_ADDRESS_1["address-level2"],
+ "VK_TAB",
+ TEST_ADDRESS_1["address-level1"],
+ "VK_TAB",
+ TEST_ADDRESS_1["postal-code"],
+ "VK_TAB",
+ TEST_ADDRESS_1.organization,
+ "VK_TAB",
+ // TEST_ADDRESS_1.country, // Country is already US
+ "VK_TAB",
+ TEST_ADDRESS_1.tel,
+ "VK_TAB",
+ TEST_ADDRESS_1.email,
+ "VK_TAB",
+ ];
+ if (AppConstants.platform != "win") {
+ keypresses.push("VK_TAB", "VK_RETURN");
+ } else {
+ keypresses.push("VK_RETURN");
+ }
+ keypresses.forEach(keypress => {
+ if (
+ doc.activeElement.localName == "select" &&
+ !keypress.startsWith("VK_")
+ ) {
+ let field = doc.activeElement;
+ while (field.value != keypress) {
+ EventUtils.synthesizeKey(keypress[0], {}, win);
+ }
+ } else {
+ EventUtils.synthesizeKey(keypress, {}, win);
+ }
+ });
+ });
+ let addresses = await getAddresses();
+
+ is(addresses.length, 1, "only one address is in storage");
+ is(
+ Object.keys(TEST_ADDRESS_1).length,
+ 11,
+ "Sanity check number of properties"
+ );
+ for (let [fieldName, fieldValue] of Object.entries(TEST_ADDRESS_1)) {
+ is(addresses[0][fieldName], fieldValue, "check " + fieldName);
+ }
+});
+
+add_task(async function test_editAddress() {
+ let addresses = await getAddresses();
+ await testDialog(
+ EDIT_ADDRESS_DIALOG_URL,
+ win => {
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey("VK_RIGHT", {}, win);
+ EventUtils.synthesizeKey("test", {}, win);
+
+ let stateSelect = win.document.querySelector("#address-level1");
+ is(
+ stateSelect.selectedOptions[0].value,
+ TEST_ADDRESS_1["address-level1"],
+ "address-level1 should be selected in the dropdown"
+ );
+
+ win.document.querySelector("#save").click();
+ },
+ {
+ record: addresses[0],
+ }
+ );
+ addresses = await getAddresses();
+
+ is(addresses.length, 1, "only one address is in storage");
+ is(
+ addresses[0]["given-name"],
+ TEST_ADDRESS_1["given-name"] + "test",
+ "given-name changed"
+ );
+ await removeAddresses([addresses[0].guid]);
+
+ addresses = await getAddresses();
+ is(addresses.length, 0, "Address storage is empty");
+});
+
+add_task(
+ async function test_editAddressFrenchCanadianChangedToEnglishRepresentation() {
+ let addressClone = Object.assign({}, TEST_ADDRESS_CA_1);
+ addressClone["address-level1"] = "Colombie-Britannique";
+ await setStorage(addressClone);
+
+ let addresses = await getAddresses();
+ await testDialog(
+ EDIT_ADDRESS_DIALOG_URL,
+ win => {
+ let stateSelect = win.document.querySelector("#address-level1");
+ is(
+ stateSelect.selectedOptions[0].value,
+ "BC",
+ "address-level1 should have 'BC' selected in the dropdown"
+ );
+
+ win.document.querySelector("#save").click();
+ },
+ {
+ record: addresses[0],
+ }
+ );
+ addresses = await getAddresses();
+
+ is(addresses.length, 1, "only one address is in storage");
+ is(addresses[0]["address-level1"], "BC", "address-level1 changed");
+ await removeAddresses([addresses[0].guid]);
+
+ addresses = await getAddresses();
+ is(addresses.length, 0, "Address storage is empty");
+ }
+);
+
+add_task(async function test_editSparseAddress() {
+ let record = { ...TEST_ADDRESS_1 };
+ info("delete some usually required properties");
+ delete record["street-address"];
+ delete record["address-level1"];
+ delete record["address-level2"];
+ await testDialog(
+ EDIT_ADDRESS_DIALOG_URL,
+ win => {
+ is(
+ win.document.querySelectorAll(":-moz-ui-invalid").length,
+ 0,
+ "Check no fields are visually invalid"
+ );
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey("VK_RIGHT", {}, win);
+ EventUtils.synthesizeKey("test", {}, win);
+ is(
+ win.document.querySelector("#save").disabled,
+ false,
+ "Save button should be enabled after an edit"
+ );
+ win.document.querySelector("#cancel").click();
+ },
+ {
+ record,
+ }
+ );
+});
+
+add_task(async function test_saveAddressCA() {
+ await testDialog(EDIT_ADDRESS_DIALOG_URL, async win => {
+ let doc = win.document;
+ // Change country to verify labels
+ doc.querySelector("#country").focus();
+ EventUtils.synthesizeKey("Canada", {}, win);
+
+ await TestUtils.waitForCondition(() => {
+ return (
+ doc.querySelector("#address-level1-container > .label-text")
+ .textContent == "Province"
+ );
+ }, "Wait for the mutation observer to change the labels");
+ is(
+ doc.querySelector("#address-level1-container > .label-text").textContent,
+ "Province",
+ "CA address-level1 label should be 'Province'"
+ );
+ is(
+ doc.querySelector("#postal-code-container > .label-text").textContent,
+ "Postal Code",
+ "CA postal-code label should be 'Postal Code'"
+ );
+ is(
+ doc.querySelector("#address-level3-container").style.display,
+ "none",
+ "CA address-level3 should be hidden"
+ );
+
+ // Input address info and verify move through form with tab keys
+ doc.querySelector("#given-name").focus();
+ let keyInputs = [
+ TEST_ADDRESS_CA_1["given-name"],
+ "VK_TAB",
+ TEST_ADDRESS_CA_1["additional-name"],
+ "VK_TAB",
+ TEST_ADDRESS_CA_1["family-name"],
+ "VK_TAB",
+ TEST_ADDRESS_CA_1.organization,
+ "VK_TAB",
+ TEST_ADDRESS_CA_1["street-address"],
+ "VK_TAB",
+ TEST_ADDRESS_CA_1["address-level2"],
+ "VK_TAB",
+ TEST_ADDRESS_CA_1["address-level1"],
+ "VK_TAB",
+ TEST_ADDRESS_CA_1["postal-code"],
+ "VK_TAB",
+ // TEST_ADDRESS_1.country, // Country is already selected above
+ "VK_TAB",
+ TEST_ADDRESS_CA_1.tel,
+ "VK_TAB",
+ TEST_ADDRESS_CA_1.email,
+ "VK_TAB",
+ ];
+ if (AppConstants.platform != "win") {
+ keyInputs.push("VK_TAB", "VK_RETURN");
+ } else {
+ keyInputs.push("VK_RETURN");
+ }
+ keyInputs.forEach(input => EventUtils.synthesizeKey(input, {}, win));
+ });
+ let addresses = await getAddresses();
+ for (let [fieldName, fieldValue] of Object.entries(TEST_ADDRESS_CA_1)) {
+ is(addresses[0][fieldName], fieldValue, "check " + fieldName);
+ }
+ await removeAllRecords();
+});
+
+add_task(async function test_saveAddressDE() {
+ await testDialog(EDIT_ADDRESS_DIALOG_URL, async win => {
+ let doc = win.document;
+ // Change country to verify labels
+ doc.querySelector("#country").focus();
+ EventUtils.synthesizeKey("Germany", {}, win);
+ await TestUtils.waitForCondition(() => {
+ return (
+ doc.querySelector("#postal-code-container > .label-text").textContent ==
+ "Postal Code"
+ );
+ }, "Wait for the mutation observer to change the labels");
+ is(
+ doc.querySelector("#postal-code-container > .label-text").textContent,
+ "Postal Code",
+ "DE postal-code label should be 'Postal Code'"
+ );
+ is(
+ doc.querySelector("#address-level1-container").style.display,
+ "none",
+ "DE address-level1 should be hidden"
+ );
+ is(
+ doc.querySelector("#address-level3-container").style.display,
+ "none",
+ "DE address-level3 should be hidden"
+ );
+ // Input address info and verify move through form with tab keys
+ doc.querySelector("#given-name").focus();
+ let keyInputs = [
+ TEST_ADDRESS_DE_1["given-name"],
+ "VK_TAB",
+ TEST_ADDRESS_DE_1["additional-name"],
+ "VK_TAB",
+ TEST_ADDRESS_DE_1["family-name"],
+ "VK_TAB",
+ TEST_ADDRESS_DE_1.organization,
+ "VK_TAB",
+ TEST_ADDRESS_DE_1["street-address"],
+ "VK_TAB",
+ TEST_ADDRESS_DE_1["postal-code"],
+ "VK_TAB",
+ TEST_ADDRESS_DE_1["address-level2"],
+ "VK_TAB",
+ // TEST_ADDRESS_1.country, // Country is already selected above
+ "VK_TAB",
+ TEST_ADDRESS_DE_1.tel,
+ "VK_TAB",
+ TEST_ADDRESS_DE_1.email,
+ "VK_TAB",
+ ];
+ if (AppConstants.platform != "win") {
+ keyInputs.push("VK_TAB", "VK_RETURN");
+ } else {
+ keyInputs.push("VK_RETURN");
+ }
+ keyInputs.forEach(input => EventUtils.synthesizeKey(input, {}, win));
+ });
+ let addresses = await getAddresses();
+ for (let [fieldName, fieldValue] of Object.entries(TEST_ADDRESS_DE_1)) {
+ is(addresses[0][fieldName], fieldValue, "check " + fieldName);
+ }
+ await removeAllRecords();
+});
+
+/**
+ * Test saving an address for a region from regionNames.properties but not in
+ * addressReferences.js (libaddressinput).
+ */
+add_task(async function test_saveAddress_nolibaddressinput() {
+ const TEST_ADDRESS = {
+ ...TEST_ADDRESS_IE_1,
+ ...{
+ "address-level3": undefined,
+ country: "XG",
+ },
+ };
+
+ isnot(
+ FormAutofillUtils.getCountryAddressData("XG").key,
+ "XG",
+ "Check that the region we're testing with isn't in libaddressinput"
+ );
+
+ await testDialog(EDIT_ADDRESS_DIALOG_URL, async win => {
+ let doc = win.document;
+
+ // Change country to verify labels
+ doc.querySelector("#country").focus();
+ EventUtils.synthesizeKey("Gaza Strip", {}, win);
+ await TestUtils.waitForCondition(() => {
+ return (
+ doc.querySelector("#postal-code-container > .label-text").textContent ==
+ "Postal Code"
+ );
+ }, "Wait for the mutation observer to change the labels");
+ is(
+ doc.querySelector("#postal-code-container > .label-text").textContent,
+ "Postal Code",
+ "XG postal-code label should be 'Postal Code'"
+ );
+ isnot(
+ doc.querySelector("#address-level1-container").style.display,
+ "none",
+ "XG address-level1 should be hidden"
+ );
+ is(
+ doc.querySelector("#address-level2").localName,
+ "input",
+ "XG address-level2 should be an <input>"
+ );
+ // Input address info and verify move through form with tab keys
+ doc.querySelector("#given-name").focus();
+ let keyInputs = [
+ TEST_ADDRESS["given-name"],
+ "VK_TAB",
+ TEST_ADDRESS["additional-name"],
+ "VK_TAB",
+ TEST_ADDRESS["family-name"],
+ "VK_TAB",
+ TEST_ADDRESS.organization,
+ "VK_TAB",
+ TEST_ADDRESS["street-address"],
+ "VK_TAB",
+ TEST_ADDRESS["address-level2"],
+ "VK_TAB",
+ TEST_ADDRESS["address-level1"],
+ "VK_TAB",
+ TEST_ADDRESS["postal-code"],
+ "VK_TAB",
+ // TEST_ADDRESS_1.country, // Country is already selected above
+ "VK_TAB",
+ TEST_ADDRESS.tel,
+ "VK_TAB",
+ TEST_ADDRESS.email,
+ "VK_TAB",
+ ];
+ if (AppConstants.platform != "win") {
+ keyInputs.push("VK_TAB", "VK_RETURN");
+ } else {
+ keyInputs.push("VK_RETURN");
+ }
+ keyInputs.forEach(input => EventUtils.synthesizeKey(input, {}, win));
+ });
+ let addresses = await getAddresses();
+ for (let [fieldName, fieldValue] of Object.entries(TEST_ADDRESS)) {
+ is(addresses[0][fieldName], fieldValue, "check " + fieldName);
+ }
+ await removeAllRecords();
+});
+
+add_task(async function test_saveAddressIE() {
+ await testDialog(EDIT_ADDRESS_DIALOG_URL, async win => {
+ let doc = win.document;
+ // Change country to verify labels
+ doc.querySelector("#country").focus();
+ EventUtils.synthesizeKey("Ireland", {}, win);
+ await TestUtils.waitForCondition(() => {
+ return (
+ doc.querySelector("#postal-code-container > .label-text").textContent ==
+ "Eircode"
+ );
+ }, "Wait for the mutation observer to change the labels");
+ is(
+ doc.querySelector("#postal-code-container > .label-text").textContent,
+ "Eircode",
+ "IE postal-code label should be 'Eircode'"
+ );
+ is(
+ doc.querySelector("#address-level1-container > .label-text").textContent,
+ "County",
+ "IE address-level1 should be 'County'"
+ );
+ is(
+ doc.querySelector("#address-level3-container > .label-text").textContent,
+ "Townland",
+ "IE address-level3 should be 'Townland'"
+ );
+
+ // Input address info and verify move through form with tab keys
+ doc.querySelector("#given-name").focus();
+ let keyInputs = [
+ TEST_ADDRESS_IE_1["given-name"],
+ "VK_TAB",
+ TEST_ADDRESS_IE_1["additional-name"],
+ "VK_TAB",
+ TEST_ADDRESS_IE_1["family-name"],
+ "VK_TAB",
+ TEST_ADDRESS_IE_1.organization,
+ "VK_TAB",
+ TEST_ADDRESS_IE_1["street-address"],
+ "VK_TAB",
+ TEST_ADDRESS_IE_1["address-level3"],
+ "VK_TAB",
+ TEST_ADDRESS_IE_1["address-level2"],
+ "VK_TAB",
+ TEST_ADDRESS_IE_1["address-level1"],
+ "VK_TAB",
+ TEST_ADDRESS_IE_1["postal-code"],
+ "VK_TAB",
+ // TEST_ADDRESS_1.country, // Country is already selected above
+ "VK_TAB",
+ TEST_ADDRESS_IE_1.tel,
+ "VK_TAB",
+ TEST_ADDRESS_IE_1.email,
+ "VK_TAB",
+ ];
+ if (AppConstants.platform != "win") {
+ keyInputs.push("VK_TAB", "VK_RETURN");
+ } else {
+ keyInputs.push("VK_RETURN");
+ }
+ keyInputs.forEach(input => EventUtils.synthesizeKey(input, {}, win));
+ });
+
+ let addresses = await getAddresses();
+ for (let [fieldName, fieldValue] of Object.entries(TEST_ADDRESS_IE_1)) {
+ is(addresses[0][fieldName], fieldValue, "check " + fieldName);
+ }
+ await removeAllRecords();
+});
+
+add_task(async function test_countryAndStateFieldLabels() {
+ await testDialog(EDIT_ADDRESS_DIALOG_URL, async win => {
+ let doc = win.document;
+ // Change country to verify labels
+ doc.querySelector("#country").focus();
+
+ let mutableLabels = [
+ "postal-code-container",
+ "address-level1-container",
+ "address-level2-container",
+ "address-level3-container",
+ ].map(containerID =>
+ doc.getElementById(containerID).querySelector(":scope > .label-text")
+ );
+
+ for (let countryOption of doc.querySelector("#country").options) {
+ if (countryOption.value == "") {
+ info("Skipping the empty country option");
+ continue;
+ }
+
+ // Clear L10N textContent to not leave leftovers between country tests
+ for (let labelEl of mutableLabels) {
+ doc.l10n.setAttributes(labelEl, "");
+ labelEl.textContent = "";
+ }
+
+ info(`Selecting '${countryOption.label}' (${countryOption.value})`);
+ EventUtils.synthesizeKey(countryOption.label, {}, win);
+
+ let l10nResolve;
+ let l10nReady = new Promise(resolve => {
+ l10nResolve = resolve;
+ });
+ let verifyL10n = () => {
+ if (mutableLabels.every(labelEl => labelEl.textContent)) {
+ win.removeEventListener("MozAfterPaint", verifyL10n);
+ l10nResolve();
+ }
+ };
+ win.addEventListener("MozAfterPaint", verifyL10n);
+ await l10nReady;
+
+ // Check that the labels were filled
+ for (let labelEl of mutableLabels) {
+ isnot(
+ labelEl.textContent,
+ "",
+ "Ensure textContent is non-empty for: " + countryOption.value
+ );
+ }
+
+ let stateOptions = doc.querySelector("#address-level1").options;
+ /* eslint-disable max-len */
+ let expectedStateOptions = {
+ BS: {
+ // The Bahamas is an interesting testcase because they have some keys that are full names, and others are replaced with ISO IDs.
+ keys: "Abaco~AK~Andros~BY~BI~CI~Crooked Island~Eleuthera~EX~Grand Bahama~HI~IN~LI~MG~N.P.~RI~RC~SS~SW".split(
+ "~"
+ ),
+ names:
+ "Abaco Islands~Acklins~Andros Island~Berry Islands~Bimini~Cat Island~Crooked Island~Eleuthera~Exuma and Cays~Grand Bahama~Harbour Island~Inagua~Long Island~Mayaguana~New Providence~Ragged Island~Rum Cay~San Salvador~Spanish Wells".split(
+ "~"
+ ),
+ },
+ US: {
+ keys: "AL~AK~AS~AZ~AR~AA~AE~AP~CA~CO~CT~DE~DC~FL~GA~GU~HI~ID~IL~IN~IA~KS~KY~LA~ME~MH~MD~MA~MI~FM~MN~MS~MO~MT~NE~NV~NH~NJ~NM~NY~NC~ND~MP~OH~OK~OR~PW~PA~PR~RI~SC~SD~TN~TX~UT~VT~VI~VA~WA~WV~WI~WY".split(
+ "~"
+ ),
+ names:
+ "Alabama~Alaska~American Samoa~Arizona~Arkansas~Armed Forces (AA)~Armed Forces (AE)~Armed Forces (AP)~California~Colorado~Connecticut~Delaware~District of Columbia~Florida~Georgia~Guam~Hawaii~Idaho~Illinois~Indiana~Iowa~Kansas~Kentucky~Louisiana~Maine~Marshall Islands~Maryland~Massachusetts~Michigan~Micronesia~Minnesota~Mississippi~Missouri~Montana~Nebraska~Nevada~New Hampshire~New Jersey~New Mexico~New York~North Carolina~North Dakota~Northern Mariana Islands~Ohio~Oklahoma~Oregon~Palau~Pennsylvania~Puerto Rico~Rhode Island~South Carolina~South Dakota~Tennessee~Texas~Utah~Vermont~Virgin Islands~Virginia~Washington~West Virginia~Wisconsin~Wyoming".split(
+ "~"
+ ),
+ },
+ };
+ /* eslint-enable max-len */
+
+ if (expectedStateOptions[countryOption.value]) {
+ let { keys, names } = expectedStateOptions[countryOption.value];
+ is(
+ stateOptions.length,
+ keys.length + 1,
+ "stateOptions should list all options plus a blank entry"
+ );
+ is(stateOptions[0].value, "", "First State option should be blank");
+ for (let i = 1; i < stateOptions.length; i++) {
+ is(
+ stateOptions[i].value,
+ keys[i - 1],
+ "Each State should be listed in alphabetical name order (key)"
+ );
+ is(
+ stateOptions[i].text,
+ names[i - 1],
+ "Each State should be listed in alphabetical name order (name)"
+ );
+ }
+ }
+ // Dispatch a dummy key event so that <select>'s incremental search is cleared.
+ EventUtils.synthesizeKey("VK_ACCEPT", {}, win);
+ }
+
+ doc.querySelector("#cancel").click();
+ });
+});
+
+add_task(async function test_combined_name_fields() {
+ await testDialog(EDIT_ADDRESS_DIALOG_URL, async win => {
+ let doc = win.document;
+ let givenNameField = doc.querySelector("#given-name");
+ let addtlNameField = doc.querySelector("#additional-name");
+ let familyNameField = doc.querySelector("#family-name");
+
+ function getComputedPropertyValue(field, property) {
+ return win.getComputedStyle(field).getPropertyValue(property);
+ }
+ function checkNameComputedPropertiesMatch(
+ field,
+ property,
+ value,
+ checkFn = is
+ ) {
+ checkFn(
+ getComputedPropertyValue(field, property),
+ value,
+ `Check ${field.id}'s ${property} is ${value}`
+ );
+ }
+ function checkNameFieldBorders(borderColorUnfocused, borderColorFocused) {
+ info("checking the perimeter colors");
+ checkNameComputedPropertiesMatch(
+ givenNameField,
+ "border-top-color",
+ borderColorFocused
+ );
+ checkNameComputedPropertiesMatch(
+ addtlNameField,
+ "border-top-color",
+ borderColorFocused
+ );
+ checkNameComputedPropertiesMatch(
+ familyNameField,
+ "border-top-color",
+ borderColorFocused
+ );
+ checkNameComputedPropertiesMatch(
+ familyNameField,
+ "border-right-color",
+ borderColorFocused
+ );
+ checkNameComputedPropertiesMatch(
+ givenNameField,
+ "border-bottom-color",
+ borderColorFocused
+ );
+ checkNameComputedPropertiesMatch(
+ addtlNameField,
+ "border-bottom-color",
+ borderColorFocused
+ );
+ checkNameComputedPropertiesMatch(
+ familyNameField,
+ "border-bottom-color",
+ borderColorFocused
+ );
+ checkNameComputedPropertiesMatch(
+ givenNameField,
+ "border-left-color",
+ borderColorFocused
+ );
+
+ info("checking the internal borders");
+ checkNameComputedPropertiesMatch(
+ givenNameField,
+ "border-right-width",
+ "0px"
+ );
+ checkNameComputedPropertiesMatch(
+ addtlNameField,
+ "border-left-width",
+ "2px"
+ );
+ checkNameComputedPropertiesMatch(
+ addtlNameField,
+ "border-left-color",
+ borderColorFocused,
+ isnot
+ );
+ checkNameComputedPropertiesMatch(
+ addtlNameField,
+ "border-right-width",
+ "2px"
+ );
+ checkNameComputedPropertiesMatch(
+ addtlNameField,
+ "border-right-color",
+ borderColorFocused,
+ isnot
+ );
+ checkNameComputedPropertiesMatch(
+ familyNameField,
+ "border-left-width",
+ "0px"
+ );
+ }
+
+ // Set these variables since the test doesn't run from a subdialog and
+ // therefore doesn't get the additional common CSS files injected.
+ let borderColor = "rgb(0, 255, 0)";
+ let borderColorFocused = "rgb(0, 0, 255)";
+ doc.body.style.setProperty("--in-content-box-border-color", borderColor);
+ doc.body.style.setProperty(
+ "--in-content-focus-outline-color",
+ borderColorFocused
+ );
+
+ givenNameField.focus();
+ checkNameFieldBorders(borderColor, borderColorFocused);
+
+ addtlNameField.focus();
+ checkNameFieldBorders(borderColor, borderColorFocused);
+
+ familyNameField.focus();
+ checkNameFieldBorders(borderColor, borderColorFocused);
+
+ info("unfocusing the name fields");
+ let cancelButton = doc.querySelector("#cancel");
+ cancelButton.focus();
+ borderColor = getComputedPropertyValue(givenNameField, "border-top-color");
+ isnot(
+ borderColor,
+ borderColorFocused,
+ "Check that the border color is different"
+ );
+ checkNameFieldBorders(borderColor, borderColor);
+
+ cancelButton.click();
+ });
+});
+
+add_task(async function test_combined_name_fields_error() {
+ await testDialog(EDIT_ADDRESS_DIALOG_URL, async win => {
+ let doc = win.document;
+ let givenNameField = doc.querySelector("#given-name");
+ info("mark the given name field as invalid");
+ givenNameField.value = "";
+ givenNameField.focus();
+ ok(
+ givenNameField.matches(":-moz-ui-invalid"),
+ "Check field is visually invalid"
+ );
+
+ let givenNameLabel = doc.querySelector("#given-name-container .label-text");
+ // Override pointer-events so that we can use elementFromPoint to know if
+ // the label text is visible.
+ givenNameLabel.style.pointerEvents = "auto";
+ let givenNameLabelRect = givenNameLabel.getBoundingClientRect();
+ // Get the center of the label
+ let el = doc.elementFromPoint(
+ givenNameLabelRect.left + givenNameLabelRect.width / 2,
+ givenNameLabelRect.top + givenNameLabelRect.height / 2
+ );
+
+ is(
+ el,
+ givenNameLabel,
+ "Check that the label text is visible in the error state"
+ );
+ is(
+ win.getComputedStyle(givenNameField).getPropertyValue("border-top-color"),
+ "rgba(0, 0, 0, 0)",
+ "Border should be transparent so that only the error outline shows"
+ );
+ doc.querySelector("#cancel").click();
+ });
+});
+
+add_task(async function test_hiddenFieldNotSaved() {
+ await testDialog(EDIT_ADDRESS_DIALOG_URL, win => {
+ let doc = win.document;
+ doc.querySelector("#address-level2").focus();
+ EventUtils.synthesizeKey(TEST_ADDRESS_1["address-level2"], {}, win);
+ doc.querySelector("#address-level1").focus();
+ EventUtils.synthesizeKey(TEST_ADDRESS_1["address-level1"], {}, win);
+ doc.querySelector("#country").focus();
+ EventUtils.synthesizeKey("Germany", {}, win);
+ doc.querySelector("#save").focus();
+ EventUtils.synthesizeKey("VK_RETURN", {}, win);
+ });
+ let addresses = await getAddresses();
+ is(addresses[0].country, "DE", "check country");
+ is(
+ addresses[0]["address-level2"],
+ TEST_ADDRESS_1["address-level2"],
+ "check address-level2"
+ );
+ is(
+ addresses[0]["address-level1"],
+ undefined,
+ "address-level1 should not be saved"
+ );
+
+ await removeAllRecords();
+});
+
+add_task(async function test_hiddenFieldRemovedWhenCountryChanged() {
+ let addresses = await getAddresses();
+ ok(!addresses.length, "no addresses at start of test");
+ await testDialog(EDIT_ADDRESS_DIALOG_URL, win => {
+ let doc = win.document;
+ doc.querySelector("#address-level2").focus();
+ EventUtils.synthesizeKey(TEST_ADDRESS_1["address-level2"], {}, win);
+ doc.querySelector("#address-level1").focus();
+ while (
+ doc.querySelector("#address-level1").value !=
+ TEST_ADDRESS_1["address-level1"]
+ ) {
+ EventUtils.synthesizeKey(TEST_ADDRESS_1["address-level1"][0], {}, win);
+ }
+ doc.querySelector("#save").focus();
+ EventUtils.synthesizeKey("VK_RETURN", {}, win);
+ });
+ addresses = await getAddresses();
+ is(addresses[0].country, "US", "check country");
+ is(
+ addresses[0]["address-level2"],
+ TEST_ADDRESS_1["address-level2"],
+ "check address-level2"
+ );
+ is(
+ addresses[0]["address-level1"],
+ TEST_ADDRESS_1["address-level1"],
+ "check address-level1"
+ );
+
+ await testDialog(
+ EDIT_ADDRESS_DIALOG_URL,
+ win => {
+ let doc = win.document;
+ doc.querySelector("#country").focus();
+ EventUtils.synthesizeKey("Germany", {}, win);
+ win.document.querySelector("#save").click();
+ },
+ {
+ record: addresses[0],
+ }
+ );
+ addresses = await getAddresses();
+
+ is(addresses.length, 1, "only one address is in storage");
+ is(
+ addresses[0]["address-level2"],
+ TEST_ADDRESS_1["address-level2"],
+ "check address-level2"
+ );
+ is(
+ addresses[0]["address-level1"],
+ undefined,
+ "address-level1 should be removed"
+ );
+ is(addresses[0].country, "DE", "country changed");
+ await removeAllRecords();
+});
+
+add_task(async function test_countrySpecificFieldsGetRequiredness() {
+ Region._setHomeRegion("RO", false);
+ await testDialog(EDIT_ADDRESS_DIALOG_URL, async win => {
+ let doc = win.document;
+ is(
+ doc.querySelector("#country").value,
+ "RO",
+ "Default country set to Romania"
+ );
+ let provinceField = doc.getElementById("address-level1");
+ ok(
+ !provinceField.required,
+ "address-level1 should not be marked as required"
+ );
+ ok(provinceField.disabled, "address-level1 should be marked as disabled");
+ is(
+ provinceField.parentNode.style.display,
+ "none",
+ "address-level1 is hidden for Romania"
+ );
+
+ doc.querySelector("#country").focus();
+ EventUtils.synthesizeKey("United States", {}, win);
+
+ await TestUtils.waitForCondition(
+ () => {
+ provinceField = doc.getElementById("address-level1");
+ return provinceField.parentNode.style.display != "none";
+ },
+ "Wait for address-level1 to become visible",
+ 10
+ );
+
+ ok(provinceField.required, "address-level1 should be marked as required");
+ ok(
+ !provinceField.disabled,
+ "address-level1 should not be marked as disabled"
+ );
+
+ // Dispatch a dummy key event so that <select>'s incremental search is cleared.
+ EventUtils.synthesizeKey("VK_ACCEPT", {}, win);
+
+ doc.querySelector("#country").focus();
+ EventUtils.synthesizeKey("Romania", {}, win);
+
+ await TestUtils.waitForCondition(
+ () => {
+ provinceField = doc.getElementById("address-level1");
+ return provinceField.parentNode.style.display == "none";
+ },
+ "Wait for address-level1 to become hidden",
+ 10
+ );
+
+ ok(
+ provinceField.required,
+ "address-level1 will still be marked as required"
+ );
+ ok(provinceField.disabled, "address-level1 should be marked as disabled");
+
+ doc.querySelector("#cancel").click();
+ });
+});
diff --git a/browser/extensions/formautofill/test/browser/browser_fathom_cc.js b/browser/extensions/formautofill/test/browser/browser_fathom_cc.js
new file mode 100644
index 0000000000..2e465fd503
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/browser_fathom_cc.js
@@ -0,0 +1,204 @@
+/**
+ * By default this test only tests 1 sample. This is to avoid publishing all samples we have
+ * to the codebase. If you update the Fathom CC model, please follow the instruction below
+ * and run the test. Doing this makes sure the optimized (Native implementation) CC fathom model produces
+ * exactly the same result as the non-optimized model (JS implementation, See CreditCardRuleset.sys.mjs).
+ *
+ * To test this:
+ * 1. Run the test setup script (fathom/test-setup.sh) to download all samples to the local
+ * directory. Note that you need to have the permission to access the fathom-form-autofill
+ * 2. Set `gTestAutofillRepoSample` to true
+ * 3. Run this test
+ */
+
+"use strict";
+
+const eligibleElementSelector =
+ "input:not([type]), input[type=text], input[type=textbox], input[type=email], input[type=tel], input[type=number], input[type=month], select, button";
+
+const skippedSamples = [
+ // TOOD: Crash while running the following testcases. Since this is not caused by the fathom CC
+ // model, we just skip those for now
+ "EN_B105b.html",
+ "EN_B312a.html",
+ "EN_B118b.html",
+ "EN_B48c.html",
+
+ // This sample is skipped because of Bug 1754256 (Support lookaround regex for native fathom CC implementation).
+ "DE_B378b.html",
+];
+
+async function run_test(path, dirs) {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.formautofill.creditCards.heuristics.mode", 1]],
+ });
+
+ // Collect files we are going to test.
+ let files = [];
+ for (let dir of dirs) {
+ let entries = new FileUtils.File(getTestFilePath(path + dir))
+ .directoryEntries;
+
+ while (entries.hasMoreElements()) {
+ let entry = entries.nextFile;
+ if (skippedSamples.includes(entry.leafName)) {
+ continue;
+ }
+
+ if (entry.leafName.endsWith(".html")) {
+ files.push(path + dir + entry.leafName);
+ }
+ }
+ }
+
+ ok(files.length, "no sample files found");
+
+ let summary = {};
+ for (let file of files) {
+ info("Testing " + file + "...");
+
+ await BrowserTestUtils.withNewTab(BASE_URL + file, async browser => {
+ summary[file] = await SpecialPowers.spawn(
+ browser,
+ [{ eligibleElementSelector, file }],
+ obj => {
+ const { FormAutofillHeuristics } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/FormAutofillHeuristics.sys.mjs"
+ );
+ const { FormAutofillUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/FormAutofillUtils.sys.mjs"
+ );
+
+ let eligibleFields = [];
+ let nodeList = content.document.querySelectorAll(
+ obj.eligibleElementSelector
+ );
+ for (let i = 0; i < nodeList.length; i++) {
+ if (FormAutofillUtils.isCreditCardOrAddressFieldType(nodeList[i])) {
+ eligibleFields.push(nodeList[i]);
+ }
+ }
+ let failedFields = [];
+
+ info("Running CC fathom model");
+ let nativeConfidencesKeyedByType =
+ ChromeUtils.getFormAutofillConfidences(eligibleFields);
+ let jsConfidencesKeyedByType =
+ FormAutofillHeuristics.getFormAutofillConfidences(eligibleFields);
+
+ if (eligibleFields.length != nativeConfidencesKeyedByType.length) {
+ ok(
+ false,
+ `Get the wrong number of confidence value from the native model`
+ );
+ }
+ if (eligibleFields.length != jsConfidencesKeyedByType.length) {
+ ok(
+ false,
+ `Get the wrong number of confidence value from the js model`
+ );
+ }
+
+ // This value should sync with the number of supported types in
+ // CreditCardRuleset.sys.mjs (See `get types()` in `this.creditCardRulesets`).
+ const EXPECTED_NUM_OF_CONFIDENCE = 2;
+ for (let i = 0; i < eligibleFields.length; i++) {
+ if (
+ Object.keys(nativeConfidencesKeyedByType[i]).length !=
+ EXPECTED_NUM_OF_CONFIDENCE
+ ) {
+ ok(
+ false,
+ `Native CC model doesn't get confidence value for all types`
+ );
+ }
+ if (
+ Object.keys(jsConfidencesKeyedByType[i]).length !=
+ EXPECTED_NUM_OF_CONFIDENCE
+ ) {
+ ok(
+ false,
+ `JS CC model doesn't get confidence value for all types`
+ );
+ }
+
+ for (let [type, confidence] of Object.entries(
+ nativeConfidencesKeyedByType[i]
+ )) {
+ // Fix to 10 digit to ignore rounding error between js and c++.
+ let nativeConfidence = confidence.toFixed(10);
+ let jsConfidence =
+ jsConfidencesKeyedByType[i][
+ FormAutofillUtils.formAutofillConfidencesKeyToCCFieldType(
+ type
+ )
+ ].toFixed(10);
+ if (jsConfidence != nativeConfidence) {
+ info(
+ `${obj.file}: Element(id=${eligibleFields[i].id} doesn't have the same confidence value when rule type is ${type}`
+ );
+ if (!failedFields.includes(i)) {
+ failedFields.push(i);
+ }
+ }
+ }
+ }
+ ok(
+ !failedFields.length,
+ `${obj.file}: has the same confidences value on both models`
+ );
+ return {
+ tested: eligibleFields.length,
+ passed: eligibleFields.length - failedFields.length,
+ };
+ }
+ );
+ });
+ }
+
+ // Generating summary report
+ let total_tested_samples = 0;
+ let total_passed_samples = 0;
+ let total_tested_fields = 0;
+ let total_passed_fields = 0;
+ info("=====Summary=====");
+ for (const [k, v] of Object.entries(summary)) {
+ total_tested_samples++;
+ if (v.tested == v.passed) {
+ total_passed_samples++;
+ } else {
+ info("Failed Case:" + k);
+ }
+ total_tested_fields += v.tested;
+ total_passed_fields += v.passed;
+ }
+ info(
+ "Passed Samples/Test Samples: " +
+ total_passed_samples +
+ "/" +
+ total_tested_samples
+ );
+ info(
+ "Passed Fields/Test Fields: " +
+ total_passed_fields +
+ "/" +
+ total_tested_fields
+ );
+}
+
+add_task(async function test_native_cc_model() {
+ const path = "fathom/";
+ const dirs = ["testing/"];
+ await run_test(path, dirs);
+});
+
+add_task(async function test_native_cc_model_autofill_repo() {
+ const path = "fathom/autofill-repo-samples/";
+ const dirs = ["validation/", "training/", "testing/"];
+ if (await IOUtils.exists(getTestFilePath(path))) {
+ // Just to ignore timeout failure while running the test on the local
+ requestLongerTimeout(10);
+
+ await run_test(path, dirs);
+ }
+});
diff --git a/browser/extensions/formautofill/test/browser/browser_first_time_use_doorhanger.js b/browser/extensions/formautofill/test/browser/browser_first_time_use_doorhanger.js
new file mode 100644
index 0000000000..40709eb984
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/browser_first_time_use_doorhanger.js
@@ -0,0 +1,142 @@
+"use strict";
+
+add_task(async function test_first_time_save() {
+ let addresses = await getAddresses();
+ is(addresses.length, 0, "No address in storage");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [FTU_PREF, true],
+ [ENABLED_AUTOFILL_ADDRESSES_PREF, true],
+ [AUTOFILL_ADDRESSES_AVAILABLE_PREF, "on"],
+ [ENABLED_AUTOFILL_ADDRESSES_CAPTURE_PREF, true],
+ ],
+ });
+
+ let onAdded = waitForStorageChangedEvents("add");
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: FORM_URL },
+ async function (browser) {
+ let onPopupShown = waitForPopupShown();
+ let tabPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ "about:preferences#privacy"
+ );
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#organization",
+ newValues: {
+ "#organization": "Sesame Street",
+ "#street-address": "123 Sesame Street",
+ "#tel": "1-345-345-3456",
+ },
+ });
+
+ await onPopupShown;
+ let cb = getDoorhangerCheckbox();
+ ok(cb.hidden, "Sync checkbox should be hidden");
+ // Open the panel via main button
+ await clickDoorhangerButton(MAIN_BUTTON);
+ let tab = await tabPromise;
+ ok(tab, "Privacy panel opened");
+ BrowserTestUtils.removeTab(tab);
+ }
+ );
+ await onAdded;
+
+ addresses = await getAddresses();
+ is(addresses.length, 1, "Address saved");
+ let ftuPref = SpecialPowers.getBoolPref(FTU_PREF);
+ is(ftuPref, false, "First time use flag is false");
+});
+
+add_task(async function test_non_first_time_save() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [ENABLED_AUTOFILL_ADDRESSES_PREF, true],
+ [AUTOFILL_ADDRESSES_AVAILABLE_PREF, "on"],
+ [ENABLED_AUTOFILL_ADDRESSES_CAPTURE_PREF, true],
+ ],
+ });
+ let addresses = await getAddresses();
+ let ftuPref = SpecialPowers.getBoolPref(FTU_PREF);
+ is(ftuPref, false, "First time use flag is false");
+ is(addresses.length, 1, "1 address in storage");
+
+ let onAdded = waitForStorageChangedEvents("add");
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: FORM_URL },
+ async function (browser) {
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#organization",
+ newValues: {
+ "#organization": "Mozilla",
+ "#street-address": "331 E. Evelyn Avenue",
+ "#tel": "1-650-903-0800",
+ },
+ });
+
+ await sleep(1000);
+ is(PopupNotifications.panel.state, "closed", "Doorhanger is hidden");
+ }
+ );
+ await onAdded;
+
+ addresses = await getAddresses();
+ is(addresses.length, 2, "Another address saved");
+
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_first_time_save_with_sync_account() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [FTU_PREF, true],
+ [ENABLED_AUTOFILL_ADDRESSES_PREF, true],
+ [AUTOFILL_ADDRESSES_AVAILABLE_PREF, "on"],
+ [SYNC_USERNAME_PREF, "foo@bar.com"],
+ [SYNC_ADDRESSES_PREF, false],
+ ],
+ });
+
+ let onAdded = waitForStorageChangedEvents("add");
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: FORM_URL },
+ async function (browser) {
+ let onPopupShown = waitForPopupShown();
+ let tabPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ "about:preferences#privacy-address-autofill"
+ );
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#organization",
+ newValues: {
+ "#organization": "Foobar",
+ "#email": "foo@bar.com",
+ "#tel": "1-234-567-8900",
+ },
+ });
+
+ await onPopupShown;
+ let cb = getDoorhangerCheckbox();
+ ok(!cb.hidden, "Sync checkbox should be visible");
+
+ is(cb.checked, false, "Checkbox state should match addresses sync state");
+ cb.click();
+ is(
+ SpecialPowers.getBoolPref(SYNC_ADDRESSES_PREF),
+ true,
+ "addresses sync should be enabled after checked"
+ );
+ // Open the panel via main button
+ await clickDoorhangerButton(MAIN_BUTTON);
+ let tab = await tabPromise;
+ ok(tab, "Privacy panel opened");
+ BrowserTestUtils.removeTab(tab);
+ }
+ );
+ await onAdded;
+
+ let ftuPref = SpecialPowers.getBoolPref(FTU_PREF);
+ is(ftuPref, false, "First time use flag is false");
+
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/browser/extensions/formautofill/test/browser/browser_manageAddressesDialog.js b/browser/extensions/formautofill/test/browser/browser_manageAddressesDialog.js
new file mode 100644
index 0000000000..b070df0fda
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/browser_manageAddressesDialog.js
@@ -0,0 +1,105 @@
+"use strict";
+
+const TEST_SELECTORS = {
+ selRecords: "#addresses",
+ btnRemove: "#remove",
+ btnAdd: "#add",
+ btnEdit: "#edit",
+};
+
+const DIALOG_SIZE = "width=600,height=400";
+
+add_task(async function test_manageAddressesInitialState() {
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: MANAGE_ADDRESSES_DIALOG_URL },
+ async function (browser) {
+ await SpecialPowers.spawn(browser, [TEST_SELECTORS], args => {
+ let selRecords = content.document.querySelector(args.selRecords);
+ let btnRemove = content.document.querySelector(args.btnRemove);
+ let btnEdit = content.document.querySelector(args.btnEdit);
+ let btnAdd = content.document.querySelector(args.btnAdd);
+
+ is(selRecords.length, 0, "No address");
+ is(btnAdd.disabled, false, "Add button enabled");
+ is(btnRemove.disabled, true, "Remove button disabled");
+ is(btnEdit.disabled, true, "Edit button disabled");
+ });
+ }
+ );
+});
+
+add_task(async function test_cancelManageAddressDialogWithESC() {
+ let win = window.openDialog(MANAGE_ADDRESSES_DIALOG_URL);
+ await waitForFocusAndFormReady(win);
+ let unloadPromise = BrowserTestUtils.waitForEvent(win, "unload");
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, win);
+ await unloadPromise;
+ ok(true, "Manage addresses dialog is closed with ESC key");
+});
+
+add_task(async function test_removingSingleAndMultipleAddresses() {
+ await setStorage(TEST_ADDRESS_1, TEST_ADDRESS_2, TEST_ADDRESS_3);
+
+ let win = window.openDialog(MANAGE_ADDRESSES_DIALOG_URL, null, DIALOG_SIZE);
+ await waitForFocusAndFormReady(win);
+
+ let selRecords = win.document.querySelector(TEST_SELECTORS.selRecords);
+ let btnRemove = win.document.querySelector(TEST_SELECTORS.btnRemove);
+ let btnEdit = win.document.querySelector(TEST_SELECTORS.btnEdit);
+
+ is(selRecords.length, 3, "Three addresses");
+
+ EventUtils.synthesizeMouseAtCenter(selRecords.children[0], {}, win);
+ is(btnRemove.disabled, false, "Remove button enabled");
+ is(btnEdit.disabled, false, "Edit button enabled");
+ EventUtils.synthesizeMouseAtCenter(btnRemove, {}, win);
+ await BrowserTestUtils.waitForEvent(selRecords, "RecordsRemoved");
+ is(selRecords.length, 2, "Two addresses left");
+
+ EventUtils.synthesizeMouseAtCenter(selRecords.children[0], {}, win);
+ EventUtils.synthesizeMouseAtCenter(
+ selRecords.children[1],
+ { shiftKey: true },
+ win
+ );
+ is(btnEdit.disabled, true, "Edit button disabled when multi-select");
+
+ EventUtils.synthesizeMouseAtCenter(btnRemove, {}, win);
+ await BrowserTestUtils.waitForEvent(selRecords, "RecordsRemoved");
+ is(selRecords.length, 0, "All addresses are removed");
+
+ win.close();
+});
+
+add_task(async function test_removingAdressViaKeyboardDelete() {
+ await setStorage(TEST_ADDRESS_1);
+ let win = window.openDialog(MANAGE_ADDRESSES_DIALOG_URL, null, DIALOG_SIZE);
+ await waitForFocusAndFormReady(win);
+
+ let selRecords = win.document.querySelector(TEST_SELECTORS.selRecords);
+
+ is(selRecords.length, 1, "One address");
+
+ EventUtils.synthesizeMouseAtCenter(selRecords.children[0], {}, win);
+ EventUtils.synthesizeKey("VK_DELETE", {}, win);
+ await BrowserTestUtils.waitForEvent(selRecords, "RecordsRemoved");
+ is(selRecords.length, 0, "No addresses left");
+
+ win.close();
+});
+
+add_task(async function test_addressesDialogWatchesStorageChanges() {
+ let win = window.openDialog(MANAGE_ADDRESSES_DIALOG_URL, null, DIALOG_SIZE);
+ await waitForFocusAndFormReady(win);
+
+ let selRecords = win.document.querySelector(TEST_SELECTORS.selRecords);
+
+ await setStorage(TEST_ADDRESS_1);
+ await BrowserTestUtils.waitForEvent(selRecords, "RecordsLoaded");
+ is(selRecords.length, 1, "One address is shown");
+
+ await removeAddresses([selRecords.options[0].value]);
+ await BrowserTestUtils.waitForEvent(selRecords, "RecordsLoaded");
+ is(selRecords.length, 0, "Address is removed");
+ win.close();
+});
diff --git a/browser/extensions/formautofill/test/browser/browser_privacyPreferences.js b/browser/extensions/formautofill/test/browser/browser_privacyPreferences.js
new file mode 100644
index 0000000000..2d5759ca66
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/browser_privacyPreferences.js
@@ -0,0 +1,439 @@
+"use strict";
+
+const PAGE_PREFS = "about:preferences";
+const PAGE_PRIVACY = PAGE_PREFS + "#privacy";
+const SELECTORS = {
+ group: "#formAutofillGroupBox",
+ addressAutofillCheckbox: "#addressAutofill checkbox",
+ creditCardAutofillCheckbox: "#creditCardAutofill checkbox",
+ savedAddressesBtn: "#addressAutofill button",
+ savedCreditCardsBtn: "#creditCardAutofill button",
+ addressAutofillLearnMore: "#addressAutofillLearnMore",
+ creditCardAutofillLearnMore: "#creditCardAutofillLearnMore",
+ reauthCheckbox: "#creditCardReauthenticate checkbox",
+};
+
+const { FormAutofill } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofill.sys.mjs"
+);
+
+// Visibility of form autofill group should be hidden when opening
+// preferences page.
+add_task(async function test_aboutPreferences() {
+ let finalPrefPaneLoaded = TestUtils.topicObserved(
+ "sync-pane-loaded",
+ () => true
+ );
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: PAGE_PREFS },
+ async function (browser) {
+ await finalPrefPaneLoaded;
+ await SpecialPowers.spawn(browser, [SELECTORS], selectors => {
+ is(
+ content.document.querySelector(selectors.group).hidden,
+ true,
+ "Form Autofill group should be hidden"
+ );
+ });
+ }
+ );
+});
+
+// Visibility of form autofill group should be visible when opening
+// directly to privacy page. Checkbox is checked by default.
+add_task(async function test_aboutPreferencesPrivacy() {
+ let finalPrefPaneLoaded = TestUtils.topicObserved(
+ "sync-pane-loaded",
+ () => true
+ );
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: PAGE_PRIVACY },
+ async function (browser) {
+ await finalPrefPaneLoaded;
+ await SpecialPowers.spawn(browser, [SELECTORS], selectors => {
+ is(
+ content.document.querySelector(selectors.group).hidden,
+ false,
+ "Form Autofill group should be visible"
+ );
+ is(
+ content.document.querySelector(selectors.addressAutofillCheckbox)
+ .checked,
+ true,
+ "Autofill addresses checkbox should be checked"
+ );
+ is(
+ content.document.querySelector(selectors.creditCardAutofillCheckbox)
+ .checked,
+ true,
+ "Autofill credit cards checkbox should be checked"
+ );
+ ok(
+ content.document
+ .querySelector(selectors.addressAutofillLearnMore)
+ .href.includes("autofill-card-address"),
+ "Autofill addresses learn more link should contain autofill-card-address"
+ );
+ ok(
+ content.document
+ .querySelector(selectors.creditCardAutofillLearnMore)
+ .href.includes("credit-card-autofill"),
+ "Autofill credit cards learn more link should contain credit-card-autofill"
+ );
+ });
+ }
+ );
+});
+
+add_task(async function test_openManageAutofillDialogs() {
+ let finalPrefPaneLoaded = TestUtils.topicObserved(
+ "sync-pane-loaded",
+ () => true
+ );
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: PAGE_PRIVACY },
+ async function (browser) {
+ await finalPrefPaneLoaded;
+ const args = [
+ SELECTORS,
+ MANAGE_ADDRESSES_DIALOG_URL,
+ MANAGE_CREDIT_CARDS_DIALOG_URL,
+ ];
+ await SpecialPowers.spawn(
+ browser,
+ [args],
+ ([selectors, addrUrl, ccUrl]) => {
+ function testManageDialogOpened(expectedUrl) {
+ return {
+ open: openUrl => is(openUrl, expectedUrl, "Manage dialog called"),
+ };
+ }
+
+ let realgSubDialog = content.window.gSubDialog;
+ content.window.gSubDialog = testManageDialogOpened(addrUrl);
+ content.document.querySelector(selectors.savedAddressesBtn).click();
+ content.window.gSubDialog = testManageDialogOpened(ccUrl);
+ content.document.querySelector(selectors.savedCreditCardsBtn).click();
+ content.window.gSubDialog = realgSubDialog;
+ }
+ );
+ }
+ );
+});
+
+add_task(async function test_autofillCheckboxes() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[ENABLED_AUTOFILL_ADDRESSES_PREF, false]],
+ });
+ await SpecialPowers.pushPrefEnv({
+ set: [[ENABLED_AUTOFILL_CREDITCARDS_PREF, false]],
+ });
+ let finalPrefPaneLoaded = TestUtils.topicObserved(
+ "sync-pane-loaded",
+ () => true
+ );
+ // Checkbox should be unchecked when form autofill addresses and credit cards are disabled.
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: PAGE_PRIVACY },
+ async function (browser) {
+ await finalPrefPaneLoaded;
+ await SpecialPowers.spawn(browser, [SELECTORS], selectors => {
+ is(
+ content.document.querySelector(selectors.group).hidden,
+ false,
+ "Form Autofill group should be visible"
+ );
+ is(
+ content.document.querySelector(selectors.addressAutofillCheckbox)
+ .checked,
+ false,
+ "Checkbox should be unchecked when Autofill Addresses is disabled"
+ );
+ is(
+ content.document.querySelector(selectors.creditCardAutofillCheckbox)
+ .checked,
+ false,
+ "Checkbox should be unchecked when Autofill Credit Cards is disabled"
+ );
+ content.document
+ .querySelector(selectors.addressAutofillCheckbox)
+ .scrollIntoView({ block: "center", behavior: "instant" });
+ });
+
+ info("test toggling the checkboxes");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ SELECTORS.addressAutofillCheckbox,
+ {},
+ browser
+ );
+ is(
+ Services.prefs.getBoolPref(ENABLED_AUTOFILL_ADDRESSES_PREF),
+ true,
+ "Check address autofill is now enabled"
+ );
+
+ await SpecialPowers.spawn(browser, [SELECTORS], selectors => {
+ content.document
+ .querySelector(selectors.creditCardAutofillCheckbox)
+ .scrollIntoView({ block: "center", behavior: "instant" });
+ });
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ SELECTORS.creditCardAutofillCheckbox,
+ {},
+ browser
+ );
+ is(
+ Services.prefs.getBoolPref(ENABLED_AUTOFILL_CREDITCARDS_PREF),
+ true,
+ "Check credit card autofill is now enabled"
+ );
+ }
+ );
+});
+
+add_task(async function test_creditCardNotAvailable() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[AUTOFILL_CREDITCARDS_AVAILABLE_PREF, false]],
+ });
+ let finalPrefPaneLoaded = TestUtils.topicObserved(
+ "sync-pane-loaded",
+ () => true
+ );
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: PAGE_PRIVACY },
+ async function (browser) {
+ await finalPrefPaneLoaded;
+ await SpecialPowers.spawn(browser, [SELECTORS], selectors => {
+ is(
+ content.document.querySelector(selectors.group).hidden,
+ false,
+ "Form Autofill group should be visible"
+ );
+ ok(
+ !content.document.querySelector(selectors.creditCardAutofillCheckbox),
+ "Autofill credit cards checkbox should not exist"
+ );
+ });
+ }
+ );
+});
+
+add_task(async function test_reauth() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[AUTOFILL_CREDITCARDS_AVAILABLE_PREF, "on"]],
+ });
+ let { OSKeyStore } = ChromeUtils.importESModule(
+ "resource://gre/modules/OSKeyStore.sys.mjs"
+ );
+
+ let finalPrefPaneLoaded = TestUtils.topicObserved(
+ "sync-pane-loaded",
+ () => true
+ );
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: PAGE_PRIVACY },
+ async function (browser) {
+ await finalPrefPaneLoaded;
+ await SpecialPowers.spawn(
+ browser,
+ [SELECTORS, OSKeyStore.canReauth()],
+ (selectors, canReauth) => {
+ is(
+ canReauth,
+ !!content.document.querySelector(selectors.reauthCheckbox),
+ "Re-authentication checkbox should be available if OSKeyStore.canReauth() is `true`"
+ );
+ }
+ );
+ }
+ );
+});
+
+add_task(async function test_addressAutofillNotAvailable() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[AUTOFILL_ADDRESSES_AVAILABLE_PREF, "off"]],
+ });
+
+ let autofillAddressEnabledValue = Services.prefs.getBoolPref(
+ ENABLED_AUTOFILL_ADDRESSES_PREF
+ );
+ let finalPrefPaneLoaded = TestUtils.topicObserved(
+ "sync-pane-loaded",
+ () => true
+ );
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: PAGE_PRIVACY },
+ async function (browser) {
+ await finalPrefPaneLoaded;
+ await SpecialPowers.spawn(browser, [SELECTORS], selectors => {
+ is(
+ content.document.querySelector(selectors.group).hidden,
+ false,
+ "Form Autofill group should be visible"
+ );
+ is(
+ content.document.querySelector(selectors.addressAutofillCheckbox),
+ null,
+ "Address checkbox should not exist when address autofill is not enabled"
+ );
+ is(
+ content.document.querySelector(selectors.creditCardAutofillCheckbox)
+ .checked,
+ true,
+ "Checkbox should be checked when Autofill Credit Cards is enabled"
+ );
+ });
+ info("test toggling the credit card autofill checkbox");
+
+ await SpecialPowers.spawn(browser, [SELECTORS], selectors => {
+ content.document
+ .querySelector(selectors.creditCardAutofillCheckbox)
+ .scrollIntoView({ block: "center", behavior: "instant" });
+ });
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ SELECTORS.creditCardAutofillCheckbox,
+ {},
+ browser
+ );
+ is(
+ Services.prefs.getBoolPref(ENABLED_AUTOFILL_CREDITCARDS_PREF),
+ false,
+ "Check credit card autofill is now disabled"
+ );
+ is(
+ Services.prefs.getBoolPref(ENABLED_AUTOFILL_ADDRESSES_PREF),
+ autofillAddressEnabledValue,
+ "Address autofill enabled's value should not change due to credit card checkbox interaction"
+ );
+ await SpecialPowers.spawn(browser, [SELECTORS], selectors => {
+ is(
+ content.document.querySelector(selectors.addressAutofillCheckbox),
+ null,
+ "Address checkbox should exist due to interaction with credit card checkbox"
+ );
+ });
+ }
+ );
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_addressAutofillNotAvailableViaRegion() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.formautofill.addresses.supported", "detect"],
+ ["extensions.formautofill.creditCards.enabled", true],
+ ["browser.search.region", "FR"],
+ [ENABLED_AUTOFILL_ADDRESSES_SUPPORTED_COUNTRIES_PREF, "US,CA"],
+ ],
+ });
+
+ const addressAutofillEnabledPrefValue = Services.prefs.getBoolPref(
+ ENABLED_AUTOFILL_ADDRESSES_PREF
+ );
+ const addressAutofillAvailablePrefValue = Services.prefs.getCharPref(
+ AUTOFILL_ADDRESSES_AVAILABLE_PREF
+ );
+ is(
+ FormAutofill.isAutofillAddressesAvailable,
+ false,
+ "Address autofill should not be available due to unsupported region"
+ );
+ let finalPrefPaneLoaded = TestUtils.topicObserved(
+ "sync-pane-loaded",
+ () => true
+ );
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: PAGE_PRIVACY },
+ async function (browser) {
+ await finalPrefPaneLoaded;
+ await SpecialPowers.spawn(browser, [SELECTORS], selectors => {
+ is(
+ content.document.querySelector(selectors.group).hidden,
+ false,
+ "Form Autofill group should be visible"
+ );
+ is(
+ content.document.querySelector(selectors.addressAutofillCheckbox),
+ null,
+ "Address checkbox should not exist due to address autofill not being available"
+ );
+ is(
+ content.document.querySelector(selectors.creditCardAutofillCheckbox)
+ .checked,
+ true,
+ "Checkbox should be checked when Autofill Credit Cards is enabled"
+ );
+ });
+ info("test toggling the credit card autofill checkbox");
+
+ await SpecialPowers.spawn(browser, [SELECTORS], selectors => {
+ content.document
+ .querySelector(selectors.creditCardAutofillCheckbox)
+ .scrollIntoView({ block: "center", behavior: "instant" });
+ });
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ SELECTORS.creditCardAutofillCheckbox,
+ {},
+ browser
+ );
+ is(
+ Services.prefs.getBoolPref(ENABLED_AUTOFILL_CREDITCARDS_PREF),
+ false,
+ "Check credit card autofill is now disabled"
+ );
+ is(
+ Services.prefs.getCharPref(AUTOFILL_ADDRESSES_AVAILABLE_PREF),
+ addressAutofillAvailablePrefValue,
+ "Address autofill availability should not change due to interaction with credit card checkbox"
+ );
+ is(
+ addressAutofillEnabledPrefValue,
+ Services.prefs.getBoolPref(ENABLED_AUTOFILL_ADDRESSES_PREF),
+ "Address autofill enabled pref should not change due to credit card checkbox"
+ );
+ await SpecialPowers.spawn(browser, [SELECTORS], selectors => {
+ is(
+ content.document.querySelector(selectors.addressAutofillCheckbox),
+ null,
+ "Address checkbox should not exist due to address autofill not being available"
+ );
+ });
+ }
+ );
+
+ await SpecialPowers.popPrefEnv();
+});
+
+// Checkboxes should be disabled based on whether or not they are locked.
+add_task(async function test_aboutPreferencesPrivacy() {
+ Services.prefs.lockPref(ENABLED_AUTOFILL_ADDRESSES_PREF);
+ Services.prefs.lockPref(ENABLED_AUTOFILL_CREDITCARDS_PREF);
+ registerCleanupFunction(function () {
+ Services.prefs.unlockPref(ENABLED_AUTOFILL_ADDRESSES_PREF);
+ Services.prefs.unlockPref(ENABLED_AUTOFILL_CREDITCARDS_PREF);
+ });
+ let finalPrefPaneLoaded = TestUtils.topicObserved(
+ "sync-pane-loaded",
+ () => true
+ );
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: PAGE_PRIVACY },
+ async function (browser) {
+ await finalPrefPaneLoaded;
+ await SpecialPowers.spawn(browser, [SELECTORS], selectors => {
+ is(
+ content.document.querySelector(selectors.addressAutofillCheckbox)
+ .disabled,
+ true,
+ "Autofill addresses checkbox should be disabled"
+ );
+ is(
+ content.document.querySelector(selectors.creditCardAutofillCheckbox)
+ .disabled,
+ true,
+ "Autofill credit cards checkbox should be disabled"
+ );
+ });
+ }
+ );
+});
diff --git a/browser/extensions/formautofill/test/browser/browser_remoteiframe.js b/browser/extensions/formautofill/test/browser/browser_remoteiframe.js
new file mode 100644
index 0000000000..4dddd27a09
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/browser_remoteiframe.js
@@ -0,0 +1,127 @@
+"use strict";
+
+const IFRAME_URL_PATH = BASE_URL + "autocomplete_iframe.html";
+
+// Start by adding a few addresses to storage.
+add_task(async function setup_storage() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [AUTOFILL_ADDRESSES_AVAILABLE_PREF, "on"],
+ [ENABLED_AUTOFILL_ADDRESSES_PREF, true],
+ [ENABLED_AUTOFILL_ADDRESSES_CAPTURE_PREF, true],
+ ],
+ });
+ await setStorage(TEST_ADDRESS_2, TEST_ADDRESS_4, TEST_ADDRESS_5);
+});
+
+// Verify that form fillin works in a remote iframe, and that changing
+// a field updates storage.
+add_task(async function test_iframe_autocomplete() {
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ IFRAME_URL_PATH,
+ true
+ );
+ let browser = tab.linkedBrowser;
+ let iframeBC = browser.browsingContext.children[1];
+ await openPopupOnSubframe(browser, iframeBC, "#street-address");
+
+ // Highlight the first item in the list. We want to verify
+ // that the warning text is correct to ensure that the preview is
+ // performed properly.
+
+ await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, iframeBC);
+ await expectWarningText(browser, "Autofills address");
+
+ // Highlight and select the second item in the list
+ await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, iframeBC);
+ await expectWarningText(browser, "Also autofills organization, email");
+ EventUtils.synthesizeKey("VK_RETURN", {});
+
+ let onLoaded = BrowserTestUtils.browserLoaded(browser, true);
+ await SpecialPowers.spawn(iframeBC, [], async function () {
+ await ContentTaskUtils.waitForCondition(() => {
+ return (
+ content.document.getElementById("street-address").value ==
+ "32 Vassar Street MIT Room 32-G524" &&
+ content.document.getElementById("country").value == "US" &&
+ content.document.getElementById("organization").value ==
+ "World Wide Web Consortium"
+ );
+ });
+ });
+
+ let onPopupShown = waitForPopupShown();
+ await focusUpdateSubmitForm(iframeBC, {
+ focusSelector: "#organization",
+ newValues: {
+ "#tel": "+16172535702",
+ },
+ });
+ await onPopupShown;
+ await onLoaded;
+
+ let onUpdated = waitForStorageChangedEvents("update");
+ await clickDoorhangerButton(MAIN_BUTTON);
+ await onUpdated;
+
+ // Check that the tel number was updated properly.
+ let addresses = await getAddresses();
+ is(addresses.length, 3, "Still 3 address in storage");
+ is(addresses[1].tel, "+16172535702", "Verify the tel field");
+
+ // Fill in the details again and then clear the form from the dropdown.
+ await openPopupOnSubframe(browser, iframeBC, "#street-address");
+ await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, iframeBC);
+ EventUtils.synthesizeKey("VK_RETURN", {});
+
+ await waitForAutofill(iframeBC, "#tel", "+16172535702");
+
+ // Open the dropdown and select the Clear Form item.
+ await openPopupOnSubframe(browser, iframeBC, "#street-address");
+ await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, iframeBC);
+ EventUtils.synthesizeKey("VK_RETURN", {});
+
+ await SpecialPowers.spawn(iframeBC, [], async function () {
+ await ContentTaskUtils.waitForCondition(() => {
+ return (
+ content.document.getElementById("street-address").value == "" &&
+ content.document.getElementById("country").value == "" &&
+ content.document.getElementById("organization").value == ""
+ );
+ });
+ });
+
+ await BrowserTestUtils.removeTab(tab);
+});
+
+// Choose preferences from the autocomplete dropdown within an iframe.
+add_task(async function test_iframe_autocomplete_preferences() {
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ IFRAME_URL_PATH,
+ true
+ );
+ let browser = tab.linkedBrowser;
+ let iframeBC = browser.browsingContext.children[1];
+ await openPopupOnSubframe(browser, iframeBC, "#organization");
+
+ await expectWarningText(browser, "Also autofills address, phone, email");
+
+ const prefTabPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ PRIVACY_PREF_URL
+ );
+
+ // Select the preferences item.
+ EventUtils.synthesizeKey("VK_DOWN", {});
+ EventUtils.synthesizeKey("VK_DOWN", {});
+ EventUtils.synthesizeKey("VK_RETURN", {});
+
+ info(`expecting tab: about:preferences#privacy opened`);
+ const prefTab = await prefTabPromise;
+ info(`expecting tab: about:preferences#privacy removed`);
+ BrowserTestUtils.removeTab(prefTab);
+
+ await BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/extensions/formautofill/test/browser/browser_submission_in_private_mode.js b/browser/extensions/formautofill/test/browser/browser_submission_in_private_mode.js
new file mode 100644
index 0000000000..05adf40935
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/browser_submission_in_private_mode.js
@@ -0,0 +1,37 @@
+"use strict";
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.formautofill.addresses.capture.enabled", true],
+ ["extensions.formautofill.addresses.supported", "on"],
+ ],
+ });
+});
+
+add_task(async function test_add_address() {
+ let privateWin = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+ let addresses = await getAddresses();
+
+ is(addresses.length, 0, "No address in storage");
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser: privateWin.gBrowser, url: FORM_URL },
+ async function (privateBrowser) {
+ await focusUpdateSubmitForm(privateBrowser, {
+ focusSelector: "#organization",
+ newValues: {
+ "#organization": "Mozilla",
+ "#street-address": "331 E. Evelyn Avenue",
+ "#tel": "1-650-903-0800",
+ },
+ });
+ }
+ );
+
+ await ensureNoAddressSaved();
+
+ await BrowserTestUtils.closeWindow(privateWin);
+});
diff --git a/browser/extensions/formautofill/test/browser/browser_update_doorhanger.js b/browser/extensions/formautofill/test/browser/browser_update_doorhanger.js
new file mode 100644
index 0000000000..73fd903de6
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/browser_update_doorhanger.js
@@ -0,0 +1,189 @@
+"use strict";
+
+/**
+ * Note that this testcase is built based on the assumption that subtests are
+ * run in order. So if you're going to add a new subtest, please add it in the end.
+ */
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.formautofill.addresses.capture.enabled", true]],
+ });
+});
+
+add_task(async function test_update_address() {
+ await setStorage(TEST_ADDRESS_1);
+ let addresses = await getAddresses();
+ is(addresses.length, 1, "1 address in storage");
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: FORM_URL },
+ async function (browser) {
+ // Autofill address fields
+ await openPopupOn(browser, "form #organization");
+ await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser);
+ await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser);
+ await waitForAutofill(browser, "#tel", addresses[0].tel);
+
+ // Update address fields and submit
+ let onPopupShown = waitForPopupShown();
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#organization",
+ newValues: {
+ "#organization": "Mozilla",
+ },
+ });
+
+ await onPopupShown;
+
+ // Choose to update address by doorhanger
+ let onUpdated = waitForStorageChangedEvents("update");
+ await clickDoorhangerButton(MAIN_BUTTON);
+ await onUpdated;
+ }
+ );
+
+ addresses = await getAddresses();
+ is(addresses.length, 1, "Still 1 address in storage");
+ is(addresses[0].organization, "Mozilla", "Verify the organization field");
+});
+
+add_task(async function test_create_new_address() {
+ let addresses = await getAddresses();
+ is(addresses.length, 1, "1 address in storage");
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: FORM_URL },
+ async function (browser) {
+ // Autofill address fields
+ await openPopupOn(browser, "form #tel");
+ await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser);
+ await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser);
+ await waitForAutofill(browser, "#tel", addresses[0].tel);
+
+ let onPopupShown = waitForPopupShown();
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#tel",
+ newValues: {
+ "#tel": "+1234567890",
+ },
+ });
+
+ await onPopupShown;
+
+ // Choose to add address by doorhanger
+ let onAdded = waitForStorageChangedEvents("add");
+ await clickDoorhangerButton(SECONDARY_BUTTON);
+ await onAdded;
+ }
+ );
+
+ addresses = await getAddresses();
+ is(addresses.length, 2, "2 addresses in storage");
+ is(addresses[1].tel, "+1234567890", "Verify the tel field");
+});
+
+add_task(async function test_create_new_address_merge() {
+ let addresses = await getAddresses();
+ is(addresses.length, 2, "2 addresses in storage");
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: FORM_URL },
+ async function (browser) {
+ await openPopupOn(browser, "form #tel");
+ await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser);
+ await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser);
+ await waitForAutofill(browser, "#tel", addresses[1].tel);
+
+ // Choose the latest address and revert to the original phone number
+ let onPopupShown = waitForPopupShown();
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#tel",
+ newValues: {
+ "#tel": "+16172535702",
+ },
+ });
+
+ await onPopupShown;
+ await clickDoorhangerButton(SECONDARY_BUTTON);
+ }
+ );
+
+ addresses = await getAddresses();
+ is(addresses.length, 2, "Still 2 addresses in storage");
+});
+
+add_task(async function test_submit_untouched_fields() {
+ let addresses = await getAddresses();
+ is(addresses.length, 2, "2 addresses in storage");
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: FORM_URL },
+ async function (browser) {
+ await openPopupOn(browser, "form #organization");
+ await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser);
+ await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser);
+ await waitForAutofill(browser, "#tel", addresses[0].tel);
+
+ let onPopupShown = waitForPopupShown();
+ await SpecialPowers.spawn(browser, [], async function () {
+ let form = content.document.getElementById("form");
+ let tel = form.querySelector("#tel");
+ tel.value = "12345"; // ".value" won't change the highlight status.
+ });
+
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#tel",
+ newValues: {
+ "#organization": "Organization",
+ },
+ });
+ await onPopupShown;
+
+ let onUpdated = waitForStorageChangedEvents("update");
+ await clickDoorhangerButton(MAIN_BUTTON);
+ await onUpdated;
+ }
+ );
+
+ addresses = await getAddresses();
+ is(addresses.length, 2, "Still 2 addresses in storage");
+ is(addresses[0].organization, "Organization", "organization should change");
+ is(addresses[0].tel, "+16172535702", "tel should remain unchanged");
+});
+
+add_task(async function test_submit_reduced_fields() {
+ let addresses = await getAddresses();
+ is(addresses.length, 2, "2 addresses in storage");
+
+ let url = BASE_URL + "autocomplete_simple_basic.html";
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url },
+ async function (browser) {
+ await openPopupOn(browser, "form#simple input[name=tel]");
+ await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser);
+ await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser);
+ await waitForAutofill(browser, "form #simple_tel", "6172535702");
+
+ let onPopupShown = waitForPopupShown();
+ await focusUpdateSubmitForm(browser, {
+ formSelector: "form#simple",
+ focusSelector: "form #simple_tel",
+ newValues: {
+ "input[name=tel]": "123456789",
+ },
+ });
+
+ await onPopupShown;
+
+ let onUpdated = waitForStorageChangedEvents("update");
+ await clickDoorhangerButton(MAIN_BUTTON);
+ await onUpdated;
+ }
+ );
+
+ addresses = await getAddresses();
+ is(addresses.length, 2, "Still 2 addresses in storage");
+ is(addresses[0].tel, "123456789", "tel should should be changed");
+ is(addresses[0]["postal-code"], "02139", "postal code should be kept");
+});
diff --git a/browser/extensions/formautofill/test/browser/creditCard/browser.ini b/browser/extensions/formautofill/test/browser/creditCard/browser.ini
new file mode 100644
index 0000000000..613b3cf126
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/creditCard/browser.ini
@@ -0,0 +1,51 @@
+[DEFAULT]
+prefs =
+ extensions.formautofill.creditCards.enabled=true
+ extensions.formautofill.reauth.enabled=true
+ # lower the interval for event telemetry in the content process to update the parent process
+ toolkit.telemetry.ipcBatchTimeout=0
+support-files =
+ ../head.js
+ !/browser/extensions/formautofill/test/fixtures/autocomplete_basic.html
+ ../../fixtures/autocomplete_creditcard_basic.html
+ ../../fixtures/autocomplete_creditcard_iframe.html
+ ../../fixtures/autocomplete_creditcard_cc_exp_field.html
+ ../../fixtures/without_autocomplete_creditcard_basic.html
+ head_cc.js
+
+[browser_anti_clickjacking.js]
+skip-if = !debug && os == "mac" # perma-fail see Bug 1600059
+[browser_creditCard_doorhanger_action.js]
+skip-if = (!debug && os == "mac") || (os == "win" && ccov) # perma-fail see Bug 1655601, Bug 1655600
+[browser_creditCard_doorhanger_display.js]
+skip-if = (!debug && os == "mac") || (os == "win" && ccov) # perma-fail see Bug 1655601, Bug 1655600
+[browser_creditCard_doorhanger_fields.js]
+skip-if = (!debug && os == "mac") || (os == "win" && ccov) # perma-fail see Bug 1655601, Bug 1655600
+[browser_creditCard_doorhanger_iframe.js]
+skip-if = (!debug && os == "mac") || (os == "win" && ccov) # perma-fail see Bug 1655601, Bug 1655600
+[browser_creditCard_doorhanger_logo.js]
+skip-if = (!debug && os == "mac") || (os == "win" && ccov) # perma-fail see Bug 1655601, Bug 1655600
+[browser_creditCard_doorhanger_sync.js]
+skip-if = (!debug && os == "mac") || (os == "win" && ccov) # perma-fail see Bug 1655601, Bug 1655600
+[browser_creditCard_dropdown_layout.js]
+skip-if = ((os == "mac") || (os == 'linux') || (os == 'win'))
+[browser_creditCard_fill_cancel_login.js]
+skip-if = ((!debug && os == "mac") || (os == 'linux') || (os == 'win'))
+[browser_creditCard_heuristics.js]
+skip-if = apple_silicon && !debug # Bug 1714221
+[browser_creditCard_heuristics_cc_type.js]
+skip-if = apple_silicon && !debug # Bug 1714221
+[browser_creditCard_submission_autodetect_type.js]
+skip-if = apple_silicon && !debug
+[browser_creditCard_submission_normalized.js]
+skip-if = apple_silicon && !debug
+[browser_creditCard_telemetry.js]
+skip-if =
+ apple_silicon && !debug # Bug 1714221
+ os == "win" && os_version == "6.1" # Skip on Azure - frequent failure
+[browser_editCreditCardDialog.js]
+skip-if = ((os == 'linux') || (os == "mac") || (os == 'win')) # perma-fail see Bug 1600059
+[browser_insecure_form.js]
+skip-if = ((os == 'mac') || (os == 'linux') || (os == 'win')) # bug 1456284
+[browser_manageCreditCardsDialog.js]
+skip-if = ((os == 'win') || (os == 'mac') || (os == 'linux'))
diff --git a/browser/extensions/formautofill/test/browser/creditCard/browser_anti_clickjacking.js b/browser/extensions/formautofill/test/browser/creditCard/browser_anti_clickjacking.js
new file mode 100644
index 0000000000..ff5feb54df
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/creditCard/browser_anti_clickjacking.js
@@ -0,0 +1,123 @@
+"use strict";
+
+const ADDRESS_URL =
+ "http://example.org/browser/browser/extensions/formautofill/test/browser/autocomplete_basic.html";
+const CC_URL =
+ "https://example.org/browser/browser/extensions/formautofill/test/browser/creditCard/autocomplete_creditcard_basic.html";
+
+add_task(async function setup_storage() {
+ await setStorage(
+ TEST_ADDRESS_1,
+ TEST_ADDRESS_2,
+ TEST_ADDRESS_3,
+ TEST_CREDIT_CARD_1,
+ TEST_CREDIT_CARD_2,
+ TEST_CREDIT_CARD_3
+ );
+});
+
+add_task(async function test_active_delay() {
+ // This is a workaround for the fact that we don't have a way
+ // to know when the popup was opened exactly and this makes our test
+ // racy when ensuring that we first test for disabled items before
+ // the delayed enabling happens.
+ //
+ // In the future we should consider adding an event when a popup
+ // gets opened and listen for it in this test before we check if the item
+ // is disabled.
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["security.notification_enable_delay", 1000],
+ ["extensions.formautofill.reauth.enabled", false],
+ ],
+ });
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: CC_URL },
+ async function (browser) {
+ const focusInput = "#cc-number";
+
+ // Open the popup -- we don't use openPopupOn() because there
+ // are things we need to check between these steps.
+ await SimpleTest.promiseFocus(browser);
+ const start = performance.now();
+ await runAndWaitForAutocompletePopupOpen(browser, async () => {
+ await focusAndWaitForFieldsIdentified(browser, focusInput);
+ });
+ const firstItem = getDisplayedPopupItems(browser)[0];
+ ok(firstItem.disabled, "Popup should be disbled upon opening.");
+ is(
+ browser.autoCompletePopup.selectedIndex,
+ -1,
+ "No item selected at first"
+ );
+
+ // Check that clicking on menu doesn't do anything while
+ // it is disabled
+ firstItem.click();
+ is(
+ browser.autoCompletePopup.selectedIndex,
+ -1,
+ "No item selected after clicking on disabled item"
+ );
+
+ // Check that the delay before enabling is as long as expected
+ await waitForPopupEnabled(browser);
+ const delta = performance.now() - start;
+ info(`Popup was disabled for ${delta} ms`);
+ ok(delta >= 1000, "Popup was disabled for at least 1000 ms");
+
+ // Check the clicking on the menu works now
+ firstItem.click();
+ is(
+ browser.autoCompletePopup.selectedIndex,
+ 0,
+ "First item selected after clicking on enabled item"
+ );
+
+ // Clean up
+ await closePopup(browser);
+ }
+ );
+});
+
+add_task(async function test_no_delay() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["security.notification_enable_delay", 1000],
+ ["extensions.formautofill.reauth.enabled", false],
+ ],
+ });
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: ADDRESS_URL },
+ async function (browser) {
+ const focusInput = "#organization";
+
+ // Open the popup -- we don't use openPopupOn() because there
+ // are things we need to check between these steps.
+ await SimpleTest.promiseFocus(browser);
+ await runAndWaitForAutocompletePopupOpen(browser, async () => {
+ await focusAndWaitForFieldsIdentified(browser, focusInput);
+ await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser);
+ });
+ const firstItem = getDisplayedPopupItems(browser)[0];
+ ok(!firstItem.disabled, "Popup should be enabled upon opening.");
+ is(
+ browser.autoCompletePopup.selectedIndex,
+ -1,
+ "No item selected at first"
+ );
+
+ // Check that clicking on menu doesn't do anything while
+ // it is disabled
+ firstItem.click();
+ is(
+ browser.autoCompletePopup.selectedIndex,
+ 0,
+ "First item selected after clicking on enabled item"
+ );
+
+ // Clean up
+ await closePopup(browser);
+ }
+ );
+});
diff --git a/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger_action.js b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger_action.js
new file mode 100644
index 0000000000..82122925d7
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger_action.js
@@ -0,0 +1,170 @@
+"use strict";
+
+add_task(async function test_save_doorhanger_click_save() {
+ let onChanged = waitForStorageChangedEvents("add");
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: CREDITCARD_FORM_URL },
+ async function (browser) {
+ let onPopupShown = waitForPopupShown();
+
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#cc-name",
+ newValues: {
+ "#cc-name": "User 1",
+ "#cc-number": "5577000055770004",
+ "#cc-exp-month": "12",
+ "#cc-exp-year": "2017",
+ "#cc-type": "mastercard",
+ },
+ });
+
+ await onPopupShown;
+ await clickDoorhangerButton(MAIN_BUTTON);
+ }
+ );
+ await onChanged;
+
+ let creditCards = await getCreditCards();
+ is(creditCards.length, 1, "1 credit card in storage");
+ is(creditCards[0]["cc-name"], "User 1", "Verify the name field");
+ is(creditCards[0]["cc-type"], "mastercard", "Verify the cc-type field");
+ await removeAllRecords();
+});
+
+add_task(async function test_save_doorhanger_click_never_save() {
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: CREDITCARD_FORM_URL },
+ async function (browser) {
+ let onPopupShown = waitForPopupShown();
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#cc-name",
+ newValues: {
+ "#cc-name": "User 0",
+ "#cc-number": "6387060366272981",
+ },
+ });
+
+ await onPopupShown;
+ await clickDoorhangerButton(MENU_BUTTON, 0);
+ }
+ );
+
+ await sleep(1000);
+ let creditCards = await getCreditCards();
+ let creditCardPref = SpecialPowers.getBoolPref(
+ ENABLED_AUTOFILL_CREDITCARDS_PREF
+ );
+ is(creditCards.length, 0, "No credit card in storage");
+ is(creditCardPref, false, "Credit card is disabled");
+ SpecialPowers.clearUserPref(ENABLED_AUTOFILL_CREDITCARDS_PREF);
+});
+
+add_task(async function test_save_doorhanger_click_cancel_save() {
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: CREDITCARD_FORM_URL },
+ async function (browser) {
+ let onPopupShown = waitForPopupShown();
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#cc-name",
+ newValues: {
+ "#cc-name": "User 1",
+ "#cc-number": "5038146897157463",
+ },
+ });
+
+ ok(
+ !SpecialPowers.Services.prefs.prefHasUserValue(SYNC_USERNAME_PREF),
+ "Sync account should not exist by default"
+ );
+ await onPopupShown;
+ let cb = getDoorhangerCheckbox();
+ ok(cb.hidden, "Sync checkbox should be hidden");
+ await clickDoorhangerButton(SECONDARY_BUTTON);
+ }
+ );
+
+ await sleep(1000);
+ let creditCards = await getCreditCards();
+ is(creditCards.length, 0, "No credit card saved");
+});
+
+add_task(async function test_update_doorhanger_click_update() {
+ await setStorage(TEST_CREDIT_CARD_1);
+ let creditCards = await getCreditCards();
+ is(creditCards.length, 1, "1 credit card in storage");
+
+ let onChanged = waitForStorageChangedEvents("update", "notifyUsed");
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: CREDITCARD_FORM_URL },
+ async function (browser) {
+ let onPopupShown = waitForPopupShown();
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#cc-name",
+ newValues: {
+ "#cc-name": "Mark Smith",
+ "#cc-number": "4111111111111111",
+ "#cc-exp-month": "4",
+ "#cc-exp-year": new Date().getFullYear(),
+ },
+ });
+
+ await onPopupShown;
+ await clickDoorhangerButton(MAIN_BUTTON);
+ }
+ );
+ await onChanged;
+
+ creditCards = await getCreditCards();
+ is(creditCards.length, 1, "Still 1 credit card in storage");
+ is(creditCards[0]["cc-name"], "Mark Smith", "name field got updated");
+ await removeAllRecords();
+});
+
+add_task(async function test_update_doorhanger_click_save() {
+ if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) {
+ todo(
+ OSKeyStoreTestUtils.canTestOSKeyStoreLogin(),
+ "Cannot test OS key store login on official builds."
+ );
+ return;
+ }
+
+ await setStorage(TEST_CREDIT_CARD_1);
+ let creditCards = await getCreditCards();
+ is(creditCards.length, 1, "1 credit card in storage");
+ let onChanged = waitForStorageChangedEvents("add");
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: CREDITCARD_FORM_URL },
+ async function (browser) {
+ let osKeyStoreLoginShown =
+ OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true);
+ let onPopupShown = waitForPopupShown();
+ await openPopupOn(browser, "form #cc-name");
+ await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser);
+ await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser);
+ await waitForAutofill(browser, "#cc-name", "John Doe");
+
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#cc-name",
+ newValues: {
+ "#cc-name": "User 1",
+ },
+ });
+
+ await onPopupShown;
+ await clickDoorhangerButton(SECONDARY_BUTTON);
+ await osKeyStoreLoginShown;
+ }
+ );
+ await onChanged;
+
+ creditCards = await getCreditCards();
+ is(creditCards.length, 2, "2 credit cards in storage");
+ is(
+ creditCards[0]["cc-name"],
+ TEST_CREDIT_CARD_1["cc-name"],
+ "Original record's cc-name field is unchanged"
+ );
+ is(creditCards[1]["cc-name"], "User 1", "cc-name field in the new record");
+ await removeAllRecords();
+});
diff --git a/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger_display.js b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger_display.js
new file mode 100644
index 0000000000..715eceb3eb
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger_display.js
@@ -0,0 +1,311 @@
+"use strict";
+
+add_task(async function test_save_doorhanger_shown_no_profile() {
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: CREDITCARD_FORM_URL },
+ async function (browser) {
+ let onPopupShown = waitForPopupShown();
+
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#cc-name",
+ newValues: {
+ "#cc-name": "User 1",
+ "#cc-number": "5577000055770004",
+ "#cc-exp-month": "12",
+ "#cc-exp-year": "2017",
+ "#cc-type": "mastercard",
+ },
+ });
+
+ await onPopupShown;
+ }
+ );
+});
+
+add_task(async function test_save_doorhanger_shown_different_card_number() {
+ await setStorage(TEST_CREDIT_CARD_1);
+ let creditCards = await getCreditCards();
+ is(creditCards.length, 1, "1 credit card in storage");
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: CREDITCARD_FORM_URL },
+ async function (browser) {
+ let onPopupShown = waitForPopupShown();
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#cc-name",
+ newValues: {
+ "#cc-number": TEST_CREDIT_CARD_2["cc-number"],
+ },
+ });
+
+ await onPopupShown;
+ }
+ );
+ await removeAllRecords();
+});
+
+add_task(async function test_update_doorhanger_shown_different_card_name() {
+ await setStorage(TEST_CREDIT_CARD_1);
+ let creditCards = await getCreditCards();
+ is(creditCards.length, 1, "1 credit card in storage");
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: CREDITCARD_FORM_URL },
+ async function (browser) {
+ let onPopupShown = waitForPopupShown();
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#cc-name",
+ newValues: {
+ "#cc-name": "Mark Smith",
+ "#cc-number": TEST_CREDIT_CARD_1["cc-number"],
+ "#cc-exp-month": TEST_CREDIT_CARD_1["cc-exp-month"],
+ "#cc-exp-year": TEST_CREDIT_CARD_1["cc-exp-year"],
+ },
+ });
+
+ await onPopupShown;
+ }
+ );
+ await removeAllRecords();
+});
+
+add_task(async function test_update_doorhanger_shown_different_card_expiry() {
+ await setStorage(TEST_CREDIT_CARD_1);
+ let creditCards = await getCreditCards();
+ is(creditCards.length, 1, "1 credit card in storage");
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: CREDITCARD_FORM_URL },
+ async function (browser) {
+ let onPopupShown = waitForPopupShown();
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#cc-name",
+ newValues: {
+ "#cc-name": TEST_CREDIT_CARD_1["cc-name"],
+ "#cc-number": TEST_CREDIT_CARD_1["cc-number"],
+ "#cc-exp-month": "12",
+ "#cc-exp-year": "2099",
+ },
+ });
+
+ await onPopupShown;
+ }
+ );
+ await removeAllRecords();
+});
+
+add_task(async function test_doorhanger_not_shown_when_autofill_untouched() {
+ if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) {
+ todo(
+ OSKeyStoreTestUtils.canTestOSKeyStoreLogin(),
+ "Cannot test OS key store login on official builds."
+ );
+ return;
+ }
+
+ await setStorage(TEST_CREDIT_CARD_1);
+ let creditCards = await getCreditCards();
+ is(creditCards.length, 1, "1 credit card in storage");
+
+ let onUsed = waitForStorageChangedEvents("notifyUsed");
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: CREDITCARD_FORM_URL },
+ async function (browser) {
+ let osKeyStoreLoginShown =
+ OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true);
+ await openPopupOn(browser, "form #cc-name");
+ await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser);
+ await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser);
+ await osKeyStoreLoginShown;
+ await waitForAutofill(browser, "#cc-name", "John Doe");
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ let form = content.document.getElementById("form");
+ form.querySelector("input[type=submit]").click();
+ });
+
+ await sleep(1000);
+ is(PopupNotifications.panel.state, "closed", "Doorhanger is hidden");
+ }
+ );
+ await onUsed;
+
+ creditCards = await getCreditCards();
+ is(creditCards.length, 1, "Still 1 credit card");
+ is(creditCards[0].timesUsed, 1, "timesUsed field set to 1");
+ await removeAllRecords();
+});
+
+add_task(async function test_doorhanger_not_shown_when_fill_duplicate() {
+ await setStorage(TEST_CREDIT_CARD_1);
+ let creditCards = await getCreditCards();
+ is(creditCards.length, 1, "1 credit card in storage");
+
+ let onUsed = waitForStorageChangedEvents("notifyUsed");
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: CREDITCARD_FORM_URL },
+ async function (browser) {
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#cc-name",
+ newValues: {
+ "#cc-name": "John Doe",
+ "#cc-number": "4111111111111111",
+ "#cc-exp-month": "4",
+ "#cc-exp-year": new Date().getFullYear(),
+ "#cc-type": "visa",
+ },
+ });
+
+ await sleep(1000);
+ is(PopupNotifications.panel.state, "closed", "Doorhanger is hidden");
+ }
+ );
+ await onUsed;
+
+ creditCards = await getCreditCards();
+ is(creditCards.length, 1, "Still 1 credit card in storage");
+ is(
+ creditCards[0]["cc-name"],
+ TEST_CREDIT_CARD_1["cc-name"],
+ "Verify the name field"
+ );
+ is(creditCards[0].timesUsed, 1, "timesUsed field set to 1");
+ await removeAllRecords();
+});
+
+add_task(
+ async function test_doorhanger_not_shown_when_autofill_then_fill_everything_duplicate() {
+ if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) {
+ todo(
+ OSKeyStoreTestUtils.canTestOSKeyStoreLogin(),
+ "Cannot test OS key store login on official builds."
+ );
+ return;
+ }
+
+ await setStorage(TEST_CREDIT_CARD_1, TEST_CREDIT_CARD_2);
+ let creditCards = await getCreditCards();
+ is(creditCards.length, 2, "2 credit card in storage");
+ let onUsed = waitForStorageChangedEvents("notifyUsed");
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: CREDITCARD_FORM_URL },
+ async function (browser) {
+ let osKeyStoreLoginShown =
+ OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true);
+ await openPopupOn(browser, "form #cc-number");
+ await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser);
+ await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser);
+ await waitForAutofill(
+ browser,
+ "#cc-number",
+ TEST_CREDIT_CARD_1["cc-number"]
+ );
+
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#cc-name",
+ newValues: {
+ // Change number to the second credit card number
+ "#cc-number": TEST_CREDIT_CARD_2["cc-number"],
+ "#cc-name": TEST_CREDIT_CARD_2["cc-name"],
+ "#cc-exp-month": TEST_CREDIT_CARD_2["cc-exp-month"],
+ "#cc-exp-year": TEST_CREDIT_CARD_2["cc-exp-year"],
+ },
+ });
+
+ await sleep(1000);
+ is(PopupNotifications.panel.state, "closed", "Doorhanger is hidden");
+ await osKeyStoreLoginShown;
+ }
+ );
+ await onUsed;
+
+ creditCards = await getCreditCards();
+ is(creditCards.length, 2, "Still 2 credit card");
+ await removeAllRecords();
+ }
+);
+
+add_task(
+ async function test_doorhanger_not_shown_when_autofill_then_fill_number_duplicate() {
+ if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) {
+ todo(
+ OSKeyStoreTestUtils.canTestOSKeyStoreLogin(),
+ "Cannot test OS key store login on official builds."
+ );
+ return;
+ }
+
+ await setStorage(TEST_CREDIT_CARD_1, {
+ ...TEST_CREDIT_CARD_1,
+ ...{ "cc-number": "5105105105105100" },
+ });
+
+ let creditCards = await getCreditCards();
+ is(creditCards.length, 2, "2 credit card in storage");
+ let onUsed = waitForStorageChangedEvents("notifyUsed");
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: CREDITCARD_FORM_URL },
+ async function (browser) {
+ let osKeyStoreLoginShown =
+ OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true);
+ await openPopupOn(browser, "form #cc-number");
+ await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser);
+ await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser);
+ await waitForAutofill(
+ browser,
+ "#cc-number",
+ TEST_CREDIT_CARD_1["cc-number"]
+ );
+
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#cc-name",
+ newValues: {
+ // Change number to the second credit card number
+ "#cc-number": "5105105105105100",
+ },
+ });
+
+ await sleep(1000);
+ is(PopupNotifications.panel.state, "closed", "Doorhanger is hidden");
+ await osKeyStoreLoginShown;
+ }
+ );
+ await onUsed;
+
+ creditCards = await getCreditCards();
+ is(creditCards.length, 2, "Still 2 credit card");
+ await removeAllRecords();
+ }
+);
+
+add_task(async function test_update_doorhanger_shown_when_fill_mergeable() {
+ await setStorage(TEST_CREDIT_CARD_3);
+ let creditCards = await getCreditCards();
+ is(creditCards.length, 1, "1 credit card in storage");
+
+ let onChanged = waitForStorageChangedEvents("update", "notifyUsed");
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: CREDITCARD_FORM_URL },
+ async function (browser) {
+ let onPopupShown = waitForPopupShown();
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#cc-name",
+ newValues: {
+ "#cc-name": "User 3",
+ "#cc-number": "5103059495477870",
+ "#cc-exp-month": "1",
+ "#cc-exp-year": "2000",
+ },
+ });
+
+ await onPopupShown;
+ await clickDoorhangerButton(MAIN_BUTTON);
+ }
+ );
+ await onChanged;
+
+ creditCards = await getCreditCards();
+ is(creditCards.length, 1, "Still 1 credit card in storage");
+ is(creditCards[0]["cc-name"], "User 3", "Verify the name field");
+ await removeAllRecords();
+});
diff --git a/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger_fields.js b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger_fields.js
new file mode 100644
index 0000000000..c1ebef737e
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger_fields.js
@@ -0,0 +1,198 @@
+"use strict";
+
+add_task(async function test_update_autofill_name_field() {
+ if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) {
+ todo(
+ OSKeyStoreTestUtils.canTestOSKeyStoreLogin(),
+ "Cannot test OS key store login on official builds."
+ );
+ return;
+ }
+
+ await setStorage(TEST_CREDIT_CARD_1);
+ let creditCards = await getCreditCards();
+ is(creditCards.length, 1, "1 credit card in storage");
+
+ let onChanged = waitForStorageChangedEvents("update", "notifyUsed");
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: CREDITCARD_FORM_URL },
+ async function (browser) {
+ let osKeyStoreLoginShown =
+ OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true);
+ let onPopupShown = waitForPopupShown();
+
+ await openPopupOn(browser, "form #cc-name");
+ await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser);
+ await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser);
+ await osKeyStoreLoginShown;
+ await waitForAutofill(browser, "#cc-name", "John Doe");
+
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#cc-name",
+ newValues: {
+ "#cc-name": "User 1",
+ },
+ });
+
+ await onPopupShown;
+ await clickDoorhangerButton(MAIN_BUTTON);
+ }
+ );
+ await onChanged;
+
+ creditCards = await getCreditCards();
+ is(creditCards.length, 1, "Still 1 credit card");
+ is(creditCards[0]["cc-name"], "User 1", "cc-name field is updated");
+ is(
+ creditCards[0]["cc-number"],
+ "************1111",
+ "Verify the card number field"
+ );
+ await removeAllRecords();
+});
+
+add_task(async function test_update_autofill_exp_date_field() {
+ if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) {
+ todo(
+ OSKeyStoreTestUtils.canTestOSKeyStoreLogin(),
+ "Cannot test OS key store login on official builds."
+ );
+ return;
+ }
+
+ await setStorage(TEST_CREDIT_CARD_1);
+ let creditCards = await getCreditCards();
+ is(creditCards.length, 1, "1 credit card in storage");
+ let onChanged = waitForStorageChangedEvents("update", "notifyUsed");
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: CREDITCARD_FORM_URL },
+ async function (browser) {
+ let osKeyStoreLoginShown =
+ OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true);
+ let onPopupShown = waitForPopupShown();
+ await openPopupOn(browser, "form #cc-name");
+ await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser);
+ await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser);
+ await osKeyStoreLoginShown;
+ await waitForAutofill(browser, "#cc-name", "John Doe");
+
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#cc-name",
+ newValues: {
+ "#cc-exp-year": "2019",
+ },
+ });
+
+ await onPopupShown;
+ await clickDoorhangerButton(MAIN_BUTTON);
+ }
+ );
+ await onChanged;
+
+ creditCards = await getCreditCards();
+ is(creditCards.length, 1, "Still 1 credit card");
+ is(creditCards[0]["cc-exp-year"], 2019, "cc-exp-year field is updated");
+ is(
+ creditCards[0]["cc-number"],
+ "************1111",
+ "Verify the card number field"
+ );
+ await removeAllRecords();
+});
+
+add_task(async function test_submit_unnormailzed_field() {
+ await setStorage(TEST_CREDIT_CARD_1);
+ let creditCards = await getCreditCards();
+ is(creditCards.length, 1, "1 credit card in storage");
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: CREDITCARD_FORM_URL },
+ async function (browser) {
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#cc-name",
+ newValues: {
+ "#cc-name": "John Doe",
+ "#cc-number": "4111111111111111",
+ "#cc-exp-month": "4",
+ "#cc-exp-year": new Date().getFullYear().toString().substr(2, 2),
+ "#cc-type": "visa",
+ },
+ });
+
+ await sleep(1000);
+ is(PopupNotifications.panel.state, "closed", "Doorhanger is hidden");
+ }
+ );
+
+ creditCards = await getCreditCards();
+ is(creditCards.length, 1, "Still 1 credit card in storage");
+ is(
+ creditCards[0]["cc-exp-year"],
+ new Date().getFullYear(),
+ "Verify the expiry year field"
+ );
+ await removeAllRecords();
+});
+
+add_task(async function test_submit_invalid_network_field() {
+ let onChanged = waitForStorageChangedEvents("add");
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: CREDITCARD_FORM_URL },
+ async function (browser) {
+ let onPopupShown = waitForPopupShown();
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#cc-name",
+ newValues: {
+ "#cc-name": "User 1",
+ "#cc-number": "5038146897157463",
+ "#cc-exp-month": "12",
+ "#cc-exp-year": "2017",
+ "#cc-type": "gringotts",
+ },
+ });
+
+ await onPopupShown;
+ await clickDoorhangerButton(MAIN_BUTTON);
+ }
+ );
+ await onChanged;
+
+ let creditCards = await getCreditCards();
+ is(creditCards.length, 1, "1 credit card in storage");
+ is(creditCards[0]["cc-name"], "User 1", "Verify the name field");
+ is(
+ creditCards[0]["cc-type"],
+ undefined,
+ "Invalid network/cc-type was not saved"
+ );
+
+ await removeAllRecords();
+});
+
+add_task(async function test_submit_combined_expiry_field() {
+ let onChanged = waitForStorageChangedEvents("add");
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: CREDITCARD_FORM_COMBINED_EXPIRY_URL },
+ async function (browser) {
+ let onPopupShown = waitForPopupShown();
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#cc-name",
+ newValues: {
+ "#cc-name": "John Doe",
+ "#cc-number": "374542158116607",
+ "#cc-exp": "05/28",
+ },
+ });
+ await onPopupShown;
+ await clickDoorhangerButton(MAIN_BUTTON);
+ }
+ );
+ await onChanged;
+
+ let creditCards = await getCreditCards();
+ is(creditCards.length, 1, "Card should be added");
+ is(creditCards[0]["cc-exp"], "2028-05", "Verify cc-exp field");
+ is(creditCards[0]["cc-exp-month"], 5, "Verify cc-exp-month field");
+ is(creditCards[0]["cc-exp-year"], 2028, "Verify cc-exp-year field");
+ await removeAllRecords();
+});
diff --git a/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger_iframe.js b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger_iframe.js
new file mode 100644
index 0000000000..2781e5acf6
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger_iframe.js
@@ -0,0 +1,103 @@
+"use strict";
+
+add_task(async function test_iframe_submit_untouched_creditCard_form() {
+ if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) {
+ todo(
+ OSKeyStoreTestUtils.canTestOSKeyStoreLogin(),
+ "Cannot test OS key store login on official builds."
+ );
+ return;
+ }
+
+ await setStorage(TEST_CREDIT_CARD_1);
+ let creditCards = await getCreditCards();
+ is(creditCards.length, 1, "1 credit card in storage");
+
+ // This test triggers two form submission events so cc 'timesUsed' count is 2.
+ // The first submission is triggered by standard form submission, and the
+ // second is triggered by page hiding.
+ const EXPECTED_ON_USED_COUNT = 2;
+ let notifyUsedCounter = EXPECTED_ON_USED_COUNT;
+ let onUsed = TestUtils.topicObserved(
+ "formautofill-storage-changed",
+ (subject, data) => {
+ if (data == "notifyUsed") {
+ notifyUsedCounter--;
+ }
+ return notifyUsedCounter == 0;
+ }
+ );
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: CREDITCARD_FORM_IFRAME_URL },
+ async function (browser) {
+ let osKeyStoreLoginShown =
+ OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true);
+ let iframeBC = browser.browsingContext.children[0];
+ await openPopupOnSubframe(browser, iframeBC, "form #cc-name");
+
+ EventUtils.synthesizeKey("VK_DOWN", {});
+ EventUtils.synthesizeKey("VK_RETURN", {});
+ await osKeyStoreLoginShown;
+ await waitForAutofill(iframeBC, "#cc-name", "John Doe");
+
+ await SpecialPowers.spawn(iframeBC, [], async function () {
+ let form = content.document.getElementById("form");
+ form.querySelector("input[type=submit]").click();
+ });
+
+ await sleep(1000);
+ is(PopupNotifications.panel.state, "closed", "Doorhanger is hidden");
+ }
+ );
+ await onUsed;
+
+ creditCards = await getCreditCards();
+ is(creditCards.length, 1, "Still 1 credit card");
+ is(
+ creditCards[0].timesUsed,
+ EXPECTED_ON_USED_COUNT,
+ "timesUsed field set to 2"
+ );
+ await removeAllRecords();
+});
+
+add_task(async function test_iframe_unload_save_card() {
+ let onChanged = waitForStorageChangedEvents("add");
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: CREDITCARD_FORM_IFRAME_URL },
+ async function (browser) {
+ let onPopupShown = waitForPopupShown();
+ let iframeBC = browser.browsingContext.children[0];
+ await focusUpdateSubmitForm(
+ iframeBC,
+ {
+ focusSelector: "#cc-name",
+ newValues: {
+ "#cc-name": "User 1",
+ "#cc-number": "4556194630960970",
+ "#cc-exp-month": "10",
+ "#cc-exp-year": "2024",
+ "#cc-type": "visa",
+ },
+ },
+ false
+ );
+
+ info("Removing iframe without submitting");
+ await SpecialPowers.spawn(browser, [], async function () {
+ let frame = content.document.querySelector("iframe");
+ frame.remove();
+ });
+
+ await onPopupShown;
+ await clickDoorhangerButton(MAIN_BUTTON);
+ }
+ );
+ await onChanged;
+
+ let creditCards = await getCreditCards();
+ is(creditCards.length, 1, "1 credit card in storage");
+ is(creditCards[0]["cc-name"], "User 1", "Verify the name field");
+ is(creditCards[0]["cc-type"], "visa", "Verify the cc-type field");
+ await removeAllRecords();
+});
diff --git a/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger_logo.js b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger_logo.js
new file mode 100644
index 0000000000..9c4f4e4825
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger_logo.js
@@ -0,0 +1,238 @@
+"use strict";
+
+/*
+ The next four tests look very similar because if we try to do multiple
+ credit card operations in one test, there's a good chance the test will timeout
+ and produce an invalid result.
+ We mitigate this issue by having each test only deal with one credit card in storage
+ and one credit card operation.
+*/
+add_task(async function test_submit_third_party_creditCard_logo() {
+ const amexCard = {
+ "cc-number": "374542158116607",
+ "cc-type": "amex",
+ "cc-name": "John Doe",
+ };
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: CREDITCARD_FORM_URL },
+ async function (browser) {
+ let onPopupShown = waitForPopupShown();
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#cc-name",
+ newValues: {
+ "#cc-name": "User 1",
+ "#cc-number": amexCard["cc-number"],
+ },
+ });
+
+ await onPopupShown;
+ let doorhanger = getNotification();
+ let creditCardLogo = doorhanger.querySelector(".desc-message-box image");
+ let creditCardLogoWithoutExtension = creditCardLogo.src.split(".", 1)[0];
+
+ is(
+ creditCardLogoWithoutExtension,
+ "chrome://formautofill/content/third-party/cc-logo-amex",
+ "CC logo should be amex"
+ );
+
+ await clickDoorhangerButton(SECONDARY_BUTTON);
+ }
+ );
+ await removeAllRecords();
+});
+
+add_task(async function test_update_third_party_creditCard_logo() {
+ const amexCard = {
+ "cc-number": "374542158116607",
+ "cc-name": "John Doe",
+ };
+
+ await setStorage(amexCard);
+ let creditCards = await getCreditCards();
+ is(creditCards.length, 1, "1 credit card in storage");
+
+ let onChanged = waitForStorageChangedEvents("update");
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: CREDITCARD_FORM_URL },
+ async function (browser) {
+ let onPopupShown = waitForPopupShown();
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#cc-name",
+ newValues: {
+ "#cc-name": "Mark Smith",
+ "#cc-number": amexCard["cc-number"],
+ "#cc-exp-month": "4",
+ "#cc-exp-year": new Date().getFullYear(),
+ },
+ });
+
+ await onPopupShown;
+
+ let doorhanger = getNotification();
+ let creditCardLogo = doorhanger.querySelector(".desc-message-box image");
+ let creditCardLogoWithoutExtension = creditCardLogo.src.split(".", 1)[0];
+ is(
+ creditCardLogoWithoutExtension,
+ `chrome://formautofill/content/third-party/cc-logo-amex`,
+ `CC Logo should be amex`
+ );
+ await clickDoorhangerButton(MAIN_BUTTON);
+ }
+ );
+ await onChanged;
+ await removeAllRecords();
+});
+
+add_task(async function test_submit_generic_creditCard_logo() {
+ const genericCard = {
+ "cc-number": "937899583135",
+ "cc-name": "John Doe",
+ };
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: CREDITCARD_FORM_URL },
+ async function (browser) {
+ let onPopupShown = waitForPopupShown();
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#cc-name",
+ newValues: {
+ "#cc-name": "User 1",
+ "#cc-number": genericCard["cc-number"],
+ },
+ });
+
+ await onPopupShown;
+ let doorhanger = getNotification();
+ let creditCardLogo = doorhanger.querySelector(".desc-message-box image");
+ let creditCardLogoWithoutExtension = creditCardLogo.src.split(".", 1)[0];
+
+ is(
+ creditCardLogoWithoutExtension,
+ "chrome://formautofill/content/icon-credit-card-generic",
+ "CC logo should be generic"
+ );
+
+ await clickDoorhangerButton(SECONDARY_BUTTON);
+ }
+ );
+ await removeAllRecords();
+});
+
+add_task(async function test_update_generic_creditCard_logo() {
+ const genericCard = {
+ "cc-number": "937899583135",
+ "cc-name": "John Doe",
+ };
+
+ await setStorage(genericCard);
+ let creditCards = await getCreditCards();
+ is(creditCards.length, 1, "1 credit card in storage");
+
+ let onChanged = waitForStorageChangedEvents("update");
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: CREDITCARD_FORM_URL },
+ async function (browser) {
+ let onPopupShown = waitForPopupShown();
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#cc-name",
+ newValues: {
+ "#cc-name": "Mark Smith",
+ "#cc-number": genericCard["cc-number"],
+ "#cc-exp-month": "4",
+ "#cc-exp-year": new Date().getFullYear(),
+ },
+ });
+
+ await onPopupShown;
+
+ let doorhanger = getNotification();
+ let creditCardLogo = doorhanger.querySelector(".desc-message-box image");
+ let creditCardLogoWithoutExtension = creditCardLogo.src.split(".", 1)[0];
+ is(
+ creditCardLogoWithoutExtension,
+ `chrome://formautofill/content/icon-credit-card-generic`,
+ `CC Logo should be generic`
+ );
+ await clickDoorhangerButton(MAIN_BUTTON);
+ }
+ );
+ await onChanged;
+ await removeAllRecords();
+});
+
+add_task(async function test_save_panel_spaces_in_cc_number_logo() {
+ const amexCard = {
+ "cc-number": "37 4542 158116 607",
+ "cc-type": "amex",
+ "cc-name": "John Doe",
+ };
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: CREDITCARD_FORM_URL },
+ async function (browser) {
+ let onPopupShown = waitForPopupShown();
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#cc-name",
+ newValues: {
+ "#cc-number": amexCard["cc-number"],
+ },
+ });
+
+ await onPopupShown;
+ let doorhanger = getNotification();
+ let creditCardLogo = doorhanger.querySelector(".desc-message-box image");
+ let creditCardLogoWithoutExtension = creditCardLogo.src.split(".", 1)[0];
+
+ is(
+ creditCardLogoWithoutExtension,
+ "chrome://formautofill/content/third-party/cc-logo-amex",
+ "CC logo should be amex"
+ );
+
+ await clickDoorhangerButton(SECONDARY_BUTTON);
+ }
+ );
+});
+
+add_task(async function test_update_panel_with_spaces_in_cc_number_logo() {
+ const amexCard = {
+ "cc-number": "374 54215 8116607",
+ "cc-name": "John Doe",
+ };
+
+ await setStorage(amexCard);
+ let creditCards = await getCreditCards();
+ is(creditCards.length, 1, "1 credit card in storage");
+
+ let onChanged = waitForStorageChangedEvents("update", "notifyUsed");
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: CREDITCARD_FORM_URL },
+ async function (browser) {
+ let onPopupShown = waitForPopupShown();
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#cc-name",
+ newValues: {
+ "#cc-name": "Mark Smith",
+ "#cc-number": amexCard["cc-number"],
+ "#cc-exp-month": "4",
+ "#cc-exp-year": new Date().getFullYear(),
+ },
+ });
+
+ await onPopupShown;
+
+ let doorhanger = getNotification();
+ let creditCardLogo = doorhanger.querySelector(".desc-message-box image");
+ let creditCardLogoWithoutExtension = creditCardLogo.src.split(".", 1)[0];
+ is(
+ creditCardLogoWithoutExtension,
+ `chrome://formautofill/content/third-party/cc-logo-amex`,
+ `CC Logo should be amex`
+ );
+ // We are not testing whether the cc-number is saved correctly,
+ // we only care that the logo in the update panel shows correctly.
+ await clickDoorhangerButton(MAIN_BUTTON);
+ }
+ );
+ await onChanged;
+ await removeAllRecords();
+});
diff --git a/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger_sync.js b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger_sync.js
new file mode 100644
index 0000000000..2064928820
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_doorhanger_sync.js
@@ -0,0 +1,117 @@
+"use strict";
+
+add_task(async function test_submit_creditCard_with_sync_account() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [SYNC_USERNAME_PREF, "foo@bar.com"],
+ [SYNC_CREDITCARDS_AVAILABLE_PREF, true],
+ [ENABLED_AUTOFILL_CREDITCARDS_PREF, true],
+ ],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: CREDITCARD_FORM_URL },
+ async function (browser) {
+ let onPopupShown = waitForPopupShown();
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#cc-name",
+ newValues: {
+ "#cc-name": "User 2",
+ "#cc-number": "6387060366272981",
+ },
+ });
+
+ await onPopupShown;
+ let cb = getDoorhangerCheckbox();
+ ok(!cb.hidden, "Sync checkbox should be visible");
+ is(
+ SpecialPowers.getBoolPref(SYNC_CREDITCARDS_PREF),
+ false,
+ "creditCards sync should be disabled by default"
+ );
+
+ // Verify if the checkbox and button state is changed.
+ let secondaryButton = getDoorhangerButton(SECONDARY_BUTTON);
+ let menuButton = getDoorhangerButton(MENU_BUTTON);
+ is(
+ cb.checked,
+ false,
+ "Checkbox state should match creditCards sync state"
+ );
+ is(
+ secondaryButton.disabled,
+ false,
+ "Not saving button should be enabled"
+ );
+ is(
+ menuButton.disabled,
+ false,
+ "Never saving menu button should be enabled"
+ );
+ // Click the checkbox to enable credit card sync.
+ cb.click();
+ is(
+ SpecialPowers.getBoolPref(SYNC_CREDITCARDS_PREF),
+ true,
+ "creditCards sync should be enabled after checked"
+ );
+ is(
+ secondaryButton.disabled,
+ true,
+ "Not saving button should be disabled"
+ );
+ is(
+ menuButton.disabled,
+ true,
+ "Never saving menu button should be disabled"
+ );
+ // Click the checkbox again to disable credit card sync.
+ cb.click();
+ is(
+ SpecialPowers.getBoolPref(SYNC_CREDITCARDS_PREF),
+ false,
+ "creditCards sync should be disabled after unchecked"
+ );
+ is(
+ secondaryButton.disabled,
+ false,
+ "Not saving button should be enabled again"
+ );
+ is(
+ menuButton.disabled,
+ false,
+ "Never saving menu button should be enabled again"
+ );
+ await clickDoorhangerButton(SECONDARY_BUTTON);
+ }
+ );
+});
+
+add_task(async function test_submit_creditCard_with_synced_already() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [SYNC_CREDITCARDS_PREF, true],
+ [SYNC_USERNAME_PREF, "foo@bar.com"],
+ [SYNC_CREDITCARDS_AVAILABLE_PREF, true],
+ ],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: CREDITCARD_FORM_URL },
+ async function (browser) {
+ let onPopupShown = waitForPopupShown();
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#cc-name",
+ newValues: {
+ "#cc-name": "User 2",
+ "#cc-number": "6387060366272981",
+ },
+ });
+
+ await onPopupShown;
+ let cb = getDoorhangerCheckbox();
+ ok(cb.hidden, "Sync checkbox should be hidden");
+ await clickDoorhangerButton(SECONDARY_BUTTON);
+ }
+ );
+});
diff --git a/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_dropdown_layout.js b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_dropdown_layout.js
new file mode 100644
index 0000000000..d69f7129ee
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_dropdown_layout.js
@@ -0,0 +1,57 @@
+"use strict";
+
+const CC_URL =
+ "https://example.org/browser/browser/extensions/formautofill/test/browser/creditCard/autocomplete_creditcard_basic.html";
+
+add_task(async function setup_storage() {
+ await setStorage(TEST_CREDIT_CARD_1, TEST_CREDIT_CARD_2, TEST_CREDIT_CARD_3);
+});
+
+async function reopenPopupWithResizedInput(browser, selector, newSize) {
+ await closePopup(browser);
+ /* eslint no-shadow: ["error", { "allow": ["selector", "newSize"] }] */
+ await SpecialPowers.spawn(
+ browser,
+ [{ selector, newSize }],
+ async function ({ selector, newSize }) {
+ const input = content.document.querySelector(selector);
+
+ input.style.boxSizing = "border-box";
+ input.style.width = newSize + "px";
+ }
+ );
+ await openPopupOn(browser, selector);
+}
+
+add_task(async function test_credit_card_dropdown() {
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: CC_URL },
+ async function (browser) {
+ const focusInput = "#cc-number";
+ await openPopupOn(browser, focusInput);
+ const firstItem = getDisplayedPopupItems(browser)[0];
+
+ isnot(firstItem.getAttribute("ac-image"), "", "Should show icon");
+ ok(
+ firstItem.getAttribute("aria-label").startsWith("Visa "),
+ "aria-label should start with Visa"
+ );
+
+ // The breakpoint of two-lines layout is 185px
+ await reopenPopupWithResizedInput(browser, focusInput, 175);
+ is(
+ firstItem._itemBox.getAttribute("size"),
+ "small",
+ "Show two-lines layout"
+ );
+ await reopenPopupWithResizedInput(browser, focusInput, 195);
+ is(
+ firstItem._itemBox.hasAttribute("size"),
+ false,
+ "Show one-line layout"
+ );
+
+ await closePopup(browser);
+ }
+ );
+});
diff --git a/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_fill_cancel_login.js b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_fill_cancel_login.js
new file mode 100644
index 0000000000..6304e4699f
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_fill_cancel_login.js
@@ -0,0 +1,37 @@
+"use strict";
+
+add_task(async function test_fill_creditCard_but_cancel_login() {
+ if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) {
+ todo(
+ OSKeyStoreTestUtils.canTestOSKeyStoreLogin(),
+ "Cannot test OS key store login on official builds."
+ );
+ return;
+ }
+
+ await setStorage(TEST_CREDIT_CARD_2);
+
+ let osKeyStoreLoginShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(false); // cancel
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: CREDITCARD_FORM_URL },
+ async function (browser) {
+ await openPopupOn(browser, "#cc-name");
+ const ccItem = getDisplayedPopupItems(browser)[0];
+ let popupClosePromise = BrowserTestUtils.waitForPopupEvent(
+ browser.autoCompletePopup,
+ "hidden"
+ );
+ await EventUtils.synthesizeMouseAtCenter(ccItem, {});
+ await Promise.all([osKeyStoreLoginShown, popupClosePromise]);
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ is(content.document.querySelector("#cc-name").value, "", "Check name");
+ is(
+ content.document.querySelector("#cc-number").value,
+ "",
+ "Check number"
+ );
+ });
+ }
+ );
+});
diff --git a/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_heuristics.js b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_heuristics.js
new file mode 100644
index 0000000000..3801239234
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_heuristics.js
@@ -0,0 +1,165 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TESTCASES = [
+ {
+ description: "@autocomplete - all fields in the same form",
+ document: `<form>
+ <input id="cc-number" autocomplete="cc-number">
+ <input id="cc-name" autocomplete="cc-name">
+ <input id="cc-exp" autocomplete="cc-exp">
+ </form>`,
+ idsToShowPopup: ["cc-number", "cc-name", "cc-exp"],
+ },
+ {
+ description: "without @autocomplete - all fields in the same form",
+ document: `<form>
+ <input id="cc-number" placeholder="credit card number">
+ <input id="cc-name" placeholder="credit card holder name">
+ <input id="cc-exp" placeholder="expiration date">
+ </form>`,
+ idsToShowPopup: ["cc-number", "cc-name", "cc-exp"],
+ },
+ {
+ description: "@autocomplete - each field in its own form",
+ document: `<form><input id="cc-number" autocomplete="cc-number"></form>
+ <form><input id="cc-name" autocomplete="cc-name"></form>
+ <form><input id="cc-exp" autocomplete="cc-exp"></form>`,
+ idsToShowPopup: ["cc-number", "cc-name", "cc-exp"],
+ },
+ {
+ description:
+ "without @autocomplete - each field in its own form (high-confidence cc-number & cc-name)",
+ document: `<form><input id="cc-number" placeholder="credit card number"></form>
+ <form><input id="cc-name" placeholder="credit card holder name"></form>
+ <form><input id="cc-exp" placeholder="expiration date"></form>`,
+ prefs: [
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.highConfidenceThreshold",
+ "0.9",
+ ],
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.testConfidence",
+ "0.95",
+ ],
+ ],
+ idsToShowPopup: ["cc-number", "cc-name"],
+ idsWithNoPopup: ["cc-exp"],
+ },
+ {
+ description:
+ "without @autocomplete - each field in its own form (normal-confidence cc-number & cc-name)",
+ document: `<form><input id="cc-number" placeholder="credit card number"></form>
+ <form><input id="cc-name" placeholder="credit card holder name"></form>
+ <form><input id="cc-exp" placeholder="expiration date"></form>`,
+ prefs: [
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.highConfidenceThreshold",
+ "0.9",
+ ],
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.testConfidence",
+ "0.8",
+ ],
+ ],
+ idsWithNoPopup: ["cc-number", "cc-name", "cc-exp"],
+ },
+ {
+ description:
+ "with @autocomplete - cc-number/cc-name and another <input> in a form",
+ document: `<form>
+ <input id="cc-number" autocomplete="cc-number">
+ <input id="password" type="password">
+ </form>
+ <form>
+ <input id="cc-name" autocomplete="cc-name">
+ <input id="password" type="password">
+ </form>`,
+ idsToShowPopup: ["cc-number", "cc-name"],
+ },
+ {
+ description:
+ "without @autocomplete - high-confidence cc-number/cc-name and another <input> in a form",
+ document: `<form>
+ <input id="cc-number" placeholder="credit card number">
+ <input id="password" type="password">
+ </form>
+ <form>
+ <input id="cc-name" placeholder="credit card holder name">
+ <input id="password" type="password">
+ </form>`,
+ idsWithNoPopup: ["cc-number", "cc-name"],
+ },
+ {
+ description:
+ "without @autocomplete - high-confidence cc-number/cc-name and another hidden <input> in a form",
+ document: `<form>
+ <input id="cc-number" placeholder="credit card number">
+ <input id="token" type="hidden">
+ </form>
+ <form>
+ <input id="cc-name" placeholder="credit card holder name">
+ <input id="token" type="hidden">
+ </form>`,
+ prefs: [
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.highConfidenceThreshold",
+ "0.9",
+ ],
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.testConfidence",
+ "0.95",
+ ],
+ ],
+ idsToShowPopup: ["cc-number", "cc-name"],
+ },
+];
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.formautofill.creditCards.supported", "on"],
+ ["extensions.formautofill.creditCards.enabled", true],
+ ],
+ });
+
+ await setStorage(TEST_CREDIT_CARD_1);
+});
+
+add_task(async function test_heuristics() {
+ for (const TEST of TESTCASES) {
+ info(`Test ${TEST.description}`);
+ if (TEST.prefs) {
+ await SpecialPowers.pushPrefEnv({ set: TEST.prefs });
+ }
+
+ await BrowserTestUtils.withNewTab(EMPTY_URL, async function (browser) {
+ await SpecialPowers.spawn(browser, [TEST.document], doc => {
+ // eslint-disable-next-line no-unsanitized/property
+ content.document.body.innerHTML = doc;
+ });
+
+ await SimpleTest.promiseFocus(browser);
+
+ let ids = TEST.idsToShowPopup ?? [];
+ for (const id of ids) {
+ await runAndWaitForAutocompletePopupOpen(browser, async () => {
+ await focusAndWaitForFieldsIdentified(browser, `#${id}`);
+ });
+ ok(true, `popup is opened on <input id=${id}>`);
+ }
+
+ ids = TEST.idsWithNoPopup ?? [];
+ for (const id of ids) {
+ await focusAndWaitForFieldsIdentified(browser, `#${id}`);
+ await ensureNoAutocompletePopup(browser);
+ }
+ });
+
+ if (TEST.prefs) {
+ await SpecialPowers.popPrefEnv();
+ }
+ }
+});
diff --git a/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_heuristics_cc_type.js b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_heuristics_cc_type.js
new file mode 100644
index 0000000000..e3f12096c2
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_heuristics_cc_type.js
@@ -0,0 +1,77 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PROFILE = {
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ // "cc-type" should be remove from proile after fixing Bug 1834768.
+ "cc-type": "visa",
+ "cc-exp-month": 4,
+ "cc-exp-year": new Date().getFullYear(),
+};
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.formautofill.creditCards.supported", "on"],
+ ["extensions.formautofill.creditCards.enabled", true],
+ ],
+ });
+});
+
+add_autofill_heuristic_tests([
+ {
+ description:
+ "cc-type select does not have any information in labels or attributes",
+ fixtureData: `
+ <form>
+ <input id="cc-number" autocomplete="cc-number">
+ <input id="cc-name" autocomplete="cc-name">
+ <select id="test">
+ <option value="" selected="">0</option>
+ <option value="VISA">1</option>
+ <option value="MasterCard">2</option>
+ <option value="DINERS">3</option>
+ <option value="Discover">4</option>
+ </select>
+ </form>
+ <form>
+ <input id="cc-number" autocomplete="cc-number">
+ <input id="cc-name" autocomplete="cc-name">
+ <select id="test">
+ <option value="0" selected="">Card Type</option>
+ <option value="1">Visa</option>
+ <option value="2">MasterCard</option>
+ <option value="3">Diners Club International</option>
+ <option value="4">Discover</option>
+ </select>
+ </form>`,
+ profile: TEST_PROFILE,
+ expectedResult: [
+ {
+ description: "cc-type option.value has the hint",
+ default: {
+ reason: "autocomplete",
+ },
+ fields: [
+ { fieldName: "cc-number", autofill: TEST_PROFILE["cc-number"] },
+ { fieldName: "cc-name", autofill: TEST_PROFILE["cc-name"] },
+ { fieldName: "cc-type", reason: "regex-heuristic", autofill: "VISA" },
+ ],
+ },
+ {
+ description: "cc-type option.text has the hint",
+ default: {
+ reason: "autocomplete",
+ },
+ fields: [
+ { fieldName: "cc-number", autofill: TEST_PROFILE["cc-number"] },
+ { fieldName: "cc-name", autofill: TEST_PROFILE["cc-name"] },
+ { fieldName: "cc-type", reason: "regex-heuristic", autofill: "1" },
+ ],
+ },
+ ],
+ },
+]);
diff --git a/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_submission_autodetect_type.js b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_submission_autodetect_type.js
new file mode 100644
index 0000000000..825a2978de
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_submission_autodetect_type.js
@@ -0,0 +1,104 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_autodetect_credit_not_set() {
+ const testCard = {
+ "cc-name": "John Doe",
+ "cc-number": "4012888888881881",
+ "cc-exp-month": "06",
+ "cc-exp-year": "2044",
+ };
+ const expectedData = {
+ ...testCard,
+ ...{ "cc-type": "visa" },
+ };
+ let onChanged = waitForStorageChangedEvents("add");
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: CREDITCARD_FORM_URL },
+ async function (browser) {
+ let promiseShown = waitForPopupShown();
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#cc-name",
+ newValues: {
+ "#cc-name": testCard["cc-name"],
+ "#cc-number": testCard["cc-number"],
+ "#cc-exp-month": testCard["cc-exp-month"],
+ "#cc-exp-year": testCard["cc-exp-year"],
+ "#cc-type": testCard["cc-type"],
+ },
+ });
+
+ await promiseShown;
+ await clickDoorhangerButton(MAIN_BUTTON);
+ }
+ );
+
+ await onChanged;
+
+ let creditCards = await getCreditCards();
+ let savedCreditCard = creditCards[0];
+ let decryptedNumber = await OSKeyStore.decrypt(
+ savedCreditCard["cc-number-encrypted"]
+ );
+ savedCreditCard["cc-number"] = decryptedNumber;
+ for (let key in testCard) {
+ let expected = expectedData[key];
+ let actual = savedCreditCard[key];
+ Assert.equal(expected, actual, `${key} should match`);
+ }
+ await removeAllRecords();
+});
+
+add_task(async function test_autodetect_credit_overwrite() {
+ const testCard = {
+ "cc-name": "John Doe",
+ "cc-number": "4012888888881881",
+ "cc-exp-month": "06",
+ "cc-exp-year": "2044",
+ "cc-type": "master", // Wrong credit card type
+ };
+ const expectedData = {
+ ...testCard,
+ ...{ "cc-type": "visa" },
+ };
+ let onChanged = waitForStorageChangedEvents("add");
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: CREDITCARD_FORM_URL },
+ async function (browser) {
+ let promiseShown = waitForPopupShown();
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#cc-name",
+ newValues: {
+ "#cc-name": testCard["cc-name"],
+ "#cc-number": testCard["cc-number"],
+ "#cc-exp-month": testCard["cc-exp-month"],
+ "#cc-exp-year": testCard["cc-exp-year"],
+ "#cc-type": testCard["cc-type"],
+ },
+ });
+
+ await promiseShown;
+ await clickDoorhangerButton(MAIN_BUTTON);
+ }
+ );
+
+ await onChanged;
+
+ let creditCards = await getCreditCards();
+ let savedCreditCard = creditCards[0];
+ let decryptedNumber = await OSKeyStore.decrypt(
+ savedCreditCard["cc-number-encrypted"]
+ );
+ savedCreditCard["cc-number"] = decryptedNumber;
+ for (let key in testCard) {
+ let expected = expectedData[key];
+ let actual = savedCreditCard[key];
+ Assert.equal(expected, actual, `${key} should match`);
+ }
+
+ await removeAllRecords();
+});
diff --git a/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_submission_normalized.js b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_submission_normalized.js
new file mode 100644
index 0000000000..76e125a196
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_submission_normalized.js
@@ -0,0 +1,109 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// We want to ensure that non-normalized credit card data is normalized
+// correctly as part of the save credit card flow
+add_task(async function test_new_submitted_card_is_normalized() {
+ const testCard = {
+ "cc-name": "Test User",
+ "cc-number": "5038146897157463",
+ "cc-exp-month": "4",
+ "cc-exp-year": "25",
+ };
+ const expectedData = {
+ "cc-name": "Test User",
+ "cc-number": "5038146897157463",
+ "cc-exp-month": "4",
+ // cc-exp-year should be normalized to 2025
+ "cc-exp-year": "2025",
+ };
+ let onChanged = waitForStorageChangedEvents("add");
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: CREDITCARD_FORM_URL },
+ async function (browser) {
+ let promiseShown = waitForPopupShown();
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#cc-name",
+ newValues: {
+ "#cc-name": testCard["cc-name"],
+ "#cc-number": testCard["cc-number"],
+ "#cc-exp-month": testCard["cc-exp-month"],
+ "#cc-exp-year": testCard["cc-exp-year"],
+ },
+ });
+
+ await promiseShown;
+ await clickDoorhangerButton(MAIN_BUTTON);
+ }
+ );
+
+ await onChanged;
+
+ let creditCards = await getCreditCards();
+ let savedCreditCard = creditCards[0];
+ let decryptedNumber = await OSKeyStore.decrypt(
+ savedCreditCard["cc-number-encrypted"]
+ );
+ savedCreditCard["cc-number"] = decryptedNumber;
+ for (let key in testCard) {
+ let expected = expectedData[key];
+ let actual = savedCreditCard[key];
+ Assert.equal(expected, actual, `${key} should match`);
+ }
+ await removeAllRecords();
+});
+
+add_task(async function test_updated_card_is_normalized() {
+ const testCard = {
+ "cc-name": "Test User",
+ "cc-number": "5038146897157463",
+ "cc-exp-month": "11",
+ "cc-exp-year": "20",
+ };
+ await saveCreditCard(testCard);
+ const expectedData = {
+ "cc-name": "Test User",
+ "cc-number": "5038146897157463",
+ "cc-exp-month": "10",
+ // cc-exp-year should be normalized to 2027
+ "cc-exp-year": "2027",
+ };
+ let onChanged = waitForStorageChangedEvents("update");
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: CREDITCARD_FORM_URL },
+ async function (browser) {
+ let promiseShown = waitForPopupShown();
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#cc-name",
+ newValues: {
+ "#cc-name": testCard["cc-name"],
+ "#cc-number": testCard["cc-number"],
+ "#cc-exp-month": "10",
+ "#cc-exp-year": "27",
+ },
+ });
+
+ await promiseShown;
+ await clickDoorhangerButton(MAIN_BUTTON);
+ }
+ );
+
+ await onChanged;
+
+ let creditCards = await getCreditCards();
+ let savedCreditCard = creditCards[0];
+ savedCreditCard["cc-number"] = await OSKeyStore.decrypt(
+ savedCreditCard["cc-number-encrypted"]
+ );
+
+ for (let key in testCard) {
+ let expected = expectedData[key];
+ let actual = savedCreditCard[key];
+ Assert.equal(expected, actual, `${key} should match`);
+ }
+ await removeAllRecords();
+});
diff --git a/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_telemetry.js b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_telemetry.js
new file mode 100644
index 0000000000..e8790243ac
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/creditCard/browser_creditCard_telemetry.js
@@ -0,0 +1,872 @@
+"use strict";
+
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+const CC_NUM_USES_HISTOGRAM = "CREDITCARD_NUM_USES";
+
+function ccFormArgsv1(method, extra) {
+ return ["creditcard", method, "cc_form", undefined, extra];
+}
+
+function ccFormArgsv2(method, extra) {
+ return ["creditcard", method, "cc_form_v2", undefined, extra];
+}
+
+function buildccFormv2Extra(extra, defaultValue) {
+ let defaults = {};
+ for (const field of [
+ "cc_name",
+ "cc_number",
+ "cc_type",
+ "cc_exp",
+ "cc_exp_month",
+ "cc_exp_year",
+ ]) {
+ defaults[field] = defaultValue;
+ }
+
+ return { ...defaults, ...extra };
+}
+
+async function assertTelemetry(expected_content, expected_parent) {
+ let snapshots;
+
+ info(
+ `Waiting for ${expected_content?.length ?? 0} content events and ` +
+ `${expected_parent?.length ?? 0} parent events`
+ );
+
+ await TestUtils.waitForCondition(
+ () => {
+ snapshots = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ false
+ );
+
+ return (
+ (snapshots.parent?.length ?? 0) >= (expected_parent?.length ?? 0) &&
+ (snapshots.content?.length ?? 0) >= (expected_content?.length ?? 0)
+ );
+ },
+ "Wait for telemetry to be collected",
+ 100,
+ 100
+ );
+
+ info(JSON.stringify(snapshots, null, 2));
+
+ if (expected_content !== undefined) {
+ expected_content = expected_content.map(
+ ([category, method, object, value, extra]) => {
+ return { category, method, object, value, extra };
+ }
+ );
+
+ let clear = expected_parent === undefined;
+
+ TelemetryTestUtils.assertEvents(
+ expected_content,
+ {
+ category: "creditcard",
+ },
+ { clear, process: "content" }
+ );
+ }
+
+ if (expected_parent !== undefined) {
+ expected_parent = expected_parent.map(
+ ([category, method, object, value, extra]) => {
+ return { category, method, object, value, extra };
+ }
+ );
+ TelemetryTestUtils.assertEvents(
+ expected_parent,
+ {
+ category: "creditcard",
+ },
+ { process: "parent" }
+ );
+ }
+}
+
+async function assertHistogram(histogramId, expectedNonZeroRanges) {
+ let actualNonZeroRanges = {};
+ await TestUtils.waitForCondition(
+ () => {
+ const snapshot = Services.telemetry
+ .getHistogramById(histogramId)
+ .snapshot();
+ // Compute the actual ranges in the format { range1: value1, range2: value2 }.
+ for (let [range, value] of Object.entries(snapshot.values)) {
+ if (value > 0) {
+ actualNonZeroRanges[range] = value;
+ }
+ }
+
+ return (
+ JSON.stringify(actualNonZeroRanges) ==
+ JSON.stringify(expectedNonZeroRanges)
+ );
+ },
+ "Wait for telemetry to be collected",
+ 100,
+ 100
+ );
+
+ Assert.equal(
+ JSON.stringify(actualNonZeroRanges),
+ JSON.stringify(expectedNonZeroRanges)
+ );
+}
+
+async function openTabAndUseCreditCard(
+ idx,
+ creditCard,
+ { closeTab = true, submitForm = true } = {}
+) {
+ let osKeyStoreLoginShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true);
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ CREDITCARD_FORM_URL
+ );
+ let browser = tab.linkedBrowser;
+
+ await openPopupOn(browser, "form #cc-name");
+ for (let i = 0; i <= idx; i++) {
+ await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser);
+ }
+ await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser);
+ await osKeyStoreLoginShown;
+ await waitForAutofill(browser, "#cc-number", creditCard["cc-number"]);
+ await focusUpdateSubmitForm(
+ browser,
+ {
+ focusSelector: "#cc-number",
+ newValues: {},
+ },
+ submitForm
+ );
+
+ if (!closeTab) {
+ return tab;
+ }
+
+ await BrowserTestUtils.removeTab(tab);
+ return null;
+}
+
+add_setup(async function () {
+ Services.telemetry.setEventRecordingEnabled("creditcard", true);
+ registerCleanupFunction(async function () {
+ Services.telemetry.setEventRecordingEnabled("creditcard", false);
+ });
+});
+
+add_task(async function test_popup_opened() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [ENABLED_AUTOFILL_CREDITCARDS_PREF, true],
+ [AUTOFILL_CREDITCARDS_AVAILABLE_PREF, "on"],
+ ],
+ });
+
+ Services.telemetry.clearEvents();
+ Services.telemetry.clearScalars();
+
+ await setStorage(TEST_CREDIT_CARD_1);
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: CREDITCARD_FORM_URL },
+ async function (browser) {
+ const focusInput = "#cc-number";
+
+ await openPopupOn(browser, focusInput);
+
+ // Clean up
+ await closePopup(browser);
+ }
+ );
+
+ await assertTelemetry([
+ ccFormArgsv2("detected", buildccFormv2Extra({ cc_exp: "false" }, "true")),
+ ccFormArgsv1("detected"),
+ ccFormArgsv2("popup_shown", { field_name: "cc-number" }),
+ ccFormArgsv1("popup_shown"),
+ ]);
+
+ TelemetryTestUtils.assertScalar(
+ TelemetryTestUtils.getProcessScalars("content"),
+ "formautofill.creditCards.detected_sections_count",
+ 1,
+ "There should be 1 section detected."
+ );
+ TelemetryTestUtils.assertScalarUnset(
+ TelemetryTestUtils.getProcessScalars("content"),
+ "formautofill.creditCards.submitted_sections_count"
+ );
+
+ await removeAllRecords();
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_popup_opened_form_without_autocomplete() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [ENABLED_AUTOFILL_CREDITCARDS_PREF, true],
+ [AUTOFILL_CREDITCARDS_AVAILABLE_PREF, "on"],
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.testConfidence",
+ "1",
+ ],
+ ],
+ });
+
+ Services.telemetry.clearEvents();
+ Services.telemetry.clearScalars();
+
+ await setStorage(TEST_CREDIT_CARD_1);
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: CREDITCARD_FORM_WITHOUT_AUTOCOMPLETE_URL },
+ async function (browser) {
+ const focusInput = "#cc-number";
+
+ await openPopupOn(browser, focusInput);
+
+ // Clean up
+ await closePopup(browser);
+ }
+ );
+
+ await assertTelemetry([
+ ccFormArgsv2(
+ "detected",
+ buildccFormv2Extra({ cc_number: "1", cc_name: "1", cc_exp: "false" }, "0")
+ ),
+ ccFormArgsv1("detected"),
+ ccFormArgsv2("popup_shown", { field_name: "cc-number" }),
+ ccFormArgsv1("popup_shown"),
+ ]);
+
+ TelemetryTestUtils.assertScalar(
+ TelemetryTestUtils.getProcessScalars("content"),
+ "formautofill.creditCards.detected_sections_count",
+ 1,
+ "There should be 1 section detected."
+ );
+ TelemetryTestUtils.assertScalarUnset(
+ TelemetryTestUtils.getProcessScalars("content"),
+ "formautofill.creditCards.submitted_sections_count"
+ );
+
+ await removeAllRecords();
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(
+ async function test_popup_opened_form_without_autocomplete_separate_cc_number() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [ENABLED_AUTOFILL_CREDITCARDS_PREF, true],
+ [AUTOFILL_CREDITCARDS_AVAILABLE_PREF, "on"],
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.testConfidence",
+ "1",
+ ],
+ ],
+ });
+
+ Services.telemetry.clearEvents();
+ Services.telemetry.clearScalars();
+
+ await setStorage(TEST_CREDIT_CARD_1);
+
+ // Click on the cc-number field of the form that only contains a cc-number field
+ // (detected by Fathom)
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: CREDITCARD_FORM_WITHOUT_AUTOCOMPLETE_URL },
+ async function (browser) {
+ await openPopupOn(browser, "#form2-cc-number #cc-number");
+ await closePopup(browser);
+ }
+ );
+
+ await assertTelemetry([
+ ccFormArgsv2("detected", buildccFormv2Extra({ cc_number: "1" }, "false")),
+ ccFormArgsv1("detected"),
+ ccFormArgsv2("popup_shown", { field_name: "cc-number" }),
+ ccFormArgsv1("popup_shown"),
+ ]);
+
+ // Then click on the cc-name field of the form that doesn't have a cc-number field
+ // (detected by regexp-based heuristic)
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: CREDITCARD_FORM_WITHOUT_AUTOCOMPLETE_URL },
+ async function (browser) {
+ await openPopupOn(browser, "#form2-cc-other #cc-name");
+ await closePopup(browser);
+ }
+ );
+
+ await assertTelemetry([
+ ccFormArgsv2(
+ "detected",
+ buildccFormv2Extra(
+ { cc_name: "1", cc_type: "0", cc_exp_month: "0", cc_exp_year: "0" },
+ "false"
+ )
+ ),
+ ccFormArgsv1("detected"),
+ ccFormArgsv2("popup_shown", { field_name: "cc-name" }),
+ ccFormArgsv1("popup_shown"),
+ ]);
+
+ TelemetryTestUtils.assertScalar(
+ TelemetryTestUtils.getProcessScalars("content"),
+ "formautofill.creditCards.detected_sections_count",
+ 2,
+ "There should be 1 section detected."
+ );
+ TelemetryTestUtils.assertScalarUnset(
+ TelemetryTestUtils.getProcessScalars("content"),
+ "formautofill.creditCards.submitted_sections_count"
+ );
+
+ await removeAllRecords();
+ await SpecialPowers.popPrefEnv();
+ }
+);
+
+add_task(async function test_submit_creditCard_new() {
+ async function test_per_command(
+ command,
+ idx,
+ useCount = {},
+ expectChanged = undefined
+ ) {
+ await SpecialPowers.pushPrefEnv({
+ set: [[ENABLED_AUTOFILL_CREDITCARDS_PREF, true]],
+ });
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: CREDITCARD_FORM_URL },
+ async function (browser) {
+ let onPopupShown = waitForPopupShown();
+ let onChanged;
+ if (expectChanged !== undefined) {
+ onChanged = TestUtils.topicObserved("formautofill-storage-changed");
+ }
+
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#cc-name",
+ newValues: {
+ "#cc-name": "User 1",
+ "#cc-number": "5038146897157463",
+ "#cc-exp-month": "12",
+ "#cc-exp-year": "2017",
+ "#cc-type": "mastercard",
+ },
+ });
+
+ await onPopupShown;
+ await clickDoorhangerButton(command, idx);
+ if (expectChanged !== undefined) {
+ await onChanged;
+ TelemetryTestUtils.assertScalar(
+ TelemetryTestUtils.getProcessScalars("parent"),
+ "formautofill.creditCards.autofill_profiles_count",
+ expectChanged,
+ "There should be ${expectChanged} profile(s) stored."
+ );
+ }
+ }
+ );
+
+ await assertHistogram(CC_NUM_USES_HISTOGRAM, useCount);
+
+ await removeAllRecords();
+ SpecialPowers.popPrefEnv();
+ }
+
+ Services.telemetry.clearEvents();
+ Services.telemetry.clearScalars();
+ Services.telemetry.getHistogramById(CC_NUM_USES_HISTOGRAM).clear();
+
+ let expected_content = [
+ ccFormArgsv2("detected", buildccFormv2Extra({ cc_exp: "false" }, "true")),
+ ccFormArgsv1("detected"),
+ ccFormArgsv2(
+ "submitted",
+ buildccFormv2Extra({ cc_exp: "unavailable" }, "user_filled")
+ ),
+ ccFormArgsv1("submitted", {
+ // 5 fields plus submit button
+ fields_not_auto: "6",
+ fields_auto: "0",
+ fields_modified: "0",
+ }),
+ ];
+ await test_per_command(MAIN_BUTTON, undefined, { 1: 1 }, 1);
+ await assertTelemetry(expected_content, [
+ ["creditcard", "show", "capture_doorhanger"],
+ ["creditcard", "save", "capture_doorhanger"],
+ ]);
+
+ await test_per_command(SECONDARY_BUTTON);
+ await assertTelemetry(expected_content, [
+ ["creditcard", "show", "capture_doorhanger"],
+ ["creditcard", "cancel", "capture_doorhanger"],
+ ]);
+
+ await test_per_command(MENU_BUTTON, 0);
+ await assertTelemetry(expected_content, [
+ ["creditcard", "show", "capture_doorhanger"],
+ ["creditcard", "disable", "capture_doorhanger"],
+ ]);
+
+ TelemetryTestUtils.assertScalar(
+ TelemetryTestUtils.getProcessScalars("content"),
+ "formautofill.creditCards.detected_sections_count",
+ 3,
+ "There should be 3 sections detected."
+ );
+ TelemetryTestUtils.assertScalar(
+ TelemetryTestUtils.getProcessScalars("content"),
+ "formautofill.creditCards.submitted_sections_count",
+ 3,
+ "There should be 1 section submitted."
+ );
+});
+
+add_task(async function test_submit_creditCard_autofill() {
+ if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) {
+ todo(
+ OSKeyStoreTestUtils.canTestOSKeyStoreLogin(),
+ "Cannot test OS key store login on official builds."
+ );
+ return;
+ }
+
+ Services.telemetry.clearEvents();
+ Services.telemetry.getHistogramById(CC_NUM_USES_HISTOGRAM).clear();
+
+ await SpecialPowers.pushPrefEnv({
+ set: [[ENABLED_AUTOFILL_CREDITCARDS_PREF, true]],
+ });
+
+ await setStorage(TEST_CREDIT_CARD_1);
+ let creditCards = await getCreditCards();
+ Assert.equal(creditCards.length, 1, "1 credit card in storage");
+
+ await openTabAndUseCreditCard(0, TEST_CREDIT_CARD_1);
+
+ await assertHistogram(CC_NUM_USES_HISTOGRAM, {
+ 1: 1,
+ });
+
+ SpecialPowers.clearUserPref(ENABLED_AUTOFILL_CREDITCARDS_PREF);
+
+ await assertTelemetry(
+ [
+ ccFormArgsv2("detected", buildccFormv2Extra({ cc_exp: "false" }, "true")),
+ ccFormArgsv1("detected"),
+ ccFormArgsv2("popup_shown", { field_name: "cc-name" }),
+ ccFormArgsv1("popup_shown"),
+ ccFormArgsv2(
+ "filled",
+ buildccFormv2Extra({ cc_exp: "unavailable" }, "filled")
+ ),
+ ccFormArgsv1("filled"),
+ ccFormArgsv2(
+ "submitted",
+ buildccFormv2Extra({ cc_exp: "unavailable" }, "autofilled")
+ ),
+ ccFormArgsv1("submitted", {
+ fields_not_auto: "3",
+ fields_auto: "5",
+ fields_modified: "0",
+ }),
+ ],
+ []
+ );
+
+ await removeAllRecords();
+ SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_submit_creditCard_update() {
+ if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) {
+ todo(
+ OSKeyStoreTestUtils.canTestOSKeyStoreLogin(),
+ "Cannot test OS key store login on official builds."
+ );
+ return;
+ }
+
+ async function test_per_command(
+ command,
+ idx,
+ useCount = {},
+ expectChanged = undefined
+ ) {
+ await SpecialPowers.pushPrefEnv({
+ set: [[ENABLED_AUTOFILL_CREDITCARDS_PREF, true]],
+ });
+
+ await setStorage(TEST_CREDIT_CARD_1);
+ let creditCards = await getCreditCards();
+ Assert.equal(creditCards.length, 1, "1 credit card in storage");
+
+ let osKeyStoreLoginShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true);
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: CREDITCARD_FORM_URL },
+ async function (browser) {
+ let onPopupShown = waitForPopupShown();
+ let onChanged;
+ if (expectChanged !== undefined) {
+ onChanged = TestUtils.topicObserved("formautofill-storage-changed");
+ }
+
+ await openPopupOn(browser, "form #cc-name");
+ await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser);
+ await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser);
+ await osKeyStoreLoginShown;
+
+ await waitForAutofill(browser, "#cc-name", "John Doe");
+ await focusUpdateSubmitForm(browser, {
+ focusSelector: "#cc-name",
+ newValues: {
+ "#cc-exp-year": "2019",
+ },
+ });
+ await onPopupShown;
+ await clickDoorhangerButton(command, idx);
+ if (expectChanged !== undefined) {
+ await onChanged;
+ TelemetryTestUtils.assertScalar(
+ TelemetryTestUtils.getProcessScalars("parent"),
+ "formautofill.creditCards.autofill_profiles_count",
+ expectChanged,
+ "There should be ${expectChanged} profile(s) stored."
+ );
+ }
+ }
+ );
+
+ await assertHistogram("CREDITCARD_NUM_USES", useCount);
+
+ SpecialPowers.clearUserPref(ENABLED_AUTOFILL_CREDITCARDS_PREF);
+
+ await removeAllRecords();
+ }
+ Services.telemetry.clearEvents();
+ Services.telemetry.getHistogramById(CC_NUM_USES_HISTOGRAM).clear();
+
+ let expected_content = [
+ ccFormArgsv2("detected", buildccFormv2Extra({ cc_exp: "false" }, "true")),
+ ccFormArgsv1("detected"),
+ ccFormArgsv2("popup_shown", { field_name: "cc-name" }),
+ ccFormArgsv1("popup_shown"),
+ ccFormArgsv2(
+ "filled",
+ buildccFormv2Extra({ cc_exp: "unavailable" }, "filled")
+ ),
+ ccFormArgsv1("filled"),
+ ccFormArgsv2("filled_modified", { field_name: "cc-exp-year" }),
+ ccFormArgsv1("filled_modified", { field_name: "cc-exp-year" }),
+ ccFormArgsv2(
+ "submitted",
+ buildccFormv2Extra(
+ { cc_exp: "unavailable", cc_exp_year: "user_filled" },
+ "autofilled"
+ )
+ ),
+ ccFormArgsv1("submitted", {
+ fields_not_auto: "3",
+ fields_auto: "5",
+ fields_modified: "1",
+ }),
+ ];
+
+ await test_per_command(MAIN_BUTTON, undefined, { 1: 1 }, 1);
+ await assertTelemetry(expected_content, [
+ ["creditcard", "show", "update_doorhanger"],
+ ["creditcard", "update", "update_doorhanger"],
+ ]);
+
+ await test_per_command(SECONDARY_BUTTON, undefined, { 0: 1, 1: 1 }, 2);
+ await assertTelemetry(expected_content, [
+ ["creditcard", "show", "update_doorhanger"],
+ ["creditcard", "save", "update_doorhanger"],
+ ]);
+});
+
+const TEST_SELECTORS = {
+ selRecords: "#credit-cards",
+ btnRemove: "#remove",
+ btnAdd: "#add",
+ btnEdit: "#edit",
+};
+
+const DIALOG_SIZE = "width=600,height=400";
+
+add_task(async function test_removingCreditCardsViaKeyboardDelete() {
+ Services.telemetry.clearEvents();
+
+ await SpecialPowers.pushPrefEnv({
+ set: [[ENABLED_AUTOFILL_CREDITCARDS_PREF, true]],
+ });
+
+ await setStorage(TEST_CREDIT_CARD_1);
+ let win = window.openDialog(
+ MANAGE_CREDIT_CARDS_DIALOG_URL,
+ null,
+ DIALOG_SIZE
+ );
+ await waitForFocusAndFormReady(win);
+
+ let selRecords = win.document.querySelector(TEST_SELECTORS.selRecords);
+
+ Assert.equal(selRecords.length, 1, "One credit card");
+
+ EventUtils.synthesizeMouseAtCenter(selRecords.children[0], {}, win);
+ EventUtils.synthesizeKey("VK_DELETE", {}, win);
+ await BrowserTestUtils.waitForEvent(selRecords, "RecordsRemoved");
+ Assert.equal(selRecords.length, 0, "No credit cards left");
+
+ win.close();
+
+ await assertTelemetry(undefined, [
+ ["creditcard", "show", "manage"],
+ ["creditcard", "delete", "manage"],
+ ]);
+
+ await removeAllRecords();
+ SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_saveCreditCard() {
+ Services.telemetry.clearEvents();
+
+ await SpecialPowers.pushPrefEnv({
+ set: [[ENABLED_AUTOFILL_CREDITCARDS_PREF, true]],
+ });
+
+ await testDialog(EDIT_CREDIT_CARD_DIALOG_URL, win => {
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey(TEST_CREDIT_CARD_1["cc-number"], {}, win);
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey(
+ "0" + TEST_CREDIT_CARD_1["cc-exp-month"].toString(),
+ {},
+ win
+ );
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey(
+ TEST_CREDIT_CARD_1["cc-exp-year"].toString(),
+ {},
+ win
+ );
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey(TEST_CREDIT_CARD_1["cc-name"], {}, win);
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ info("saving credit card");
+ EventUtils.synthesizeKey("VK_RETURN", {}, win);
+ });
+
+ await assertTelemetry(undefined, [["creditcard", "add", "manage"]]);
+
+ await removeAllRecords();
+ SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_editCreditCard() {
+ Services.telemetry.clearEvents();
+
+ await SpecialPowers.pushPrefEnv({
+ set: [[ENABLED_AUTOFILL_CREDITCARDS_PREF, true]],
+ });
+
+ await setStorage(TEST_CREDIT_CARD_1);
+
+ let creditCards = await getCreditCards();
+ Assert.equal(creditCards.length, 1, "only one credit card is in storage");
+ await testDialog(
+ EDIT_CREDIT_CARD_DIALOG_URL,
+ win => {
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey("VK_RIGHT", {}, win);
+ EventUtils.synthesizeKey("test", {}, win);
+ win.document.querySelector("#save").click();
+ },
+ {
+ record: creditCards[0],
+ }
+ );
+
+ await assertTelemetry(undefined, [
+ ["creditcard", "show_entry", "manage"],
+ ["creditcard", "edit", "manage"],
+ ]);
+
+ await removeAllRecords();
+ SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_histogram() {
+ if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) {
+ todo(
+ OSKeyStoreTestUtils.canTestOSKeyStoreLogin(),
+ "Cannot test OS key store login on official builds."
+ );
+ return;
+ }
+
+ await SpecialPowers.pushPrefEnv({
+ set: [[ENABLED_AUTOFILL_CREDITCARDS_PREF, true]],
+ });
+
+ Services.telemetry.getHistogramById(CC_NUM_USES_HISTOGRAM).clear();
+
+ await setStorage(
+ TEST_CREDIT_CARD_1,
+ TEST_CREDIT_CARD_2,
+ TEST_CREDIT_CARD_3,
+ TEST_CREDIT_CARD_5
+ );
+ let creditCards = await getCreditCards();
+ Assert.equal(creditCards.length, 4, "3 credit cards in storage");
+
+ await assertHistogram(CC_NUM_USES_HISTOGRAM, {
+ 0: 4,
+ });
+
+ await openTabAndUseCreditCard(0, TEST_CREDIT_CARD_1);
+ await assertHistogram(CC_NUM_USES_HISTOGRAM, {
+ 0: 3,
+ 1: 1,
+ });
+
+ await openTabAndUseCreditCard(1, TEST_CREDIT_CARD_2);
+ await assertHistogram(CC_NUM_USES_HISTOGRAM, {
+ 0: 2,
+ 1: 2,
+ });
+
+ await openTabAndUseCreditCard(0, TEST_CREDIT_CARD_2);
+ await assertHistogram(CC_NUM_USES_HISTOGRAM, {
+ 0: 2,
+ 1: 1,
+ 2: 1,
+ });
+
+ await openTabAndUseCreditCard(1, TEST_CREDIT_CARD_1);
+ await assertHistogram(CC_NUM_USES_HISTOGRAM, {
+ 0: 2,
+ 2: 2,
+ });
+
+ await openTabAndUseCreditCard(2, TEST_CREDIT_CARD_5);
+ await assertHistogram(CC_NUM_USES_HISTOGRAM, {
+ 0: 1,
+ 1: 1,
+ 2: 2,
+ });
+
+ await removeAllRecords();
+ SpecialPowers.popPrefEnv();
+
+ await assertHistogram(CC_NUM_USES_HISTOGRAM, {});
+});
+
+add_task(async function test_clear_creditCard_autofill() {
+ if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) {
+ todo(
+ OSKeyStoreTestUtils.canTestOSKeyStoreLogin(),
+ "Cannot test OS key store login on official builds."
+ );
+ return;
+ }
+
+ Services.telemetry.clearEvents();
+ Services.telemetry.getHistogramById(CC_NUM_USES_HISTOGRAM).clear();
+
+ await SpecialPowers.pushPrefEnv({
+ set: [[ENABLED_AUTOFILL_CREDITCARDS_PREF, true]],
+ });
+
+ await setStorage(TEST_CREDIT_CARD_1);
+ let creditCards = await getCreditCards();
+ Assert.equal(creditCards.length, 1, "1 credit card in storage");
+
+ let tab = await openTabAndUseCreditCard(0, TEST_CREDIT_CARD_1, {
+ closeTab: false,
+ submitForm: false,
+ });
+
+ let expected_content = [
+ ccFormArgsv2("detected", buildccFormv2Extra({ cc_exp: "false" }, "true")),
+ ccFormArgsv1("detected"),
+ ccFormArgsv2("popup_shown", { field_name: "cc-name" }),
+ ccFormArgsv1("popup_shown"),
+ ccFormArgsv2(
+ "filled",
+ buildccFormv2Extra({ cc_exp: "unavailable" }, "filled")
+ ),
+ ccFormArgsv1("filled"),
+ ];
+ await assertTelemetry(expected_content, []);
+ Services.telemetry.clearEvents();
+
+ let browser = tab.linkedBrowser;
+
+ let popupshown = BrowserTestUtils.waitForPopupEvent(
+ browser.autoCompletePopup,
+ "shown"
+ );
+ // Already focus in "cc-number" field, press 'down' to bring to popup.
+ await BrowserTestUtils.synthesizeKey("KEY_ArrowDown", {}, browser);
+ await popupshown;
+
+ expected_content = [
+ ccFormArgsv2("popup_shown", { field_name: "cc-number" }),
+ ccFormArgsv1("popup_shown"),
+ ];
+ await assertTelemetry(expected_content, []);
+ Services.telemetry.clearEvents();
+
+ // kPress Clear Form.
+ await BrowserTestUtils.synthesizeKey("KEY_ArrowDown", {}, browser);
+ await BrowserTestUtils.synthesizeKey("KEY_Enter", {}, browser);
+
+ expected_content = [
+ ccFormArgsv2("filled_modified", { field_name: "cc-name" }),
+ ccFormArgsv1("filled_modified", { field_name: "cc-name" }),
+ ccFormArgsv2("filled_modified", { field_name: "cc-number" }),
+ ccFormArgsv1("filled_modified", { field_name: "cc-number" }),
+ ccFormArgsv2("filled_modified", { field_name: "cc-exp-month" }),
+ ccFormArgsv1("filled_modified", { field_name: "cc-exp-month" }),
+ ccFormArgsv2("filled_modified", { field_name: "cc-exp-year" }),
+ ccFormArgsv1("filled_modified", { field_name: "cc-exp-year" }),
+ ccFormArgsv2("filled_modified", { field_name: "cc-type" }),
+ ccFormArgsv1("filled_modified", { field_name: "cc-type" }),
+ ccFormArgsv2("cleared", { field_name: "cc-number" }),
+ // popup is shown again because when the field is cleared and is focused,
+ // we automatically triggers the popup.
+ ccFormArgsv2("popup_shown", { field_name: "cc-number" }),
+ ccFormArgsv1("popup_shown"),
+ ];
+
+ await assertTelemetry(expected_content, []);
+ Services.telemetry.clearEvents();
+
+ await BrowserTestUtils.removeTab(tab);
+
+ await removeAllRecords();
+ SpecialPowers.popPrefEnv();
+});
diff --git a/browser/extensions/formautofill/test/browser/creditCard/browser_editCreditCardDialog.js b/browser/extensions/formautofill/test/browser/creditCard/browser_editCreditCardDialog.js
new file mode 100644
index 0000000000..f532ea5da9
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/creditCard/browser_editCreditCardDialog.js
@@ -0,0 +1,422 @@
+"use strict";
+
+add_setup(async function () {
+ let { formAutofillStorage } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillStorage.sys.mjs"
+ );
+ await formAutofillStorage.initialize();
+});
+
+add_task(async function test_cancelEditCreditCardDialog() {
+ await testDialog(EDIT_CREDIT_CARD_DIALOG_URL, win => {
+ win.document.querySelector("#cancel").click();
+ });
+});
+
+add_task(async function test_cancelEditCreditCardDialogWithESC() {
+ await testDialog(EDIT_CREDIT_CARD_DIALOG_URL, win => {
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, win);
+ });
+});
+
+add_task(async function test_saveCreditCard() {
+ await testDialog(EDIT_CREDIT_CARD_DIALOG_URL, win => {
+ ok(
+ win.document.documentElement
+ .querySelector("title")
+ .textContent.includes("Add"),
+ "Add card dialog title is correct"
+ );
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey(TEST_CREDIT_CARD_1["cc-number"], {}, win);
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey(
+ "0" + TEST_CREDIT_CARD_1["cc-exp-month"].toString(),
+ {},
+ win
+ );
+ is(
+ win.document.activeElement.selectedOptions[0].text,
+ "04 - April",
+ "Displayed month should match number and name"
+ );
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey(
+ TEST_CREDIT_CARD_1["cc-exp-year"].toString(),
+ {},
+ win
+ );
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey(TEST_CREDIT_CARD_1["cc-name"], {}, win);
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ info("saving credit card");
+ EventUtils.synthesizeKey("VK_RETURN", {}, win);
+ });
+ let creditCards = await getCreditCards();
+
+ is(creditCards.length, 1, "only one credit card is in storage");
+ for (let [fieldName, fieldValue] of Object.entries(TEST_CREDIT_CARD_1)) {
+ if (fieldName === "cc-number") {
+ fieldValue = "*".repeat(fieldValue.length - 4) + fieldValue.substr(-4);
+ }
+ is(creditCards[0][fieldName], fieldValue, "check " + fieldName);
+ }
+ is(creditCards[0].billingAddressGUID, undefined, "check billingAddressGUID");
+ ok(creditCards[0]["cc-number-encrypted"], "cc-number-encrypted exists");
+});
+
+add_task(async function test_saveCreditCardWithMaxYear() {
+ await testDialog(EDIT_CREDIT_CARD_DIALOG_URL, win => {
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey(TEST_CREDIT_CARD_2["cc-number"], {}, win);
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey(
+ TEST_CREDIT_CARD_2["cc-exp-month"].toString(),
+ {},
+ win
+ );
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey(
+ TEST_CREDIT_CARD_2["cc-exp-year"].toString(),
+ {},
+ win
+ );
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey(TEST_CREDIT_CARD_2["cc-name"], {}, win);
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ info("saving credit card");
+ EventUtils.synthesizeKey("VK_RETURN", {}, win);
+ });
+ let creditCards = await getCreditCards();
+
+ is(creditCards.length, 2, "Two credit cards are in storage");
+ for (let [fieldName, fieldValue] of Object.entries(TEST_CREDIT_CARD_2)) {
+ if (fieldName === "cc-number") {
+ fieldValue = "*".repeat(fieldValue.length - 4) + fieldValue.substr(-4);
+ }
+ is(creditCards[1][fieldName], fieldValue, "check " + fieldName);
+ }
+ ok(creditCards[1]["cc-number-encrypted"], "cc-number-encrypted exists");
+ await removeCreditCards([creditCards[1].guid]);
+});
+
+add_task(async function test_saveCreditCardWithBillingAddress() {
+ await setStorage(TEST_ADDRESS_4, TEST_ADDRESS_1);
+ let addresses = await getAddresses();
+ let billingAddress = addresses[0];
+
+ const TEST_CREDIT_CARD = Object.assign({}, TEST_CREDIT_CARD_2, {
+ billingAddressGUID: undefined,
+ });
+
+ await testDialog(EDIT_CREDIT_CARD_DIALOG_URL, win => {
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey(TEST_CREDIT_CARD["cc-number"], {}, win);
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey(
+ TEST_CREDIT_CARD["cc-exp-month"].toString(),
+ {},
+ win
+ );
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey(
+ TEST_CREDIT_CARD["cc-exp-year"].toString(),
+ {},
+ win
+ );
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey(TEST_CREDIT_CARD["cc-name"], {}, win);
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey(billingAddress["given-name"], {}, win);
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ info("saving credit card");
+ EventUtils.synthesizeKey("VK_RETURN", {}, win);
+ });
+ let creditCards = await getCreditCards();
+
+ is(creditCards.length, 2, "Two credit cards are in storage");
+ for (let [fieldName, fieldValue] of Object.entries(TEST_CREDIT_CARD)) {
+ if (fieldName === "cc-number") {
+ fieldValue = "*".repeat(fieldValue.length - 4) + fieldValue.substr(-4);
+ }
+ is(creditCards[1][fieldName], fieldValue, "check " + fieldName);
+ }
+ ok(creditCards[1]["cc-number-encrypted"], "cc-number-encrypted exists");
+ await removeCreditCards([creditCards[1].guid]);
+ await removeAddresses([addresses[0].guid, addresses[1].guid]);
+});
+
+add_task(async function test_editCreditCard() {
+ let creditCards = await getCreditCards();
+ is(creditCards.length, 1, "only one credit card is in storage");
+ await testDialog(
+ EDIT_CREDIT_CARD_DIALOG_URL,
+ win => {
+ ok(
+ win.document.documentElement
+ .querySelector("title")
+ .textContent.includes("Edit"),
+ "Edit card dialog title is correct"
+ );
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey("VK_RIGHT", {}, win);
+ EventUtils.synthesizeKey("test", {}, win);
+ win.document.querySelector("#save").click();
+ },
+ {
+ record: creditCards[0],
+ }
+ );
+ ok(true, "Edit credit card dialog is closed");
+ creditCards = await getCreditCards();
+
+ is(creditCards.length, 1, "only one credit card is in storage");
+ is(
+ creditCards[0]["cc-name"],
+ TEST_CREDIT_CARD_1["cc-name"] + "test",
+ "cc name changed"
+ );
+ await removeCreditCards([creditCards[0].guid]);
+
+ creditCards = await getCreditCards();
+ is(creditCards.length, 0, "Credit card storage is empty");
+});
+
+add_task(async function test_editCreditCardWithMissingBillingAddress() {
+ const TEST_CREDIT_CARD = Object.assign({}, TEST_CREDIT_CARD_2, {
+ billingAddressGUID: "unknown-guid",
+ });
+ await setStorage(TEST_CREDIT_CARD);
+
+ let creditCards = await getCreditCards();
+ is(creditCards.length, 1, "one credit card in storage");
+ is(
+ creditCards[0].billingAddressGUID,
+ TEST_CREDIT_CARD.billingAddressGUID,
+ "Check saved billingAddressGUID"
+ );
+ await testDialog(
+ EDIT_CREDIT_CARD_DIALOG_URL,
+ win => {
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey("VK_RIGHT", {}, win);
+ EventUtils.synthesizeKey("test", {}, win);
+ win.document.querySelector("#save").click();
+ },
+ {
+ record: creditCards[0],
+ }
+ );
+ ok(true, "Edit credit card dialog is closed");
+ creditCards = await getCreditCards();
+
+ is(creditCards.length, 1, "only one credit card is in storage");
+ is(
+ creditCards[0]["cc-name"],
+ TEST_CREDIT_CARD["cc-name"] + "test",
+ "cc name changed"
+ );
+ is(
+ creditCards[0].billingAddressGUID,
+ undefined,
+ "unknown GUID removed upon manual save"
+ );
+ await removeCreditCards([creditCards[0].guid]);
+
+ creditCards = await getCreditCards();
+ is(creditCards.length, 0, "Credit card storage is empty");
+});
+
+add_task(async function test_addInvalidCreditCard() {
+ await testDialog(EDIT_CREDIT_CARD_DIALOG_URL, async win => {
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey("test", {}, win);
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey("test name", {}, win);
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeMouseAtCenter(
+ win.document.querySelector("#save"),
+ {},
+ win
+ );
+
+ is(
+ win.document.querySelector("form").checkValidity(),
+ false,
+ "cc-number is invalid"
+ );
+ await ensureCreditCardDialogNotClosed(win);
+ info("closing");
+ win.close();
+ });
+ info("closed");
+ let creditCards = await getCreditCards();
+
+ is(creditCards.length, 0, "Credit card storage is empty");
+});
+
+add_task(async function test_editInvalidCreditCardNumber() {
+ await setStorage(TEST_ADDRESS_4);
+ let addresses = await getAddresses();
+ let billingAddress = addresses[0];
+
+ const INVALID_CREDIT_CARD_NUMBER = "123456789";
+ const TEST_CREDIT_CARD = Object.assign({}, TEST_CREDIT_CARD_2, {
+ billingAddressGUID: billingAddress.guid,
+ guid: "invalid-number",
+ version: 2,
+ "cc-number": INVALID_CREDIT_CARD_NUMBER,
+ });
+
+ // Directly use FormAutofillStorage so we can set
+ // sourceSync: true, since saveCreditCard uses FormAutofillParent
+ // which doesn't expose this option.
+ let { formAutofillStorage } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillStorage.sys.mjs"
+ );
+ await formAutofillStorage.initialize();
+ // Use `sourceSync: true` to bypass field normalization which will
+ // fail due to the invalid credit card number.
+ await formAutofillStorage.creditCards.add(TEST_CREDIT_CARD, {
+ sourceSync: true,
+ });
+
+ let creditCards = await getCreditCards();
+ is(creditCards.length, 1, "only one credit card is in storage");
+ is(
+ creditCards[0]["cc-number"],
+ "*********",
+ "invalid credit card number stored"
+ );
+ await testDialog(
+ EDIT_CREDIT_CARD_DIALOG_URL,
+ win => {
+ is(
+ win.document.querySelector("#cc-number").value,
+ INVALID_CREDIT_CARD_NUMBER,
+ "cc-number field should be showing invalid credit card number"
+ );
+ is(
+ win.document.querySelector("#cc-number").checkValidity(),
+ false,
+ "cc-number is invalid"
+ );
+ win.document.querySelector("#cancel").click();
+ },
+ {
+ record: creditCards[0],
+ skipDecryption: true,
+ }
+ );
+ ok(true, "Edit credit card dialog is closed");
+ creditCards = await getCreditCards();
+
+ is(creditCards.length, 1, "only one credit card is in storage");
+ is(
+ creditCards[0]["cc-number"],
+ "*********",
+ "invalid cc number still in record"
+ );
+ await removeCreditCards([creditCards[0].guid]);
+ await removeAddresses([addresses[0].guid]);
+
+ creditCards = await getCreditCards();
+ is(creditCards.length, 0, "Credit card storage is empty");
+ addresses = await getAddresses();
+ is(addresses.length, 0, "Address storage is empty");
+});
+
+add_task(async function test_editCreditCardWithInvalidNumber() {
+ const TEST_CREDIT_CARD = Object.assign({}, TEST_CREDIT_CARD_1);
+ await setStorage(TEST_CREDIT_CARD);
+
+ let creditCards = await getCreditCards();
+ is(creditCards.length, 1, "only one credit card is in storage");
+ await testDialog(
+ EDIT_CREDIT_CARD_DIALOG_URL,
+ win => {
+ ok(
+ win.document.documentElement
+ .querySelector("title")
+ .textContent.includes("Edit"),
+ "Edit card dialog title is correct"
+ );
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ is(
+ win.document.querySelector("#cc-number").validity.customError,
+ false,
+ "cc-number field should not have a custom error"
+ );
+ EventUtils.synthesizeKey("4111111111111112", {}, win);
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ is(
+ win.document.querySelector("#cc-number").validity.customError,
+ true,
+ "cc-number field should have a custom error"
+ );
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ EventUtils.synthesizeKey("VK_TAB", {}, win);
+ win.document.querySelector("#cancel").click();
+ },
+ {
+ record: creditCards[0],
+ }
+ );
+ ok(true, "Edit credit card dialog is closed");
+ creditCards = await getCreditCards();
+
+ is(creditCards.length, 1, "only one credit card is in storage");
+ await removeCreditCards([creditCards[0].guid]);
+
+ creditCards = await getCreditCards();
+ is(creditCards.length, 0, "Credit card storage is empty");
+});
+
+add_task(async function test_noAutocompletePopupOnSystemTab() {
+ await setStorage(TEST_CREDIT_CARD_1);
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: PRIVACY_PREF_URL },
+ async browser => {
+ // Open credit card manage dialog
+ await SpecialPowers.spawn(browser, [], async () => {
+ let button = content.document.querySelector(
+ "#creditCardAutofill button"
+ );
+ button.click();
+ });
+ let dialog = await waitForSubDialogLoad(
+ content,
+ MANAGE_CREDIT_CARDS_DIALOG_URL
+ );
+
+ // Open edit credit card dialog
+ await SpecialPowers.spawn(dialog, [], async () => {
+ let button = content.document.querySelector("#add");
+ button.click();
+ });
+ dialog = await waitForSubDialogLoad(content, EDIT_CREDIT_CARD_DIALOG_URL);
+
+ // Focus on credit card number field
+ await SpecialPowers.spawn(dialog, [], async () => {
+ let number = content.document.querySelector("#cc-number");
+ number.focus();
+ });
+
+ // autocomplete popup should not appear
+ await ensureNoAutocompletePopup(browser);
+ }
+ );
+
+ await removeAllRecords();
+});
diff --git a/browser/extensions/formautofill/test/browser/creditCard/browser_insecure_form.js b/browser/extensions/formautofill/test/browser/creditCard/browser_insecure_form.js
new file mode 100644
index 0000000000..5de499b942
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/creditCard/browser_insecure_form.js
@@ -0,0 +1,145 @@
+"use strict";
+
+// Remove the scheme from the URLs so we can switch between http: and https: later.
+const TEST_URL_PATH_CC =
+ "://example.org" +
+ HTTP_TEST_PATH +
+ "creditCard/autocomplete_creditcard_basic.html";
+const TEST_URL_PATH =
+ "://example.org" + HTTP_TEST_PATH + "autocomplete_basic.html";
+
+add_task(async function setup_storage() {
+ await setStorage(
+ TEST_ADDRESS_1,
+ TEST_ADDRESS_2,
+ TEST_ADDRESS_3,
+ TEST_CREDIT_CARD_1,
+ TEST_CREDIT_CARD_2,
+ TEST_CREDIT_CARD_3
+ );
+});
+
+add_task(async function test_insecure_form() {
+ async function runTest({
+ urlPath,
+ protocol,
+ focusInput,
+ expectedType,
+ expectedResultLength,
+ }) {
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: protocol + urlPath },
+ async function (browser) {
+ await openPopupOn(browser, focusInput);
+
+ const items = getDisplayedPopupItems(browser);
+ is(
+ items.length,
+ expectedResultLength,
+ `Should show correct amount of results in "${protocol}"`
+ );
+ const firstItem = items[0];
+ is(
+ firstItem.getAttribute("originaltype"),
+ expectedType,
+ `Item should attach with correct binding in "${protocol}"`
+ );
+
+ await closePopup(browser);
+ }
+ );
+ }
+
+ const testSets = [
+ {
+ urlPath: TEST_URL_PATH,
+ protocol: "https",
+ focusInput: "#organization",
+ expectedType: "autofill-profile",
+ expectedResultLength: 2,
+ },
+ {
+ urlPath: TEST_URL_PATH,
+ protocol: "http",
+ focusInput: "#organization",
+ expectedType: "autofill-profile",
+ expectedResultLength: 2,
+ },
+ {
+ urlPath: TEST_URL_PATH_CC,
+ protocol: "https",
+ focusInput: "#cc-name",
+ expectedType: "autofill-profile",
+ expectedResultLength: 3,
+ },
+ {
+ urlPath: TEST_URL_PATH_CC,
+ protocol: "http",
+ focusInput: "#cc-name",
+ expectedType: "autofill-insecureWarning", // insecure warning field
+ expectedResultLength: 1,
+ },
+ ];
+
+ for (const test of testSets) {
+ await runTest(test);
+ }
+});
+
+add_task(async function test_click_on_insecure_warning() {
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "http" + TEST_URL_PATH_CC },
+ async function (browser) {
+ await openPopupOn(browser, "#cc-name");
+ const insecureItem = getDisplayedPopupItems(browser)[0];
+ let popupClosePromise = BrowserTestUtils.waitForPopupEvent(
+ browser.autoCompletePopup,
+ "hidden"
+ );
+ await EventUtils.synthesizeMouseAtCenter(insecureItem, {});
+ // Check input's value after popup closed to ensure the completion of autofilling.
+ await popupClosePromise;
+
+ const inputValue = await SpecialPowers.spawn(
+ browser,
+ [],
+ async function () {
+ return content.document.querySelector("#cc-name").value;
+ }
+ );
+ is(inputValue, "");
+
+ await closePopup(browser);
+ }
+ );
+});
+
+add_task(async function test_press_enter_on_insecure_warning() {
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "http" + TEST_URL_PATH_CC },
+ async function (browser) {
+ await openPopupOn(browser, "#cc-name");
+
+ await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser);
+
+ let popupClosePromise = BrowserTestUtils.waitForPopupEvent(
+ browser.autoCompletePopup,
+ "hidden"
+ );
+ await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, browser);
+ // Check input's value after popup closed to ensure the completion of autofilling.
+ await popupClosePromise;
+
+ const inputValue = await SpecialPowers.spawn(
+ browser,
+ [],
+ async function () {
+ return content.document.querySelector("#cc-name").value;
+ }
+ );
+ is(inputValue, "");
+
+ await closePopup(browser);
+ }
+ );
+});
diff --git a/browser/extensions/formautofill/test/browser/creditCard/browser_manageCreditCardsDialog.js b/browser/extensions/formautofill/test/browser/creditCard/browser_manageCreditCardsDialog.js
new file mode 100644
index 0000000000..7f54140c78
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/creditCard/browser_manageCreditCardsDialog.js
@@ -0,0 +1,290 @@
+"use strict";
+
+const TEST_SELECTORS = {
+ selRecords: "#credit-cards",
+ btnRemove: "#remove",
+ btnAdd: "#add",
+ btnEdit: "#edit",
+};
+
+const DIALOG_SIZE = "width=600,height=400";
+
+add_task(async function test_manageCreditCardsInitialState() {
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: MANAGE_CREDIT_CARDS_DIALOG_URL },
+ async function (browser) {
+ await SpecialPowers.spawn(browser, [TEST_SELECTORS], args => {
+ let selRecords = content.document.querySelector(args.selRecords);
+ let btnRemove = content.document.querySelector(args.btnRemove);
+ let btnAdd = content.document.querySelector(args.btnAdd);
+ let btnEdit = content.document.querySelector(args.btnEdit);
+
+ is(selRecords.length, 0, "No credit card");
+ is(btnRemove.disabled, true, "Remove button disabled");
+ is(btnAdd.disabled, false, "Add button enabled");
+ is(btnEdit.disabled, true, "Edit button disabled");
+ });
+ }
+ );
+});
+
+add_task(async function test_cancelManageCreditCardsDialogWithESC() {
+ let win = window.openDialog(MANAGE_CREDIT_CARDS_DIALOG_URL);
+ await waitForFocusAndFormReady(win);
+ let unloadPromise = BrowserTestUtils.waitForEvent(win, "unload");
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, win);
+ await unloadPromise;
+ ok(true, "Manage credit cards dialog is closed with ESC key");
+});
+
+add_task(async function test_removingSingleAndMultipleCreditCards() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.reduceTimerPrecision", false]],
+ });
+ await setStorage(
+ TEST_CREDIT_CARD_1,
+ TEST_CREDIT_CARD_2,
+ TEST_CREDIT_CARD_3,
+ TEST_CREDIT_CARD_4,
+ TEST_CREDIT_CARD_5
+ );
+
+ let win = window.openDialog(
+ MANAGE_CREDIT_CARDS_DIALOG_URL,
+ null,
+ DIALOG_SIZE
+ );
+ await waitForFocusAndFormReady(win);
+
+ let selRecords = win.document.querySelector(TEST_SELECTORS.selRecords);
+ let btnRemove = win.document.querySelector(TEST_SELECTORS.btnRemove);
+ let btnEdit = win.document.querySelector(TEST_SELECTORS.btnEdit);
+
+ const expectedLabels = [
+ {
+ id: "credit-card-label-number-name-2",
+ args: { number: "**** 1881", name: "Chris P. Bacon", type: "Visa" },
+ },
+ {
+ id: "credit-card-label-number-2",
+ args: { number: "**** 5100", type: "MasterCard" },
+ },
+ {
+ id: "credit-card-label-number-expiration-2",
+ args: {
+ number: "**** 7870",
+ month: "1",
+ year: "2000",
+ type: "MasterCard",
+ },
+ },
+ {
+ id: "credit-card-label-number-name-expiration-2",
+ args: {
+ number: "**** 1045",
+ name: "Timothy Berners-Lee",
+ month: "12",
+ year: (new Date().getFullYear() + 10).toString(),
+ type: "Visa",
+ },
+ },
+ {
+ id: "credit-card-label-number-name-expiration-2",
+ args: {
+ number: "**** 1111",
+ name: "John Doe",
+ month: "4",
+ year: new Date().getFullYear().toString(),
+ type: "Visa",
+ },
+ },
+ ];
+
+ is(
+ selRecords.length,
+ expectedLabels.length,
+ "Correct number of credit cards"
+ );
+ expectedLabels.forEach((expected, i) => {
+ const l10nAttrs = document.l10n.getAttributes(selRecords[i]);
+ is(
+ l10nAttrs.id,
+ expected.id,
+ `l10n id set for credit card ${expectedLabels.length - i}`
+ );
+ Object.keys(expected.args).forEach(arg => {
+ is(
+ l10nAttrs.args[arg],
+ expected.args[arg],
+ `Set display ${arg} for credit card ${expectedLabels.length - i}`
+ );
+ });
+ });
+
+ EventUtils.synthesizeMouseAtCenter(selRecords.children[0], {}, win);
+ is(btnRemove.disabled, false, "Remove button enabled");
+ is(btnEdit.disabled, false, "Edit button enabled");
+ EventUtils.synthesizeMouseAtCenter(btnRemove, {}, win);
+ await BrowserTestUtils.waitForEvent(selRecords, "RecordsRemoved");
+ is(selRecords.length, 4, "Four credit cards left");
+
+ EventUtils.synthesizeMouseAtCenter(selRecords.children[0], {}, win);
+ EventUtils.synthesizeMouseAtCenter(
+ selRecords.children[3],
+ { shiftKey: true },
+ win
+ );
+ is(btnEdit.disabled, true, "Edit button disabled when multi-select");
+
+ EventUtils.synthesizeMouseAtCenter(btnRemove, {}, win);
+ await BrowserTestUtils.waitForEvent(selRecords, "RecordsRemoved");
+ is(selRecords.length, 0, "All credit cards are removed");
+
+ win.close();
+});
+
+add_task(async function test_removingCreditCardsViaKeyboardDelete() {
+ await setStorage(TEST_CREDIT_CARD_1);
+ let win = window.openDialog(
+ MANAGE_CREDIT_CARDS_DIALOG_URL,
+ null,
+ DIALOG_SIZE
+ );
+ await waitForFocusAndFormReady(win);
+
+ let selRecords = win.document.querySelector(TEST_SELECTORS.selRecords);
+
+ is(selRecords.length, 1, "One credit card");
+
+ EventUtils.synthesizeMouseAtCenter(selRecords.children[0], {}, win);
+ EventUtils.synthesizeKey("VK_DELETE", {}, win);
+ await BrowserTestUtils.waitForEvent(selRecords, "RecordsRemoved");
+ is(selRecords.length, 0, "No credit cards left");
+
+ win.close();
+});
+
+add_task(async function test_creditCardsDialogWatchesStorageChanges() {
+ let win = window.openDialog(
+ MANAGE_CREDIT_CARDS_DIALOG_URL,
+ null,
+ DIALOG_SIZE
+ );
+ await waitForFocusAndFormReady(win);
+
+ let selRecords = win.document.querySelector(TEST_SELECTORS.selRecords);
+
+ await setStorage(TEST_CREDIT_CARD_1);
+ await BrowserTestUtils.waitForEvent(selRecords, "RecordsLoaded");
+ is(selRecords.length, 1, "One credit card is shown");
+
+ await removeCreditCards([selRecords.options[0].value]);
+ await BrowserTestUtils.waitForEvent(selRecords, "RecordsLoaded");
+ is(selRecords.length, 0, "Credit card is removed");
+ win.close();
+});
+
+add_task(async function test_showCreditCardIcons() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.reduceTimerPrecision", false]],
+ });
+ await setStorage(TEST_CREDIT_CARD_1);
+ await setStorage(TEST_CREDIT_CARD_3);
+
+ let win = window.openDialog(
+ MANAGE_CREDIT_CARDS_DIALOG_URL,
+ null,
+ DIALOG_SIZE
+ );
+ await waitForFocusAndFormReady(win);
+
+ let selRecords = win.document.querySelector(TEST_SELECTORS.selRecords);
+
+ is(
+ selRecords.classList.contains("branded"),
+ AppConstants.MOZILLA_OFFICIAL,
+ "record picker has 'branded' class in an MOZILLA_OFFICIAL build"
+ );
+
+ let option0 = selRecords.options[0];
+ let icon0Url = win.getComputedStyle(option0, "::before").backgroundImage;
+ let option1 = selRecords.options[1];
+ let icon1Url = win.getComputedStyle(option1, "::before").backgroundImage;
+
+ is(
+ option0.getAttribute("cc-type"),
+ "mastercard",
+ "Option has the expected cc-type"
+ );
+ is(
+ option1.getAttribute("cc-type"),
+ "visa",
+ "Option has the expected cc-type"
+ );
+
+ if (AppConstants.MOZILLA_OFFICIAL) {
+ ok(
+ icon0Url.includes("icon-credit-card-generic.svg"),
+ "unknown network option ::before element has the generic icon as backgroundImage: " +
+ icon0Url
+ );
+ ok(
+ icon1Url.includes("cc-logo-visa.svg"),
+ "visa option ::before element has the visa icon as backgroundImage " +
+ icon1Url
+ );
+ }
+
+ await removeCreditCards([option0.value, option1.value]);
+ await BrowserTestUtils.waitForEvent(selRecords, "RecordsLoaded");
+ is(selRecords.length, 0, "Credit card is removed");
+ win.close();
+});
+
+add_task(async function test_hasEditLoginPrompt() {
+ if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) {
+ todo(
+ OSKeyStoreTestUtils.canTestOSKeyStoreLogin(),
+ "Cannot test OS key store login on official builds."
+ );
+ return;
+ }
+
+ await setStorage(TEST_CREDIT_CARD_1);
+
+ let win = window.openDialog(
+ MANAGE_CREDIT_CARDS_DIALOG_URL,
+ null,
+ DIALOG_SIZE
+ );
+ await waitForFocusAndFormReady(win);
+
+ let selRecords = win.document.querySelector(TEST_SELECTORS.selRecords);
+ let btnRemove = win.document.querySelector(TEST_SELECTORS.btnRemove);
+ let btnAdd = win.document.querySelector(TEST_SELECTORS.btnAdd);
+ let btnEdit = win.document.querySelector(TEST_SELECTORS.btnEdit);
+
+ EventUtils.synthesizeMouseAtCenter(selRecords.children[0], {}, win);
+
+ let osKeyStoreLoginShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(); // cancel
+ EventUtils.synthesizeMouseAtCenter(btnEdit, {}, win);
+ await osKeyStoreLoginShown;
+ await new Promise(resolve => waitForFocus(resolve, win));
+ await new Promise(resolve => executeSoon(resolve));
+
+ // Login is not required for removing credit cards.
+ EventUtils.synthesizeMouseAtCenter(btnRemove, {}, win);
+ await BrowserTestUtils.waitForEvent(selRecords, "RecordsRemoved");
+ is(selRecords.length, 0, "Credit card is removed");
+
+ // gSubDialog.open should be called when trying to add a credit card,
+ // no OS login dialog is required.
+ window.gSubDialog = {
+ open: url =>
+ is(url, EDIT_CREDIT_CARD_DIALOG_URL, "Edit credit card dialog is called"),
+ };
+ EventUtils.synthesizeMouseAtCenter(btnAdd, {}, win);
+ delete window.gSubDialog;
+
+ win.close();
+});
diff --git a/browser/extensions/formautofill/test/browser/creditCard/head_cc.js b/browser/extensions/formautofill/test/browser/creditCard/head_cc.js
new file mode 100644
index 0000000000..42196e8422
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/creditCard/head_cc.js
@@ -0,0 +1 @@
+/* import-globals-from ../head.js */
diff --git a/browser/extensions/formautofill/test/browser/empty.html b/browser/extensions/formautofill/test/browser/empty.html
new file mode 100644
index 0000000000..1ad28bb1f7
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/empty.html
@@ -0,0 +1,8 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+<title>Empty file</title>
+</head>
+<body>
+</body>
+</html>
diff --git a/browser/extensions/formautofill/test/browser/fathom/test-setup.sh b/browser/extensions/formautofill/test/browser/fathom/test-setup.sh
new file mode 100755
index 0000000000..1547c4a395
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/fathom/test-setup.sh
@@ -0,0 +1,39 @@
+#!/bin/sh
+# This script download samples for testing
+
+clean=1
+cleanall=0
+sample_dir=autofill-repo-samples
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ -c) clean=1 ;;
+ -cc) cleanall=1 ;;
+ esac
+ shift
+done
+
+# Check out source code
+if ! [ -d "fathom-form-autofill" ]; then
+ echo "Get samples from repo..."
+ git clone https://github.com/mozilla-services/fathom-form-autofill
+fi
+
+if ! [ -d "samples" ]; then
+ echo "Copy samples..."
+ mkdir $sample_dir
+ cp -r fathom-form-autofill/samples/testing $sample_dir
+ cp -r fathom-form-autofill/samples/training $sample_dir
+ cp -r fathom-form-autofill/samples/validation $sample_dir
+else
+ echo "\`samples\` directory already exists"
+fi
+
+if [ "$clean" = 1 ] || [ "$cleanall" = 1 ]; then
+ echo "Cleanup..."
+ rm -rf fathom-form-autofill
+fi
+
+if [ "$cleanall" = 1 ]; then
+ rm -rf $sample_dir
+fi
diff --git a/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/1.svg b/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/1.svg
new file mode 100644
index 0000000000..179f3b5cce
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/1.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="30" height="20" viewBox="0 0 30 20">
+ <path fill="#CCC" fill-rule="evenodd" d="M30 5V1a1 1 0 0 0-1-1H1a1 1 0 0 0-1 1v4h30zm0 4v10a1 1 0 0 1-1 1H1a1 1 0 0 1-1-1V9h30z"/>
+</svg>
diff --git a/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/10.svg b/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/10.svg
new file mode 100644
index 0000000000..619b82106d
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/10.svg
@@ -0,0 +1 @@
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="348.333px" height="348.333px" viewBox="0 0 348.333 348.334" style="enable-background:new 0 0 348.333 348.334;" xml:space="preserve"><g><path fill="#565656" d="M336.559,68.611L231.016,174.165l105.543,105.549c15.699,15.705,15.699,41.145,0,56.85c-7.844,7.844-18.128,11.769-28.407,11.769c-10.296,0-20.581-3.919-28.419-11.769L174.167,231.003L68.609,336.563c-7.843,7.844-18.128,11.769-28.416,11.769c-10.285,0-20.563-3.919-28.413-11.769c-15.699-15.698-15.699-41.139,0-56.85l105.54-105.549L11.774,68.611c-15.699-15.699-15.699-41.145,0-56.844c15.696-15.687,41.127-15.687,56.829,0l105.563,105.554L279.721,11.767c15.705-15.687,41.139-15.687,56.832,0C352.258,27.466,352.258,52.912,336.559,68.611z"/></g></svg> \ No newline at end of file
diff --git a/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/11.png b/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/11.png
new file mode 100644
index 0000000000..446ba13cec
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/11.png
Binary files differ
diff --git a/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/12.gif b/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/12.gif
new file mode 100644
index 0000000000..b3aa80d843
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/12.gif
Binary files differ
diff --git a/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/13.svg b/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/13.svg
new file mode 100644
index 0000000000..ed16723fa0
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/13.svg
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ width="164.105px" height="48.177px" viewBox="0 21.835 164.105 48.177" enable-background="new 0 21.835 164.105 48.177"
+ xml:space="preserve">
+<path fill="#78BE20" d="M134.879,52.052l4.932-16.246l4.919,16.246H134.879z M98.851,57.406h-3.028V34.901h2.989
+ c4.53,0,9.571,1.875,9.571,11.2C108.383,54.361,103.918,57.406,98.851,57.406 M18.927,52.052l5.033-16.436l4.94,16.436H18.927z
+ M148.76,23.221h-16.905l-9.64,27.871c0.427-2.314,0.504-4.164,0.504-4.968c0-11.677-6.697-22.902-22.437-22.902H82.209
+ l0.056,27.724c-1.565-7.876-8.77-9.987-17.731-12.708c-3.348-1.03-5.186-2.666-4.516-4.193c0.574-1.332,2.626-1.75,5.118-1.405
+ c3.802,0.543,6.838,1.813,9.763,3.401l3.995-9.885c-0.902-0.442-7.374-4.323-15.485-4.323c-11.311,0-18.535,5.511-18.535,13.644
+ c0,7.249,4.469,11.462,12.686,13.823c8.839,2.562,11.079,3.589,10.814,6.13c-0.231,2.187-5.708,4.853-19.11-3.384l-3.982,8.23
+ L32.989,23.221h-16.9L0.115,69.285h13.321l2.078-6.524h16.797l1.987,6.524h13.985l-1.585-4.507
+ c4.729,2.73,10.591,5.228,17.604,5.228c10.747,0,16.555-5.86,17.939-11.477v10.756h18.025c10.74,0,16.361-5.118,19.298-10.591
+ l-3.687,10.591h13.312l2.162-6.524h16.781l1.931,6.524h14.001L148.76,23.221z"/>
+</svg>
diff --git a/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/14.svg b/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/14.svg
new file mode 100644
index 0000000000..21f7d71bf6
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/14.svg
@@ -0,0 +1,14 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="32" height="32" viewBox="0 0 32 32">
+ <defs>
+ <path id="a" d="M11.5 17a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1zm1.003-3h-1a.5.5 0 0 1-.5-.5v-7a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-.5.5zm-9.218 5h17.43a.5.5 0 0 0 .436-.746l-8.716-15.42a.5.5 0 0 0-.87 0l-8.716 15.42a.5.5 0 0 0 .436.746zM13.74 1.08l9.572 16.936A2 2 0 0 1 21.573 21H2.427a2 2 0 0 1-1.741-2.984L10.259 1.08a2 2 0 0 1 3.482 0z"/>
+ </defs>
+ <g fill="none" fill-rule="evenodd" transform="translate(4 6)">
+ <mask id="b" fill="#fff">
+ <use xlink:href="#a"/>
+ </mask>
+ <use fill="#d43030" xlink:href="#a"/>
+ <g fill="#d43030" mask="url(#b)">
+ <path d="M-4-6h32v32H-4z"/>
+ </g>
+ </g>
+</svg>
diff --git a/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/15.svg b/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/15.svg
new file mode 100644
index 0000000000..44c5c294d8
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/15.svg
@@ -0,0 +1 @@
+<svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg"><path d="M17 19.5a.5.5 0 01-.5.5h-1a.5.5 0 01-.5-.5V17h-2.5a.5.5 0 01-.5-.5v-1a.5.5 0 01.5-.5H15v-2.5a.5.5 0 01.5-.5h1a.5.5 0 01.5.5V15h2.5a.5.5 0 01.5.5v1a.5.5 0 01-.5.5H17zm-7.611 4.504L12.394 22H25.5a.5.5 0 00.5-.5v-11a.5.5 0 00-.5-.5h-19a.5.5 0 00-.5.5v11a.5.5 0 00.5.5H9v1.796a.25.25 0 00.389.208zM7 27.066V24H6a2 2 0 01-2-2V10a2 2 0 012-2h20a2 2 0 012 2v12a2 2 0 01-2 2H13l-5.223 3.482A.5.5 0 017 27.066z" fill="#3d3d3d"/></svg>
diff --git a/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/16.svg b/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/16.svg
new file mode 100644
index 0000000000..05105ed133
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/16.svg
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="111px" height="33px" viewBox="0 0 111 33" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <title>ASDA Logo - White</title>
+ <g id="Footer-Amends" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+ <g id="Group" transform="translate(-1.000000, -1.000000)" fill="#FFFFFF">
+ <g id="Logo" transform="translate(1.000000, 1.000000)">
+ <path d="M100.697948,1.012292 L89.3366436,1.012292 L82.8584522,19.7642556 C83.148104,18.2068119 83.1992581,16.9618991 83.1992581,16.4207628 C83.1992581,8.56536178 78.6975861,1.012292 68.1226373,1.012292 L55.9778554,1.012292 L56.013619,19.6652524 C54.9634097,14.3658639 50.1220389,12.9452287 44.1009128,11.1157202 C41.8504643,10.4218107 40.6150151,9.3208018 41.0659906,8.29407301 C41.4513073,7.398942 42.8306965,7.11767529 44.5051631,7.34860889 C47.0601003,7.71502058 49.0999533,8.56990728 51.0644038,9.63765645 L53.7484443,2.98659136 C53.1426778,2.68836218 48.7941359,0.0782470703 43.3442306,0.0782470703 C35.744192,0.0782470703 30.8900881,3.78626686 30.8900881,9.25771912 C30.8900881,14.1342651 33.8930113,16.9692162 39.4152188,18.5582567 C45.353524,20.2807797 46.8595806,20.9735805 46.6813163,22.6821344 C46.5266361,24.1539896 42.8459763,25.947245 33.8399749,20.4062798 L31.1644602,25.9433647 L22.9041793,1.012292 L11.5474142,1.012292 L0.81413124,32.0042908 L9.76565687,32.0042908 L11.5,27 L22,27 L23.7839856,32.0042908 L33.1815042,32.0042908 L32.1156829,28.9725528 C35.293438,30.8094893 39.2329685,32.4896615 43.945236,32.4896615 C51.1667121,32.4896615 55.0690396,28.5473822 56,24.7678539 L56,32.0042908 L68.1107899,32.0042908 C75.3288336,32.0042908 79.1053795,28.561573 81.0783558,24.878498 L78.600814,32.0042908 L87.5461392,32.0042908 L89.3366436,27 L100,27 L101.571997,32.0042908 L110.981142,32.0042908 L100.697948,1.012292 Z M13.5,20 L16.8375459,9.35239857 L20,20 L13.5,20 Z M65,24.0118596 L65,9 L67.1348759,9 C70.178213,9 73.5,10.2318086 73.5,16.5059298 C73.5,22.063414 70.5657441,24.0118596 67.1610065,24.0118596 L65,24.0118596 Z M91.5,20 L94.6839085,9.47878566 L98,20 L91.5,20 Z" id="Fill-3"></path>
+ </g>
+ </g>
+ </g>
+</svg>
diff --git a/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/17.bin b/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/17.bin
new file mode 100644
index 0000000000..5fa299eb94
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/17.bin
Binary files differ
diff --git a/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/18.svg b/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/18.svg
new file mode 100644
index 0000000000..6f66ac60a2
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/18.svg
@@ -0,0 +1 @@
+<svg width="136" height="16" xmlns="http://www.w3.org/2000/svg"><g fill="none"><path d="M79.039 7.346c0 1.784-.449 3.186-1.346 4.206-.897 1.021-2.152 1.532-3.767 1.532-1.641 0-2.905-.505-3.791-1.513-.887-1.008-1.335-2.422-1.346-4.24 0-1.815.449-3.221 1.346-4.22.897-1 2.165-1.498 3.805-1.496 1.6 0 2.85.507 3.748 1.523.899 1.015 1.35 2.418 1.351 4.208zm-8.88 0c0 1.51.32 2.654.963 3.434.642.78 1.577 1.17 2.804 1.168 1.234 0 2.166-.388 2.796-1.165.63-.777.945-1.923.947-3.437 0-1.498-.314-2.634-.942-3.41-.627-.774-1.557-1.163-2.787-1.164-1.235 0-2.173.39-2.815 1.17-.642.78-.964 1.915-.964 3.404h-.002zm16.891 5.587V7.535c0-.68-.155-1.188-.466-1.523-.31-.336-.795-.504-1.455-.504-.874 0-1.514.236-1.922.708-.407.472-.61 1.251-.61 2.339v4.378h-1.265V4.575h1.028l.204 1.143h.062a2.583 2.583 0 011.076-.955 3.541 3.541 0 011.564-.339c1.006 0 1.763.242 2.271.727.508.484.762 1.26.762 2.327v5.455H87.05zm7.392.151c-1.234 0-2.208-.376-2.922-1.128-.714-.752-1.073-1.796-1.077-3.132 0-1.346.332-2.415.996-3.208a3.302 3.302 0 012.672-1.19 3.151 3.151 0 012.484 1.034c.61.689.915 1.597.915 2.723v.808h-5.75c.024.98.272 1.725.742 2.233a2.57 2.57 0 001.986.762 6.727 6.727 0 002.667-.566v1.128c-.409.18-.834.319-1.27.414-.476.09-.96.13-1.443.122zm-.344-7.597c-.609-.03-1.2.21-1.615.656a3.022 3.022 0 00-.705 1.814h4.378c0-.798-.18-1.409-.538-1.832a1.884 1.884 0 00-1.52-.638zm9.192 7.446h-1.294V2.94h-3.53V1.79h8.341v1.152h-3.517zm8.824-8.506c.335-.004.67.027.998.091l-.175 1.173c-.3-.07-.607-.108-.915-.113a2.228 2.228 0 00-1.733.824c-.488.57-.745 1.3-.72 2.05v4.48h-1.266V4.576h1.044l.146 1.547h.062c.27-.5.654-.93 1.119-1.257a2.521 2.521 0 011.44-.438zm3.748.148v5.422c0 .682.155 1.19.466 1.523.31.334.795.501 1.456.503.873 0 1.512-.238 1.916-.716.403-.477.605-1.256.605-2.338V4.575h1.265v8.342h-1.044l-.183-1.12h-.068c-.26.412-.633.74-1.076.945a3.627 3.627 0 01-1.574.328c-1.016 0-1.776-.241-2.282-.724-.506-.482-.759-1.255-.759-2.317V4.575h1.278zm13.781 6.079a2.09 2.09 0 01-.87 1.797c-.579.422-1.392.633-2.437.633-1.107 0-1.971-.18-2.592-.539v-1.16c.412.207.845.368 1.292.48.434.113.88.172 1.33.174.526.03 1.051-.08 1.522-.317a1.076 1.076 0 00.11-1.798 6.663 6.663 0 00-1.649-.807 8.931 8.931 0 01-1.658-.775 2.258 2.258 0 01-.737-.73 1.923 1.923 0 01-.24-.981 1.884 1.884 0 01.832-1.615c.554-.393 1.314-.59 2.28-.59a6.668 6.668 0 012.636.539l-.449 1.028a6.052 6.052 0 00-2.28-.52 2.624 2.624 0 00-1.345.27.872.872 0 00-.457.777.947.947 0 00.172.57c.15.188.338.341.552.45.474.237.963.443 1.464.616.99.36 1.659.718 2.007 1.077.36.383.546.896.517 1.42zm4.755 1.386c.217 0 .434-.016.648-.049.167-.024.332-.058.495-.102v.968a2.312 2.312 0 01-.605.165c-.238.04-.48.062-.721.064-1.615 0-2.422-.851-2.422-2.554v-4.97h-1.198v-.61l1.198-.539.538-1.784h.732v1.946h2.422v.982h-2.422v4.922c-.028.416.1.829.358 1.157.25.273.607.42.977.403z" fill="#6FBE4A"/><text transform="translate(.79 .615)" fill="#696969" font-family="ArialMT, Arial" font-size="12.109"><tspan x=".034" y="11">Powered by</tspan></text></g></svg> \ No newline at end of file
diff --git a/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/2.svg b/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/2.svg
new file mode 100644
index 0000000000..01977c77b0
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/2.svg
@@ -0,0 +1,8 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="30" height="20" viewBox="0 0 30 20">
+ <g fill="none" fill-rule="evenodd">
+ <rect width="30" height="20" fill="#353A48" rx="1"/>
+ <circle cx="11" cy="10" r="6" fill="#ED0006"/>
+ <circle cx="20" cy="10" r="6" fill="#F9A000"/>
+ <path fill="#FF8150" d="M15.5 6a5.731 5.731 0 0 1 1.597 4 5.731 5.731 0 0 1-1.597 4 5.731 5.731 0 0 1-1.597-4c0-1.567.612-2.983 1.597-4z"/>
+ </g>
+</svg>
diff --git a/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/3.svg b/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/3.svg
new file mode 100644
index 0000000000..bb024231ef
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/3.svg
@@ -0,0 +1 @@
+<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 22"><defs><style>.cls-1{fill:#191f70;}.cls-2{fill:#fff;}</style></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1-2"><path class="cls-1" d="M.91,0H31.09A1,1,0,0,1,32,1V21a1,1,0,0,1-.91,1H.91A1,1,0,0,1,0,21V1A1,1,0,0,1,.91,0Z"/><path class="cls-2" d="M16.14,9.52c0,1.23,1.09,1.91,1.92,2.32s1.15.68,1.15,1.05c0,.58-.69.83-1.32.84a4.55,4.55,0,0,1-2.26-.54l-.4,1.87a6.82,6.82,0,0,0,2.45.45c2.31,0,3.82-1.15,3.83-2.91,0-2.24-3.1-2.38-3.08-3.38,0-.31.29-.63.93-.71a4.07,4.07,0,0,1,2.17.37l.39-1.8a6,6,0,0,0-2.06-.37c-2.17,0-3.7,1.16-3.73,2.81m9.51-2.66a1,1,0,0,0-.94.63l-3.3,7.89h2.31l.46-1.27H27l.26,1.27h2L27.52,6.86ZM26,9.17l.67,3.19H24.81ZM13.33,6.86l-1.81,8.51h2.2l1.82-8.51Zm-3.25,0-2.29,5.8L6.86,7.73a1,1,0,0,0-1-.87H2.09l0,.25a9.22,9.22,0,0,1,2.17.72.93.93,0,0,1,.53.75l1.75,6.79H8.82l3.57-8.51Z"/></g></g></svg> \ No newline at end of file
diff --git a/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/4.svg b/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/4.svg
new file mode 100644
index 0000000000..634943eee9
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/4.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="109" viewBox="0 0 24 109">
+ <g fill="none" fill-rule="nonzero">
+ <rect width="24" height="109" fill="#3D3D3D" rx="12"/>
+ <path stroke="#EEE" stroke-linecap="square" stroke-width="2" d="M5.5 50.5h13M5.5 55.25h13M5.5 60.25h13"/>
+ </g>
+</svg>
diff --git a/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/5.svg b/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/5.svg
new file mode 100644
index 0000000000..857064edc9
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/5.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="30" height="20" viewBox="0 0 30 20">
+ <g fill="none" fill-rule="evenodd">
+ <rect width="30" height="20" fill="#007ECD" rx="1"/>
+ <path fill="#FFF" d="M9.72 13H8.47l-.498-1.363H5.69L5.222 13H4l2.22-6h1.218l2.283 6zm-2.119-2.374L6.816 8.4l-.77 2.226H7.6zM10.316 13V7h1.723l1.034 4.093L14.096 7h1.727v6h-1.07V8.277L13.622 13h-1.109l-1.128-4.723V13h-1.07zm6.65 0V7h4.228v1.015h-3.077v1.33h2.863v1.011h-2.863v1.633h3.186V13h-4.337zm4.733 0l1.949-3.131L21.882 7h1.346l1.143 1.928L25.491 7h1.334l-1.773 2.914L27 13h-1.388l-1.264-2.075L23.08 13H21.7z"/>
+ </g>
+</svg>
diff --git a/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/6.svg b/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/6.svg
new file mode 100644
index 0000000000..0678ad42f7
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/6.svg
@@ -0,0 +1,8 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="30" height="20" viewBox="0 0 30 20">
+ <g fill="none" fill-rule="evenodd">
+ <rect width="30" height="20" fill="#000052" rx="1"/>
+ <circle cx="11" cy="10" r="6" fill="#06C"/>
+ <circle cx="20" cy="10" r="6" fill="#C00"/>
+ <path fill="#C70B8C" d="M15.5 6a5.731 5.731 0 0 1 1.597 4 5.731 5.731 0 0 1-1.597 4 5.731 5.731 0 0 1-1.597-4c0-1.567.612-2.983 1.597-4z"/>
+ </g>
+</svg>
diff --git a/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/7.woff2 b/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/7.woff2
new file mode 100644
index 0000000000..52b6d6916a
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/7.woff2
Binary files differ
diff --git a/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/8.woff2 b/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/8.woff2
new file mode 100644
index 0000000000..e379beda77
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/8.woff2
Binary files differ
diff --git a/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/9.woff2 b/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/9.woff2
new file mode 100644
index 0000000000..efa300c564
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/fathom/testing/resources/sample/9.woff2
Binary files differ
diff --git a/browser/extensions/formautofill/test/browser/fathom/testing/sample.html b/browser/extensions/formautofill/test/browser/fathom/testing/sample.html
new file mode 100644
index 0000000000..ddb0f5f2da
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/fathom/testing/sample.html
@@ -0,0 +1,20 @@
+<html lang="en-GB"><!--
+ Page saved with SingleFile
+ url: https://www.asda.com/account?request_origin=asda&redirect_uri=https%3A%2F%2Fwww.asda.com%2Fgood-living%2Ftag%2Frecipes
+ saved date: Wed Mar 16 2022 11:21:53 GMT+0200 (Eastern European Standard Time)
+--><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=0,shrink-to-fit=no"><meta name="referrer" content="no-referrer-when-downgrade"><title>My Account | Account Settings – Asda Groceries</title><style>:root{--sf-img-16: url("resources/sample/1.svg");--sf-img-19: url("resources/sample/2.svg");--sf-img-20: url("resources/sample/3.svg");--sf-img-15: url("resources/sample/4.svg");--sf-img-17: url("resources/sample/5.svg");--sf-img-18: url("resources/sample/6.svg")}</style><style>html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video{margin:0;padding:0;border:0;font-size:100%;font:inherit;vertical-align:baseline}article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section{display:block}body{line-height:1}ol,ul{list-style:none}blockquote,q{quotes:none}blockquote:before,blockquote:after,q:before,q:after{content:"";content:none}table{border-collapse:collapse;border-spacing:0}html{box-sizing:border-box}*,*:before,*:after{box-sizing:inherit}article *,article *:before,article *:after{box-sizing:border-box}@font-face{font-family:"SourceSansProBold";src:url(resources/sample/7.woff2) format("woff2")}@font-face{font-family:"SourceSansProSemiBold";src:url(resources/sample/8.woff2) format("woff2")}@font-face{font-family:"SourceSansProRegular";src:url(resources/sample/9.woff2) format("woff2")}h1,h2,h3,h4,p,a{color:#191919;padding-bottom:8px;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}h1{font-size:1.25em;font-weight:700;color:#191919;padding-bottom:16px;line-height:1.25}.theme-ghs h1{font-family:SourceSansProBold,sans-serif}.theme-george h1{font-family:LatoBold,sans-serif}h2{font-size:1.125em;font-weight:800}.theme-ghs h2{font-family:SourceSansProBold,sans-serif}.theme-george h2{font-family:LatoBold,sans-serif}h3{font-size:1em;font-weight:600}.theme-ghs h3{font-family:SourceSansProSemiBold,sans-serif}.theme-george h3{font-family:LatoRegular,sans-serif}h4{font-size:.875em;font-weight:400}.theme-ghs h4{font-family:SourceSansProRegular,sans-serif}.theme-george h4{font-family:LatoRegular,sans-serif}p{color:off-black;padding-bottom:16px;font-size:.875em;letter-spacing:.2px;line-height:1.3}.theme-ghs p{font-family:SourceSansProRegular,sans-serif}.theme-george p{font-family:LatoRegular,sans-serif}a{margin-bottom:16px;padding-bottom:0;font-weight:500;font-size:1em}.theme-ghs a{color:#0073b1;text-decoration:none;font-family:SourceSansProBold,sans-serif}.theme-george a{color:#191919;text-decoration:underline;font-family:LatoRegular,sans-serif}.button-as-link{display:inline;background-color:transparent;border:0;padding:0;width:auto!important;height:auto!important;color:#0073b1;margin-left:0;padding-bottom:16px;font-weight:500;text-decoration:none}.theme-ghs .button-as-link{font-size:.875em}.theme-george .button-as-link{font-size:12px}.theme-ghs .button-as-link{color:#0073b1}.theme-george .button-as-link{color:#191919}.short-password-error{bottom:62px}.blank-password-error{bottom:50px}a:hover{cursor:pointer}.theme-ghs strong{font-family:SourceSansProBold,sans-serif}.theme-george strong{font-family:LatoBold,sans-serif}.input-error{font-family:SourceSansProRegular,sans-serif;font-size:.75em;color:#d43030;line-height:1.125em}.theme-ghs .input-error{font-family:SourceSansProRegular,sans-serif;font-size:.75em;color:#d43030;clear:both}.theme-george .input-error{font-family:LatoBold,sans-serif;font-size:.875em;color:#da291c;clear:both}.theme-ghs .input-error a{font-family:SourceSansProRegular,sans-serif;color:#d43030;text-decoration:underline}.theme-george .input-error a{font-family:LatoBold,sans-serif;color:#da291c;text-decoration:underline}.left{float:left!important}.right{float:right!important;text-align:right!important}.theme-ghs .right{padding-top:3px;line-height:1.5}.theme-george .right{line-height:1.5}.center{text-align:center!important}.red{color:#da0500!important}.orange{color:#fdb45b!important}.pink{color:#ec938e!important}.yellow{color:#fae100!important}.green{color:#68a51c!important}.blue{color:#0073b1!important}.grey{color:grey1!important}.white{color:#fff!important}.black{color:#191919!important}.regular{font-weight:400!important}.bold{font-weight:700!important}@media (min-width:768px){.main-container.login{margin-top:32px}}form{border-bottom:1px solid #ccc;padding:8px 0 16px;margin-bottom:16px}form label.for-username{padding-bottom:4px;display:block}.theme-ghs form label.for-username{font-family:SourceSansProRegular,sans-serif;color:#3d3d3d;font-size:.875em;padding:0 0 4px}.theme-george form label.for-username{font-family:LatoRegular,sans-serif;color:#191919;font-size:14px;padding:0 0 8px}form .username-box.error{height:74px}form .username-box .alert{top:4px}form .login-links{width:100%;height:30px;padding:10px 0;margin-bottom:10px}form .login-links a{font-size:14px}form .form-error{margin-bottom:20px;margin-top:5px;font-family:SourceSansProRegular,sans-serif;font-size:.75em;color:#da291c;line-height:16px;clear:both}form .form-error a{color:#da291c;font-family:SourceSansProRegular,sans-serif;text-decoration:underline}form .dropdown-wrapper.phone-login-dropdown{float:none}form .dropdown-wrapper.phone-login-dropdown .dropdown-list::-webkit-scrollbar-thumb{height:150px}form .dropdown-wrapper{width:100%}form .dropdown-wrapper .list-wrapper .dropdown-list{width:498px;border:solid 1px #ccc;height:192px;padding:20px 20px}form .dropdown-wrapper .list-wrapper .dropdown-list .dropdown-list-item{display:flex;font-size:14px;font-weight:normal;padding-bottom:12px;justify-content:start}form .dropdown-wrapper .list-wrapper .dropdown-list .dropdown-list-item .country-name{width:auto;text-align:left;padding-right:15px}form .dropdown-header.phone-login-dropdown-header{height:44px;width:unset;border-radius:4px;border:solid 1px #ccc;margin-bottom:10px}form .dropdown-header.phone-login-dropdown-header .country-code{flex-grow:1;font-weight:normal;font-size:14px;margin-left:10px}form .remember-me{bottom:38px;width:130px}.theme-ghs form .remember-me{text-decoration:none}.theme-george form .remember-me{text-decoration:underline}a{user-select:none}a.login-link{color:#da291c;text-decoration:underline}.login-container .softLogin-form{border-bottom:0;margin:0;padding:8px 0 0}.login-container button.reset-pwd{border:0;font-size:14px;padding:0px;height:auto;width:auto;margin:0 0 16px 0;letter-spacing:inherit;text-transform:inherit;-webkit-font-smoothing:antialiased}.theme-ghs .login-container button.reset-pwd{color:#0073b1;text-decoration:none;font-family:SourceSansProBold,sans-serif}.theme-george .login-container button.reset-pwd{color:#191919;text-decoration:underline;font-family:LatoRegular,sans-serif}.reset-password-container .code-link{display:inline-block;padding-bottom:0;margin-top:16px}.reset-password-container button.basic{margin-top:20px}.reset-password-container form{border-bottom:0;margin-bottom:0}.reset-password-container .input-box input::placeholder{font-size:14px;color:#767676}.reset-code form{padding-bottom:0}.reset-code .input-box input.half,.reset-code button{width:48%;margin-right:0}.reset-code .reset-code-container{position:relative;padding-bottom:27px}.reset-code .reset-code-container .input-error{position:absolute;top:57px}.reset-code .reset-code-container .alert{top:20px;left:38%}@media (min-width:768px){.reset-code .reset-code-container .alert{left:44%}}.reset-code .code-label{display:inline;padding-bottom:0}.req-new-code{margin-top:8px;margin-bottom:8px;display:inline-block}.theme-ghs .req-new-code{font-family:SourceSansProBold,sans-serif;text-decoration:none;padding:0;text-transform:none}.theme-george .req-new-code{font-family:LatoBold,sans-serif;text-decoration:underline;font-size:14px;padding:0;text-transform:none}.try-other-email{font-size:1em}@media (min-width:768px){.reset-code .input-box input.half{width:54%}.reset-code button{width:44%}}.recaptcha-container{border-bottom:1px #ccc solid;margin-bottom:20px}.recaptcha-container .g-recaptcha{margin:10px 0 20px 0}.reset-confirmation{clear:fix();height:240px}.reset-confirmation button.full{margin-bottom:16px}.reset-confirmation button.full{width:100%;margin:0 0 16px;float:left}.theme-ghs .reset-confirmation button.next-dest a{color:#68a51c}.theme-george .reset-confirmation button.next-dest a{color:#191919}.password-strength{clear:both;font-family:SourceSansProRegular,sans-serif}.password-strength .strength-label{font-family:SourceSansProSemiBold,sans-serif;font-size:.875em}.password-strength .password-strength-measure{color:#767676;font-size:.875em;margin-top:5px;display:flex;justify-content:space-between}.theme-ghs .password-strength .password-strength-measure{font-family:SourceSansProRegular,sans-serif}.theme-george .password-strength .password-strength-measure{font-family:LatoRegular,sans-serif}.password-strength .strength-text{font-size:.875em}.password-strength .strength-indicator{display:inline-block;height:5px;border-radius:5px;position:relative;bottom:5px}.password-strength .strength-indicator.invalid{background-color:#c2c2c2;width:10%}.password-strength .strength-indicator.weak{background:#d43030;width:20%}.password-strength .strength-indicator.fair{background:#fbc42c;width:50%}.password-strength .strength-indicator.strong{background:#68a51c;width:100%}.password-strength .strength-indicator-bar{background:#e9e9e9;width:100%;height:5px;border-radius:5px}.password-strength .strength-desc{margin-top:16px}.password-strength .strength-desc button{font-size:.875em;display:inline;background-color:transparent;border:0;padding:0;color:#0073b1;margin-left:0;text-transform:none;letter-spacing:normal;margin-bottom:16px;font-weight:500}.theme-ghs .password-strength .strength-desc button{color:#0073b1;letter-spacing:.5px}.theme-george .password-strength .strength-desc button{color:#191919;letter-spacing:.5px}.password-strength .strength-desc button .chevron{padding-left:6px}.password-strength .strength-desc button .chevron.flipped{-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg);padding-top:0;padding-bottom:0;padding-left:0;padding-right:6px}.password-strength .strength-desc .strength-para{margin-bottom:16px}.password-strength .strength-desc .strength-para p{padding:0}.password-strength .strength-desc .strength-para ul{list-style-type:unset;list-style-position:inside;margin-top:8px}.password-strength .strength-desc .strength-para ul li{font-family:SourceSansProRegular,sans-serif;font-size:.875em;margin-left:8px;margin-bottom:4px}.postcode-wrap{width:100%;float:left;margin-bottom:8px}.postcode-wrap .custom-input.half{width:50%;text-transform:uppercase}.postcode-wrap .custom-input.half+.input-error{clear:both;line-height:17px;padding-top:5px}.regCheckTnC .register-link{font-size:100%}.theme-ghs .regCheckTnC .register-link{font-family:SourceSansProBold,sans-serif}.theme-george .regCheckTnC .register-link{font-family:LatoBold,sans-serif}.regCheckTnC .input-error{font-size:.875em}.register-container form{border-bottom:0;padding-bottom:0;margin-bottom:0}.register-container form button+p{padding-top:20px;padding-bottom:0}.register-container .register-link{font-size:100%}.theme-ghs .register-container .register-link{font-family:SourceSansProBold,sans-serif}.theme-george .register-container .register-link{font-family:LatoBold,sans-serif}.register-container .george-reward-link{font-size:14px;font-family:LatoBold,sans-serif}.password-container{position:relative}.password-container .button-show-password{position:absolute;padding-bottom:0;text-decoration:none;right:12px;text-align:center;display:inline-block;margin:0;width:40px!important;transition:all 200ms ease;padding:0}.theme-ghs .password-container .button-show-password{font-family:SourceSansProSemiBold,sans-serif;top:30px;text-transform:none;font-size:1em}.theme-george .password-container .button-show-password{font-family:LatoRegular,sans-serif;top:37px;text-transform:uppercase;font-size:.75em}.password-container .input-box input.show-password{padding-right:60px}.password-container .show-password{padding-right:65px;margin-bottom:7px}.co-third-party-login__container .primary{margin-bottom:16px}.co-third-party-login__container h3{margin:7.2px 0 24px 0}.co-login-disclaimer p{padding:16px 0 0}.co-login-disclaimer a{font-family:SourceSansProRegular;font-weight:bold;font-size:.95em}form#cd-reg-form{border-bottom:0;padding-bottom:0;margin-bottom:0}form#cd-reg-form .form-error{margin-bottom:20px;margin-top:5px;font-family:SourceSansProRegular,sans-serif;font-size:.75em;color:#da291c;line-height:16px;clear:both}form#cd-reg-form input:last-child{margin-bottom:16px}a{user-select:none}a.login-link{color:#da291c;text-decoration:underline}.login-container .softLogin-form{border-bottom:0;margin:0;padding:8px 0 0}.page-mask{position:fixed;width:100vw;height:100vh;left:0;top:0;background-color:#000;opacity:.5}.cd-modal{position:absolute;z-index:1000;top:10vh;left:calc(50% - 200px);width:400px;height:auto;border-radius:2px;background-color:#fff;box-shadow:0 5px 10px 0 rgba(0,0,0,.2)}.cd-modal h1{padding:20px;border-bottom:1px solid #ccc}.cd-modal p{padding:20px 40px 20px 20px;border-bottom:1px solid #ccc}.theme-george .register-container a.tc-link-register{font-family:LatoBold,sans-serif}.theme-george .register-container .password-strength .strength-desc button{text-decoration:underline}.app{display:flex;flex-wrap:wrap;align-items:flex-start;flex-direction:row}.container-wrapper{height:auto;flex-basis:calc((100% * 1) - 16px);min-width:0;margin:8px;margin-left:calc(100% * 0 + (16px / 2))!important;min-height:calc(100vh - 200px)}.theme-ghs .container-wrapper{min-height:calc(100vh - 300px)}.theme-george .container-wrapper{min-height:calc(100vh - 261px)}@media (min-width:481px){.container-wrapper{flex-basis:calc((100% * 0.6666666667) - 16px);min-width:0;margin:8px;margin-left:calc(100% * 0.1666666667 + (16px / 2))!important;min-height:calc(100vh - 200px)}}@media (min-width:768px){.container-wrapper{flex-basis:calc((100% * 0.5) - 16px);min-width:0;margin:8px;margin-left:calc(100% * 0.25 + (16px / 2))!important}}@media (min-width:961px){.container-wrapper{flex-basis:calc((100% * 0.3333333333) - 16px);min-width:0;margin:8px;margin-left:calc(100% * 0.3333333333 + (16px / 2))!important}}.main-container{height:auto;padding:4px;max-width:432px;margin:0 auto}@media (min-width:481px){.main-container{border:1px solid #ccc}.theme-ghs .main-container{padding:20px;box-shadow:0 0}.theme-george .main-container{padding:32px;box-shadow:4px 4px 4px 0 rgba(0,0,0,.06)}}footer{width:100%;margin-top:60px}.theme-ghs footer{background-color:#191919;min-height:100px}.theme-george footer{background-color:#fff;min-height:60px}.theme-ghs footer{border-top:0}.theme-george footer{border-top:1px solid #cbcbcb}footer .up-arrow{margin-left:5px;-webkit-transform:rotate(-90deg);-ms-transform:rotate(-90deg);transform:rotate(-90deg);height:10px;width:10px}.footer-wrapper{position:relative;max-width:1024px;margin:0 auto;padding:16px}.footer-top{position:absolute;display:flex;justify-content:space-between;flex-direction:row;top:-50px}.footer-top .back-top button{font-weight:600;letter-spacing:.2px;color:#191919;font-family:SourceSansProSemiBold,sans-serif;font-size:13px;display:inline;background-color:transparent;border:0;padding:0}.footer-top a.website-feedback-link{align-items:center;color:#191919;display:none}.footer-top .feedback-icon{width:30px;height:30px;margin-right:5px}.footer-main{text-align:center}.footer-logo{margin:40px 0 40px}.footer-george-logo{font-family:LatoRegular,sans-serif}@media (min-width:320px){.footer-george-logo{position:absolute;bottom:16px;width:95%;text-align:center}}.footer-links{font-family:SourceSansProSemiBold,sans-serif;margin:8px 0;display:-webkit-box;display:-ms-flexbox;display:-webkit-flex;display:flex;-webkit-justify-content:center;-ms-justify-content:center;justify-content:center}.footer-links ul{display:-webkit-box;display:-ms-flexbox;display:-webkit-flex;display:flex;-webkit-flex-wrap:wrap;-moz-flex-wrap:wrap;-ms-flex-wrap:wrap;flex-wrap:wrap;max-width:320px;-webkit-justify-content:center;-ms-justify-content:center;justify-content:center;width:100%}.footer-links li{font-size:.8125em;letter-spacing:.2px}.theme-ghs .footer-links li{color:#fff}.theme-george .footer-links li{color:#191919}.theme-george .footer-links li{border-right:1px solid #cbcbcb}.theme-ghs .footer-links li{padding:4px}.theme-george .footer-links li{padding:0}.theme-ghs .footer-links li{padding-right:0}.theme-george .footer-links li{padding-right:4px}.theme-ghs .footer-links li{margin:0}.theme-george .footer-links li{margin:4px}.footer-links li a{font-size:100%}.theme-ghs .footer-links li a{color:#fff}.theme-george .footer-links li a{color:#191919}.theme-ghs .footer-links li a{font-family:SourceSansProSemiBold,sans-serif}.theme-george .footer-links li a{font-family:LatoRegular,sans-serif}.theme-george .footer-links li a{text-decoration:none}.footer-links li a:hover{text-decoration:underline}.footer-links li:last-child{border-right:0}@media (min-width:768px){footer{min-height:102px}.footer-main{display:-webkit-box;display:-ms-flexbox;display:-webkit-flex;display:flex;-webkit-justify-content:space-between;-ms-justify-content:space-between;justify-content:space-between;-webkit-align-items:center;-moz-align-items:center;-ms-align-items:center;align-items:center;width:100%}.footer-logo{margin:0}.footer-links{margin:0;order:-1;-webkit-align-self:flex-end;-moz-align-self:flex-end;-ms-align-self:flex-end;align-self:flex-end}.footer-links ul{max-width:100%}.footer-top .back-top{top:-55px}}@media (min-width:320px){.theme-ghs footer{height:205px}.theme-george footer{height:92px}.theme-ghs .footer-wrapper{height:205px}.theme-george .footer-wrapper{height:92px}.footer-top{top:-50px}}@media (min-width:768px){.theme-ghs footer{height:100px}.theme-george footer{height:60px}.theme-ghs .footer-wrapper{height:100px}.theme-george .footer-wrapper{height:60px}.footer-george-logo{font-family:LatoRegular,sans-serif;position:relative;bottom:0;width:auto;text-align:left}}header{height:92px;width:100%;background-color:#fff;transition-property:margin-top;transition-duration:.5s;transition-delay:.5s}.theme-ghs header{box-shadow:0 2px 4px 0 #7f7f7f;margin-bottom:32px}.theme-george header{box-shadow:0 2px 4px 0 #cbcbcb;margin-bottom:32px}header[data-apply-onetrust=true]{margin-top:150px}header .header-container{display:flex;height:92px;justify-content:space-between;align-items:center;background-color:#fff;width:100%;max-width:1024px;margin:0 auto;padding:8px}header .header-container #logo{padding:0}header .header-container #logo img{width:93px}.theme-ghs header .header-container nav{margin-bottom:0}.theme-george header .header-container nav{margin-bottom:8px}header .header-container nav button{width:142px;letter-spacing:normal;text-transform:none}.theme-ghs header .header-container nav button{width:200px;background-image:none}.theme-george header .header-container nav button{width:120px;border:0;background-image:none}header .header-container nav button:hover{box-shadow:none}header .header-container nav .need-help{display:none}@media (min-width:320px){header{margin-bottom:24px;height:48px;transition-property:margin-top;transition-duration:.5s;transition-delay:.5s}header[data-apply-onetrust=true]{margin-top:150px}.theme-ghs header{margin-bottom:24px}.theme-george header{margin-bottom:24px}header .header-container{height:48px;padding:13px 16px 10px}header .header-container #logo{margin-bottom:0}header .header-container nav{margin-bottom:0}.theme-ghs header .header-container nav{margin-bottom:2px}.theme-george header .header-container nav{margin-bottom:2px}.theme-ghs header .header-container nav button{font-family:SourceSansProSemiBold,sans-serif;width:144px;margin-right:0;margin-left:8px}.theme-george header .header-container nav button{font-family:LatoRegular,sans-serif;width:88px;padding:16px 0 16px 12px;text-align:right;font-size:12px;margin-right:0;margin-left:8px}header .header-container nav button.basic:hover{background-image:none}}@media (min-width:481px){header{height:92px;margin-bottom:24px;padding-top:0;transition-property:margin-top;transition-duration:.5s;transition-delay:.5s}header[data-apply-onetrust=true]{margin-top:125px}header .header-container{height:92px;width:100%;padding:16px}.theme-ghs header .header-container{padding-top:20px}.theme-george header .header-container{padding-top:31px}header .header-container #logo img{width:113px}.theme-george header .header-container nav button{border:0}}@media (min-width:768px){header{transition-property:margin-top;transition-duration:.5s;transition-delay:.5s}header[data-apply-onetrust=true]{margin-top:140px}.theme-ghs header .header-container{padding-top:20px}.theme-george header .header-container{padding-top:31px}.theme-ghs header .header-container nav button{text-align:center}.theme-george header .header-container nav button{text-align:center;padding:0}header .header-container nav .need-help{display:inline-block}.theme-ghs header .header-container nav .need-help{background-image:none;text-transform:none;font-size:18px;width:200px}.theme-george header .header-container nav .need-help{border-right:1px solid #cbcbcb;border-radius:0;background-image:none;text-transform:none;font-size:14px;width:120px}.theme-ghs header .header-container nav .back-shop{background-image:none;width:200px;text-transform:none;font-size:18px}.theme-george header .header-container nav .back-shop{background-image:none;width:120px;text-transform:none;font-size:14px}}@media (min-width:961px){header{padding-top:0;transition-property:margin-top;transition-duration:.5s;transition-delay:.5s}.theme-ghs header{margin-bottom:32px}.theme-george header{margin-bottom:32px}header[data-apply-onetrust=true]{margin-top:75px}header .header-container{height:92px}.theme-ghs header .header-container{padding-top:20px}.theme-george header .header-container{padding-top:31px}header .header-container #logo img{width:140px}.theme-ghs header .header-container nav button{font-family:SourceSansProSemiBold,sans-serif}.theme-george header .header-container nav button{width:150px;border:0;border-radius:0;padding:0;height:18px;font-family:LatoRegular,sans-serif;margin:0}}@media (min-width:768px){.layout__section{min-height:640px}}@media (min-width:1024px){.layout__section{display:flex}.layout__main{flex:1 1 auto}}@media (min-width:1280px){.site-width.layout__section{margin:0 auto;max-width:1392px;padding:0 16px}.site-width.layout__main{flex:1 1 auto}}.onetrust-consent-sdk-box[data-apply-ot-banner-popup=true] .onetrust-pc-dark-filter.ot-hide{display:block!important}.onetrust-consent-sdk-box #onetrust-banner-sdk:focus{outline-color:none!important;outline-width:0px!important}.onetrust-consent-sdk-box #onetrust-banner-sdk{background-color:#fff!important;max-width:500px;min-width:500px;width:100%;top:23%;left:50%!important;transform:translate(-50%,-25%);padding:16px 22px 16px 24px;border-radius:2px;z-index:2147483646!important}.onetrust-consent-sdk-box #onetrust-banner-sdk #onetrust-accept-btn-handler{background-color:#0073b1;border-color:#0073b1;color:#fff;border-radius:4px}.onetrust-consent-sdk-box #onetrust-banner-sdk #onetrust-pc-btn-handler{background-color:#fff;border:1px solid #0073b1;color:#0073b1;border-radius:4px}.onetrust-consent-sdk-box #onetrust-banner-sdk #onetrust-group-container button{color:#0073b1;display:inline}.onetrust-consent-sdk-box #onetrust-banner-sdk #onetrust-policy-title,.onetrust-consent-sdk-box #onetrust-banner-sdk .ot-dpd-title{width:100%!important}.onetrust-consent-sdk-box #onetrust-banner-sdk #onetrust-policy-text{width:100%!important;line-height:1.5}.onetrust-consent-sdk-box #onetrust-banner-sdk .ot-b-addl-desc{width:100%!important;border-right:0;color:#000}.onetrust-consent-sdk-box #onetrust-banner-sdk .ot-dpd-content .ot-dpd-desc{line-height:1.5}.onetrust-consent-sdk-box #onetrust-banner-sdk .ot-dpd-container{width:100%!important;padding:0!important}.onetrust-consent-sdk-box #onetrust-banner-sdk .ot-sdk-container .ot-sdk-row{display:flex;flex-direction:column}.onetrust-consent-sdk-box #onetrust-banner-sdk .ot-sdk-container .ot-sdk-row #onetrust-group-container{width:100%}.onetrust-consent-sdk-box #onetrust-banner-sdk .ot-sdk-container .ot-sdk-row #onetrust-group-container p,.onetrust-consent-sdk-box #onetrust-banner-sdk .ot-sdk-container .ot-sdk-row #onetrust-group-container h3{color:#000}.onetrust-consent-sdk-box #onetrust-banner-sdk .ot-sdk-container .ot-sdk-row #onetrust-button-group-parent{position:relative!important;width:100%;left:auto!important;top:auto;transform:translateY(0%);right:0%}.onetrust-consent-sdk-box #onetrust-banner-sdk .ot-sdk-container .ot-sdk-row #onetrust-button-group-parent #onetrust-button-group{display:flex;justify-content:space-evenly}.onetrust-consent-sdk-box #onetrust-banner-sdk .ot-sdk-container .ot-sdk-row #onetrust-button-group-parent #onetrust-button-group #onetrust-pc-btn-handler,.onetrust-consent-sdk-box #onetrust-banner-sdk .ot-sdk-container .ot-sdk-row #onetrust-button-group-parent #onetrust-button-group #onetrust-accept-btn-handler{margin-right:0px;max-width:112px;width:100%;margin-top:1em}.onetrust-consent-sdk-box #onetrust-banner-sdk #onetrust-policy{margin:0px}.onetrust-consent-sdk-box #onetrust-pc-sdk{background-color:#fff!important;color:#000;max-width:500px!important;min-width:100%;padding:7px 16px 25px}.onetrust-consent-sdk-box #onetrust-pc-sdk #ot-pc-content{overflow-x:hidden}.onetrust-consent-sdk-box #onetrust-pc-sdk *:focus{outline:none!important}.onetrust-consent-sdk-box #onetrust-pc-sdk #ot-fltr-cnt,.onetrust-consent-sdk-box #onetrust-pc-sdk #ot-anchor{background-color:#fff!important}.onetrust-consent-sdk-box #onetrust-pc-sdk #ot-fltr-cnt{width:auto}.onetrust-consent-sdk-box #onetrust-pc-sdk .ot-sel-blk{background-color:#fff}.onetrust-consent-sdk-box #onetrust-pc-sdk .ot-pc-header{padding:0}.onetrust-consent-sdk-box #onetrust-pc-sdk #ot-pc-title,.onetrust-consent-sdk-box #onetrust-pc-sdk #ot-pc-content,.onetrust-consent-sdk-box #onetrust-pc-sdk #ot-category-title{color:#000!important;width:auto;margin:0;padding:0;padding-bottom:5px}.onetrust-consent-sdk-box #onetrust-pc-sdk #onetrust-group-container button{color:#0073b1}.onetrust-consent-sdk-box #onetrust-pc-sdk #ot-pc-desc,.onetrust-consent-sdk-box #onetrust-pc-sdk .ot-accordion-layout .ot-cat-header{color:#000!important;line-height:1.5!important;min-height:20px}.onetrust-consent-sdk-box #onetrust-pc-sdk #ot-lst-title span{color:#000!important;line-height:1.5}.onetrust-consent-sdk-box #onetrust-pc-sdk .ot-ven-name{color:#000!important;font-weight:400!important;line-height:1.5}.onetrust-consent-sdk-box #onetrust-pc-sdk #ot-pc-desc{margin-bottom:10px}.onetrust-consent-sdk-box #onetrust-pc-sdk #ot-pc-desc .privacy-notice-link{color:#0073b1;text-decoration:none}.onetrust-consent-sdk-box #onetrust-pc-sdk #accept-recommended-btn-handler,.onetrust-consent-sdk-box #onetrust-pc-sdk .save-preference-btn-handler,.onetrust-consent-sdk-box #onetrust-pc-sdk #filter-apply-handler,.onetrust-consent-sdk-box #onetrust-pc-sdk #filter-cancel-handler{background-color:#0073b1!important;border-color:#0073b1!important;color:#fff!important;margin-bottom:10px;border-radius:4px;margin-right:25px}.onetrust-consent-sdk-box #onetrust-pc-sdk .ot-cat-grp{margin-top:0px}.onetrust-consent-sdk-box #onetrust-pc-sdk .ot-acc-grpdesc,.onetrust-consent-sdk-box #onetrust-pc-sdk .ot-subgrp-desc,.onetrust-consent-sdk-box #onetrust-pc-sdk .ot-subgrp h5,.onetrust-consent-sdk-box #onetrust-pc-sdk .ot-ven-disc h4,.onetrust-consent-sdk-box #onetrust-pc-sdk .ot-ven-dets p,.onetrust-consent-sdk-box #onetrust-pc-sdk .ot-ven-dets span,.onetrust-consent-sdk-box #onetrust-pc-sdk .ot-label-txt{color:#000!important;font-size:12px!important}.onetrust-consent-sdk-box #onetrust-pc-sdk .ot-ven-dets h4,.onetrust-consent-sdk-box #onetrust-pc-sdk .ot-ven-pur h4,.onetrust-consent-sdk-box #onetrust-pc-sdk #clear-filters-handler{color:#000!important}.onetrust-consent-sdk-box #onetrust-pc-sdk .ot-vlst-cntr a,.onetrust-consent-sdk-box #onetrust-pc-sdk .ot-ven-link{color:#0073b1!important;padding:0px}.onetrust-consent-sdk-box #onetrust-pc-sdk .ot-vlst-cntr button{color:#0073b1!important}.onetrust-consent-sdk-box #onetrust-pc-sdk .ot-tgl input:checked+.ot-switch .ot-switch-nob{background-color:#0073b1;border:1px solid #fff}.onetrust-consent-sdk-box #onetrust-pc-sdk .ot-tgl input:checked+.ot-switch .ot-switch-nob:before{background-color:#fff;border-color:#fff;width:9px;height:9px}.onetrust-consent-sdk-box #onetrust-pc-sdk .ot-switch{width:28px;height:11px;padding:1px 1px 1px 4px}.onetrust-consent-sdk-box #onetrust-pc-sdk .ot-switch-nob:before{background-color:#fff;width:9px;height:9px;border-radius:5.5px}.onetrust-consent-sdk-box #onetrust-pc-sdk .ot-switch-nob{background-color:#767676;border-radius:5.5px;padding-left:1px}.onetrust-consent-sdk-box #onetrust-pc-sdk .ot-acc-hdr{min-height:auto}.onetrust-consent-sdk-box #onetrust-pc-sdk .ot-always-active{color:#000}.onetrust-consent-sdk-box #onetrust-pc-sdk .ot-pc-footer-logo a{margin-right:25px}.onetrust-consent-sdk-box #onetrust-pc-sdk #filter-btn-handler{width:30px;height:30px;border-radius:4px;background-color:#538316}.onetrust-consent-sdk-box #onetrust-pc-sdk #ot-sel-blk{background-color:#fff!important}.onetrust-consent-sdk-box #onetrust-pc-sdk .ot-chkbox label::before{border:1px solid #538316}.onetrust-consent-sdk-box #onetrust-pc-sdk .ot-chkbox input:checked~label::before{background-color:#538316}.onetrust-consent-sdk-box #onetrust-pc-sdk .ot-plus-minus{width:15px;height:15px}.onetrust-consent-sdk-box #onetrust-pc-sdk #vendor-search-handler{border-radius:4px}@media (max-width:767px){.onetrust-consent-sdk-box #onetrust-banner-sdk{top:28%;max-width:480px;min-width:325px;height:fit-content}.onetrust-consent-sdk-box #onetrust-banner-sdk .ot-sdk-container{padding:0px}}@media (min-width:768px)and (max-width:1023px){.onetrust-consent-sdk-box #onetrust-banner-sdk{top:23%;max-width:452px;min-width:452px;height:fit-content!important}}@media (min-width:768px){.onetrust-consent-sdk-box #onetrust-pc-sdk{min-width:500px!important}}@media (min-width:1024px){.onetrust-consent-sdk-box #onetrust-banner-sdk{height:fit-content}}button,a.reg-link,.register-nav-link{width:200px;height:40px;border:1px solid #191919;border-radius:4px;text-align:center;user-select:none;margin:0 5px;line-height:14px;font-weight:500;letter-spacing:.5px;cursor:pointer;background:transparent;transition:all 200ms ease}.theme-ghs button,.theme-ghs a.reg-link,.theme-ghs .register-nav-link{font-family:SourceSansProSemiBold,sans-serif;height:40px;font-size:18px;padding:11px}.theme-george button,.theme-george a.reg-link,.theme-george .register-nav-link{font-family:LatoBold,sans-serif;text-transform:uppercase;height:42px;font-size:14px;padding:14px}button a,a.reg-link a,.register-nav-link a{color:inherit;font-size:inherit;font-weight:inherit;font-family:inherit}.theme-george button a,.theme-george a.reg-link a,.theme-george .register-nav-link a{text-decoration:none}button.primary,a.reg-link.primary,.register-nav-link.primary{color:#fff;text-decoration:none}.theme-ghs button.primary,.theme-ghs a.reg-link.primary,.theme-ghs .register-nav-link.primary{background-color:#0073b1;border-color:#0073b1}.theme-george button.primary,.theme-george a.reg-link.primary,.theme-george .register-nav-link.primary{background-image:linear-gradient(to bottom,#424242 35%,black);border-color:#191919}button.primary:hover,a.reg-link.primary:hover,.register-nav-link.primary:hover{box-shadow:0 2px 10px 0 rgba(61,61,61,.1)}.theme-ghs button.primary:hover,.theme-ghs a.reg-link.primary:hover,.theme-ghs .register-nav-link.primary:hover{background-color:#005a8b}.theme-george button.primary:hover,.theme-george a.reg-link.primary:hover,.theme-george .register-nav-link.primary:hover{background-image:linear-gradient(to bottom,black 35%,#424242)}.theme-ghs button.secondary,.theme-ghs a.reg-link.secondary,.theme-ghs .register-nav-link.secondary{color:#fff;background-color:#68a51c;border-color:#68a51c}.theme-george button.secondary,.theme-george a.reg-link.secondary,.theme-george .register-nav-link.secondary{color:#fff;background-color:#191919;background-image:linear-gradient(to bottom,#424242 35%,black);border-color:#191919;text-transform:uppercase}button.secondary:hover,a.reg-link.secondary:hover,.register-nav-link.secondary:hover{background-color:#538316;box-shadow:0 2px 10px 0 rgba(61,61,61,.1)}.theme-george button.secondary:hover,.theme-george a.reg-link.secondary:hover,.theme-george .register-nav-link.secondary:hover{background-image:linear-gradient(to bottom,black 35%,#424242)}.theme-ghs button.secondary:disabled,.theme-ghs a.reg-link.secondary:disabled,.theme-ghs .register-nav-link.secondary:disabled{background-color:#68a51c;opacity:.5;cursor:not-allowed}.theme-george button.secondary:disabled,.theme-george a.reg-link.secondary:disabled,.theme-george .register-nav-link.secondary:disabled{background-color:#191919;background-image:linear-gradient(to bottom,#424242 35%,black);opacity:.5;cursor:not-allowed}.theme-ghs button.secondary.empty,.theme-ghs a.reg-link.secondary.empty,.theme-ghs .register-nav-link.secondary.empty{border-color:#68a51c;color:#68a51c;background-color:#fff}.theme-george button.secondary.empty,.theme-george a.reg-link.secondary.empty,.theme-george .register-nav-link.secondary.empty{border-color:#191919;color:#fff}.theme-ghs button.destructive,.theme-ghs a.reg-link.destructive,.theme-ghs .register-nav-link.destructive{color:#d43030;background-color:#fff;border-color:#d43030}button.destructive:hover,a.reg-link.destructive:hover,.register-nav-link.destructive:hover{box-shadow:0 2px 10px 0 rgba(61,61,61,.1)}.theme-ghs button.destructive:hover,.theme-ghs a.reg-link.destructive:hover,.theme-ghs .register-nav-link.destructive:hover{border-width:2px}.theme-ghs button.basic,.theme-ghs a.reg-link.basic,.theme-ghs .register-nav-link.basic{background-color:#fff;border-color:#7ebd2f;color:#68a51c}.theme-george button.basic,.theme-george a.reg-link.basic,.theme-george .register-nav-link.basic{background-image:linear-gradient(to bottom,#fdfdfd,#d8d8d8);border-color:#b9b9b9;color:#191919}button.basic:hover,a.reg-link.basic:hover,.register-nav-link.basic:hover{box-shadow:0 2px 10px 0 rgba(61,61,61,.1)}.theme-ghs button.basic:hover,.theme-ghs a.reg-link.basic:hover,.theme-ghs .register-nav-link.basic:hover{border-width:2px}.theme-george button.basic:hover,.theme-george a.reg-link.basic:hover,.theme-george .register-nav-link.basic:hover{background-image:linear-gradient(to bottom,#d8d8d8,white);border-width:1px}button.full,a.reg-link.full,.register-nav-link.full{width:100%;border-radius:4px;margin:0}@media (min-width:320px){button.half,a.reg-link.half,.register-nav-link.half{width:100%}}@media (min-width:768px){button.half,a.reg-link.half,.register-nav-link.half{width:40%;float:left;margin-right:8px}}button.center,a.reg-link.center,.register-nav-link.center{margin:20px auto;display:block;float:none}@keyframes circle1s{0%{left:0;top:0;width:15px;height:15px;z-index:100}25%{left:26.25px;top:0;width:15px;height:15px;z-index:100}50%{left:52.5px;top:0;width:15px;height:15px;z-index:100}75%{left:52.5px;top:7.5px;width:0;height:0;z-index:0}76%{left:7.5px;top:7.5px;width:0;height:0;z-index:0}100%{left:0;top:0;width:15px;height:15px;z-index:100}}@keyframes circle2s{0%{left:26.25px;top:0;width:15px;height:15px;z-index:100}25%{left:52.5px;top:0;width:15px;height:15px;z-index:100}50%{left:52.5px;top:7.5px;width:0;height:0;z-index:0}51%{left:7.5px;top:7.5px;width:0;height:0;z-index:0}75%{left:0;top:0;width:15px;height:15px;z-index:100}100%{left:26.25px;top:0;width:15px;height:15px;z-index:100}}@keyframes circle3s{0%{left:52.5px;top:0;width:15px;height:15px;z-index:100}25%{left:52.5px;top:7.5px;width:0;height:0;z-index:0}26%{left:7.5px;top:7.5px;width:0;height:0;z-index:0}50%{left:0;top:0;width:15px;height:15px;z-index:100}75%{left:26.25px;top:0;width:15px;height:15px;z-index:100}100%{left:52.5px;top:0;width:15px;height:15px;z-index:100}}@keyframes circle4s{0%{left:7.5px;top:7.5px;width:0;height:0;z-index:0}25%{left:0;top:0;width:15px;height:15px;z-index:100}50%{left:26.25px;top:0;width:15px;height:15px;z-index:100}75%{left:52.5px;top:0;width:15px;height:15px;z-index:100}100%{left:52.5px;top:7.5px;width:0;height:0;z-index:0}}button #loading,a.reg-link #loading,.register-nav-link #loading{display:block;margin:0 auto;width:67.5px;height:15px;position:relative}.theme-ghs button #loading,.theme-ghs a.reg-link #loading,.theme-ghs .register-nav-link #loading{margin-top:0}.theme-george button #loading,.theme-george a.reg-link #loading,.theme-george .register-nav-link #loading{margin-top:-2px}button #loading .animation,a.reg-link #loading .animation,.register-nav-link #loading .animation{position:relative}button #loading .animation .circle,a.reg-link #loading .animation .circle,.register-nav-link #loading .animation .circle{position:absolute;border-radius:100%;border:1px solid #fff;animation-name:circle1s;animation-duration:1.1s;animation-iteration-count:infinite}.theme-ghs button #loading .animation .circle,.theme-ghs a.reg-link #loading .animation .circle,.theme-ghs .register-nav-link #loading .animation .circle{background-color:#fff;border-color:#fff}.theme-george button #loading .animation .circle,.theme-george a.reg-link #loading .animation .circle,.theme-george .register-nav-link #loading .animation .circle{background-color:#d9dddf;border-color:#d9dddf}button #loading .animation .circle:nth-child(2),a.reg-link #loading .animation .circle:nth-child(2),.register-nav-link #loading .animation .circle:nth-child(2){animation-name:circle2s}.theme-ghs button #loading .animation .circle:nth-child(2),.theme-ghs a.reg-link #loading .animation .circle:nth-child(2),.theme-ghs .register-nav-link #loading .animation .circle:nth-child(2){background-color:#fff;border-color:#fff}.theme-george button #loading .animation .circle:nth-child(2),.theme-george a.reg-link #loading .animation .circle:nth-child(2),.theme-george .register-nav-link #loading .animation .circle:nth-child(2){background-color:#b8babd;border-color:#b8babd}button #loading .animation .circle:nth-child(3),a.reg-link #loading .animation .circle:nth-child(3),.register-nav-link #loading .animation .circle:nth-child(3){animation-name:circle3s}.theme-ghs button #loading .animation .circle:nth-child(3),.theme-ghs a.reg-link #loading .animation .circle:nth-child(3),.theme-ghs .register-nav-link #loading .animation .circle:nth-child(3){background-color:#fff;border-color:#fff}.theme-george button #loading .animation .circle:nth-child(3),.theme-george a.reg-link #loading .animation .circle:nth-child(3),.theme-george .register-nav-link #loading .animation .circle:nth-child(3){background-color:#a3a3ab;border-color:#a3a3ab}button #loading .animation .circle:nth-child(4),a.reg-link #loading .animation .circle:nth-child(4),.register-nav-link #loading .animation .circle:nth-child(4){animation-name:circle4s}.theme-ghs button #loading .animation .circle:nth-child(4),.theme-ghs a.reg-link #loading .animation .circle:nth-child(4),.theme-ghs .register-nav-link #loading .animation .circle:nth-child(4){background-color:#fff;border-color:#fff}.theme-george button #loading .animation .circle:nth-child(4),.theme-george a.reg-link #loading .animation .circle:nth-child(4),.theme-george .register-nav-link #loading .animation .circle:nth-child(4){background-color:#efefef;border-color:#efefef}a.reg-link,.register-nav-link{display:block;-webkit-font-smoothing:auto}.saved{width:88px;margin:0 auto;display:block}.saved .tick{float:left;margin-right:8px;margin-top:-2px}.saved .saved-text{width:auto;float:left}label.check-box{clear:both;margin-bottom:16px;display:block;font-size:.875em;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;position:relative;padding-left:34px;line-height:1.5;color:#3d3d3d}.theme-ghs label.check-box{font-family:SourceSansProRegular,sans-serif}.theme-george label.check-box{font-family:LatoRegular,sans-serif}label.check-box .checkmark{position:absolute;top:0;left:0;border:1px solid;background-color:#fff}.theme-ghs label.check-box .checkmark{width:20px;height:20px;border-radius:2px;border-color:#68a51c}.theme-george label.check-box .checkmark{width:24px;height:24px;border-radius:5px;border-color:#ccc}label.check-box .checkmark.error{border-color:#d43030}label.check-box .checkmark:after{content:"";position:absolute;display:none;border:solid #fff;-webkit-transform:rotate(45deg);-ms-transform:rotate(45deg);transform:rotate(45deg)}.theme-ghs label.check-box .checkmark:after{left:6px;top:0;width:6px;height:14px;border-width:0 2px 2px 0}.theme-george label.check-box .checkmark:after{left:7px;top:3px;width:8px;height:13px;border-width:0 3px 3px 0}.theme-ghs label.check-box .checkmark.disabled{background-color:#fff;border:1px solid #7ebd2f;opacity:.5;cursor:not-allowed!important}.theme-george label.check-box .checkmark.disabled{background-color:#d8d8d8;border:1px solid #d8d8d8;opacity:.5;cursor:not-allowed!important}label.check-box input[type=checkbox]{position:absolute;opacity:0;height:20px;width:20px;margin:0;left:0}label.check-box input[type=checkbox]:focus~.checkmark,label.check-box input[type=checkbox]:active~.checkmark{outline:#75aee8 auto 5px}.theme-ghs label.check-box input[type=checkbox]:checked~.checkmark{background-color:#68a51c}.theme-george label.check-box input[type=checkbox]:checked~.checkmark{background-color:#191919}label.check-box input[type=checkbox]:checked~.checkmark:after{display:block}label.check-box-tc{padding-left:54px}label.check-box-tc .checkmark{width:40px;height:40px;border-radius:8px;border:solid 2px #767676;background-color:#fff}label.check-box-tc .checkmark:after{left:14px;top:6px;width:12.3px;height:17.8px;border:solid #68a51c;border-width:0 3px 3px 0}label.check-box-tc input[type=checkbox]:checked~.checkmark{background-color:#fff}.input-error{margin-bottom:8px;margin-top:4px}.input-box{position:relative}.input-box__wrapper{position:relative;overflow-wrap:break-word}.theme-ghs .input-box__wrapper.read-only{font-size:1em;font-family:SourceSansProRegular,sans-serif;color:#3d3d3d}.theme-george .input-box__wrapper.read-only{font-size:.875em;font-family:LatoRegular,sans-serif;color:#191919}.input-box.half{width:calc(50% - 4px);margin-right:4px;float:left}.input-box.half.last{margin-left:4px;margin-right:0}.input-box.custom-icon{position:relative}.input-box.custom-icon:before{content:"";position:absolute}.input-box.custom-icon input{padding-left:38px}.input-box label{padding-bottom:4px;display:block}.theme-ghs .input-box label{font-family:SourceSansProRegular,sans-serif;color:#3d3d3d;font-size:.875em;padding:0 0 4px}.theme-george .input-box label{font-family:LatoRegular,sans-serif;color:#191919;font-size:14px;padding:0 0 8px}.input-box .alert{position:absolute;top:3px;right:12px}.theme-ghs .input-box .alert{display:block}.theme-george .input-box .alert{display:none}.input-box input{width:100%;border-radius:4px;border:1px solid #ccc;clear:both;transition:border 200ms ease,box-shadow 200ms ease}.theme-ghs .input-box input{height:40px;font-size:1em;padding:12px;margin-bottom:12px;font-family:SourceSansProRegular,sans-serif;color:#3d3d3d}.theme-george .input-box input{height:42px;font-size:.875em;padding:14px 12px;margin-bottom:16px;font-family:LatoRegular,sans-serif;color:#191919}.input-box input:hover{box-shadow:0 2px 10px 0 rgba(61,61,61,.1);cursor:default}.input-box input:focus{border-color:#767676}.input-box input::placeholder{color:#767676}.input-box input.half{width:40%;float:left;margin-right:10}.input-box input.error{margin-bottom:0}.theme-ghs .input-box input.error{border:2px solid #d43030}.theme-george .input-box input.error{border:1px solid #da291c}.input-box input.read-only{border:0;outline:0;padding:0;margin:0;height:20px;pointer-events:none;background:0;box-shadow:none}.input-box.check-box-grouped label.check-box{width:20px;padding-left:unset;float:left}.input-box.check-box-grouped input[type=checkbox]{width:20px;top:0;left:0;height:20px;padding:0;margin:0}.input-box.check-box-grouped label{line-height:20px;padding-left:34px}.input-box.check-box-grouped .input-error{margin-top:-10px}.theme-ghs .input-error{margin:4px 0 16px}.theme-george .input-error{margin:8px 0 16px}.input-warning{padding:8px;margin:-1px 0 10px 16px;background-color:#ebf1f5;color:#000;font-size:.75em;font-family:SourceSansProRegular,sans-serif;z-index:100;display:inline-block;position:relative}.input-warning .triangle{position:absolute;top:-20px}.input-label{padding-bottom:4px;display:block}.theme-ghs .input-label{font-family:SourceSansProRegular,sans-serif;color:#3d3d3d;font-size:.875em}.theme-george .input-label{font-family:LatoRegular,sans-serif;color:#191919;font-size:14px}label.radio{margin-bottom:16px;display:block;font-size:.875em;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;position:relative;padding-left:28px;padding-right:16px;line-height:1.5}.theme-ghs label.radio{font-family:SourceSansProRegular,sans-serif}.theme-george label.radio{font-family:LatoRegular,sans-serif}label.radio .checkmark{position:absolute;top:0;left:0;height:20px;width:20px;background-color:#fff;border-radius:90px;transition:all 250ms ease}.theme-ghs label.radio .checkmark{border:1px solid #68a51c}.theme-george label.radio .checkmark{border:1px solid #191919}label.radio .checkmark:hover{border-width:2px}label.radio .checkmark.error{border-color:#d43030}label.radio.property-type-radio{float:left}label.radio input[type=radio]{position:absolute;opacity:0}label.radio input[type=radio]:focus~.checkmark,label.radio input[type=radio]:active~.checkmark{outline:#75aee8 auto 5px}.theme-ghs label.radio input[type=radio]:checked~.checkmark{border:1px solid #68a51c}.theme-george label.radio input[type=radio]:checked~.checkmark{border:1px solid #191919}label.radio input[type=radio]:checked~.checkmark:before{content:"";display:inline-block;width:18px;height:18px;position:relative;border-radius:100%;vertical-align:top;text-align:center;cursor:pointer;transition:all 250ms ease}.theme-ghs label.radio input[type=radio]:checked~.checkmark:before{background-color:#68a51c}.theme-george label.radio input[type=radio]:checked~.checkmark:before{background-color:#fff}.theme-ghs label.radio input[type=radio]:checked~.checkmark:before{box-shadow:inset 0 0 0 4px #fff}.theme-george label.radio input[type=radio]:checked~.checkmark:before{box-shadow:inset 0 0 0 5px #191919}@keyframes circle1{0%{left:0;top:0;width:40px;height:40px;z-index:100}25%{left:70px;top:0;width:40px;height:40px;z-index:100}50%{left:140px;top:0;width:40px;height:40px;z-index:100}75%{left:140px;top:20px;width:0;height:0;z-index:0}76%{left:20px;top:20px;width:0;height:0;z-index:0}100%{left:0;top:0;width:40px;height:40px;z-index:100}}@keyframes circle2{0%{left:70px;top:0;width:40px;height:40px;z-index:100}25%{left:140px;top:0;width:40px;height:40px;z-index:100}50%{left:140px;top:20px;width:0;height:0;z-index:0}51%{left:20px;top:20px;width:0;height:0;z-index:0}75%{left:0;top:0;width:40px;height:40px;z-index:100}100%{left:70px;top:0;width:40px;height:40px;z-index:100}}@keyframes circle3{0%{left:140px;top:0;width:40px;height:40px;z-index:100}25%{left:140px;top:20px;width:0;height:0;z-index:0}26%{left:20px;top:20px;width:0;height:0;z-index:0}50%{left:0;top:0;width:40px;height:40px;z-index:100}75%{left:70px;top:0;width:40px;height:40px;z-index:100}100%{left:140px;top:0;width:40px;height:40px;z-index:100}}@keyframes circle4{0%{left:20px;top:20px;width:0;height:0;z-index:0}25%{left:0;top:0;width:40px;height:40px;z-index:100}50%{left:70px;top:0;width:40px;height:40px;z-index:100}75%{left:140px;top:0;width:40px;height:40px;z-index:100}100%{left:140px;top:20px;width:0;height:0;z-index:0}}#loading{display:block;margin:calc(50vh - 171px) auto;width:180px;height:40px}#loading .animation{position:relative}#loading .animation .circle{position:absolute;background-color:#d9dddf;border-radius:100%;border:1px solid #d9dddf;animation-name:circle1;animation-duration:1.1s;animation-iteration-count:infinite}#loading .animation .circle:nth-child(2){background-color:#b8babd;border-color:#b8babd;animation-name:circle2}#loading .animation .circle:nth-child(3){background-color:#a3a3ab;border-color:#a3a3ab;animation-name:circle3}#loading .animation .circle:nth-child(4){background-color:#efefef;border-color:#efefef;animation-name:circle4}.asda-backdrop{bottom:0;left:0;position:fixed;right:0;top:0;z-index:10}.asda-backdrop[data-color=transparent]{background-color:transparent}.asda-backdrop[data-color=black]{background-color:rgba(68,70,82,.88)}.dropdown-list-item.selected,.dropdown-list-item:hover{color:#fff;background-color:#767676}.dropdown-wrapper-single{user-select:none;position:relative;width:265px}.dropdown-wrapper-single .dropdown-header{border:1px solid #ccc}.dropdown-wrapper-single .dropdown-header .dropdown-header-name{font-weight:400}.dropdown-wrapper-single .dropdown-list{border:1px solid #ccc;border-top:0}.dropdown-wrapper{font-family:SourceSansProRegular,sans-serif;font-size:24px;font-weight:600;font-style:normal;font-stretch:normal;line-height:normal;letter-spacing:.3px;float:left;margin-right:16px}.dropdown-wrapper .dropdown-header{display:flex;align-items:center;justify-content:space-between;width:176px;height:78px;border:1px solid #767676;cursor:default;position:relative;background-color:#fff;padding:20px}.dropdown-wrapper .dropdown-header.active{border-bottom:0px;top:1px;z-index:11}.dropdown-wrapper .list-wrapper .dropdown-list{z-index:10;position:absolute;width:560px;height:636px;border:1px solid #767676;background-color:#fff;padding:20px 75px 20px 20px;overflow-y:scroll;color:#767676}.dropdown-wrapper .list-wrapper .dropdown-list .dropdown-list-item{width:100%;padding:8px 0px;line-height:1.54px;cursor:default;display:flex;align-items:center;justify-content:space-between;text-overflow:ellipsis}.dropdown-wrapper .list-wrapper .dropdown-list .dropdown-list-item:nth-child(5){padding-bottom:18px;border-bottom:2px solid #979797}.dropdown-wrapper .list-wrapper .dropdown-list .dropdown-list-item .country-name{width:420px;text-align:left;padding-left:15px}.dropdown-wrapper .list-wrapper .dropdown-list .dropdown-list-item .country-code{width:80px;background-color:none}.dropdown-wrapper .dropdown-list::-webkit-scrollbar-thumb{background-image:var(--sf-img-15);height:109px;background-repeat:no-repeat}.dropdown-wrapper .dropdown-list-item.selected,.dropdown-wrapper .dropdown-list-item:hover{color:#fff;background-color:#767676}div[class^=country-flag-]{width:34px;height:20px;background-size:34px 20px}div[class^=country-flag-] img{width:34px;height:20px;background-repeat:no-repeat;background-size:34px 20px}.modal-wrapper{position:absolute;z-index:1000;top:206px;width:802px;height:300px;border-radius:14px;box-shadow:2px 3px 8px 3px rgba(0,0,0,.1);border:solid 1px #ccc;background-color:#fff;padding:30px;font-family:SourceSansProRegular,sans-serif;font-weight:600;font-style:normal;font-stretch:normal;line-height:normal;color:#3d3d3d}.phone-input{width:100%}@media (min-width:320px){.phone-input{display:flex;flex-direction:column}}@media (min-width:481px){.phone-input{flex-direction:row;height:52px}}.phone-input .dropdown-wrapper{float:left;height:42px;margin-right:8px;color:#3d3d3d;font-family:SourceSansProRegular,sans-serif;transition:all 200ms ease;font-size:1em;user-select:none}@media (min-width:320px){.phone-input .dropdown-wrapper{width:100%}}@media (min-width:481px){.phone-input .dropdown-wrapper{width:32%}}.phone-input .dropdown-wrapper .dropdown-header{cursor:pointer;width:100%;height:40px;padding:0 8px;border-radius:4px;border:1px solid #ccc;transition:box-shadow 200ms ease}.phone-input .dropdown-wrapper .dropdown-header.active{height:41px;border-top-color:#767676;border-right-color:#767676;border-left-color:#767676;border-bottom-left-radius:0;border-bottom-right-radius:0;top:0}.phone-input .dropdown-wrapper .dropdown-header.active .country-code{transition:none;margin-top:-1px}.phone-input .dropdown-wrapper .dropdown-header.active div[class^=country-flag-] img{transition:none;margin-top:-1px}.phone-input .dropdown-wrapper .dropdown-header.active .down-arrow{transform:rotate(0.5turn);padding-bottom:4px}.phone-input .dropdown-wrapper .dropdown-header:hover{box-shadow:0 2px 10px 0 rgba(61,61,61,.1)}.phone-input .dropdown-wrapper .dropdown-header .country-code{font-size:16px;padding-left:4px}@media (min-width:320px){.phone-input .dropdown-wrapper .dropdown-header .country-code{flex:1;padding-left:15px;display:flex;align-items:center}}.phone-input .dropdown-wrapper .dropdown-header .country-code .country-name{width:100px;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:215px}@media (min-width:481px){.phone-input .dropdown-wrapper .dropdown-header .country-code .country-name{display:none}}.phone-input .dropdown-wrapper .dropdown-header .down-arrow{width:20px;padding-top:4px}.phone-input .dropdown-wrapper .dropdown-header div[class^=country-flag-] img{width:32px;background-repeat:no-repeat;background-size:34px 20px}.phone-input .dropdown-wrapper .list-wrapper{position:relative}.phone-input .dropdown-wrapper .list-wrapper .dropdown-list{margin-top:-1px;z-index:10;position:absolute;height:200px;border:1px solid #767676;border-radius:0 0 4px 4px;background-color:#fff;padding:0;overflow-y:scroll;color:#767676}@media (min-width:320px){.phone-input .dropdown-wrapper .list-wrapper .dropdown-list{width:100%}}@media (min-width:481px){.phone-input .dropdown-wrapper .list-wrapper .dropdown-list{width:372px}}.phone-input .dropdown-wrapper .list-wrapper .dropdown-list::-webkit-scrollbar *{background:transparent}.phone-input .dropdown-wrapper .list-wrapper .dropdown-list .dropdown-list-item{color:#191919;height:40px;font-size:1em}.phone-input .dropdown-wrapper .list-wrapper .dropdown-list .dropdown-list-item:hover{background-color:#f6f6f6}@media (min-width:320px){.phone-input .dropdown-wrapper .list-wrapper .dropdown-list .dropdown-list-item{padding:12px 8px 12px 8px;height:48px}}@media (min-width:481px){.phone-input .dropdown-wrapper .list-wrapper .dropdown-list .dropdown-list-item{padding:12px 80px 12px 8px;height:40px}}.phone-input .dropdown-wrapper .list-wrapper .dropdown-list .dropdown-list-item .country-name{line-height:initial}@media (min-width:320px){.phone-input .dropdown-wrapper .list-wrapper .dropdown-list .dropdown-list-item .country-name{width:calc(100% - 32px)}}@media (min-width:481px){.phone-input .dropdown-wrapper .list-wrapper .dropdown-list .dropdown-list-item .country-name{width:420px}}.phone-input .dropdown-wrapper .list-wrapper .dropdown-list .dropdown-list-item .country-code{text-align:right}@media (min-width:320px){.phone-input .dropdown-wrapper .list-wrapper .dropdown-list .dropdown-list-item .country-code{width:60px}}@media (min-width:481px){.phone-input .dropdown-wrapper .list-wrapper .dropdown-list .dropdown-list-item .country-code{width:80px}}@media (min-width:320px){.phone-input .input-box{width:100%;margin-top:12px}}@media (min-width:481px){.phone-input .input-box{width:calc(68% - 8px);margin-top:0px}}.divider{width:100%;border-top:1px solid #cbcbcb;margin-bottom:24px}.react-datepicker__year-read-view--down-arrow,.react-datepicker__month-read-view--down-arrow,.react-datepicker__month-year-read-view--down-arrow,.react-datepicker-popper[data-placement^=top] .react-datepicker__triangle,.react-datepicker-popper[data-placement^=bottom] .react-datepicker__triangle{margin-left:-8px;position:absolute}.react-datepicker__year-read-view--down-arrow,.react-datepicker__month-read-view--down-arrow,.react-datepicker__month-year-read-view--down-arrow,.react-datepicker-popper[data-placement^=top] .react-datepicker__triangle,.react-datepicker-popper[data-placement^=bottom] .react-datepicker__triangle,.react-datepicker__year-read-view--down-arrow::before,.react-datepicker__month-read-view--down-arrow::before,.react-datepicker__month-year-read-view--down-arrow::before,.react-datepicker-popper[data-placement^=top] .react-datepicker__triangle::before,.react-datepicker-popper[data-placement^=bottom] .react-datepicker__triangle::before{box-sizing:content-box;position:absolute;border:8px solid transparent;height:0;width:1px}.react-datepicker__year-read-view--down-arrow::before,.react-datepicker__month-read-view--down-arrow::before,.react-datepicker__month-year-read-view--down-arrow::before,.react-datepicker-popper[data-placement^=top] .react-datepicker__triangle::before,.react-datepicker-popper[data-placement^=bottom] .react-datepicker__triangle::before{content:"";z-index:-1;border-width:8px;left:-8px}.theme-ghs .react-datepicker__year-read-view--down-arrow::before,.theme-ghs .react-datepicker__month-read-view--down-arrow::before,.theme-ghs .react-datepicker__month-year-read-view--down-arrow::before,.theme-ghs .react-datepicker-popper[data-placement^=top] .react-datepicker__triangle::before,.react-datepicker-popper[data-placement^=top] .theme-ghs .react-datepicker__triangle::before,.theme-ghs .react-datepicker-popper[data-placement^=bottom] .react-datepicker__triangle::before,.react-datepicker-popper[data-placement^=bottom] .theme-ghs .react-datepicker__triangle::before{border-bottom-color:#538316}.theme-george .react-datepicker__year-read-view--down-arrow::before,.theme-george .react-datepicker__month-read-view--down-arrow::before,.theme-george .react-datepicker__month-year-read-view--down-arrow::before,.theme-george .react-datepicker-popper[data-placement^=top] .react-datepicker__triangle::before,.react-datepicker-popper[data-placement^=top] .theme-george .react-datepicker__triangle::before,.theme-george .react-datepicker-popper[data-placement^=bottom] .react-datepicker__triangle::before,.react-datepicker-popper[data-placement^=bottom] .theme-george .react-datepicker__triangle::before{border-bottom-color:#020202}.react-datepicker-popper[data-placement^=bottom] .react-datepicker__triangle{top:0;margin-top:-8px}.react-datepicker-popper[data-placement^=bottom] .react-datepicker__triangle,.react-datepicker-popper[data-placement^=bottom] .react-datepicker__triangle::before{border-top:0}.theme-ghs .react-datepicker-popper[data-placement^=bottom] .react-datepicker__triangle,.react-datepicker-popper[data-placement^=bottom] .theme-ghs .react-datepicker__triangle,.theme-ghs .react-datepicker-popper[data-placement^=bottom] .react-datepicker__triangle::before,.react-datepicker-popper[data-placement^=bottom] .theme-ghs .react-datepicker__triangle::before{border-bottom-color:#538316}.theme-george .react-datepicker-popper[data-placement^=bottom] .react-datepicker__triangle,.react-datepicker-popper[data-placement^=bottom] .theme-george .react-datepicker__triangle,.theme-george .react-datepicker-popper[data-placement^=bottom] .react-datepicker__triangle::before,.react-datepicker-popper[data-placement^=bottom] .theme-george .react-datepicker__triangle::before{border-bottom-color:#020202}.react-datepicker-popper[data-placement^=bottom] .react-datepicker__triangle::before{top:-1px}.theme-ghs .react-datepicker-popper[data-placement^=bottom] .react-datepicker__triangle::before,.react-datepicker-popper[data-placement^=bottom] .theme-ghs .react-datepicker__triangle::before{border-bottom-color:#538316}.theme-george .react-datepicker-popper[data-placement^=bottom] .react-datepicker__triangle::before,.react-datepicker-popper[data-placement^=bottom] .theme-george .react-datepicker__triangle::before{border-bottom-color:#020202}.react-datepicker__year-read-view--down-arrow,.react-datepicker__month-read-view--down-arrow,.react-datepicker__month-year-read-view--down-arrow,.react-datepicker-popper[data-placement^=top] .react-datepicker__triangle{bottom:0;margin-bottom:-8px}.react-datepicker__year-read-view--down-arrow,.react-datepicker__month-read-view--down-arrow,.react-datepicker__month-year-read-view--down-arrow,.react-datepicker-popper[data-placement^=top] .react-datepicker__triangle,.react-datepicker__year-read-view--down-arrow::before,.react-datepicker__month-read-view--down-arrow::before,.react-datepicker__month-year-read-view--down-arrow::before,.react-datepicker-popper[data-placement^=top] .react-datepicker__triangle::before{border-bottom:0}.theme-ghs .react-datepicker__year-read-view--down-arrow,.theme-ghs .react-datepicker__month-read-view--down-arrow,.theme-ghs .react-datepicker__month-year-read-view--down-arrow,.theme-ghs .react-datepicker-popper[data-placement^=top] .react-datepicker__triangle,.react-datepicker-popper[data-placement^=top] .theme-ghs .react-datepicker__triangle,.theme-ghs .react-datepicker__year-read-view--down-arrow::before,.theme-ghs .react-datepicker__month-read-view--down-arrow::before,.theme-ghs .react-datepicker__month-year-read-view--down-arrow::before,.theme-ghs .react-datepicker-popper[data-placement^=top] .react-datepicker__triangle::before,.react-datepicker-popper[data-placement^=top] .theme-ghs .react-datepicker__triangle::before{border-top-color:#538316}.theme-george .react-datepicker__year-read-view--down-arrow,.theme-george .react-datepicker__month-read-view--down-arrow,.theme-george .react-datepicker__month-year-read-view--down-arrow,.theme-george .react-datepicker-popper[data-placement^=top] .react-datepicker__triangle,.react-datepicker-popper[data-placement^=top] .theme-george .react-datepicker__triangle,.theme-george .react-datepicker__year-read-view--down-arrow::before,.theme-george .react-datepicker__month-read-view--down-arrow::before,.theme-george .react-datepicker__month-year-read-view--down-arrow::before,.theme-george .react-datepicker-popper[data-placement^=top] .react-datepicker__triangle::before,.react-datepicker-popper[data-placement^=top] .theme-george .react-datepicker__triangle::before{border-top-color:#020202}.react-datepicker__year-read-view--down-arrow::before,.react-datepicker__month-read-view--down-arrow::before,.react-datepicker__month-year-read-view--down-arrow::before,.react-datepicker-popper[data-placement^=top] .react-datepicker__triangle::before{bottom:-1px}.theme-ghs .react-datepicker__year-read-view--down-arrow::before,.theme-ghs .react-datepicker__month-read-view--down-arrow::before,.theme-ghs .react-datepicker__month-year-read-view--down-arrow::before,.theme-ghs .react-datepicker-popper[data-placement^=top] .react-datepicker__triangle::before,.react-datepicker-popper[data-placement^=top] .theme-ghs .react-datepicker__triangle::before{border-top-color:#538316}.theme-george .react-datepicker__year-read-view--down-arrow::before,.theme-george .react-datepicker__month-read-view--down-arrow::before,.theme-george .react-datepicker__month-year-read-view--down-arrow::before,.theme-george .react-datepicker-popper[data-placement^=top] .react-datepicker__triangle::before,.react-datepicker-popper[data-placement^=top] .theme-george .react-datepicker__triangle::before{border-top-color:#020202}.react-datepicker-wrapper{display:inline-block;padding:0;border:0;width:100%}.react-datepicker_input-default{font-size:13px;border-radius:4px;line-height:16px;padding:6px 10px 5px;width:100%;height:40px}.theme-ghs .react-datepicker_input-default{border:1px solid #538316}.theme-george .react-datepicker_input-default{border:1px solid #020202}.react-datepicker_input-default:hover{box-shadow:0px 2px 10px 0px rgba(0,0,0,.1)}.theme-ghs .react-datepicker_input-default:focus{border-color:#37570f}.theme-george .react-datepicker_input-default:focus{border-color:#000}.react-datepicker{font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:.8rem;background-color:#fff;border-radius:.3rem;display:inline-block;position:relative}.theme-ghs .react-datepicker{color:#000;border:1px solid #538316}.theme-george .react-datepicker{color:#000;border:1px solid #020202}.react-datepicker--time-only .react-datepicker__triangle{left:35px}.react-datepicker--time-only .react-datepicker__time-container{border-left:0}.react-datepicker--time-only .react-datepicker__time{border-radius:.3rem}.react-datepicker--time-only .react-datepicker__time-box{border-radius:.3rem}.react-datepicker__triangle{position:absolute;left:50px}.react-datepicker-popper{z-index:1}.react-datepicker-popper[data-placement=bottom-end] .react-datepicker__triangle,.react-datepicker-popper[data-placement=top-end] .react-datepicker__triangle{left:auto;right:50px}.react-datepicker-popper[data-placement^=top]{margin-bottom:10px}.react-datepicker-popper[data-placement^=right]{margin-left:8px}.react-datepicker-popper[data-placement^=right] .react-datepicker__triangle{left:auto;right:42px}.react-datepicker-popper[data-placement^=left]{margin-right:8px}.react-datepicker-popper[data-placement^=left] .react-datepicker__triangle{left:42px;right:auto}.react-datepicker__header{text-align:center;border-top-left-radius:.3rem;border-top-right-radius:.3rem;padding-top:8px;position:relative}.theme-ghs .react-datepicker__header{background-color:#538316;border-bottom:1px solid #538316}.theme-george .react-datepicker__header{background-color:#020202;border-bottom:1px solid #020202}.react-datepicker__header--time{padding-bottom:8px;padding-left:5px;padding-right:5px}.react-datepicker__year-dropdown-container--select,.react-datepicker__month-dropdown-container--select,.react-datepicker__month-year-dropdown-container--select,.react-datepicker__year-dropdown-container--scroll,.react-datepicker__month-dropdown-container--scroll,.react-datepicker__month-year-dropdown-container--scroll{display:inline-block;margin:0 2px}.react-datepicker__current-month,.react-datepicker-time__header,.react-datepicker-year-header{margin-top:0;font-weight:bold;font-size:.944rem;margin-bottom:8px}.theme-ghs .react-datepicker__current-month,.theme-ghs .react-datepicker-time__header,.theme-ghs .react-datepicker-year-header{color:#fff}.theme-george .react-datepicker__current-month,.theme-george .react-datepicker-time__header,.theme-george .react-datepicker-year-header{color:#fff}.react-datepicker-time__header{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.react-datepicker__navigation{background:0;line-height:1.7rem;text-align:center;cursor:pointer;position:absolute;top:10px;width:0;padding:0;border:.45rem solid transparent;z-index:1;height:10px;width:10px;text-indent:-999em;overflow:hidden}.react-datepicker__navigation--previous{left:10px;padding:0!important;height:0!important}.theme-ghs .react-datepicker__navigation--previous{border-right-color:#ccc}.theme-george .react-datepicker__navigation--previous{border-right-color:#ccc}.theme-ghs .react-datepicker__navigation--previous:hover{border-right-color:#b3b3b3}.theme-george .react-datepicker__navigation--previous:hover{border-right-color:#b3b3b3}.react-datepicker__navigation--previous--disabled,.react-datepicker__navigation--previous--disabled:hover{cursor:default}.theme-ghs .react-datepicker__navigation--previous--disabled,.theme-ghs .react-datepicker__navigation--previous--disabled:hover{border-right-color:#e6e6e6}.theme-george .react-datepicker__navigation--previous--disabled,.theme-george .react-datepicker__navigation--previous--disabled:hover{border-right-color:#e6e6e6}.react-datepicker__navigation--next{right:10px;padding:0!important;height:0!important}.theme-ghs .react-datepicker__navigation--next{border-left-color:#ccc}.theme-george .react-datepicker__navigation--next{border-left-color:#ccc}.react-datepicker__navigation--next--with-time:not(.react-datepicker__navigation--next--with-today-button){right:80px}.theme-ghs .react-datepicker__navigation--next:hover{border-left-color:#b3b3b3}.theme-george .react-datepicker__navigation--next:hover{border-left-color:#b3b3b3}.react-datepicker__navigation--next--disabled,.react-datepicker__navigation--next--disabled:hover{cursor:default}.theme-ghs .react-datepicker__navigation--next--disabled,.theme-ghs .react-datepicker__navigation--next--disabled:hover{border-left-color:#e6e6e6}.theme-george .react-datepicker__navigation--next--disabled,.theme-george .react-datepicker__navigation--next--disabled:hover{border-left-color:#e6e6e6}.react-datepicker__navigation--years{position:relative;top:0;display:block;margin-left:auto;margin-right:auto}.react-datepicker__navigation--years-previous{top:4px}.theme-ghs .react-datepicker__navigation--years-previous{border-top-color:#ccc}.theme-george .react-datepicker__navigation--years-previous{border-top-color:#ccc}.theme-ghs .react-datepicker__navigation--years-previous:hover{border-top-color:#b3b3b3}.theme-george .react-datepicker__navigation--years-previous:hover{border-top-color:#b3b3b3}.react-datepicker__navigation--years-upcoming{top:-4px}.theme-ghs .react-datepicker__navigation--years-upcoming{border-bottom-color:#ccc}.theme-george .react-datepicker__navigation--years-upcoming{border-bottom-color:#ccc}.theme-ghs .react-datepicker__navigation--years-upcoming:hover{border-bottom-color:#b3b3b3}.theme-george .react-datepicker__navigation--years-upcoming:hover{border-bottom-color:#b3b3b3}.react-datepicker__month-container{float:left}.react-datepicker__month{margin:.4rem;text-align:center}.react-datepicker__month .react-datepicker__month-text,.react-datepicker__month .react-datepicker__quarter-text{display:inline-block;width:4rem;margin:2px}.react-datepicker__input-time-container{clear:both;width:100%;float:left;margin:5px 0 10px 15px;text-align:left}.react-datepicker__input-time-container .react-datepicker-time__caption{display:inline-block}.react-datepicker__input-time-container .react-datepicker-time__input-container{display:inline-block}.react-datepicker__input-time-container .react-datepicker-time__input-container .react-datepicker-time__input{display:inline-block;margin-left:10px}.react-datepicker__input-time-container .react-datepicker-time__input-container .react-datepicker-time__input input{width:85px}.react-datepicker__input-time-container .react-datepicker-time__input-container .react-datepicker-time__input input[type=time]::-webkit-inner-spin-button,.react-datepicker__input-time-container .react-datepicker-time__input-container .react-datepicker-time__input input[type=time]::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}.react-datepicker__input-time-container .react-datepicker-time__input-container .react-datepicker-time__input input[type=time]{-moz-appearance:textfield}.react-datepicker__input-time-container .react-datepicker-time__input-container .react-datepicker-time__delimiter{margin-left:5px;display:inline-block}.react-datepicker__time-container{float:right;width:85px}.theme-ghs .react-datepicker__time-container{border-left:1px solid #538316}.theme-george .react-datepicker__time-container{border-left:1px solid #020202}.react-datepicker__time-container--with-today-button{display:inline;border:1px solid #aeaeae;border-radius:.3rem;position:absolute;right:-72px;top:0}.react-datepicker__time-container .react-datepicker__time{position:relative;background:#fff}.react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box{width:85px;overflow-x:hidden;margin:0 auto;text-align:center}.react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box ul.react-datepicker__time-list{list-style:none;margin:0;height:calc(195px + (1.7rem / 2));overflow-y:scroll;padding-right:0px;padding-left:0px;width:100%;box-sizing:content-box}.react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box ul.react-datepicker__time-list li.react-datepicker__time-list-item{height:30px;padding:5px 10px;white-space:nowrap}.react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box ul.react-datepicker__time-list li.react-datepicker__time-list-item:hover{cursor:pointer}.theme-ghs .react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box ul.react-datepicker__time-list li.react-datepicker__time-list-item:hover{background-color:#538316}.theme-george .react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box ul.react-datepicker__time-list li.react-datepicker__time-list-item:hover{background-color:#020202}.react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box ul.react-datepicker__time-list li.react-datepicker__time-list-item--selected{color:#fff;font-weight:bold}.theme-ghs .react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box ul.react-datepicker__time-list li.react-datepicker__time-list-item--selected{background-color:#538316}.theme-ghs .react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box ul.react-datepicker__time-list li.react-datepicker__time-list-item--selected:hover{background-color:#538316}.theme-george .react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box ul.react-datepicker__time-list li.react-datepicker__time-list-item--selected{background-color:#020202}.theme-george .react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box ul.react-datepicker__time-list li.react-datepicker__time-list-item--selected:hover{background-color:#020202}.theme-ghs .react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box ul.react-datepicker__time-list li.react-datepicker__time-list-item--disabled{color:#ccc}.theme-george .react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box ul.react-datepicker__time-list li.react-datepicker__time-list-item--disabled{color:#ccc}.react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box ul.react-datepicker__time-list li.react-datepicker__time-list-item--disabled:hover{cursor:default;background-color:transparent}.react-datepicker__week-number{display:inline-block;width:1.7rem;line-height:1.7rem;text-align:center;margin:.166rem}.theme-ghs .react-datepicker__week-number{color:#ccc}.theme-george .react-datepicker__week-number{color:#ccc}.react-datepicker__week-number.react-datepicker__week-number--clickable{cursor:pointer}.react-datepicker__week-number.react-datepicker__week-number--clickable:hover{border-radius:.3rem}.theme-ghs .react-datepicker__week-number.react-datepicker__week-number--clickable:hover{background-color:#538316}.theme-george .react-datepicker__week-number.react-datepicker__week-number--clickable:hover{background-color:#020202}.react-datepicker__day-names,.react-datepicker__week{white-space:nowrap}.react-datepicker__day,.react-datepicker__day-name,.react-datepicker__time-name{display:inline-block;width:1.7rem;line-height:1.7rem;text-align:center;margin:.166rem}.theme-ghs .react-datepicker__day,.theme-ghs .react-datepicker__day-name,.theme-ghs .react-datepicker__time-name{color:#000}.theme-george .react-datepicker__day,.theme-george .react-datepicker__day-name,.theme-george .react-datepicker__time-name{color:#000}.theme-ghs .react-datepicker__day-name{color:#fff}.theme-george .react-datepicker__day-name{color:#fff}.react-datepicker__month--selected,.react-datepicker__month--in-selecting-range,.react-datepicker__month--in-range,.react-datepicker__quarter--selected,.react-datepicker__quarter--in-selecting-range,.react-datepicker__quarter--in-range{border-radius:.3rem;color:#fff}.theme-ghs .react-datepicker__month--selected,.theme-ghs .react-datepicker__month--in-selecting-range,.theme-ghs .react-datepicker__month--in-range,.theme-ghs .react-datepicker__quarter--selected,.theme-ghs .react-datepicker__quarter--in-selecting-range,.theme-ghs .react-datepicker__quarter--in-range{background-color:#538316}.theme-ghs .react-datepicker__month--selected:hover,.theme-ghs .react-datepicker__month--in-selecting-range:hover,.theme-ghs .react-datepicker__month--in-range:hover,.theme-ghs .react-datepicker__quarter--selected:hover,.theme-ghs .react-datepicker__quarter--in-selecting-range:hover,.theme-ghs .react-datepicker__quarter--in-range:hover{background-color:#456d12}.theme-george .react-datepicker__month--selected,.theme-george .react-datepicker__month--in-selecting-range,.theme-george .react-datepicker__month--in-range,.theme-george .react-datepicker__quarter--selected,.theme-george .react-datepicker__quarter--in-selecting-range,.theme-george .react-datepicker__quarter--in-range{background-color:#020202}.theme-george .react-datepicker__month--selected:hover,.theme-george .react-datepicker__month--in-selecting-range:hover,.theme-george .react-datepicker__month--in-range:hover,.theme-george .react-datepicker__quarter--selected:hover,.theme-george .react-datepicker__quarter--in-selecting-range:hover,.theme-george .react-datepicker__quarter--in-range:hover{background-color:#000}.react-datepicker__month--disabled,.react-datepicker__quarter--disabled{pointer-events:none}.theme-ghs .react-datepicker__month--disabled,.theme-ghs .react-datepicker__quarter--disabled{color:#ccc}.theme-george .react-datepicker__month--disabled,.theme-george .react-datepicker__quarter--disabled{color:#ccc}.react-datepicker__month--disabled:hover,.react-datepicker__quarter--disabled:hover{cursor:default;background-color:transparent}.react-datepicker__day,.react-datepicker__day-name,.react-datepicker__month-text,.react-datepicker__quarter-text{cursor:pointer}.react-datepicker__day:hover,.react-datepicker__day-name:hover,.react-datepicker__month-text:hover,.react-datepicker__quarter-text:hover{border-radius:.3rem}.theme-ghs .react-datepicker__day:hover,.theme-ghs .react-datepicker__day-name:hover,.theme-ghs .react-datepicker__month-text:hover,.theme-ghs .react-datepicker__quarter-text:hover{background-color:#68a51c;color:#fff}.theme-george .react-datepicker__day:hover,.theme-george .react-datepicker__day-name:hover,.theme-george .react-datepicker__month-text:hover,.theme-george .react-datepicker__quarter-text:hover{background-color:#3d3d3d;color:#fff}.react-datepicker__day--today,.react-datepicker__month-text--today,.react-datepicker__quarter-text--today{font-weight:bold}.react-datepicker__day--highlighted,.react-datepicker__month-text--highlighted,.react-datepicker__quarter-text--highlighted{border-radius:.3rem;color:#fff}.theme-ghs .react-datepicker__day--highlighted,.theme-ghs .react-datepicker__month-text--highlighted,.theme-ghs .react-datepicker__quarter-text--highlighted{background-color:#68a51c}.theme-ghs .react-datepicker__day--highlighted:hover,.theme-ghs .react-datepicker__month-text--highlighted:hover,.theme-ghs .react-datepicker__quarter-text--highlighted:hover{background-color:#5a8f18}.theme-george .react-datepicker__day--highlighted,.theme-george .react-datepicker__month-text--highlighted,.theme-george .react-datepicker__quarter-text--highlighted{background-color:#3d3d3d}.theme-george .react-datepicker__day--highlighted:hover,.theme-george .react-datepicker__month-text--highlighted:hover,.theme-george .react-datepicker__quarter-text--highlighted:hover{background-color:#303030}.react-datepicker__day--highlighted-custom-1,.react-datepicker__month-text--highlighted-custom-1,.react-datepicker__quarter-text--highlighted-custom-1{color:#f0f}.react-datepicker__day--highlighted-custom-2,.react-datepicker__month-text--highlighted-custom-2,.react-datepicker__quarter-text--highlighted-custom-2{color:green}.theme-ghs .react-datepicker__day--in-selecting-range:not(.react-datepicker__day--in-range,.react-datepicker__month-text--in-range,.react-datepicker__quarter-text--in-range),.theme-ghs .react-datepicker__month-text--in-selecting-range:not(.react-datepicker__day--in-range,.react-datepicker__month-text--in-range,.react-datepicker__quarter-text--in-range),.theme-ghs .react-datepicker__quarter-text--in-selecting-range:not(.react-datepicker__day--in-range,.react-datepicker__month-text--in-range,.react-datepicker__quarter-text--in-range){background-color:rgba(83,131,22,.5)}.theme-george .react-datepicker__day--in-selecting-range:not(.react-datepicker__day--in-range,.react-datepicker__month-text--in-range,.react-datepicker__quarter-text--in-range),.theme-george .react-datepicker__month-text--in-selecting-range:not(.react-datepicker__day--in-range,.react-datepicker__month-text--in-range,.react-datepicker__quarter-text--in-range),.theme-george .react-datepicker__quarter-text--in-selecting-range:not(.react-datepicker__day--in-range,.react-datepicker__month-text--in-range,.react-datepicker__quarter-text--in-range){background-color:rgba(2,2,2,.5)}.theme-ghs .react-datepicker__month--selecting-range .react-datepicker__day--in-range:not(.react-datepicker__day--in-selecting-range,.react-datepicker__month-text--in-selecting-range,.react-datepicker__quarter-text--in-selecting-range),.theme-ghs .react-datepicker__month--selecting-range .react-datepicker__month-text--in-range:not(.react-datepicker__day--in-selecting-range,.react-datepicker__month-text--in-selecting-range,.react-datepicker__quarter-text--in-selecting-range),.theme-ghs .react-datepicker__month--selecting-range .react-datepicker__quarter-text--in-range:not(.react-datepicker__day--in-selecting-range,.react-datepicker__month-text--in-selecting-range,.react-datepicker__quarter-text--in-selecting-range){background-color:#538316;color:#000}.theme-george .react-datepicker__month--selecting-range .react-datepicker__day--in-range:not(.react-datepicker__day--in-selecting-range,.react-datepicker__month-text--in-selecting-range,.react-datepicker__quarter-text--in-selecting-range),.theme-george .react-datepicker__month--selecting-range .react-datepicker__month-text--in-range:not(.react-datepicker__day--in-selecting-range,.react-datepicker__month-text--in-selecting-range,.react-datepicker__quarter-text--in-selecting-range),.theme-george .react-datepicker__month--selecting-range .react-datepicker__quarter-text--in-range:not(.react-datepicker__day--in-selecting-range,.react-datepicker__month-text--in-selecting-range,.react-datepicker__quarter-text--in-selecting-range){background-color:#020202;color:#000}.react-datepicker__day--selected,.react-datepicker__day--in-selecting-range,.react-datepicker__day--in-range,.react-datepicker__month-text--selected,.react-datepicker__month-text--in-selecting-range,.react-datepicker__month-text--in-range,.react-datepicker__quarter-text--selected,.react-datepicker__quarter-text--in-selecting-range,.react-datepicker__quarter-text--in-range{border-radius:.3rem}.theme-ghs .react-datepicker__day--selected,.theme-ghs .react-datepicker__day--in-selecting-range,.theme-ghs .react-datepicker__day--in-range,.theme-ghs .react-datepicker__month-text--selected,.theme-ghs .react-datepicker__month-text--in-selecting-range,.theme-ghs .react-datepicker__month-text--in-range,.theme-ghs .react-datepicker__quarter-text--selected,.theme-ghs .react-datepicker__quarter-text--in-selecting-range,.theme-ghs .react-datepicker__quarter-text--in-range{color:#fff;background-color:#538316}.theme-ghs .react-datepicker__day--selected:hover,.theme-ghs .react-datepicker__day--in-selecting-range:hover,.theme-ghs .react-datepicker__day--in-range:hover,.theme-ghs .react-datepicker__month-text--selected:hover,.theme-ghs .react-datepicker__month-text--in-selecting-range:hover,.theme-ghs .react-datepicker__month-text--in-range:hover,.theme-ghs .react-datepicker__quarter-text--selected:hover,.theme-ghs .react-datepicker__quarter-text--in-selecting-range:hover,.theme-ghs .react-datepicker__quarter-text--in-range:hover{background-color:#456d12}.theme-george .react-datepicker__day--selected,.theme-george .react-datepicker__day--in-selecting-range,.theme-george .react-datepicker__day--in-range,.theme-george .react-datepicker__month-text--selected,.theme-george .react-datepicker__month-text--in-selecting-range,.theme-george .react-datepicker__month-text--in-range,.theme-george .react-datepicker__quarter-text--selected,.theme-george .react-datepicker__quarter-text--in-selecting-range,.theme-george .react-datepicker__quarter-text--in-range{color:#fff;background-color:#020202}.theme-george .react-datepicker__day--selected:hover,.theme-george .react-datepicker__day--in-selecting-range:hover,.theme-george .react-datepicker__day--in-range:hover,.theme-george .react-datepicker__month-text--selected:hover,.theme-george .react-datepicker__month-text--in-selecting-range:hover,.theme-george .react-datepicker__month-text--in-range:hover,.theme-george .react-datepicker__quarter-text--selected:hover,.theme-george .react-datepicker__quarter-text--in-selecting-range:hover,.theme-george .react-datepicker__quarter-text--in-range:hover{background-color:#000}.react-datepicker__day--keyboard-selected,.react-datepicker__month-text--keyboard-selected,.react-datepicker__quarter-text--keyboard-selected{border-radius:.3rem}.theme-ghs .react-datepicker__day--keyboard-selected,.theme-ghs .react-datepicker__month-text--keyboard-selected,.theme-ghs .react-datepicker__quarter-text--keyboard-selected{color:#fff;background-color:#6faf1d}.theme-ghs .react-datepicker__day--keyboard-selected:hover,.theme-ghs .react-datepicker__month-text--keyboard-selected:hover,.theme-ghs .react-datepicker__quarter-text--keyboard-selected:hover{background-color:#456d12}.theme-george .react-datepicker__day--keyboard-selected,.theme-george .react-datepicker__month-text--keyboard-selected,.theme-george .react-datepicker__quarter-text--keyboard-selected{color:#fff;background-color:#1c1c1c}.theme-george .react-datepicker__day--keyboard-selected:hover,.theme-george .react-datepicker__month-text--keyboard-selected:hover,.theme-george .react-datepicker__quarter-text--keyboard-selected:hover{background-color:#000}.react-datepicker__day--disabled,.react-datepicker__month-text--disabled,.react-datepicker__quarter-text--disabled{cursor:default}.theme-ghs .react-datepicker__day--disabled,.theme-ghs .react-datepicker__month-text--disabled,.theme-ghs .react-datepicker__quarter-text--disabled{color:#ccc}.theme-george .react-datepicker__day--disabled,.theme-george .react-datepicker__month-text--disabled,.theme-george .react-datepicker__quarter-text--disabled{color:#ccc}.react-datepicker__day--disabled:hover,.react-datepicker__month-text--disabled:hover,.react-datepicker__quarter-text--disabled:hover{background-color:transparent}.theme-ghs .react-datepicker__month-text.react-datepicker__month--selected:hover,.theme-ghs .react-datepicker__month-text.react-datepicker__month--in-range:hover,.theme-ghs .react-datepicker__month-text.react-datepicker__quarter--selected:hover,.theme-ghs .react-datepicker__month-text.react-datepicker__quarter--in-range:hover,.theme-ghs .react-datepicker__quarter-text.react-datepicker__month--selected:hover,.theme-ghs .react-datepicker__quarter-text.react-datepicker__month--in-range:hover,.theme-ghs .react-datepicker__quarter-text.react-datepicker__quarter--selected:hover,.theme-ghs .react-datepicker__quarter-text.react-datepicker__quarter--in-range:hover{background-color:#538316}.theme-george .react-datepicker__month-text.react-datepicker__month--selected:hover,.theme-george .react-datepicker__month-text.react-datepicker__month--in-range:hover,.theme-george .react-datepicker__month-text.react-datepicker__quarter--selected:hover,.theme-george .react-datepicker__month-text.react-datepicker__quarter--in-range:hover,.theme-george .react-datepicker__quarter-text.react-datepicker__month--selected:hover,.theme-george .react-datepicker__quarter-text.react-datepicker__month--in-range:hover,.theme-george .react-datepicker__quarter-text.react-datepicker__quarter--selected:hover,.theme-george .react-datepicker__quarter-text.react-datepicker__quarter--in-range:hover{background-color:#020202}.theme-ghs .react-datepicker__month-text:hover,.theme-ghs .react-datepicker__quarter-text:hover{background-color:#538316}.theme-george .react-datepicker__month-text:hover,.theme-george .react-datepicker__quarter-text:hover{background-color:#020202}.react-datepicker__input-container{position:relative;display:inline-block;width:100%}.react-datepicker__year-read-view,.react-datepicker__month-read-view,.react-datepicker__month-year-read-view{border:1px solid transparent;border-radius:.3rem}.react-datepicker__year-read-view:hover,.react-datepicker__month-read-view:hover,.react-datepicker__month-year-read-view:hover{cursor:pointer}.theme-ghs .react-datepicker__year-read-view:hover .react-datepicker__year-read-view--down-arrow,.theme-ghs .react-datepicker__year-read-view:hover .react-datepicker__month-read-view--down-arrow,.theme-ghs .react-datepicker__month-read-view:hover .react-datepicker__year-read-view--down-arrow,.theme-ghs .react-datepicker__month-read-view:hover .react-datepicker__month-read-view--down-arrow,.theme-ghs .react-datepicker__month-year-read-view:hover .react-datepicker__year-read-view--down-arrow,.theme-ghs .react-datepicker__month-year-read-view:hover .react-datepicker__month-read-view--down-arrow{border-top-color:#b3b3b3}.theme-george .react-datepicker__year-read-view:hover .react-datepicker__year-read-view--down-arrow,.theme-george .react-datepicker__year-read-view:hover .react-datepicker__month-read-view--down-arrow,.theme-george .react-datepicker__month-read-view:hover .react-datepicker__year-read-view--down-arrow,.theme-george .react-datepicker__month-read-view:hover .react-datepicker__month-read-view--down-arrow,.theme-george .react-datepicker__month-year-read-view:hover .react-datepicker__year-read-view--down-arrow,.theme-george .react-datepicker__month-year-read-view:hover .react-datepicker__month-read-view--down-arrow{border-top-color:#b3b3b3}.react-datepicker__year-read-view--down-arrow,.react-datepicker__month-read-view--down-arrow,.react-datepicker__month-year-read-view--down-arrow{float:right;margin-left:20px;top:8px;position:relative;border-width:.45rem}.theme-ghs .react-datepicker__year-read-view--down-arrow,.theme-ghs .react-datepicker__month-read-view--down-arrow,.theme-ghs .react-datepicker__month-year-read-view--down-arrow{border-top-color:#ccc}.theme-george .react-datepicker__year-read-view--down-arrow,.theme-george .react-datepicker__month-read-view--down-arrow,.theme-george .react-datepicker__month-year-read-view--down-arrow{border-top-color:#ccc}.react-datepicker__year-dropdown,.react-datepicker__month-dropdown,.react-datepicker__month-year-dropdown{position:absolute;width:50%;left:25%;top:30px;z-index:1;text-align:center;border-radius:.3rem}.theme-ghs .react-datepicker__year-dropdown,.theme-ghs .react-datepicker__month-dropdown,.theme-ghs .react-datepicker__month-year-dropdown{background-color:#538316;border:1px solid #538316}.theme-george .react-datepicker__year-dropdown,.theme-george .react-datepicker__month-dropdown,.theme-george .react-datepicker__month-year-dropdown{background-color:#020202;border:1px solid #020202}.react-datepicker__year-dropdown:hover,.react-datepicker__month-dropdown:hover,.react-datepicker__month-year-dropdown:hover{cursor:pointer}.react-datepicker__year-dropdown--scrollable,.react-datepicker__month-dropdown--scrollable,.react-datepicker__month-year-dropdown--scrollable{height:150px;overflow-y:scroll}.react-datepicker__year-option,.react-datepicker__month-option,.react-datepicker__month-year-option{line-height:20px;width:100%;display:block;margin-left:auto;margin-right:auto}.react-datepicker__year-option:first-of-type,.react-datepicker__month-option:first-of-type,.react-datepicker__month-year-option:first-of-type{border-top-left-radius:.3rem;border-top-right-radius:.3rem}.react-datepicker__year-option:last-of-type,.react-datepicker__month-option:last-of-type,.react-datepicker__month-year-option:last-of-type{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;border-bottom-left-radius:.3rem;border-bottom-right-radius:.3rem}.theme-ghs .react-datepicker__year-option:hover,.theme-ghs .react-datepicker__month-option:hover,.theme-ghs .react-datepicker__month-year-option:hover{background-color:#ccc}.theme-ghs .react-datepicker__year-option:hover .react-datepicker__navigation--years-upcoming,.theme-ghs .react-datepicker__month-option:hover .react-datepicker__navigation--years-upcoming,.theme-ghs .react-datepicker__month-year-option:hover .react-datepicker__navigation--years-upcoming{border-bottom-color:#b3b3b3}.theme-ghs .react-datepicker__year-option:hover .react-datepicker__navigation--years-previous,.theme-ghs .react-datepicker__month-option:hover .react-datepicker__navigation--years-previous,.theme-ghs .react-datepicker__month-year-option:hover .react-datepicker__navigation--years-previous{border-top-color:#b3b3b3}.theme-george .react-datepicker__year-option:hover,.theme-george .react-datepicker__month-option:hover,.theme-george .react-datepicker__month-year-option:hover{background-color:#ccc}.theme-george .react-datepicker__year-option:hover .react-datepicker__navigation--years-upcoming,.theme-george .react-datepicker__month-option:hover .react-datepicker__navigation--years-upcoming,.theme-george .react-datepicker__month-year-option:hover .react-datepicker__navigation--years-upcoming{border-bottom-color:#b3b3b3}.theme-george .react-datepicker__year-option:hover .react-datepicker__navigation--years-previous,.theme-george .react-datepicker__month-option:hover .react-datepicker__navigation--years-previous,.theme-george .react-datepicker__month-year-option:hover .react-datepicker__navigation--years-previous{border-top-color:#b3b3b3}.react-datepicker__year-option--selected,.react-datepicker__month-option--selected,.react-datepicker__month-year-option--selected{position:absolute;left:15px}.react-datepicker__close-icon{cursor:pointer;background-color:transparent;border:0;outline:0;padding:0px 6px 0px 0px;position:absolute;top:0;right:0;height:100%;display:table-cell;vertical-align:middle}.react-datepicker__close-icon::after{cursor:pointer;color:#fff;border-radius:50%;height:16px;width:16px;padding:2px;font-size:12px;line-height:1;text-align:center;display:table-cell;vertical-align:middle;content:"\D7"}.theme-ghs .react-datepicker__close-icon::after{background-color:#538316}.theme-george .react-datepicker__close-icon::after{background-color:#020202}.react-datepicker__today-button{cursor:pointer;text-align:center;font-weight:bold;padding:5px 0;clear:left}.theme-ghs .react-datepicker__today-button{background:#538316;border-top:1px solid #538316}.theme-george .react-datepicker__today-button{background:#020202;border-top:1px solid #020202}.react-datepicker__portal{position:fixed;width:100vw;height:100vh;background-color:rgba(0,0,0,.8);left:0;top:0;justify-content:center;align-items:center;display:flex;z-index:2147483647}.react-datepicker__portal .react-datepicker__day-name,.react-datepicker__portal .react-datepicker__day,.react-datepicker__portal .react-datepicker__time-name{width:3rem;line-height:3rem}@media (max-width:400px),(max-height:550px){.react-datepicker__portal .react-datepicker__day-name,.react-datepicker__portal .react-datepicker__day,.react-datepicker__portal .react-datepicker__time-name{width:2rem;line-height:2rem}}.react-datepicker__portal .react-datepicker__current-month,.react-datepicker__portal .react-datepicker-time__header{font-size:1.44rem}.react-datepicker__portal .react-datepicker__navigation{border:.81rem solid transparent}.theme-ghs .react-datepicker__portal .react-datepicker__navigation--previous{border-right-color:#ccc}.theme-ghs .react-datepicker__portal .react-datepicker__navigation--previous:hover{border-right-color:#b3b3b3}.theme-george .react-datepicker__portal .react-datepicker__navigation--previous{border-right-color:#ccc}.theme-george .react-datepicker__portal .react-datepicker__navigation--previous:hover{border-right-color:#b3b3b3}.react-datepicker__portal .react-datepicker__navigation--previous--disabled,.react-datepicker__portal .react-datepicker__navigation--previous--disabled:hover{cursor:default}.theme-ghs .react-datepicker__portal .react-datepicker__navigation--previous--disabled,.theme-ghs .react-datepicker__portal .react-datepicker__navigation--previous--disabled:hover{border-right-color:#e6e6e6}.theme-george .react-datepicker__portal .react-datepicker__navigation--previous--disabled,.theme-george .react-datepicker__portal .react-datepicker__navigation--previous--disabled:hover{border-right-color:#e6e6e6}.theme-ghs .react-datepicker__portal .react-datepicker__navigation--next{border-left-color:#ccc}.theme-ghs .react-datepicker__portal .react-datepicker__navigation--next:hover{border-left-color:#b3b3b3}.theme-george .react-datepicker__portal .react-datepicker__navigation--next{border-left-color:#ccc}.theme-george .react-datepicker__portal .react-datepicker__navigation--next:hover{border-left-color:#b3b3b3}.react-datepicker__portal .react-datepicker__navigation--next--disabled,.react-datepicker__portal .react-datepicker__navigation--next--disabled:hover{cursor:default}.theme-ghs .react-datepicker__portal .react-datepicker__navigation--next--disabled,.theme-ghs .react-datepicker__portal .react-datepicker__navigation--next--disabled:hover{border-left-color:#e6e6e6}.theme-george .react-datepicker__portal .react-datepicker__navigation--next--disabled,.theme-george .react-datepicker__portal .react-datepicker__navigation--next--disabled:hover{border-left-color:#e6e6e6}.datepicker-wrapper{position:relative}.datepicker-wrapper--error input{border:2px solid #d43030}.theme-ghs .what-can-i-expect,.theme-george .what-can-i-expect{font-family:SourceSansProRegular,sans-serif;padding:2px 0 28px 0}.theme-ghs .what-can-i-expect__button,.theme-george .what-can-i-expect__button{font-size:14px;padding:0px;text-transform:inherit}.theme-ghs .what-can-i-expect__chevron,.theme-george .what-can-i-expect__chevron{padding-left:6px}.theme-ghs .what-can-i-expect__chevron--flipped,.theme-george .what-can-i-expect__chevron--flipped{transform:rotate(180deg)}.theme-ghs .what-can-i-expect__description,.theme-george .what-can-i-expect__description{color:#3d3d3d;font-size:14px;line-height:1.5;letter-spacing:.2px;list-style:disc;padding-left:20px;padding-top:14px}.theme-ghs .what-can-i-expect__description li,.theme-george .what-can-i-expect__description li{padding-bottom:16px}.theme-ghs .what-can-i-expect__description li:last-child,.theme-george .what-can-i-expect__description li:last-child{padding-bottom:0px}.theme-george .what-can-i-expect__button{text-decoration:underline}</style><style>html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video{margin:0;padding:0;border:0;font-size:100%;font:inherit;vertical-align:baseline}article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section{display:block}body{line-height:1}ol,ul{list-style:none}blockquote,q{quotes:none}blockquote:before,blockquote:after,q:before,q:after{content:"";content:none}table{border-collapse:collapse;border-spacing:0}html{box-sizing:border-box}*,*:before,*:after{box-sizing:inherit}article *,article *:before,article *:after{box-sizing:border-box}@font-face{font-family:"SourceSansProBold";src:url(resources/sample/7.woff2) format("woff2")}@font-face{font-family:"SourceSansProSemiBold";src:url(resources/sample/8.woff2) format("woff2")}@font-face{font-family:"SourceSansProRegular";src:url(resources/sample/9.woff2) format("woff2")}h1,h2,h3,h4,p,a{color:#191919;padding-bottom:8px;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}h1{font-size:1.25em;font-weight:700;color:#191919;padding-bottom:16px;line-height:1.25}.theme-ghs h1{font-family:SourceSansProBold,sans-serif}.theme-george h1{font-family:LatoBold,sans-serif}h2{font-size:1.125em;font-weight:800}.theme-ghs h2{font-family:SourceSansProBold,sans-serif}.theme-george h2{font-family:LatoBold,sans-serif}h3{font-size:1em;font-weight:600}.theme-ghs h3{font-family:SourceSansProSemiBold,sans-serif}.theme-george h3{font-family:LatoRegular,sans-serif}h4{font-size:.875em;font-weight:400}.theme-ghs h4{font-family:SourceSansProRegular,sans-serif}.theme-george h4{font-family:LatoRegular,sans-serif}p{color:off-black;padding-bottom:16px;font-size:.875em;letter-spacing:.2px;line-height:1.3}.theme-ghs p{font-family:SourceSansProRegular,sans-serif}.theme-george p{font-family:LatoRegular,sans-serif}a{margin-bottom:16px;padding-bottom:0;font-weight:500;font-size:1em}.theme-ghs a{color:#0073b1;text-decoration:none;font-family:SourceSansProBold,sans-serif}.theme-george a{color:#191919;text-decoration:underline;font-family:LatoRegular,sans-serif}.button-as-link{display:inline;background-color:transparent;border:0;padding:0;width:auto!important;height:auto!important;color:#0073b1;margin-left:0;padding-bottom:16px;font-weight:500;text-decoration:none}.theme-ghs .button-as-link{font-size:.875em}.theme-george .button-as-link{font-size:12px}.theme-ghs .button-as-link{color:#0073b1}.theme-george .button-as-link{color:#191919}.short-password-error{bottom:62px}.blank-password-error{bottom:50px}a:hover{cursor:pointer}.theme-ghs strong{font-family:SourceSansProBold,sans-serif}.theme-george strong{font-family:LatoBold,sans-serif}.input-error{font-family:SourceSansProRegular,sans-serif;font-size:.75em;color:#d43030;line-height:1.125em}.theme-ghs .input-error{font-family:SourceSansProRegular,sans-serif;font-size:.75em;color:#d43030;clear:both}.theme-george .input-error{font-family:LatoBold,sans-serif;font-size:.875em;color:#da291c;clear:both}.theme-ghs .input-error a{font-family:SourceSansProRegular,sans-serif;color:#d43030;text-decoration:underline}.theme-george .input-error a{font-family:LatoBold,sans-serif;color:#da291c;text-decoration:underline}.left{float:left!important}.right{float:right!important;text-align:right!important}.theme-ghs .right{padding-top:3px;line-height:1.5}.theme-george .right{line-height:1.5}.center{text-align:center!important}.red{color:#da0500!important}.orange{color:#fdb45b!important}.pink{color:#ec938e!important}.yellow{color:#fae100!important}.green{color:#68a51c!important}.blue{color:#0073b1!important}.grey{color:grey1!important}.white{color:#fff!important}.black{color:#191919!important}.regular{font-weight:400!important}.bold{font-weight:700!important}html{overflow-x:hidden;width:100vw}.co-account{min-height:calc(100vh - 294px);height:auto;width:calc(100% - 32px);min-height:calc(100vh - 356px);margin:0 16px;position:relative}.theme-ghs .co-account{min-height:calc(100vh - 300px)}.theme-george .co-account{min-height:calc(100vh - 261px)}@media (min-width:481px){.co-account{width:416px;margin:0 auto}.theme-ghs .co-account{min-height:calc(100vh - 284px)}.theme-george .co-account{min-height:calc(100vh - 245px)}}.co-account{height:auto;margin:0 auto 60px}@media (min-width:481px){.co-account{width:406px}}.co-sign-in-details__password-container,.co-sign-in-details__email-container,.co-sign-in-details__phone-container{position:relative;margin-bottom:20px;height:auto;display:block}.co-sign-in-details__password-container{margin-bottom:0}.co-sign-in-details__email-container--read-only .input-box{max-width:86%}.with-whats-this{height:32px}.with-whats-this .whats-this-label{float:left}.address-phone-book .address-section,.address-phone-book .contact-section{margin-bottom:16px}@media (min-width:320px){.address-phone-book .address-section label[for^=account-postcode],.address-phone-book .contact-section label[for^=account-postcode]{height:32px;display:flex;flex-direction:column-reverse}}@media (min-width:481px){.address-phone-book .address-section label[for^=account-postcode],.address-phone-book .contact-section label[for^=account-postcode]{height:auto}}.address-phone-book .address-section .list .list-item,.address-phone-book .contact-section .list .list-item{display:flex}.address-phone-book .address-section .list .list-item .list-item-delete-button img,.address-phone-book .contact-section .list .list-item .list-item-delete-button img{margin-top:0}.permission-centre button.add_phone{margin-bottom:16px}.wallet-card .co-list-card{display:flex;flex-direction:column}.wallet-card .co-list-card .list-item .list-item-radio{padding-right:0}.wallet-card .co-edit-card{margin-top:16px;padding-bottom:16px;border-bottom:1px solid #ccc}.wallet-card .modal{font-weight:400}.wallet-card .co-add-card{padding-top:10px}.wallet-card .co-add-card .custom-icon input{padding-left:50px}.wallet-card .co-add-card .custom-icon:before{left:10px;top:30px;width:30px;height:20px;background-image:var(--sf-img-16);z-index:1}.wallet-card .co-add-card .custom-icon.amex:before{background-image:var(--sf-img-17)}.wallet-card .co-add-card .custom-icon.maestro:before{background-image:var(--sf-img-18)}.wallet-card .co-add-card .custom-icon.mastercard:before{background-image:var(--sf-img-19)}.wallet-card .co-add-card .custom-icon.visa:before{background-image:var(--sf-img-20)}.wallet-card .co-add-card .date-input:before{top:30px;left:10px}.theme-ghs .wallet-card .co-add-card .date-input:before{top:30px}.theme-george .wallet-card .co-add-card .date-input:before{top:35px}@media (min-width:320px){.wallet-card .co-add-card label[for^=account-postcode]{height:32px;display:flex;flex-direction:column-reverse}}@media (min-width:481px){.wallet-card .co-add-card label[for^=account-postcode]{height:auto}}.wallet-card .date-input{position:relative}.wallet-card .date-input input{letter-spacing:.8px}.wallet-card .date-input:before{content:" / ";pointer-events:none;white-space:pre;position:absolute;top:28px;left:13px;display:block;width:40px;height:19px;color:#191919;font-size:1.25em;z-index:1}.co-personal-details__colleague-details{position:relative;margin-bottom:20px;height:auto;display:block}.underlined-link-toggle{border:0;padding:0!important;text-align:left;font-size:14px;width:auto;height:auto;margin:0;text-decoration:underline}.theme-ghs .underlined-link-toggle{color:#0073b1;letter-spacing:normal}.theme-george .underlined-link-toggle{color:#191919;letter-spacing:normal}.co-account .leave-btc .destructive{font-size:19px;margin-top:36px}.co-account .leave-btc .close-icon{position:absolute;top:8px;right:8px;width:auto;height:auto;border:0;padding:0;margin:0;background:transparent}.co-account .leave-btc .modal{width:400px;top:25%}.co-account .leave-btc .modal .modal-title,.co-account .leave-btc .modal .modal-content{border-bottom:0;padding-bottom:0px}.co-account .leave-btc .modal .modal-footer{flex-direction:column-reverse;gap:16px}.co-account .leave-btc .modal .modal-footer button{font-size:19px;font-weight:bold}.co-account .leave-btc .modal .modal-footer button.destructive{margin-top:0px}@media (min-width:481px){.leave-btc .modal{left:calc(50% - 200px)}}.panel{position:relative;border:1px solid #ccc}.theme-ghs .panel{border-radius:4px;margin:0 0 8px}.theme-george .panel{border-radius:0;margin:0 0 16px}.theme-ghs .panel.open{border-color:#ccc;box-shadow:none}.theme-george .panel.open{border-color:#9b9b9b;box-shadow:4px 4px 4px 0 rgba(0,0,0,.08)}.panel__header{width:100%;border:1px solid transparent;text-align:right;line-height:40px;margin:0;overflow:hidden;text-transform:none;letter-spacing:normal}.theme-ghs .panel__header{box-shadow:none;background:#f6f6f6;height:52px;padding:6px 16px;border-radius:4px}.theme-george .panel__header{box-shadow:4px 4px 4px 0 rgba(0,0,0,.08);background:#fff;height:52px;padding:6px 16px;border-radius:0}.panel__header:hover{box-shadow:0 2px 10px 0 rgba(61,61,61,.1)}.panel__header.add-bottom-border{border:1px solid transparent;border-bottom:1px solid transparent}.theme-ghs .panel__header.add-bottom-border{border-bottom:1px solid #ccc;box-shadow:none}.theme-george .panel__header.add-bottom-border{border-bottom:1px solid #9b9b9b;box-shadow:none}.panel__chevron{padding:16px 0}.panel__toggle{color:#0979b2}.theme-ghs .panel__toggle{font-family:SourceSansProRegular,sans-serif;font-size:18px;font-weight:600}.theme-george .panel__toggle{font-family:LatoRegular,sans-serif;font-size:14px;font-weight:500}.panel__title{float:left;text-transform:none}.theme-ghs .panel__title{font-family:SourceSansProRegular,sans-serif;font-size:18px;font-weight:600}.theme-george .panel__title{font-family:LatoRegular,sans-serif;font-size:14px;font-weight:normal}.panel__section-title{font-size:16px;text-align:left;color:#000;padding-bottom:16px}.theme-ghs .panel__section-title{font-family:SourceSansProRegular,sans-serif}.theme-george .panel__section-title{font-family:LatoRegular,sans-serif}.panel__sub-title{font-size:12px;letter-spacing:.2px;text-align:left;color:#000}.panel__anchor{display:block;padding:0;float:right}.panel__main{transition:height .2s cubic-bezier(0.4,0,0.2,1);padding:16px}.panel__main .expanded_container{padding:16px;float:left;width:100%}.panel__footer{height:40px;line-height:40px;padding:0 10px}@media (min-width:320px){.panel__anchor{padding:0 10px 0 25px}}@media (min-width:768px){.panel--no-border{border:0}}.button-container{width:100%;height:44px;display:block;margin-top:4px}@media (min-width:320px){.button-container{height:auto}}@media (min-width:481px){.button-container{height:44px}}.button-container .half{margin:0 2% 0 0}@media (min-width:320px){.button-container .half.half{width:100%;margin:0 0 8px}}@media (min-width:481px){.button-container .half.half{width:calc(50% - 8px);float:right;margin-right:8px}.button-container .half.half:nth-child(1){margin:0 0 0 2%}}h4.address-form-title{padding-bottom:12px}.theme-ghs h4.address-form-title{font-family:SourceSansProSemiBold,sans-serif}.theme-george h4.address-form-title{font-family:LatoRegular,sans-serif}h4.address-form-title button{height:20px;padding:0;font-size:14px;text-transform:none}.link-toggle{border:0;padding:0;text-align:left;font-size:14px;width:auto;height:auto;margin:0}.theme-ghs .link-toggle{font-family:SourceSansProRegular,sans-serif;color:#0073b1;text-decoration:none;letter-spacing:normal}.theme-george .link-toggle{font-family:LatoBold,sans-serif;color:#191919;text-decoration:underline;letter-spacing:normal}.link-toggle:hover{text-decoration:underline}label.radio-group-label{font-size:16px;width:100%;display:block;padding-bottom:12px}.theme-ghs label.radio-group-label{font-family:SourceSansProRegular,sans-serif}.theme-george label.radio-group-label{font-family:LatoRegular,sans-serif}.theme-ghs .find-button{margin-bottom:16px}.theme-george .find-button{margin-bottom:16px}.select-box{position:relative}.select-box label{padding-bottom:4px;display:block}.theme-ghs .select-box label{font-family:SourceSansProRegular,sans-serif;color:#3d3d3d;font-size:.875em;padding:0 0 4px}.theme-george .select-box label{font-family:LatoRegular,sans-serif;color:#191919;font-size:14px;padding:0 0 8px}.select-box .select-arrow{position:absolute;top:36px;right:12px;z-index:-100}.theme-ghs .select-box .select-arrow{top:36px}.theme-george .select-box .select-arrow{top:41px}.theme-ghs .select-box .select-arrow.no-label{top:18px}.theme-george .select-box .select-arrow.no-label{top:18px}.select-box select{width:100%;height:40px;font-size:1em;color:#767676;border-radius:4px;border:1px solid #ccc;clear:both;appearance:none;-webkit-appearance:none;cursor:pointer;background-color:transparent;padding:0 30px 0 12px;margin-bottom:8px;transition:all 200ms ease}.theme-ghs .select-box select{height:40px;font-size:1em;margin-bottom:12px;font-family:SourceSansProRegular,sans-serif;color:#3d3d3d}.theme-george .select-box select{height:42px;font-size:.875em;margin-bottom:16px;font-family:LatoRegular,sans-serif;color:#191919}.select-box select:hover{box-shadow:0 2px 10px 0 rgba(61,61,61,.1)}.select-box select:focus{border-color:#767676}.select-box select.half{width:calc(50% - 10px);float:left;margin-right:8px}.select-box select.error{border:2px solid #d43030;margin-bottom:0}.select-box.half{width:calc(50% - 5px);float:left;margin-right:10px}.select-box.no-gutter{margin:0}.select-error{margin-bottom:8px;margin-top:4px}.text-area label{padding-bottom:4px;display:block}.theme-ghs .text-area label{font-family:SourceSansProRegular,sans-serif;color:#3d3d3d;font-size:.875em}.theme-george .text-area label{font-family:LatoRegular,sans-serif;color:#191919;font-size:14px}.text-area textarea{width:100%;height:110px;font-size:1em;border-radius:4px;border:1px solid #ccc;clear:both;padding:8px;margin-bottom:8px;color:#191919;font-family:SourceSansProRegular,sans-serif;resize:none}.text-area textarea::-webkit-input-placeholder{color:#767676}.text-area textarea:-moz-placeholder{color:#767676}.text-area textarea::-moz-placeholder{color:#767676}.text-area textarea:-ms-input-placeholder{color:#767676}.text-area textarea.half{width:calc(50% - 10px);float:left;margin-right:8px}.text-area textarea.error{border:solid 1px #d43030;margin-bottom:0}.text-area.half{width:calc(50% - 5px);float:left;margin-right:10px}.text-area.no-gutter{margin:0}.input-error{margin-bottom:8px;margin-top:4px}.co-account__title{font-size:22px;padding-bottom:8px}.theme-ghs .co-account__title{font-family:SourceSansProRegular,sans-serif;padding-bottom:8px}.theme-george .co-account__title{font-family:LatoRegular,sans-serif;padding-bottom:32px}.co-account__description{font-size:14px;padding-bottom:16px}.theme-ghs .co-account__description{font-family:SourceSansProRegular,sans-serif}.theme-george .co-account__description{font-family:LatoRegular,sans-serif}a.george-account-link{font-family:LatoRegular,sans-serif;font-size:14px;color:#191919;display:block}a.george-account-link img{margin-right:8px}.edit-link{position:absolute;right:0;top:22px;padding:0;border:0;width:auto;height:auto;margin:0;font-size:18px;background:transparent;transition:all 200ms ease;letter-spacing:normal}.theme-ghs .edit-link{padding:0;height:auto;text-transform:none}.theme-george .edit-link{padding:0;height:auto;text-transform:none}.edit-link:hover{text-decoration:underline}.edit-link.change{top:16px}.theme-ghs .edit-link{font-family:SourceSansProRegular,sans-serif;color:#0073b1;text-decoration:none}.theme-george .edit-link{font-family:LatoBold,sans-serif;color:#191919;text-decoration:underline}.dropdown-list-item.selected,.dropdown-list-item:hover{color:#fff;background-color:#767676}.dropdown-wrapper-single{user-select:none;position:relative;width:265px}.dropdown-wrapper-single .dropdown-header{border:1px solid #ccc}.dropdown-wrapper-single .dropdown-header .dropdown-header-name{font-weight:400}.dropdown-wrapper-single .dropdown-list{border:1px solid #ccc;border-top:0}.dropdown-wrapper{font-family:SourceSansProRegular,sans-serif;font-size:24px;font-weight:600;font-style:normal;font-stretch:normal;line-height:normal;letter-spacing:.3px;float:left;margin-right:16px}.dropdown-wrapper .dropdown-header{display:flex;align-items:center;justify-content:space-between;width:176px;height:78px;border:1px solid #767676;cursor:default;position:relative;background-color:#fff;padding:20px}.dropdown-wrapper .dropdown-header.active{border-bottom:0px;top:1px;z-index:11}.dropdown-wrapper .list-wrapper .dropdown-list{z-index:10;position:absolute;width:560px;height:636px;border:1px solid #767676;background-color:#fff;padding:20px 75px 20px 20px;overflow-y:scroll;color:#767676}.dropdown-wrapper .list-wrapper .dropdown-list .dropdown-list-item{width:100%;padding:8px 0px;line-height:1.54px;cursor:default;display:flex;align-items:center;justify-content:space-between;text-overflow:ellipsis}.dropdown-wrapper .list-wrapper .dropdown-list .dropdown-list-item:nth-child(5){padding-bottom:18px;border-bottom:2px solid #979797}.dropdown-wrapper .list-wrapper .dropdown-list .dropdown-list-item .country-name{width:420px;text-align:left;padding-left:15px}.dropdown-wrapper .list-wrapper .dropdown-list .dropdown-list-item .country-code{width:80px;background-color:none}.dropdown-wrapper .dropdown-list::-webkit-scrollbar-thumb{background-image:var(--sf-img-15);height:109px;background-repeat:no-repeat}.dropdown-wrapper .dropdown-list-item.selected,.dropdown-wrapper .dropdown-list-item:hover{color:#fff;background-color:#767676}div[class^=country-flag-]{width:34px;height:20px;background-size:34px 20px}div[class^=country-flag-] img{width:34px;height:20px;background-repeat:no-repeat;background-size:34px 20px}.list{font-family:SourceSansProRegular,sans-serif;font-size:14px;padding:8px 0 16px}.list-header{width:100%;padding-bottom:16px}.list-header-name{float:left;width:68%;margin-right:5%;font-family:SourceSansProSemiBold,sans-serif}.list-header-default{float:left;color:gray}.list-item{width:100%;height:auto;min-height:48px;border-bottom:1px solid #ccc;padding:0}.list-item.editing{border-bottom:0}.list-item-name{width:68%;text-overflow:ellipsis;float:left;white-space:nowrap;overflow:hidden;-o-text-overflow:ellipsis;text-overflow:ellipsis;margin-right:6%;padding:16px 0 0}.list-item-radio{clear:none;float:left;margin-top:13px}.list-item-edit-button{display:block;width:auto;height:auto;border:0;padding:0;color:#000;font-family:SourceSansProRegular,sans-serif;font-size:14px;clear:both}.theme-ghs .list-item-edit-button{height:auto;padding:0;margin:0 0 16px 42px;font-size:14px;text-transform:none;letter-spacing:normal;text-align:left;cursor:pointer;font-family:SourceSansProRegular,sans-serif;color:#0073b1;text-decoration:none}.theme-george .list-item-edit-button{height:auto;padding:0;margin:0 0 16px 42px;font-size:14px;text-transform:none;letter-spacing:normal;text-align:left;cursor:pointer;font-family:LatoRegular,sans-serif;color:#191919;text-decoration:underline}.list-item-edit-button:hover{text-decoration:underline}.list-item-delete-button{display:block;width:auto;border:0;float:right;color:#000;font-family:SourceSansProRegular,sans-serif;font-size:14px}.theme-ghs .list-item-delete-button{height:auto;padding:0;margin:0;font-size:14px;text-transform:none;letter-spacing:normal;font-family:SourceSansProRegular,sans-serif;text-decoration:underline;cursor:pointer}.theme-george .list-item-delete-button{height:auto;padding:0;margin:0;font-size:14px;text-transform:none;letter-spacing:normal;font-family:LatoRegular,sans-serif;text-decoration:underline;cursor:pointer}.list-item-delete-button:hover{text-decoration:underline}.list-item-delete-button img{margin-top:6px;float:right}.phone-input{width:100%}@media (min-width:320px){.phone-input{display:flex;flex-direction:column}}@media (min-width:481px){.phone-input{flex-direction:row;height:52px}}.phone-input .dropdown-wrapper{float:left;height:42px;margin-right:8px;color:#3d3d3d;font-family:SourceSansProRegular,sans-serif;transition:all 200ms ease;font-size:1em;user-select:none}@media (min-width:320px){.phone-input .dropdown-wrapper{width:100%}}@media (min-width:481px){.phone-input .dropdown-wrapper{width:32%}}.phone-input .dropdown-wrapper .dropdown-header{cursor:pointer;width:100%;height:40px;padding:0 8px;border-radius:4px;border:1px solid #ccc;transition:box-shadow 200ms ease}.phone-input .dropdown-wrapper .dropdown-header.active{height:41px;border-top-color:#767676;border-right-color:#767676;border-left-color:#767676;border-bottom-left-radius:0;border-bottom-right-radius:0;top:0}.phone-input .dropdown-wrapper .dropdown-header.active .country-code{transition:none;margin-top:-1px}.phone-input .dropdown-wrapper .dropdown-header.active div[class^=country-flag-] img{transition:none;margin-top:-1px}.phone-input .dropdown-wrapper .dropdown-header.active .down-arrow{transform:rotate(0.5turn);padding-bottom:4px}.phone-input .dropdown-wrapper .dropdown-header:hover{box-shadow:0 2px 10px 0 rgba(61,61,61,.1)}.phone-input .dropdown-wrapper .dropdown-header .country-code{font-size:16px;padding-left:4px}@media (min-width:320px){.phone-input .dropdown-wrapper .dropdown-header .country-code{flex:1;padding-left:15px;display:flex;align-items:center}}.phone-input .dropdown-wrapper .dropdown-header .country-code .country-name{width:100px;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:215px}@media (min-width:481px){.phone-input .dropdown-wrapper .dropdown-header .country-code .country-name{display:none}}.phone-input .dropdown-wrapper .dropdown-header .down-arrow{width:20px;padding-top:4px}.phone-input .dropdown-wrapper .dropdown-header div[class^=country-flag-] img{width:32px;background-repeat:no-repeat;background-size:34px 20px}.phone-input .dropdown-wrapper .list-wrapper{position:relative}.phone-input .dropdown-wrapper .list-wrapper .dropdown-list{margin-top:-1px;z-index:10;position:absolute;height:200px;border:1px solid #767676;border-radius:0 0 4px 4px;background-color:#fff;padding:0;overflow-y:scroll;color:#767676}@media (min-width:320px){.phone-input .dropdown-wrapper .list-wrapper .dropdown-list{width:100%}}@media (min-width:481px){.phone-input .dropdown-wrapper .list-wrapper .dropdown-list{width:372px}}.phone-input .dropdown-wrapper .list-wrapper .dropdown-list::-webkit-scrollbar *{background:transparent}.phone-input .dropdown-wrapper .list-wrapper .dropdown-list .dropdown-list-item{color:#191919;height:40px;font-size:1em}.phone-input .dropdown-wrapper .list-wrapper .dropdown-list .dropdown-list-item:hover{background-color:#f6f6f6}@media (min-width:320px){.phone-input .dropdown-wrapper .list-wrapper .dropdown-list .dropdown-list-item{padding:12px 8px 12px 8px;height:48px}}@media (min-width:481px){.phone-input .dropdown-wrapper .list-wrapper .dropdown-list .dropdown-list-item{padding:12px 80px 12px 8px;height:40px}}.phone-input .dropdown-wrapper .list-wrapper .dropdown-list .dropdown-list-item .country-name{line-height:initial}@media (min-width:320px){.phone-input .dropdown-wrapper .list-wrapper .dropdown-list .dropdown-list-item .country-name{width:calc(100% - 32px)}}@media (min-width:481px){.phone-input .dropdown-wrapper .list-wrapper .dropdown-list .dropdown-list-item .country-name{width:420px}}.phone-input .dropdown-wrapper .list-wrapper .dropdown-list .dropdown-list-item .country-code{text-align:right}@media (min-width:320px){.phone-input .dropdown-wrapper .list-wrapper .dropdown-list .dropdown-list-item .country-code{width:60px}}@media (min-width:481px){.phone-input .dropdown-wrapper .list-wrapper .dropdown-list .dropdown-list-item .country-code{width:80px}}@media (min-width:320px){.phone-input .input-box{width:100%;margin-top:12px}}@media (min-width:481px){.phone-input .input-box{width:calc(68% - 8px);margin-top:0px}}.card-name{line-height:initial}.card-name .custom-icon:before{content:"";width:30px;margin-right:12px;margin-top:0px;height:20px;float:left;background-image:var(--sf-img-16)}.card-name .custom-icon.amex:before{background-image:var(--sf-img-17)}.card-name .custom-icon.maestro:before{background-image:var(--sf-img-18)}.card-name .custom-icon.mastercard:before{background-image:var(--sf-img-19)}.card-name .custom-icon.visa:before{background-image:var(--sf-img-20)}.card-name .card-info{font-size:12px;clear:both;padding-left:42px;padding-bottom:12px;color:#767676}.card-name .card-info .highlight-red{color:#d43030}.modal{position:absolute;z-index:1000;top:8px;left:8px;display:block;width:536px;max-width:calc(100% - 16px);height:auto;border-radius:8px;box-shadow:2px 3px 8px 3px rgba(0,0,0,.1);border:solid 1px #ccc;background-color:#fff;font-style:normal;font-stretch:normal;line-height:normal;color:#3d3d3d}.theme-ghs .modal{font-family:SourceSansProRegular,sans-serif}.theme-george .modal{font-family:LatoRegular,sans-serif}.modal-title{font-size:22px;padding:16px;border-bottom:solid 1px #ccc}.theme-ghs .modal-title{font-family:SourceSansProSemiBold,sans-serif}.theme-george .modal-title{font-family:LatoRegular,sans-serif}.modal-content{font-size:16px;padding:16px;border-bottom:solid 1px #ccc}.modal-footer{display:flex;justify-content:center;padding:16px}.modal-footer .half{width:45%}.modal-footer .full{width:100%}.input-error.server-error{margin:0 0 12px}.theme-ghs .help-icon{width:auto;height:auto;border:0;padding:0;margin:-7px 0 0 4px;float:left}.theme-george .help-icon{width:auto;height:auto;border:0;padding:0;margin:-7px 0 0 4px;float:left}.bubble{position:absolute;top:100px;left:16px;width:288px;height:auto;background-color:#191919;color:#fff;padding:12px 30px 12px 12px;border-radius:8px;font-size:14px;z-index:1000;font-family:SourceSansProRegular,sans-serif;box-shadow:0 2px 10px 0 rgba(0,0,0,.2);line-height:18px}.bubble .close-icon{position:absolute;top:8px;right:8px;width:auto;height:auto;border:0;padding:0;margin:0;background:transparent}.bubble .triangle{width:0;height:0;border-left:10px solid transparent;border-right:10px solid transparent;border-bottom:10px solid #191919;left:20px;top:-10px;position:absolute}.alert-box{display:flex;flex-direction:row;width:auto;height:auto;border-radius:4px;background-color:rgba(247,204,0,.2);margin-bottom:16px;padding:10px 24px 10px 8px}.alert-box img{width:24px;height:24px;object-fit:contain}.alert-box .alert-message{padding:0;margin-left:8px;font-size:16px;color:#3d3d3d}.divider{width:100%;border-top:1px solid #cbcbcb;margin-bottom:24px}</style><meta http-equiv="origin-trial" content="A3v9QjmVUCOO7YqFMKHP/NKbn6kY1G1pa2S1TfeXJZUD/tysMONTy6lV0Jkou3rrCjSKRGbqTrgTaZkm1XJ7pQUAAACKeyJvcmlnaW4iOiJodHRwczovL2dvb2dsZXRhZ21hbmFnZXIuY29tOjQ0MyIsImZlYXR1cmUiOiJDb252ZXJzaW9uTWVhc3VyZW1lbnQiLCJleHBpcnkiOjE2NDMxNTUxOTksImlzU3ViZG9tYWluIjp0cnVlLCJpc1RoaXJkUGFydHkiOnRydWV9"><style id="onetrust-style">#onetrust-banner-sdk{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}#onetrust-banner-sdk .onetrust-vendors-list-handler{cursor:pointer;color:#1f96db;font-size:inherit;font-weight:bold;text-decoration:none;margin-left:5px}#onetrust-banner-sdk .onetrust-vendors-list-handler:hover{color:#1f96db}#onetrust-banner-sdk:focus{outline:2px solid #000;outline-offset:-2px}#onetrust-banner-sdk a:focus{outline:2px solid #000}#onetrust-banner-sdk #onetrust-accept-btn-handler,#onetrust-banner-sdk #onetrust-reject-all-handler,#onetrust-banner-sdk #onetrust-pc-btn-handler{outline-offset:1px}#onetrust-banner-sdk .ot-close-icon,#onetrust-pc-sdk .ot-close-icon,#ot-sync-ntfy .ot-close-icon{background-image:url("resources/sample/10.svg");background-size:contain;background-repeat:no-repeat;background-position:center;height:12px;width:12px}#onetrust-banner-sdk .powered-by-logo,#onetrust-banner-sdk .ot-pc-footer-logo a,#onetrust-pc-sdk .powered-by-logo,#onetrust-pc-sdk .ot-pc-footer-logo a,#ot-sync-ntfy .powered-by-logo,#ot-sync-ntfy .ot-pc-footer-logo a{background-size:contain;background-repeat:no-repeat;background-position:center;height:25px;width:152px;display:block}#onetrust-banner-sdk h3 *,#onetrust-banner-sdk h4 *,#onetrust-banner-sdk h6 *,#onetrust-banner-sdk button *,#onetrust-banner-sdk a[data-parent-id] *,#onetrust-pc-sdk h3 *,#onetrust-pc-sdk h4 *,#onetrust-pc-sdk h6 *,#onetrust-pc-sdk button *,#onetrust-pc-sdk a[data-parent-id] *,#ot-sync-ntfy h3 *,#ot-sync-ntfy h4 *,#ot-sync-ntfy h6 *,#ot-sync-ntfy button *,#ot-sync-ntfy a[data-parent-id] *{font-size:inherit;font-weight:inherit;color:inherit}#onetrust-banner-sdk .ot-hide,#onetrust-pc-sdk .ot-hide,#ot-sync-ntfy .ot-hide{display:none!important}#onetrust-pc-sdk .ot-sdk-row .ot-sdk-column{padding:0}#onetrust-pc-sdk .ot-sdk-container{padding-right:0}#onetrust-pc-sdk .ot-sdk-row{flex-direction:initial;width:100%}#onetrust-pc-sdk [type="checkbox"]:checked,#onetrust-pc-sdk [type="checkbox"]:not(:checked){pointer-events:initial}#onetrust-pc-sdk [type="checkbox"]:disabled+label::before,#onetrust-pc-sdk [type="checkbox"]:disabled+label:after,#onetrust-pc-sdk [type="checkbox"]:disabled+label{pointer-events:none;opacity:0.7}#onetrust-pc-sdk #vendor-list-content{transform:translate3d(0,0,0)}#onetrust-pc-sdk li input[type="checkbox"]{z-index:1}#onetrust-pc-sdk li .ot-checkbox label{z-index:2}#onetrust-pc-sdk li .ot-checkbox input[type="checkbox"]{height:auto;width:auto}#onetrust-pc-sdk li .host-title a,#onetrust-pc-sdk li .ot-host-name a,#onetrust-pc-sdk li .accordion-text,#onetrust-pc-sdk li .ot-acc-txt{z-index:2;position:relative}#onetrust-pc-sdk input{margin:3px 0.1ex}#onetrust-pc-sdk .pc-logo,#onetrust-pc-sdk .ot-pc-logo{height:60px;width:180px;background-position:center;background-size:contain;background-repeat:no-repeat}#onetrust-pc-sdk .screen-reader-only,#onetrust-pc-sdk .ot-scrn-rdr,.ot-sdk-cookie-policy .screen-reader-only,.ot-sdk-cookie-policy .ot-scrn-rdr{border:0;clip:rect(0 0 0 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}#onetrust-pc-sdk.ot-fade-in,.onetrust-pc-dark-filter.ot-fade-in,#onetrust-banner-sdk.ot-fade-in{animation-name:onetrust-fade-in;animation-duration:400ms;animation-timing-function:ease-in-out}#onetrust-pc-sdk.ot-hide{display:none!important}.onetrust-pc-dark-filter.ot-hide{display:none!important}#ot-sdk-btn.ot-sdk-show-settings,#ot-sdk-btn.optanon-show-settings{color:#68b631;border:1px solid #68b631;height:auto;white-space:normal;word-wrap:break-word;padding:0.8em 2em;font-size:0.8em;line-height:1.2;cursor:pointer;-moz-transition:0.1s ease;-o-transition:0.1s ease;-webkit-transition:1s ease;transition:0.1s ease}#ot-sdk-btn.ot-sdk-show-settings:hover,#ot-sdk-btn.optanon-show-settings:hover{color:#fff;background-color:#68b631}.onetrust-pc-dark-filter{background:rgba(0,0,0,0.5);z-index:2147483646;width:100%;height:100%;overflow:hidden;position:fixed;top:0;bottom:0;left:0}@keyframes onetrust-fade-in{0%{opacity:0}100%{opacity:1}}.ot-cookie-label{text-decoration:underline}@media only screen and (min-width:426px) and (max-width:896px) and (orientation:landscape){#onetrust-pc-sdk p{font-size:0.75em}}#onetrust-banner-sdk .banner-option-input:focus+label{outline:1px solid #000;outline-style:auto}.category-vendors-list-handler+a:focus,.category-vendors-list-handler+a:focus-visible{outline:2px solid #000}#onetrust-banner-sdk,#onetrust-pc-sdk,#ot-sdk-cookie-policy,#ot-sync-ntfy{font-size:16px}#onetrust-banner-sdk *,#onetrust-banner-sdk ::after,#onetrust-banner-sdk ::before,#onetrust-pc-sdk *,#onetrust-pc-sdk ::after,#onetrust-pc-sdk ::before,#ot-sdk-cookie-policy *,#ot-sdk-cookie-policy ::after,#ot-sdk-cookie-policy ::before,#ot-sync-ntfy *,#ot-sync-ntfy ::after,#ot-sync-ntfy ::before{-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box}#onetrust-banner-sdk div,#onetrust-banner-sdk span,#onetrust-banner-sdk h1,#onetrust-banner-sdk h2,#onetrust-banner-sdk h3,#onetrust-banner-sdk h4,#onetrust-banner-sdk h5,#onetrust-banner-sdk h6,#onetrust-banner-sdk p,#onetrust-banner-sdk img,#onetrust-banner-sdk svg,#onetrust-banner-sdk button,#onetrust-banner-sdk section,#onetrust-banner-sdk a,#onetrust-banner-sdk label,#onetrust-banner-sdk input,#onetrust-banner-sdk ul,#onetrust-banner-sdk li,#onetrust-banner-sdk nav,#onetrust-banner-sdk table,#onetrust-banner-sdk thead,#onetrust-banner-sdk tr,#onetrust-banner-sdk td,#onetrust-banner-sdk tbody,#onetrust-banner-sdk .ot-main-content,#onetrust-banner-sdk .ot-toggle,#onetrust-banner-sdk #ot-content,#onetrust-banner-sdk #ot-pc-content,#onetrust-banner-sdk .checkbox,#onetrust-pc-sdk div,#onetrust-pc-sdk span,#onetrust-pc-sdk h1,#onetrust-pc-sdk h2,#onetrust-pc-sdk h3,#onetrust-pc-sdk h4,#onetrust-pc-sdk h5,#onetrust-pc-sdk h6,#onetrust-pc-sdk p,#onetrust-pc-sdk img,#onetrust-pc-sdk svg,#onetrust-pc-sdk button,#onetrust-pc-sdk section,#onetrust-pc-sdk a,#onetrust-pc-sdk label,#onetrust-pc-sdk input,#onetrust-pc-sdk ul,#onetrust-pc-sdk li,#onetrust-pc-sdk nav,#onetrust-pc-sdk table,#onetrust-pc-sdk thead,#onetrust-pc-sdk tr,#onetrust-pc-sdk td,#onetrust-pc-sdk tbody,#onetrust-pc-sdk .ot-main-content,#onetrust-pc-sdk .ot-toggle,#onetrust-pc-sdk #ot-content,#onetrust-pc-sdk #ot-pc-content,#onetrust-pc-sdk .checkbox,#ot-sdk-cookie-policy div,#ot-sdk-cookie-policy span,#ot-sdk-cookie-policy h1,#ot-sdk-cookie-policy h2,#ot-sdk-cookie-policy h3,#ot-sdk-cookie-policy h4,#ot-sdk-cookie-policy h5,#ot-sdk-cookie-policy h6,#ot-sdk-cookie-policy p,#ot-sdk-cookie-policy img,#ot-sdk-cookie-policy svg,#ot-sdk-cookie-policy button,#ot-sdk-cookie-policy section,#ot-sdk-cookie-policy a,#ot-sdk-cookie-policy label,#ot-sdk-cookie-policy input,#ot-sdk-cookie-policy ul,#ot-sdk-cookie-policy li,#ot-sdk-cookie-policy nav,#ot-sdk-cookie-policy table,#ot-sdk-cookie-policy thead,#ot-sdk-cookie-policy tr,#ot-sdk-cookie-policy td,#ot-sdk-cookie-policy tbody,#ot-sdk-cookie-policy .ot-main-content,#ot-sdk-cookie-policy .ot-toggle,#ot-sdk-cookie-policy #ot-content,#ot-sdk-cookie-policy #ot-pc-content,#ot-sdk-cookie-policy .checkbox,#ot-sync-ntfy div,#ot-sync-ntfy span,#ot-sync-ntfy h1,#ot-sync-ntfy h2,#ot-sync-ntfy h3,#ot-sync-ntfy h4,#ot-sync-ntfy h5,#ot-sync-ntfy h6,#ot-sync-ntfy p,#ot-sync-ntfy img,#ot-sync-ntfy svg,#ot-sync-ntfy button,#ot-sync-ntfy section,#ot-sync-ntfy a,#ot-sync-ntfy label,#ot-sync-ntfy input,#ot-sync-ntfy ul,#ot-sync-ntfy li,#ot-sync-ntfy nav,#ot-sync-ntfy table,#ot-sync-ntfy thead,#ot-sync-ntfy tr,#ot-sync-ntfy td,#ot-sync-ntfy tbody,#ot-sync-ntfy .ot-main-content,#ot-sync-ntfy .ot-toggle,#ot-sync-ntfy #ot-content,#ot-sync-ntfy #ot-pc-content,#ot-sync-ntfy .checkbox{font-family:inherit;font-weight:normal;-webkit-font-smoothing:auto;letter-spacing:normal;line-height:normal;padding:0;margin:0;height:auto;min-height:0;max-height:none;width:auto;min-width:0;max-width:none;border-radius:0;border:none;clear:none;float:none;position:static;bottom:auto;left:auto;right:auto;top:auto;text-align:left;text-decoration:none;text-indent:0;text-shadow:none;text-transform:none;white-space:normal;background:none;overflow:visible;vertical-align:baseline;visibility:visible;z-index:auto;box-shadow:none}#onetrust-banner-sdk label:before,#onetrust-banner-sdk label:after,#onetrust-banner-sdk .checkbox:after,#onetrust-banner-sdk .checkbox:before,#onetrust-pc-sdk label:before,#onetrust-pc-sdk label:after,#onetrust-pc-sdk .checkbox:after,#onetrust-pc-sdk .checkbox:before,#ot-sdk-cookie-policy label:before,#ot-sdk-cookie-policy label:after,#ot-sdk-cookie-policy .checkbox:after,#ot-sdk-cookie-policy .checkbox:before,#ot-sync-ntfy label:before,#ot-sync-ntfy label:after,#ot-sync-ntfy .checkbox:after,#ot-sync-ntfy .checkbox:before{content:"";content:none}#onetrust-banner-sdk .ot-sdk-container,#onetrust-pc-sdk .ot-sdk-container,#ot-sdk-cookie-policy .ot-sdk-container{position:relative;width:100%;max-width:100%;margin:0 auto;padding:0 20px;box-sizing:border-box}#onetrust-banner-sdk .ot-sdk-column,#onetrust-banner-sdk .ot-sdk-columns,#onetrust-pc-sdk .ot-sdk-column,#onetrust-pc-sdk .ot-sdk-columns,#ot-sdk-cookie-policy .ot-sdk-column,#ot-sdk-cookie-policy .ot-sdk-columns{width:100%;float:left;box-sizing:border-box;padding:0;display:initial}@media (min-width:400px){#onetrust-banner-sdk .ot-sdk-container,#onetrust-pc-sdk .ot-sdk-container,#ot-sdk-cookie-policy .ot-sdk-container{width:90%;padding:0}}@media (min-width:550px){#onetrust-banner-sdk .ot-sdk-container,#onetrust-pc-sdk .ot-sdk-container,#ot-sdk-cookie-policy .ot-sdk-container{width:100%}#onetrust-banner-sdk .ot-sdk-column,#onetrust-banner-sdk .ot-sdk-columns,#onetrust-pc-sdk .ot-sdk-column,#onetrust-pc-sdk .ot-sdk-columns,#ot-sdk-cookie-policy .ot-sdk-column,#ot-sdk-cookie-policy .ot-sdk-columns{margin-left:4%}#onetrust-banner-sdk .ot-sdk-column:first-child,#onetrust-banner-sdk .ot-sdk-columns:first-child,#onetrust-pc-sdk .ot-sdk-column:first-child,#onetrust-pc-sdk .ot-sdk-columns:first-child,#ot-sdk-cookie-policy .ot-sdk-column:first-child,#ot-sdk-cookie-policy .ot-sdk-columns:first-child{margin-left:0}#onetrust-banner-sdk .ot-sdk-two.ot-sdk-columns,#onetrust-pc-sdk .ot-sdk-two.ot-sdk-columns,#ot-sdk-cookie-policy .ot-sdk-two.ot-sdk-columns{width:13.3333333333%}#onetrust-banner-sdk .ot-sdk-three.ot-sdk-columns,#onetrust-pc-sdk .ot-sdk-three.ot-sdk-columns,#ot-sdk-cookie-policy .ot-sdk-three.ot-sdk-columns{width:22%}#onetrust-banner-sdk .ot-sdk-four.ot-sdk-columns,#onetrust-pc-sdk .ot-sdk-four.ot-sdk-columns,#ot-sdk-cookie-policy .ot-sdk-four.ot-sdk-columns{width:30.6666666667%}#onetrust-banner-sdk .ot-sdk-eight.ot-sdk-columns,#onetrust-pc-sdk .ot-sdk-eight.ot-sdk-columns,#ot-sdk-cookie-policy .ot-sdk-eight.ot-sdk-columns{width:65.3333333333%}#onetrust-banner-sdk .ot-sdk-nine.ot-sdk-columns,#onetrust-pc-sdk .ot-sdk-nine.ot-sdk-columns,#ot-sdk-cookie-policy .ot-sdk-nine.ot-sdk-columns{width:74%}#onetrust-banner-sdk .ot-sdk-ten.ot-sdk-columns,#onetrust-pc-sdk .ot-sdk-ten.ot-sdk-columns,#ot-sdk-cookie-policy .ot-sdk-ten.ot-sdk-columns{width:82.6666666667%}#onetrust-banner-sdk .ot-sdk-eleven.ot-sdk-columns,#onetrust-pc-sdk .ot-sdk-eleven.ot-sdk-columns,#ot-sdk-cookie-policy .ot-sdk-eleven.ot-sdk-columns{width:91.3333333333%}#onetrust-banner-sdk .ot-sdk-twelve.ot-sdk-columns,#onetrust-pc-sdk .ot-sdk-twelve.ot-sdk-columns,#ot-sdk-cookie-policy .ot-sdk-twelve.ot-sdk-columns{width:100%;margin-left:0}}#onetrust-banner-sdk h1,#onetrust-banner-sdk h2,#onetrust-banner-sdk h3,#onetrust-banner-sdk h4,#onetrust-banner-sdk h5,#onetrust-banner-sdk h6,#onetrust-pc-sdk h1,#onetrust-pc-sdk h2,#onetrust-pc-sdk h3,#onetrust-pc-sdk h4,#onetrust-pc-sdk h5,#onetrust-pc-sdk h6,#ot-sdk-cookie-policy h1,#ot-sdk-cookie-policy h2,#ot-sdk-cookie-policy h3,#ot-sdk-cookie-policy h4,#ot-sdk-cookie-policy h5,#ot-sdk-cookie-policy h6{margin-top:0;font-weight:600;font-family:inherit}#onetrust-banner-sdk h1,#onetrust-pc-sdk h1,#ot-sdk-cookie-policy h1{font-size:1.5rem;line-height:1.2}#onetrust-banner-sdk h2,#onetrust-pc-sdk h2,#ot-sdk-cookie-policy h2{font-size:1.5rem;line-height:1.25}#onetrust-banner-sdk h3,#onetrust-pc-sdk h3,#ot-sdk-cookie-policy h3{font-size:1.5rem;line-height:1.3}#onetrust-banner-sdk h4,#onetrust-pc-sdk h4,#ot-sdk-cookie-policy h4{font-size:1.5rem;line-height:1.35}#onetrust-banner-sdk h5,#onetrust-pc-sdk h5,#ot-sdk-cookie-policy h5{font-size:1.5rem;line-height:1.5}#onetrust-banner-sdk h6,#onetrust-pc-sdk h6,#ot-sdk-cookie-policy h6{font-size:1.5rem;line-height:1.6}@media (min-width:550px){#onetrust-banner-sdk h1,#onetrust-pc-sdk h1,#ot-sdk-cookie-policy h1{font-size:1.5rem}#onetrust-banner-sdk h2,#onetrust-pc-sdk h2,#ot-sdk-cookie-policy h2{font-size:1.5rem}#onetrust-banner-sdk h3,#onetrust-pc-sdk h3,#ot-sdk-cookie-policy h3{font-size:1.5rem}#onetrust-banner-sdk h4,#onetrust-pc-sdk h4,#ot-sdk-cookie-policy h4{font-size:1.5rem}#onetrust-banner-sdk h5,#onetrust-pc-sdk h5,#ot-sdk-cookie-policy h5{font-size:1.5rem}#onetrust-banner-sdk h6,#onetrust-pc-sdk h6,#ot-sdk-cookie-policy h6{font-size:1.5rem}}#onetrust-banner-sdk p,#onetrust-pc-sdk p,#ot-sdk-cookie-policy p{margin:0 0 1em 0;font-family:inherit;line-height:normal}#onetrust-banner-sdk a,#onetrust-pc-sdk a,#ot-sdk-cookie-policy a{color:#565656;text-decoration:underline}#onetrust-banner-sdk a:hover,#onetrust-pc-sdk a:hover,#ot-sdk-cookie-policy a:hover{color:#565656;text-decoration:none}#onetrust-banner-sdk .ot-sdk-button,#onetrust-banner-sdk button,#onetrust-pc-sdk .ot-sdk-button,#onetrust-pc-sdk button,#ot-sdk-cookie-policy .ot-sdk-button,#ot-sdk-cookie-policy button{margin-bottom:1rem;font-family:inherit}#onetrust-banner-sdk .ot-sdk-button,#onetrust-banner-sdk button,#onetrust-pc-sdk .ot-sdk-button,#onetrust-pc-sdk button,#ot-sdk-cookie-policy .ot-sdk-button,#ot-sdk-cookie-policy button{display:inline-block;height:38px;padding:0 30px;color:#555;text-align:center;font-size:0.9em;font-weight:400;line-height:38px;letter-spacing:0.01em;text-decoration:none;white-space:nowrap;background-color:transparent;border-radius:2px;border:1px solid #bbb;cursor:pointer;box-sizing:border-box}#onetrust-banner-sdk .ot-sdk-button:hover,#onetrust-banner-sdk :not(.ot-leg-btn-container)>button:hover,#onetrust-banner-sdk :not(.ot-leg-btn-container)>button:focus,#onetrust-pc-sdk .ot-sdk-button:hover,#onetrust-pc-sdk :not(.ot-leg-btn-container)>button:hover,#onetrust-pc-sdk :not(.ot-leg-btn-container)>button:focus,#ot-sdk-cookie-policy .ot-sdk-button:hover,#ot-sdk-cookie-policy :not(.ot-leg-btn-container)>button:hover,#ot-sdk-cookie-policy :not(.ot-leg-btn-container)>button:focus{color:#333;border-color:#888;opacity:0.7}#onetrust-banner-sdk .ot-sdk-button:focus,#onetrust-banner-sdk :not(.ot-leg-btn-container)>button:focus,#onetrust-pc-sdk .ot-sdk-button:focus,#onetrust-pc-sdk :not(.ot-leg-btn-container)>button:focus,#ot-sdk-cookie-policy .ot-sdk-button:focus,#ot-sdk-cookie-policy :not(.ot-leg-btn-container)>button:focus{outline:2px solid #000}#onetrust-banner-sdk .ot-sdk-button.ot-sdk-button-primary,#onetrust-banner-sdk button.ot-sdk-button-primary,#onetrust-banner-sdk input[type="submit"].ot-sdk-button-primary,#onetrust-banner-sdk input[type="reset"].ot-sdk-button-primary,#onetrust-banner-sdk input[type="button"].ot-sdk-button-primary,#onetrust-pc-sdk .ot-sdk-button.ot-sdk-button-primary,#onetrust-pc-sdk button.ot-sdk-button-primary,#onetrust-pc-sdk input[type="submit"].ot-sdk-button-primary,#onetrust-pc-sdk input[type="reset"].ot-sdk-button-primary,#onetrust-pc-sdk input[type="button"].ot-sdk-button-primary,#ot-sdk-cookie-policy .ot-sdk-button.ot-sdk-button-primary,#ot-sdk-cookie-policy button.ot-sdk-button-primary,#ot-sdk-cookie-policy input[type="submit"].ot-sdk-button-primary,#ot-sdk-cookie-policy input[type="reset"].ot-sdk-button-primary,#ot-sdk-cookie-policy input[type="button"].ot-sdk-button-primary{color:#fff;background-color:#33c3f0;border-color:#33c3f0}#onetrust-banner-sdk .ot-sdk-button.ot-sdk-button-primary:hover,#onetrust-banner-sdk button.ot-sdk-button-primary:hover,#onetrust-banner-sdk input[type="submit"].ot-sdk-button-primary:hover,#onetrust-banner-sdk input[type="reset"].ot-sdk-button-primary:hover,#onetrust-banner-sdk input[type="button"].ot-sdk-button-primary:hover,#onetrust-banner-sdk .ot-sdk-button.ot-sdk-button-primary:focus,#onetrust-banner-sdk button.ot-sdk-button-primary:focus,#onetrust-banner-sdk input[type="submit"].ot-sdk-button-primary:focus,#onetrust-banner-sdk input[type="reset"].ot-sdk-button-primary:focus,#onetrust-banner-sdk input[type="button"].ot-sdk-button-primary:focus,#onetrust-pc-sdk .ot-sdk-button.ot-sdk-button-primary:hover,#onetrust-pc-sdk button.ot-sdk-button-primary:hover,#onetrust-pc-sdk input[type="submit"].ot-sdk-button-primary:hover,#onetrust-pc-sdk input[type="reset"].ot-sdk-button-primary:hover,#onetrust-pc-sdk input[type="button"].ot-sdk-button-primary:hover,#onetrust-pc-sdk .ot-sdk-button.ot-sdk-button-primary:focus,#onetrust-pc-sdk button.ot-sdk-button-primary:focus,#onetrust-pc-sdk input[type="submit"].ot-sdk-button-primary:focus,#onetrust-pc-sdk input[type="reset"].ot-sdk-button-primary:focus,#onetrust-pc-sdk input[type="button"].ot-sdk-button-primary:focus,#ot-sdk-cookie-policy .ot-sdk-button.ot-sdk-button-primary:hover,#ot-sdk-cookie-policy button.ot-sdk-button-primary:hover,#ot-sdk-cookie-policy input[type="submit"].ot-sdk-button-primary:hover,#ot-sdk-cookie-policy input[type="reset"].ot-sdk-button-primary:hover,#ot-sdk-cookie-policy input[type="button"].ot-sdk-button-primary:hover,#ot-sdk-cookie-policy .ot-sdk-button.ot-sdk-button-primary:focus,#ot-sdk-cookie-policy button.ot-sdk-button-primary:focus,#ot-sdk-cookie-policy input[type="submit"].ot-sdk-button-primary:focus,#ot-sdk-cookie-policy input[type="reset"].ot-sdk-button-primary:focus,#ot-sdk-cookie-policy input[type="button"].ot-sdk-button-primary:focus{color:#fff;background-color:#1eaedb;border-color:#1eaedb}#onetrust-banner-sdk input[type="text"],#onetrust-pc-sdk input[type="text"],#ot-sdk-cookie-policy input[type="text"]{height:38px;padding:6px 10px;background-color:#fff;border:1px solid #d1d1d1;border-radius:4px;box-shadow:none;box-sizing:border-box}#onetrust-banner-sdk input[type="text"],#onetrust-pc-sdk input[type="text"],#ot-sdk-cookie-policy input[type="text"]{-webkit-appearance:none;-moz-appearance:none;appearance:none}#onetrust-banner-sdk input[type="text"]:focus,#onetrust-pc-sdk input[type="text"]:focus,#ot-sdk-cookie-policy input[type="text"]:focus{border:1px solid #000;outline:0}#onetrust-banner-sdk label,#onetrust-pc-sdk label,#ot-sdk-cookie-policy label{display:block;margin-bottom:0.5rem;font-weight:600}#onetrust-banner-sdk input[type="checkbox"],#onetrust-pc-sdk input[type="checkbox"],#ot-sdk-cookie-policy input[type="checkbox"]{display:inline}#onetrust-banner-sdk ul,#onetrust-pc-sdk ul,#ot-sdk-cookie-policy ul{list-style:circle inside}#onetrust-banner-sdk ul,#onetrust-pc-sdk ul,#ot-sdk-cookie-policy ul{padding-left:0;margin-top:0}#onetrust-banner-sdk ul ul,#onetrust-pc-sdk ul ul,#ot-sdk-cookie-policy ul ul{margin:1.5rem 0 1.5rem 3rem;font-size:90%}#onetrust-banner-sdk li,#onetrust-pc-sdk li,#ot-sdk-cookie-policy li{margin-bottom:1rem}#onetrust-banner-sdk th,#onetrust-banner-sdk td,#onetrust-pc-sdk th,#onetrust-pc-sdk td,#ot-sdk-cookie-policy th,#ot-sdk-cookie-policy td{padding:12px 15px;text-align:left;border-bottom:1px solid #e1e1e1}#onetrust-banner-sdk button,#onetrust-pc-sdk button,#ot-sdk-cookie-policy button{margin-bottom:1rem;font-family:inherit}#onetrust-banner-sdk .ot-sdk-container:after,#onetrust-banner-sdk .ot-sdk-row:after,#onetrust-pc-sdk .ot-sdk-container:after,#onetrust-pc-sdk .ot-sdk-row:after,#ot-sdk-cookie-policy .ot-sdk-container:after,#ot-sdk-cookie-policy .ot-sdk-row:after{content:"";display:table;clear:both}#onetrust-banner-sdk .ot-sdk-row,#onetrust-pc-sdk .ot-sdk-row,#ot-sdk-cookie-policy .ot-sdk-row{margin:0;max-width:none;display:block}#onetrust-banner-sdk{box-shadow:0 0 18px rgba(0,0,0,.2)}#onetrust-banner-sdk.otFlat{position:fixed;z-index:2147483645;bottom:0;right:0;left:0;background-color:#fff;max-height:90%;overflow-x:hidden;overflow-y:auto}#onetrust-banner-sdk.otFlat.top{top:0px;bottom:auto}#onetrust-banner-sdk.otRelFont{font-size:1rem}#onetrust-banner-sdk>.ot-sdk-container{overflow:hidden}#onetrust-banner-sdk::-webkit-scrollbar{width:11px}#onetrust-banner-sdk::-webkit-scrollbar-thumb{border-radius:10px;background:#c1c1c1}#onetrust-banner-sdk{scrollbar-arrow-color:#c1c1c1;scrollbar-darkshadow-color:#c1c1c1;scrollbar-face-color:#c1c1c1;scrollbar-shadow-color:#c1c1c1}#onetrust-banner-sdk #onetrust-policy{margin:1.25em 0 .625em 2em;overflow:hidden}#onetrust-banner-sdk #onetrust-policy .ot-gv-list-handler{float:left;font-size:.82em;padding:0;margin-bottom:0;border:0;line-height:normal;height:auto;width:auto}#onetrust-banner-sdk #onetrust-policy-title{font-size:1.2em;line-height:1.3;margin-bottom:10px}#onetrust-banner-sdk #onetrust-policy-text{clear:both;text-align:left;font-size:.88em;line-height:1.4}#onetrust-banner-sdk #onetrust-policy-text *{font-size:inherit;line-height:inherit}#onetrust-banner-sdk #onetrust-policy-text a{font-weight:bold;margin-left:5px}#onetrust-banner-sdk #onetrust-policy-title,#onetrust-banner-sdk #onetrust-policy-text{color:dimgray;float:left}#onetrust-banner-sdk #onetrust-button-group-parent{min-height:1px;text-align:center}#onetrust-banner-sdk #onetrust-button-group{display:inline-block}#onetrust-banner-sdk #onetrust-accept-btn-handler,#onetrust-banner-sdk #onetrust-reject-all-handler,#onetrust-banner-sdk #onetrust-pc-btn-handler{background-color:#68b631;color:#fff;border-color:#68b631;margin-right:1em;min-width:125px;height:auto;white-space:normal;word-break:break-word;word-wrap:break-word;padding:12px 10px;line-height:1.2;font-size:.813em;font-weight:600}#onetrust-banner-sdk #onetrust-pc-btn-handler.cookie-setting-link{background-color:#fff;border:none;color:#68b631;text-decoration:underline;padding-left:0;padding-right:0}#onetrust-banner-sdk .onetrust-close-btn-ui{width:44px;height:44px;background-size:12px;border:none;position:relative;margin:auto;padding:0}#onetrust-banner-sdk .banner_logo{display:none}#onetrust-banner-sdk .ot-b-addl-desc{clear:both;float:left;display:block}#onetrust-banner-sdk #banner-options{float:left;display:table;margin-right:0;margin-left:1em;width:calc(100% - 1em)}#onetrust-banner-sdk .banner-option-input{cursor:pointer;width:auto;height:auto;border:none;padding:0;padding-right:3px;margin:0 0 10px;font-size:.82em;line-height:1.4}#onetrust-banner-sdk .banner-option-input *{pointer-events:none;font-size:inherit;line-height:inherit}#onetrust-banner-sdk .banner-option-input[aria-expanded=true]~.banner-option-details{display:block;height:auto}#onetrust-banner-sdk .banner-option-input[aria-expanded=true] .ot-arrow-container{transform:rotate(90deg)}#onetrust-banner-sdk .banner-option{margin-bottom:12px;margin-left:0;border:none;float:left;padding:0}#onetrust-banner-sdk .banner-option:first-child{padding-left:2px}#onetrust-banner-sdk .banner-option:not(:first-child){padding:0;border:none}#onetrust-banner-sdk .banner-option-header{cursor:pointer;display:inline-block}#onetrust-banner-sdk .banner-option-header :first-child{color:dimgray;font-weight:bold;float:left}#onetrust-banner-sdk .banner-option-header .ot-arrow-container{display:inline-block;border-top:6px solid transparent;border-bottom:6px solid transparent;border-left:6px solid dimgray;margin-left:10px;vertical-align:middle}#onetrust-banner-sdk .banner-option-details{display:none;font-size:.83em;line-height:1.5;padding:10px 0px 5px 10px;margin-right:10px;height:0px}#onetrust-banner-sdk .banner-option-details *{font-size:inherit;line-height:inherit;color:dimgray}#onetrust-banner-sdk .ot-arrow-container,#onetrust-banner-sdk .banner-option-details{transition:all 300ms ease-in 0s;-webkit-transition:all 300ms ease-in 0s;-moz-transition:all 300ms ease-in 0s;-o-transition:all 300ms ease-in 0s}#onetrust-banner-sdk .ot-dpd-container{float:left}#onetrust-banner-sdk .ot-dpd-title{margin-bottom:10px}#onetrust-banner-sdk .ot-dpd-title,#onetrust-banner-sdk .ot-dpd-desc{font-size:.88em;line-height:1.4;color:dimgray}#onetrust-banner-sdk .ot-dpd-title *,#onetrust-banner-sdk .ot-dpd-desc *{font-size:inherit;line-height:inherit}#onetrust-banner-sdk.ot-iab-2 #onetrust-policy-text *{margin-bottom:0}#onetrust-banner-sdk.ot-iab-2 .onetrust-vendors-list-handler{display:block;margin-left:0;margin-top:5px;clear:both;margin-bottom:0;padding:0;border:0;height:auto;width:auto}#onetrust-banner-sdk.ot-iab-2 #onetrust-button-group button{display:block}#onetrust-banner-sdk.ot-close-btn-link{padding-top:25px}#onetrust-banner-sdk.ot-close-btn-link #onetrust-close-btn-container{top:15px;transform:none;right:15px}#onetrust-banner-sdk.ot-close-btn-link #onetrust-close-btn-container button{padding:0;white-space:pre-wrap;border:none;height:auto;line-height:1.5;text-decoration:underline;font-size:.69em}#onetrust-banner-sdk #onetrust-policy-text,#onetrust-banner-sdk .ot-dpd-desc,#onetrust-banner-sdk .ot-b-addl-desc{font-size:.813em;line-height:1.5}#onetrust-banner-sdk .ot-dpd-desc{margin-bottom:10px}#onetrust-banner-sdk .ot-dpd-desc>.ot-b-addl-desc{margin-top:10px;margin-bottom:10px;font-size:1em}@media only screen and (max-width:425px){#onetrust-banner-sdk #onetrust-close-btn-container{position:absolute;top:6px;right:2px}#onetrust-banner-sdk #onetrust-policy{margin-left:0;margin-top:3em}#onetrust-banner-sdk #onetrust-button-group{display:block}#onetrust-banner-sdk #onetrust-accept-btn-handler,#onetrust-banner-sdk #onetrust-reject-all-handler,#onetrust-banner-sdk #onetrust-pc-btn-handler{width:100%}#onetrust-banner-sdk .onetrust-close-btn-ui{top:auto;transform:none}#onetrust-banner-sdk #onetrust-policy-title{display:inline;float:none}#onetrust-banner-sdk #banner-options{margin:0;padding:0;width:100%}}@media only screen and (min-width:426px)and (max-width:896px){#onetrust-banner-sdk #onetrust-close-btn-container{position:absolute;top:0;right:0}#onetrust-banner-sdk #onetrust-policy{margin-left:1em;margin-right:1em}#onetrust-banner-sdk .onetrust-close-btn-ui{top:10px;right:10px}#onetrust-banner-sdk:not(.ot-iab-2) #onetrust-group-container{width:95%}#onetrust-banner-sdk.ot-iab-2 #onetrust-group-container{width:100%}#onetrust-banner-sdk #onetrust-button-group-parent{width:100%;position:relative;margin-left:0}#onetrust-banner-sdk #onetrust-button-group button{display:inline-block}#onetrust-banner-sdk #onetrust-button-group{margin-right:0;text-align:center}#onetrust-banner-sdk .has-reject-all-button #onetrust-pc-btn-handler{float:left}#onetrust-banner-sdk .has-reject-all-button #onetrust-reject-all-handler,#onetrust-banner-sdk .has-reject-all-button #onetrust-accept-btn-handler{float:right}#onetrust-banner-sdk .has-reject-all-button #onetrust-button-group{width:calc(100% - 2em);margin-right:0}#onetrust-banner-sdk .has-reject-all-button #onetrust-pc-btn-handler.cookie-setting-link{padding-left:0px;text-align:left}#onetrust-banner-sdk.ot-buttons-fw .ot-sdk-three button{width:100%;text-align:center}#onetrust-banner-sdk.ot-buttons-fw #onetrust-button-group-parent button{float:none}#onetrust-banner-sdk.ot-buttons-fw #onetrust-pc-btn-handler.cookie-setting-link{text-align:center}}@media only screen and (min-width:550px){#onetrust-banner-sdk .banner-option:not(:first-child){border-left:1px solid #d8d8d8;padding-left:25px}}@media only screen and (min-width:425px)and (max-width:550px){#onetrust-banner-sdk.ot-iab-2 #onetrust-button-group,#onetrust-banner-sdk.ot-iab-2 #onetrust-policy,#onetrust-banner-sdk.ot-iab-2 .banner-option{width:100%}}@media only screen and (min-width:769px){#onetrust-banner-sdk #onetrust-button-group{margin-right:30%}#onetrust-banner-sdk #banner-options{margin-left:2em;margin-right:5em;margin-bottom:1.25em;width:calc(100% - 7em)}}@media only screen and (min-width:897px)and (max-width:1023px){#onetrust-banner-sdk.vertical-align-content #onetrust-button-group-parent{position:absolute;top:50%;left:75%;transform:translateY(-50%)}#onetrust-banner-sdk #onetrust-close-btn-container{top:50%;margin:auto;transform:translate(-50%,-50%);position:absolute;padding:0;right:0}#onetrust-banner-sdk #onetrust-close-btn-container button{position:relative;margin:0;right:-22px;top:2px}}@media only screen and (min-width:1024px){#onetrust-banner-sdk #onetrust-close-btn-container{top:50%;margin:auto;transform:translate(-50%,-50%);position:absolute;right:0}#onetrust-banner-sdk #onetrust-close-btn-container button{right:-12px}#onetrust-banner-sdk #onetrust-policy{margin-left:2em}#onetrust-banner-sdk.vertical-align-content #onetrust-button-group-parent{position:absolute;top:50%;left:60%;transform:translateY(-50%)}#onetrust-banner-sdk.ot-iab-2 #onetrust-policy-title{width:50%}#onetrust-banner-sdk.ot-iab-2 #onetrust-policy-text,#onetrust-banner-sdk.ot-iab-2 :not(.ot-dpd-desc)>.ot-b-addl-desc{margin-bottom:1em;width:50%;border-right:1px solid #d8d8d8;padding-right:1rem}#onetrust-banner-sdk.ot-iab-2 #onetrust-policy-text{margin-bottom:0;padding-bottom:1em}#onetrust-banner-sdk.ot-iab-2 :not(.ot-dpd-desc)>.ot-b-addl-desc{margin-bottom:0;padding-bottom:1em}#onetrust-banner-sdk.ot-iab-2 .ot-dpd-container{width:45%;padding-left:1rem;display:inline-block;float:none}#onetrust-banner-sdk.ot-iab-2 .ot-dpd-title{line-height:1.7}#onetrust-banner-sdk.ot-iab-2 #onetrust-button-group-parent{left:auto;right:4%;margin-left:0}#onetrust-banner-sdk.ot-iab-2 #onetrust-button-group button{display:block}#onetrust-banner-sdk:not(.ot-iab-2) #onetrust-button-group-parent{margin:auto;width:30%}#onetrust-banner-sdk:not(.ot-iab-2) #onetrust-group-container{width:60%}#onetrust-banner-sdk #onetrust-button-group{margin-right:auto}#onetrust-banner-sdk #onetrust-accept-btn-handler,#onetrust-banner-sdk #onetrust-reject-all-handler,#onetrust-banner-sdk #onetrust-pc-btn-handler{margin-top:1em}}@media only screen and (min-width:890px){#onetrust-banner-sdk.ot-buttons-fw:not(.ot-iab-2) #onetrust-button-group-parent{padding-left:3%;padding-right:4%;margin-left:0}#onetrust-banner-sdk.ot-buttons-fw:not(.ot-iab-2) #onetrust-button-group{margin-right:0;margin-top:1.25em;width:100%}#onetrust-banner-sdk.ot-buttons-fw:not(.ot-iab-2) #onetrust-button-group button{width:100%;margin-bottom:5px;margin-top:5px}#onetrust-banner-sdk.ot-buttons-fw:not(.ot-iab-2) #onetrust-button-group button:last-of-type{margin-bottom:20px}}@media only screen and (min-width:1280px){#onetrust-banner-sdk:not(.ot-iab-2) #onetrust-group-container{width:55%}#onetrust-banner-sdk:not(.ot-iab-2) #onetrust-button-group-parent{width:44%;padding-left:2%;padding-right:2%}#onetrust-banner-sdk:not(.ot-iab-2).vertical-align-content #onetrust-button-group-parent{position:absolute;left:55%}}#onetrust-consent-sdk #onetrust-banner-sdk{background-color:#191919}#onetrust-consent-sdk #onetrust-policy-title,#onetrust-consent-sdk #onetrust-policy-text,#onetrust-consent-sdk .ot-b-addl-desc,#onetrust-consent-sdk .ot-dpd-desc,#onetrust-consent-sdk .ot-dpd-title,#onetrust-consent-sdk #onetrust-policy-text *:not(.onetrust-vendors-list-handler),#onetrust-consent-sdk .ot-dpd-desc *:not(.onetrust-vendors-list-handler),#onetrust-consent-sdk #onetrust-banner-sdk #banner-options *,#onetrust-banner-sdk .ot-cat-header{color:#FFFFFF}#onetrust-consent-sdk #onetrust-banner-sdk .banner-option-details{background-color:#E9E9E9}#onetrust-consent-sdk #onetrust-banner-sdk a[href],#onetrust-consent-sdk #onetrust-banner-sdk a[href] font,#onetrust-consent-sdk #onetrust-banner-sdk .ot-link-btn{color:#FFFFFF}#onetrust-consent-sdk #onetrust-accept-btn-handler,#onetrust-banner-sdk #onetrust-reject-all-handler{background-color:#FDC301;border-color:#FDC301;color:#000000}#onetrust-consent-sdk #onetrust-banner-sdk *:focus,#onetrust-consent-sdk #onetrust-banner-sdk:focus{outline-color:#000000;outline-width:1px}#onetrust-consent-sdk #onetrust-pc-btn-handler,#onetrust-consent-sdk #onetrust-pc-btn-handler.cookie-setting-link{color:#191919;border-color:#191919;background-color:#FDC301}#onetrust-consent-sdk{font-family:Arial,sans-serif}#onetrust-consent-sdk #onetrust-policy-title{font-size:12pt;margin-bottom:5px}#onetrust-consent-sdk #onetrust-policy-text{margin-bottom:0px}#onetrust-consent-sdk #onetrust-pc-btn-handler.cookie-setting-link{color:#fff}@media only screen and (max-width:1024px){#onetrust-banner-sdk #onetrust-policy{margin-top:5px}}@media only screen and (max-width:425px){#onetrust-banner-sdk #onetrust-button-group{display:flex}}#onetrust-pc-sdk.otPcCenter{overflow:hidden;position:fixed;margin:0 auto;top:5%;right:0;left:0;width:40%;max-width:575px;min-width:575px;border-radius:2.5px;z-index:2147483647;background-color:#fff;-webkit-box-shadow:0px 2px 10px -3px #999;-moz-box-shadow:0px 2px 10px -3px #999;box-shadow:0px 2px 10px -3px #999}#onetrust-pc-sdk.otPcCenter[dir=rtl]{right:0;left:0}#onetrust-pc-sdk.otRelFont{font-size:1rem}#onetrust-pc-sdk #ot-addtl-venlst .ot-arw-cntr,#onetrust-pc-sdk #ot-addtl-venlst .ot-plus-minus,#onetrust-pc-sdk .ot-hide-tgl{visibility:hidden}#onetrust-pc-sdk #ot-addtl-venlst .ot-arw-cntr *,#onetrust-pc-sdk #ot-addtl-venlst .ot-plus-minus *,#onetrust-pc-sdk .ot-hide-tgl *{visibility:hidden}#onetrust-pc-sdk #ot-gn-venlst .ot-ven-item .ot-acc-hdr{min-height:40px}#onetrust-pc-sdk .ot-pc-header{height:39px;padding:10px 0 10px 30px;border-bottom:1px solid #e9e9e9}#onetrust-pc-sdk #ot-pc-title,#onetrust-pc-sdk #ot-category-title,#onetrust-pc-sdk .ot-cat-header,#onetrust-pc-sdk #ot-lst-title,#onetrust-pc-sdk .ot-ven-hdr .ot-ven-name,#onetrust-pc-sdk .ot-always-active{font-weight:bold;color:dimgray}#onetrust-pc-sdk .ot-cat-header{float:left;font-weight:600;font-size:.875em;line-height:1.5;max-width:90%;vertical-align:middle}#onetrust-pc-sdk .ot-always-active-group .ot-cat-header{width:55%;font-weight:700}#onetrust-pc-sdk .ot-cat-item p{clear:both;float:left;margin-top:10px;margin-bottom:5px;line-height:1.5;font-size:.812em;color:dimgray}#onetrust-pc-sdk .ot-close-icon{height:44px;width:44px;background-size:10px}#onetrust-pc-sdk #ot-pc-title{float:left;font-size:1em;line-height:1.5;margin-bottom:10px;margin-top:10px;width:100%}#onetrust-pc-sdk #accept-recommended-btn-handler{margin-right:10px;margin-bottom:25px;outline-offset:-1px}#onetrust-pc-sdk #ot-pc-desc{clear:both;width:100%;font-size:.812em;line-height:1.5;margin-bottom:25px}#onetrust-pc-sdk #ot-pc-desc a{margin-left:5px}#onetrust-pc-sdk #ot-pc-desc *{font-size:inherit;line-height:inherit}#onetrust-pc-sdk #ot-pc-desc ul li{padding:10px 0px}#onetrust-pc-sdk a{color:#656565;cursor:pointer}#onetrust-pc-sdk a:hover{color:#3860be}#onetrust-pc-sdk label{margin-bottom:0}#onetrust-pc-sdk #vdr-lst-dsc{font-size:.812em;line-height:1.5;padding:10px 15px 5px 15px}#onetrust-pc-sdk button{max-width:394px;padding:12px 30px;line-height:1;word-break:break-word;word-wrap:break-word;white-space:normal;font-weight:bold;height:auto}#onetrust-pc-sdk .ot-link-btn{padding:0;margin-bottom:0;border:0;font-weight:normal;line-height:normal;width:auto;height:auto}#onetrust-pc-sdk #ot-pc-content{position:absolute;overflow-y:scroll;padding-left:0px;padding-right:30px;top:60px;bottom:110px;margin:1px 3px 0 30px;width:calc(100% - 63px)}#onetrust-pc-sdk .ot-cat-grp .ot-always-active{float:right;clear:none;color:#3860be;margin:0;font-size:.813em;line-height:1.3}#onetrust-pc-sdk .ot-pc-scrollbar::-webkit-scrollbar-track{margin-right:20px}#onetrust-pc-sdk .ot-pc-scrollbar::-webkit-scrollbar{width:11px}#onetrust-pc-sdk .ot-pc-scrollbar::-webkit-scrollbar-thumb{border-radius:10px;background:#d8d8d8}#onetrust-pc-sdk input[type=checkbox]:focus+.ot-acc-hdr{outline:#000 1px solid}#onetrust-pc-sdk .ot-pc-scrollbar{scrollbar-arrow-color:#d8d8d8;scrollbar-darkshadow-color:#d8d8d8;scrollbar-face-color:#d8d8d8;scrollbar-shadow-color:#d8d8d8}#onetrust-pc-sdk .save-preference-btn-handler{margin-right:20px}#onetrust-pc-sdk .ot-pc-refuse-all-handler{margin-right:10px}#onetrust-pc-sdk #ot-pc-desc .privacy-notice-link{margin-left:0}#onetrust-pc-sdk .ot-subgrp-cntr{display:inline-block;clear:both;width:100%;padding-top:15px}#onetrust-pc-sdk .ot-switch+.ot-subgrp-cntr{padding-top:10px}#onetrust-pc-sdk ul.ot-subgrps{margin:0;font-size:initial}#onetrust-pc-sdk ul.ot-subgrps li p,#onetrust-pc-sdk ul.ot-subgrps li h5{font-size:.813em;line-height:1.4;color:dimgray}#onetrust-pc-sdk ul.ot-subgrps .ot-switch{min-height:auto}#onetrust-pc-sdk ul.ot-subgrps .ot-switch-nob{top:0}#onetrust-pc-sdk ul.ot-subgrps .ot-acc-hdr{display:inline-block;width:100%}#onetrust-pc-sdk ul.ot-subgrps .ot-acc-txt{margin:0}#onetrust-pc-sdk ul.ot-subgrps li{padding:0;border:none}#onetrust-pc-sdk ul.ot-subgrps li h5{position:relative;top:5px;font-weight:bold;margin-bottom:0;float:left}#onetrust-pc-sdk li.ot-subgrp{margin-left:20px;overflow:auto}#onetrust-pc-sdk li.ot-subgrp>h5{width:calc(100% - 100px)}#onetrust-pc-sdk .ot-cat-item p>ul,#onetrust-pc-sdk li.ot-subgrp p>ul{margin:0px;list-style:disc;margin-left:15px;font-size:inherit}#onetrust-pc-sdk .ot-cat-item p>ul li,#onetrust-pc-sdk li.ot-subgrp p>ul li{font-size:inherit;padding-top:10px;padding-left:0px;padding-right:0px;border:none}#onetrust-pc-sdk .ot-cat-item p>ul li:last-child,#onetrust-pc-sdk li.ot-subgrp p>ul li:last-child{padding-bottom:10px}#onetrust-pc-sdk .ot-pc-logo{height:40px;width:120px;display:inline-block}#onetrust-pc-sdk .ot-pc-footer{position:absolute;bottom:0px;width:100%;max-height:160px;border-top:1px solid #d8d8d8}#onetrust-pc-sdk.ot-ftr-stacked .ot-pc-refuse-all-handler{margin-bottom:0px}#onetrust-pc-sdk.ot-ftr-stacked #ot-pc-content{bottom:160px}#onetrust-pc-sdk.ot-ftr-stacked .ot-pc-footer button{width:100%;max-width:none}#onetrust-pc-sdk.ot-ftr-stacked .ot-btn-container{margin:0 30px;width:calc(100% - 60px);padding-right:0}#onetrust-pc-sdk .ot-pc-footer-logo{height:30px;width:100%;text-align:right;background:#f4f4f4}#onetrust-pc-sdk .ot-pc-footer-logo a{display:inline-block;margin-top:5px;margin-right:10px}#onetrust-pc-sdk[dir=rtl] .ot-pc-footer-logo{direction:rtl}#onetrust-pc-sdk[dir=rtl] .ot-pc-footer-logo a{margin-right:25px}#onetrust-pc-sdk .ot-tgl{float:right;position:relative;z-index:1}#onetrust-pc-sdk .ot-tgl input:checked+.ot-switch .ot-switch-nob{background-color:#cddcf2;border:1px solid #3860be}#onetrust-pc-sdk .ot-tgl input:checked+.ot-switch .ot-switch-nob:before{-webkit-transform:translateX(20px);-ms-transform:translateX(20px);transform:translateX(20px);background-color:#3860be;border-color:#3860be}#onetrust-pc-sdk .ot-tgl input:focus+.ot-switch{outline:#000 solid 1px}#onetrust-pc-sdk .ot-switch{position:relative;display:inline-block;width:45px;height:25px}#onetrust-pc-sdk .ot-switch-nob{position:absolute;cursor:pointer;top:0;left:0;right:0;bottom:0;background-color:#f2f1f1;border:1px solid #ddd;transition:all .2s ease-in 0s;-moz-transition:all .2s ease-in 0s;-o-transition:all .2s ease-in 0s;-webkit-transition:all .2s ease-in 0s;border-radius:20px}#onetrust-pc-sdk .ot-switch-nob:before{position:absolute;content:"";height:21px;width:21px;bottom:1px;background-color:#7d7d7d;-webkit-transition:.4s;transition:.4s;border-radius:20px}#onetrust-pc-sdk .ot-chkbox input:checked~label::before{background-color:#3860be}#onetrust-pc-sdk .ot-chkbox input+label::after{content:none;color:#fff}#onetrust-pc-sdk .ot-chkbox input:checked+label::after{content:""}#onetrust-pc-sdk .ot-chkbox input:focus+label::before{outline-style:solid;outline-width:2px;outline-style:auto}#onetrust-pc-sdk .ot-chkbox label{position:relative;display:inline-block;padding-left:30px;cursor:pointer;font-weight:500}#onetrust-pc-sdk .ot-chkbox label::before,#onetrust-pc-sdk .ot-chkbox label::after{position:absolute;content:"";display:inline-block;border-radius:3px}#onetrust-pc-sdk .ot-chkbox label::before{height:18px;width:18px;border:1px solid #3860be;left:0px;top:auto}#onetrust-pc-sdk .ot-chkbox label::after{height:5px;width:9px;border-left:3px solid;border-bottom:3px solid;transform:rotate(-45deg);-o-transform:rotate(-45deg);-ms-transform:rotate(-45deg);-webkit-transform:rotate(-45deg);left:4px;top:5px}#onetrust-pc-sdk .ot-label-txt{display:none}#onetrust-pc-sdk .ot-chkbox input,#onetrust-pc-sdk .ot-tgl input{position:absolute;opacity:0;width:0;height:0}#onetrust-pc-sdk .ot-arw-cntr{float:right;position:relative;pointer-events:none}#onetrust-pc-sdk .ot-arw-cntr .ot-arw{width:16px;height:16px;margin-left:5px;color:dimgray;display:inline-block;vertical-align:middle;-webkit-transition:all 150ms ease-in 0s;-moz-transition:all 150ms ease-in 0s;-o-transition:all 150ms ease-in 0s;transition:all 150ms ease-in 0s}#onetrust-pc-sdk input:checked~.ot-acc-hdr .ot-arw,#onetrust-pc-sdk button[aria-expanded=true]~.ot-acc-hdr .ot-arw-cntr svg{transform:rotate(90deg);-o-transform:rotate(90deg);-ms-transform:rotate(90deg);-webkit-transform:rotate(90deg)}#onetrust-pc-sdk input[type=checkbox]:focus+.ot-acc-hdr{outline:#000 1px solid}#onetrust-pc-sdk .ot-tgl-cntr,#onetrust-pc-sdk .ot-arw-cntr{display:inline-block}#onetrust-pc-sdk .ot-tgl-cntr{width:45px;float:right;margin-top:2px}#onetrust-pc-sdk #ot-lst-cnt .ot-tgl-cntr{margin-top:10px}#onetrust-pc-sdk .ot-always-active-subgroup{width:auto;padding-left:0px!important;top:3px;position:relative}#onetrust-pc-sdk .ot-label-status{padding-left:5px;font-size:.75em;display:none}#onetrust-pc-sdk .ot-arw-cntr{margin-top:-1px}#onetrust-pc-sdk .ot-arw-cntr svg{-webkit-transition:all 300ms ease-in 0s;-moz-transition:all 300ms ease-in 0s;-o-transition:all 300ms ease-in 0s;transition:all 300ms ease-in 0s;height:10px;width:10px}#onetrust-pc-sdk input:checked~.ot-acc-hdr .ot-arw{transform:rotate(90deg);-o-transform:rotate(90deg);-ms-transform:rotate(90deg);-webkit-transform:rotate(90deg)}#onetrust-pc-sdk .ot-arw{width:10px;margin-left:15px;transition:all 300ms ease-in 0s;-webkit-transition:all 300ms ease-in 0s;-moz-transition:all 300ms ease-in 0s;-o-transition:all 300ms ease-in 0s}#onetrust-pc-sdk .ot-vlst-cntr{margin-bottom:0}#onetrust-pc-sdk .ot-hlst-cntr{margin-top:5px;display:inline-block;width:100%}#onetrust-pc-sdk .category-vendors-list-handler,#onetrust-pc-sdk .category-vendors-list-handler+a,#onetrust-pc-sdk .category-host-list-handler{clear:both;color:#3860be;margin-left:0;font-size:.813em;text-decoration:none;float:left;overflow:hidden}#onetrust-pc-sdk .category-vendors-list-handler:hover,#onetrust-pc-sdk .category-vendors-list-handler+a:hover,#onetrust-pc-sdk .category-host-list-handler:hover{color:#1883fd}#onetrust-pc-sdk .category-vendors-list-handler+a{clear:none}#onetrust-pc-sdk .category-vendors-list-handler+a::after{content:"";height:15px;width:15px;background-repeat:no-repeat;margin-left:5px;float:right;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 511.626 511.627'%3E%3Cg fill='%231276CE'%3E%3Cpath d='M392.857 292.354h-18.274c-2.669 0-4.859.855-6.563 2.573-1.718 1.708-2.573 3.897-2.573 6.563v91.361c0 12.563-4.47 23.315-13.415 32.262-8.945 8.945-19.701 13.414-32.264 13.414H82.224c-12.562 0-23.317-4.469-32.264-13.414-8.945-8.946-13.417-19.698-13.417-32.262V155.31c0-12.562 4.471-23.313 13.417-32.259 8.947-8.947 19.702-13.418 32.264-13.418h200.994c2.669 0 4.859-.859 6.57-2.57 1.711-1.713 2.566-3.9 2.566-6.567V82.221c0-2.662-.855-4.853-2.566-6.563-1.711-1.713-3.901-2.568-6.57-2.568H82.224c-22.648 0-42.016 8.042-58.102 24.125C8.042 113.297 0 132.665 0 155.313v237.542c0 22.647 8.042 42.018 24.123 58.095 16.086 16.084 35.454 24.13 58.102 24.13h237.543c22.647 0 42.017-8.046 58.101-24.13 16.085-16.077 24.127-35.447 24.127-58.095v-91.358c0-2.669-.856-4.859-2.574-6.57-1.713-1.718-3.903-2.573-6.565-2.573z'/%3E%3Cpath d='M506.199 41.971c-3.617-3.617-7.905-5.424-12.85-5.424H347.171c-4.948 0-9.233 1.807-12.847 5.424-3.617 3.615-5.428 7.898-5.428 12.847s1.811 9.233 5.428 12.85l50.247 50.248-186.147 186.151c-1.906 1.903-2.856 4.093-2.856 6.563 0 2.479.953 4.668 2.856 6.571l32.548 32.544c1.903 1.903 4.093 2.852 6.567 2.852s4.665-.948 6.567-2.852l186.148-186.148 50.251 50.248c3.614 3.617 7.898 5.426 12.847 5.426s9.233-1.809 12.851-5.426c3.617-3.616 5.424-7.898 5.424-12.847V54.818c-.001-4.952-1.814-9.232-5.428-12.847z'/%3E%3C/g%3E%3C/svg%3E")}#onetrust-pc-sdk .back-btn-handler{font-size:1em;text-decoration:none}#onetrust-pc-sdk .back-btn-handler:hover{opacity:.6}#onetrust-pc-sdk #ot-lst-title h3{display:inline-block;word-break:break-word;word-wrap:break-word;margin-bottom:0;color:#656565;font-size:1em;font-weight:bold;margin-left:15px}#onetrust-pc-sdk #ot-lst-title{margin:10px 0 10px 0px;font-size:1em;text-align:left}#onetrust-pc-sdk #ot-pc-hdr{margin:0 0 0 30px;height:auto;width:auto}#onetrust-pc-sdk #ot-pc-hdr input::placeholder{color:#d4d4d4;font-style:italic}#onetrust-pc-sdk #vendor-search-handler{height:31px;width:100%;border-radius:50px;font-size:.8em;padding-right:35px;padding-left:15px;float:left;margin-left:15px}#onetrust-pc-sdk .ot-ven-name{display:block;width:auto;padding-right:5px}#onetrust-pc-sdk #ot-lst-cnt{overflow-y:auto;margin-left:20px;margin-right:7px;width:calc(100% - 27px);max-height:calc(100% - 80px);height:100%;transform:translate3d(0,0,0)}#onetrust-pc-sdk #ot-pc-lst{width:100%;bottom:100px;position:absolute;top:60px}#onetrust-pc-sdk #ot-pc-lst:not(.ot-enbl-chr) .ot-tgl-cntr .ot-arw-cntr,#onetrust-pc-sdk #ot-pc-lst:not(.ot-enbl-chr) .ot-tgl-cntr .ot-arw-cntr *{visibility:hidden}#onetrust-pc-sdk #ot-pc-lst .ot-tgl-cntr{right:12px;position:absolute}#onetrust-pc-sdk #ot-pc-lst .ot-arw-cntr{float:right;position:relative}#onetrust-pc-sdk #ot-pc-lst .ot-arw{margin-left:10px}#onetrust-pc-sdk #ot-pc-lst .ot-acc-hdr{overflow:hidden;cursor:pointer}#onetrust-pc-sdk .ot-vlst-cntr{overflow:hidden}#onetrust-pc-sdk #ot-sel-blk{overflow:hidden;width:100%;position:sticky;position:-webkit-sticky;top:0;z-index:3}#onetrust-pc-sdk #ot-back-arw{height:12px;width:12px}#onetrust-pc-sdk .ot-lst-subhdr{width:100%;display:inline-block}#onetrust-pc-sdk .ot-search-cntr{float:left;width:78%;position:relative}#onetrust-pc-sdk .ot-search-cntr>svg{width:30px;height:30px;position:absolute;float:left;right:-15px}#onetrust-pc-sdk .ot-fltr-cntr{float:right;right:50px;position:relative}#onetrust-pc-sdk #filter-btn-handler{background-color:#3860be;border-radius:17px;display:inline-block;position:relative;width:32px;height:32px;-moz-transition:.1s ease;-o-transition:.1s ease;-webkit-transition:1s ease;transition:.1s ease;padding:0;margin:0}#onetrust-pc-sdk #filter-btn-handler:hover{background-color:#3860be}#onetrust-pc-sdk #filter-btn-handler svg{width:12px;height:12px;margin:3px 10px 0 10px;display:block;position:static;right:auto;top:auto}#onetrust-pc-sdk .ot-ven-link{color:#3860be;text-decoration:none;font-weight:100;display:inline-block;padding-top:10px;transform:translate(0,1%);-o-transform:translate(0,1%);-ms-transform:translate(0,1%);-webkit-transform:translate(0,1%);position:relative;z-index:2}#onetrust-pc-sdk .ot-ven-link *{font-size:inherit}#onetrust-pc-sdk .ot-ven-link:hover{text-decoration:underline}#onetrust-pc-sdk .ot-ven-hdr{width:calc(100% - 160px);height:auto;float:left;word-break:break-word;word-wrap:break-word;vertical-align:middle;padding-bottom:3px}#onetrust-pc-sdk .ot-ven-link{letter-spacing:.03em;font-size:.75em;font-weight:400}#onetrust-pc-sdk .ot-ven-dets{border-radius:2px;background-color:#f8f8f8}#onetrust-pc-sdk .ot-ven-dets li:first-child p:first-child{border-top:none}#onetrust-pc-sdk .ot-ven-dets .ot-ven-disc:not(:first-child){border-top:1px solid #e9e9e9}#onetrust-pc-sdk .ot-ven-dets .ot-ven-disc:nth-child(n+3) p{display:inline-block}#onetrust-pc-sdk .ot-ven-dets .ot-ven-disc:nth-child(n+3) p:nth-of-type(odd){width:30%}#onetrust-pc-sdk .ot-ven-dets .ot-ven-disc:nth-child(n+3) p:nth-of-type(even){width:50%;word-break:break-word;word-wrap:break-word}#onetrust-pc-sdk .ot-ven-dets .ot-ven-disc p,#onetrust-pc-sdk .ot-ven-dets .ot-ven-disc h4{padding-top:5px;padding-bottom:5px;display:block}#onetrust-pc-sdk .ot-ven-dets .ot-ven-disc h4{display:inline-block}#onetrust-pc-sdk .ot-ven-dets p,#onetrust-pc-sdk .ot-ven-dets h4,#onetrust-pc-sdk .ot-ven-dets span{font-size:.69em;text-align:left;vertical-align:middle;word-break:break-word;word-wrap:break-word;margin:0;padding-bottom:10px;padding-left:15px;color:#2e3644}#onetrust-pc-sdk .ot-ven-dets h4{padding-top:5px}#onetrust-pc-sdk .ot-ven-dets span{color:dimgray;padding:0;vertical-align:baseline}#onetrust-pc-sdk .ot-ven-dets .ot-ven-pur h4{border-top:1px solid #e9e9e9;border-bottom:1px solid #e9e9e9;padding-bottom:5px;margin-bottom:5px;font-weight:bold}#onetrust-pc-sdk #ot-host-lst .ot-sel-all{float:right;position:relative;margin-right:42px;top:10px}#onetrust-pc-sdk #ot-host-lst .ot-sel-all input[type=checkbox]{width:auto;height:auto}#onetrust-pc-sdk #ot-host-lst .ot-sel-all label{height:20px;width:20px;padding-left:0px}#onetrust-pc-sdk #ot-host-lst .ot-acc-txt{overflow:hidden;width:95%}#onetrust-pc-sdk .ot-host-hdr{position:relative;z-index:1;pointer-events:none;width:calc(100% - 125px);float:left}#onetrust-pc-sdk .ot-host-name,#onetrust-pc-sdk .ot-host-desc{display:inline-block;width:90%}#onetrust-pc-sdk .ot-host-name{pointer-events:none}#onetrust-pc-sdk .ot-host-hdr>a{text-decoration:underline;font-size:.82em;position:relative;z-index:2;float:left;margin-bottom:5px;pointer-events:initial}#onetrust-pc-sdk .ot-host-name+a{margin-top:5px}#onetrust-pc-sdk .ot-host-name,#onetrust-pc-sdk .ot-host-name a,#onetrust-pc-sdk .ot-host-desc,#onetrust-pc-sdk .ot-host-info{color:dimgray;word-break:break-word;word-wrap:break-word}#onetrust-pc-sdk .ot-host-name,#onetrust-pc-sdk .ot-host-name a{font-weight:bold;font-size:.82em;line-height:1.3}#onetrust-pc-sdk .ot-host-name a{font-size:1em}#onetrust-pc-sdk .ot-host-expand{margin-top:3px;margin-bottom:3px;clear:both;display:block;color:#3860be;font-size:.72em;font-weight:normal}#onetrust-pc-sdk .ot-host-expand *{font-size:inherit}#onetrust-pc-sdk .ot-host-desc,#onetrust-pc-sdk .ot-host-info{font-size:.688em;line-height:1.4;font-weight:normal}#onetrust-pc-sdk .ot-host-desc{margin-top:10px}#onetrust-pc-sdk .ot-host-opt{margin:0;font-size:inherit;display:inline-block;width:100%}#onetrust-pc-sdk .ot-host-opt li>div div{font-size:.8em;padding:5px 0}#onetrust-pc-sdk .ot-host-opt li>div div:nth-child(1){width:30%;float:left}#onetrust-pc-sdk .ot-host-opt li>div div:nth-child(2){width:70%;float:left;word-break:break-word;word-wrap:break-word}#onetrust-pc-sdk .ot-host-info{border:none;display:inline-block;width:calc(100% - 10px);padding:10px;margin-bottom:10px;background-color:#f8f8f8}#onetrust-pc-sdk .ot-host-info>div{overflow:auto}#onetrust-pc-sdk #no-results{text-align:center;margin-top:30px}#onetrust-pc-sdk #no-results p{font-size:1em;color:#2e3644;word-break:break-word;word-wrap:break-word}#onetrust-pc-sdk #no-results p span{font-weight:bold}#onetrust-pc-sdk #ot-fltr-modal{width:100%;height:auto;display:none;-moz-transition:.2s ease;-o-transition:.2s ease;-webkit-transition:2s ease;transition:.2s ease;overflow:hidden;opacity:1;right:0}#onetrust-pc-sdk #ot-fltr-modal .ot-label-txt{display:inline-block;font-size:.85em;color:dimgray}#onetrust-pc-sdk #ot-fltr-cnt{z-index:2147483646;background-color:#fff;position:absolute;height:90%;max-height:300px;width:325px;left:210px;margin-top:10px;margin-bottom:20px;padding-right:10px;border-radius:3px;-webkit-box-shadow:0px 0px 12px 2px #c7c5c7;-moz-box-shadow:0px 0px 12px 2px #c7c5c7;box-shadow:0px 0px 12px 2px #c7c5c7}#onetrust-pc-sdk .ot-fltr-scrlcnt{overflow-y:auto;overflow-x:hidden;clear:both;max-height:calc(100% - 60px)}#onetrust-pc-sdk #ot-anchor{border:12px solid transparent;display:none;position:absolute;z-index:2147483647;right:55px;top:75px;transform:rotate(45deg);-o-transform:rotate(45deg);-ms-transform:rotate(45deg);-webkit-transform:rotate(45deg);background-color:#fff;-webkit-box-shadow:-3px -3px 5px -2px #c7c5c7;-moz-box-shadow:-3px -3px 5px -2px #c7c5c7;box-shadow:-3px -3px 5px -2px #c7c5c7}#onetrust-pc-sdk .ot-fltr-btns{margin-left:15px}#onetrust-pc-sdk #filter-apply-handler{margin-right:15px}#onetrust-pc-sdk .ot-fltr-opt{margin-bottom:25px;margin-left:15px;width:75%;position:relative}#onetrust-pc-sdk .ot-fltr-opt p{display:inline-block;margin:0;font-size:.9em;color:#2e3644}#onetrust-pc-sdk .ot-chkbox label span{font-size:.85em;color:dimgray}#onetrust-pc-sdk .ot-chkbox input[type=checkbox]+label::after{content:none;color:#fff}#onetrust-pc-sdk .ot-chkbox input[type=checkbox]:checked+label::after{content:""}#onetrust-pc-sdk .ot-chkbox input[type=checkbox]:focus+label::before{outline-style:solid;outline-width:2px;outline-style:auto}#onetrust-pc-sdk #ot-selall-vencntr,#onetrust-pc-sdk #ot-selall-adtlvencntr,#onetrust-pc-sdk #ot-selall-hostcntr,#onetrust-pc-sdk #ot-selall-licntr,#onetrust-pc-sdk #ot-selall-gnvencntr{right:15px;position:relative;width:20px;height:20px;float:right}#onetrust-pc-sdk #ot-selall-vencntr label,#onetrust-pc-sdk #ot-selall-adtlvencntr label,#onetrust-pc-sdk #ot-selall-hostcntr label,#onetrust-pc-sdk #ot-selall-licntr label,#onetrust-pc-sdk #ot-selall-gnvencntr label{float:left;padding-left:0}#onetrust-pc-sdk #ot-ven-lst:first-child{border-top:1px solid #e2e2e2}#onetrust-pc-sdk ul{list-style:none;padding:0}#onetrust-pc-sdk ul li{position:relative;margin:0;padding:15px 15px 15px 10px;border-bottom:1px solid #e2e2e2}#onetrust-pc-sdk ul li h3{font-size:.75em;color:#656565;margin:0;display:inline-block;width:70%;height:auto;word-break:break-word;word-wrap:break-word}#onetrust-pc-sdk ul li p{margin:0;font-size:.7em}#onetrust-pc-sdk ul li input[type=checkbox]{position:absolute;cursor:pointer;width:100%;height:100%;opacity:0;margin:0;top:0;left:0}#onetrust-pc-sdk .ot-cat-item>button:focus,#onetrust-pc-sdk .ot-acc-cntr>button:focus,#onetrust-pc-sdk li>button:focus{outline:#000 solid 2px}#onetrust-pc-sdk .ot-cat-item>button,#onetrust-pc-sdk .ot-acc-cntr>button,#onetrust-pc-sdk li>button{position:absolute;cursor:pointer;width:100%;height:100%;margin:0;top:0;left:0;z-index:1;max-width:none;border:none}#onetrust-pc-sdk .ot-cat-item>button[aria-expanded=false]~.ot-acc-txt,#onetrust-pc-sdk .ot-acc-cntr>button[aria-expanded=false]~.ot-acc-txt,#onetrust-pc-sdk li>button[aria-expanded=false]~.ot-acc-txt{margin-top:0;max-height:0;opacity:0;overflow:hidden;width:100%;transition:.25s ease-out;display:none}#onetrust-pc-sdk .ot-cat-item>button[aria-expanded=true]~.ot-acc-txt,#onetrust-pc-sdk .ot-acc-cntr>button[aria-expanded=true]~.ot-acc-txt,#onetrust-pc-sdk li>button[aria-expanded=true]~.ot-acc-txt{transition:.1s ease-in;margin-top:10px;width:100%;overflow:auto;display:block}#onetrust-pc-sdk .ot-cat-item>button[aria-expanded=true]~.ot-acc-grpcntr,#onetrust-pc-sdk .ot-acc-cntr>button[aria-expanded=true]~.ot-acc-grpcntr,#onetrust-pc-sdk li>button[aria-expanded=true]~.ot-acc-grpcntr{width:auto;margin-top:0px;padding-bottom:10px}#onetrust-pc-sdk .ot-host-item>button:focus,#onetrust-pc-sdk .ot-ven-item>button:focus{outline:0;border:2px solid #000}#onetrust-pc-sdk .ot-hide-acc>button{pointer-events:none}#onetrust-pc-sdk .ot-hide-acc .ot-plus-minus>*,#onetrust-pc-sdk .ot-hide-acc .ot-arw-cntr>*{visibility:hidden}#onetrust-pc-sdk .ot-hide-acc .ot-acc-hdr{min-height:30px}#onetrust-pc-sdk.ot-addtl-vendors #ot-lst-cnt:not(.ot-host-cnt){padding-right:10px;width:calc(100% - 37px);margin-top:10px;max-height:calc(100% - 90px)}#onetrust-pc-sdk.ot-addtl-vendors #ot-lst-cnt:not(.ot-host-cnt) #ot-sel-blk{background-color:#f9f9fc;border:1px solid #e2e2e2;width:calc(100% - 2px);padding-bottom:5px;padding-top:5px}#onetrust-pc-sdk.ot-addtl-vendors #ot-lst-cnt:not(.ot-host-cnt) .ot-sel-all{padding-right:34px}#onetrust-pc-sdk.ot-addtl-vendors #ot-lst-cnt:not(.ot-host-cnt) .ot-sel-all-chkbox{width:auto}#onetrust-pc-sdk.ot-addtl-vendors #ot-lst-cnt:not(.ot-host-cnt) ul li{border:1px solid #e2e2e2;margin-bottom:10px}#onetrust-pc-sdk.ot-addtl-vendors #ot-lst-cnt:not(.ot-host-cnt) .ot-acc-cntr>.ot-acc-hdr{padding:10px 0 10px 15px}#onetrust-pc-sdk.ot-addtl-vendors .ot-sel-all-chkbox{float:right}#onetrust-pc-sdk.ot-addtl-vendors .ot-plus-minus~.ot-sel-all-chkbox{right:34px}#onetrust-pc-sdk.ot-addtl-vendors #ot-ven-lst:first-child{border-top:none}#onetrust-pc-sdk .ot-acc-cntr{position:relative;border-left:1px solid #e2e2e2;border-right:1px solid #e2e2e2;border-bottom:1px solid #e2e2e2}#onetrust-pc-sdk .ot-acc-cntr input{z-index:1}#onetrust-pc-sdk .ot-acc-cntr>.ot-acc-hdr{background-color:#f9f9fc;padding:5px 0 5px 15px;width:auto}#onetrust-pc-sdk .ot-acc-cntr>.ot-acc-hdr .ot-plus-minus{vertical-align:middle;top:auto}#onetrust-pc-sdk .ot-acc-cntr>.ot-acc-hdr .ot-arw-cntr{right:10px}#onetrust-pc-sdk .ot-acc-cntr>.ot-acc-hdr input{z-index:2}#onetrust-pc-sdk .ot-acc-cntr>input[type=checkbox]:checked~.ot-acc-hdr{border-bottom:1px solid #e2e2e2}#onetrust-pc-sdk .ot-acc-cntr>.ot-acc-txt{padding-left:10px;padding-right:10px}#onetrust-pc-sdk .ot-acc-cntr button[aria-expanded=true]~.ot-acc-txt{width:auto}#onetrust-pc-sdk .ot-acc-cntr .ot-addtl-venbox{display:none}#onetrust-pc-sdk .ot-vlst-cntr{margin-bottom:0;width:100%}#onetrust-pc-sdk .ot-vensec-title{font-size:.813em;vertical-align:middle;display:inline-block}#onetrust-pc-sdk .category-vendors-list-handler,#onetrust-pc-sdk .category-vendors-list-handler+a{margin-left:0;margin-top:10px}#onetrust-pc-sdk #ot-selall-vencntr.line-through label::after,#onetrust-pc-sdk #ot-selall-adtlvencntr.line-through label::after,#onetrust-pc-sdk #ot-selall-licntr.line-through label::after,#onetrust-pc-sdk #ot-selall-hostcntr.line-through label::after,#onetrust-pc-sdk #ot-selall-gnvencntr.line-through label::after{height:auto;border-left:0;transform:none;-o-transform:none;-ms-transform:none;-webkit-transform:none;left:5px;top:9px}#onetrust-pc-sdk #ot-category-title{float:left;padding-bottom:10px;font-size:1em;width:100%}#onetrust-pc-sdk .ot-cat-grp{margin-top:10px}#onetrust-pc-sdk .ot-cat-item{line-height:1.1;margin-top:10px;display:inline-block;width:100%}#onetrust-pc-sdk .ot-btn-container{text-align:right}#onetrust-pc-sdk .ot-btn-container button{display:inline-block;font-size:.75em;letter-spacing:.08em;margin-top:19px}#onetrust-pc-sdk #close-pc-btn-handler.ot-close-icon{position:absolute;top:10px;right:0;z-index:1;padding:0;background-color:transparent;border:none}#onetrust-pc-sdk #close-pc-btn-handler.ot-close-icon:hover{opacity:.7}#onetrust-pc-sdk #close-pc-btn-handler.ot-close-icon svg{display:block;height:10px;width:10px}#onetrust-pc-sdk #clear-filters-handler{margin-top:20px;margin-bottom:10px;float:right;max-width:200px;text-decoration:none;color:#3860be;font-size:.9em;font-weight:bold;background-color:transparent;border-color:transparent;padding:1px}#onetrust-pc-sdk #clear-filters-handler:hover{color:#2285f7}#onetrust-pc-sdk #clear-filters-handler:focus{outline:#000 solid 1px}#onetrust-pc-sdk .ot-accordion-layout.ot-cat-item{position:relative;border-radius:2px;margin:0;padding:0;border:1px solid #d8d8d8;border-top:none;width:calc(100% - 2px);float:left}#onetrust-pc-sdk .ot-accordion-layout.ot-cat-item:first-of-type{margin-top:10px;border-top:1px solid #d8d8d8}#onetrust-pc-sdk .ot-accordion-layout .ot-acc-grpdesc{padding-left:20px;padding-right:20px;width:calc(100% - 40px);font-size:.812em;margin-bottom:10px;margin-top:15px}#onetrust-pc-sdk .ot-accordion-layout .ot-acc-grpdesc>ul{padding-top:10px}#onetrust-pc-sdk .ot-accordion-layout .ot-acc-grpdesc>ul li{padding-top:0;line-height:1.5;padding-bottom:10px}#onetrust-pc-sdk .ot-accordion-layout div+.ot-acc-grpdesc{margin-top:5px}#onetrust-pc-sdk .ot-accordion-layout .ot-vlst-cntr:first-child{margin-top:10px}#onetrust-pc-sdk .ot-accordion-layout .ot-vlst-cntr:last-child,#onetrust-pc-sdk .ot-accordion-layout .ot-hlst-cntr:last-child{margin-bottom:5px}#onetrust-pc-sdk .ot-accordion-layout .ot-acc-hdr{padding-top:11.5px;padding-bottom:11.5px;padding-left:20px;padding-right:20px;width:calc(100% - 40px);display:inline-block}#onetrust-pc-sdk .ot-accordion-layout .ot-acc-txt{width:100%;padding:0px}#onetrust-pc-sdk .ot-accordion-layout .ot-subgrp-cntr{padding-left:20px;padding-right:15px;padding-bottom:0;width:calc(100% - 35px)}#onetrust-pc-sdk .ot-accordion-layout .ot-subgrp{padding-right:5px}#onetrust-pc-sdk .ot-accordion-layout .ot-acc-grpcntr{z-index:1;position:relative}#onetrust-pc-sdk .ot-accordion-layout .ot-cat-header+.ot-arw-cntr{position:absolute;top:50%;transform:translateY(-50%);right:20px;margin-top:-2px}#onetrust-pc-sdk .ot-accordion-layout .ot-cat-header+.ot-arw-cntr .ot-arw{width:15px;height:20px;margin-left:5px;color:dimgray}#onetrust-pc-sdk .ot-accordion-layout .ot-cat-header{float:none;color:#2e3644;margin:0;display:inline-block;height:auto;word-wrap:break-word;min-height:inherit}#onetrust-pc-sdk .ot-accordion-layout .ot-vlst-cntr,#onetrust-pc-sdk .ot-accordion-layout .ot-hlst-cntr{padding-left:20px;width:calc(100% - 20px);display:inline-block;margin-top:0px;padding-bottom:2px}#onetrust-pc-sdk .ot-accordion-layout .ot-acc-hdr{position:relative;min-height:25px}#onetrust-pc-sdk .ot-accordion-layout h4~.ot-tgl,#onetrust-pc-sdk .ot-accordion-layout h4~.ot-always-active{position:absolute;top:50%;transform:translateY(-50%);right:20px}#onetrust-pc-sdk .ot-accordion-layout h4~.ot-tgl+.ot-tgl{right:95px}#onetrust-pc-sdk .ot-accordion-layout .category-vendors-list-handler,#onetrust-pc-sdk .ot-accordion-layout .category-vendors-list-handler+a{margin-top:5px}#onetrust-pc-sdk .ot-enbl-chr h4~.ot-tgl,#onetrust-pc-sdk .ot-enbl-chr h4~.ot-always-active{right:45px}#onetrust-pc-sdk .ot-enbl-chr h4~.ot-tgl+.ot-tgl{right:120px}#onetrust-pc-sdk .ot-enbl-chr .ot-pli-hdr.ot-leg-border-color span:first-child{width:90px}#onetrust-pc-sdk .ot-enbl-chr li.ot-subgrp>h5+.ot-tgl-cntr{padding-right:25px}#onetrust-pc-sdk .ot-plus-minus{width:20px;height:20px;font-size:1.5em;position:relative;display:inline-block;margin-right:5px;top:3px}#onetrust-pc-sdk .ot-plus-minus span{position:absolute;background:#27455c;border-radius:1px}#onetrust-pc-sdk .ot-plus-minus span:first-of-type{top:25%;bottom:25%;width:10%;left:45%}#onetrust-pc-sdk .ot-plus-minus span:last-of-type{left:25%;right:25%;height:10%;top:45%}#onetrust-pc-sdk button[aria-expanded=true]~.ot-acc-hdr .ot-arw,#onetrust-pc-sdk button[aria-expanded=true]~.ot-acc-hdr .ot-plus-minus span:first-of-type,#onetrust-pc-sdk button[aria-expanded=true]~.ot-acc-hdr .ot-plus-minus span:last-of-type{transform:rotate(90deg)}#onetrust-pc-sdk button[aria-expanded=true]~.ot-acc-hdr .ot-plus-minus span:last-of-type{left:50%;right:50%}#onetrust-pc-sdk #ot-selall-vencntr label,#onetrust-pc-sdk #ot-selall-adtlvencntr label,#onetrust-pc-sdk #ot-selall-hostcntr label,#onetrust-pc-sdk #ot-selall-licntr label{position:relative;display:inline-block;width:20px;height:20px}#onetrust-pc-sdk .ot-host-item .ot-plus-minus,#onetrust-pc-sdk .ot-ven-item .ot-plus-minus{float:left;margin-right:8px;top:10px}#onetrust-pc-sdk .ot-ven-item ul{list-style:none inside;font-size:100%;margin:0}#onetrust-pc-sdk .ot-ven-item ul li{margin:0!important;padding:0;border:none!important}#onetrust-pc-sdk .ot-pli-hdr{color:#77808e;overflow:hidden;padding-top:7.5px;padding-bottom:7.5px;width:calc(100% - 2px);border-top-left-radius:3px;border-top-right-radius:3px}#onetrust-pc-sdk .ot-pli-hdr span:first-child{top:50%;transform:translateY(50%);max-width:90px}#onetrust-pc-sdk .ot-pli-hdr span:last-child{padding-right:10px;max-width:95px;text-align:center}#onetrust-pc-sdk .ot-li-title{float:right;font-size:.813em}#onetrust-pc-sdk .ot-pli-hdr.ot-leg-border-color{background-color:#f4f4f4;border:1px solid #d8d8d8}#onetrust-pc-sdk .ot-pli-hdr.ot-leg-border-color span:first-child{text-align:left;width:70px}#onetrust-pc-sdk li.ot-subgrp>h5,#onetrust-pc-sdk .ot-cat-header{width:calc(100% - 130px)}#onetrust-pc-sdk li.ot-subgrp>h5+.ot-tgl-cntr{padding-left:13px}#onetrust-pc-sdk .ot-acc-grpcntr .ot-acc-grpdesc{margin-bottom:5px}#onetrust-pc-sdk .ot-acc-grpcntr .ot-subgrp-cntr{border-top:1px solid #d8d8d8}#onetrust-pc-sdk .ot-acc-grpcntr .ot-vlst-cntr+.ot-subgrp-cntr{border-top:none}#onetrust-pc-sdk .ot-acc-hdr .ot-arw-cntr+.ot-tgl-cntr,#onetrust-pc-sdk .ot-acc-txt h4+.ot-tgl-cntr{padding-left:13px}#onetrust-pc-sdk .ot-pli-hdr~.ot-cat-item .ot-subgrp>h5,#onetrust-pc-sdk .ot-pli-hdr~.ot-cat-item .ot-cat-header{width:calc(100% - 145px)}#onetrust-pc-sdk .ot-pli-hdr~.ot-cat-item h5+.ot-tgl-cntr,#onetrust-pc-sdk .ot-pli-hdr~.ot-cat-item .ot-cat-header+.ot-tgl{padding-left:28px}#onetrust-pc-sdk .ot-sel-all-hdr,#onetrust-pc-sdk .ot-sel-all-chkbox{display:inline-block;width:100%;position:relative}#onetrust-pc-sdk .ot-sel-all-chkbox{z-index:1}#onetrust-pc-sdk .ot-sel-all{margin:0;position:relative;padding-right:23px;float:right}#onetrust-pc-sdk .ot-consent-hdr,#onetrust-pc-sdk .ot-li-hdr{float:right;font-size:.812em;line-height:normal;text-align:center;word-break:break-word;word-wrap:break-word}#onetrust-pc-sdk .ot-li-hdr{max-width:100px;padding-right:10px}#onetrust-pc-sdk .ot-consent-hdr{max-width:55px}#onetrust-pc-sdk #ot-selall-licntr{display:block;width:21px;height:auto;float:right;position:relative;right:80px}#onetrust-pc-sdk #ot-selall-licntr label{position:absolute}#onetrust-pc-sdk .ot-ven-ctgl{margin-left:66px}#onetrust-pc-sdk .ot-ven-litgl+.ot-arw-cntr{margin-left:81px}#onetrust-pc-sdk .ot-enbl-chr .ot-host-cnt .ot-tgl-cntr{width:auto}#onetrust-pc-sdk #ot-lst-cnt:not(.ot-host-cnt) .ot-tgl-cntr{width:auto;top:auto;height:20px}#onetrust-pc-sdk #ot-lst-cnt .ot-chkbox{position:relative;display:inline-block;width:20px;height:20px}#onetrust-pc-sdk #ot-lst-cnt .ot-chkbox label{position:absolute;padding:0;width:20px;height:20px}#onetrust-pc-sdk .ot-acc-grpdesc+.ot-leg-btn-container{padding-left:20px;padding-right:20px;width:calc(100% - 40px);margin-bottom:5px}#onetrust-pc-sdk .ot-subgrp .ot-leg-btn-container{margin-bottom:5px}#onetrust-pc-sdk #ot-ven-lst .ot-leg-btn-container{margin-top:10px}#onetrust-pc-sdk .ot-leg-btn-container{display:inline-block;width:100%;margin-bottom:10px}#onetrust-pc-sdk .ot-leg-btn-container button{height:auto;padding:6.5px 8px;margin-bottom:0;letter-spacing:0;font-size:.75em;line-height:normal}#onetrust-pc-sdk .ot-leg-btn-container svg{display:none;height:14px;width:14px;padding-right:5px;vertical-align:sub}#onetrust-pc-sdk .ot-active-leg-btn{cursor:default;pointer-events:none}#onetrust-pc-sdk .ot-active-leg-btn svg{display:inline-block}#onetrust-pc-sdk .ot-remove-objection-handler{text-decoration:underline;padding:0;font-size:.75em;font-weight:600;line-height:1;padding-left:10px}#onetrust-pc-sdk .ot-obj-leg-btn-handler span{font-weight:bold;text-align:center;font-size:inherit;line-height:1.5}#onetrust-pc-sdk.ot-close-btn-link #close-pc-btn-handler{border:none;height:auto;line-height:1.5;text-decoration:underline;font-size:.69em;background:none;right:15px;top:15px;width:auto;font-weight:normal}#onetrust-pc-sdk[dir=rtl] #ot-back-arw,#onetrust-pc-sdk[dir=rtl] input~.ot-acc-hdr .ot-arw{transform:rotate(180deg);-o-transform:rotate(180deg);-ms-transform:rotate(180deg);-webkit-transform:rotate(180deg)}#onetrust-pc-sdk[dir=rtl] input:checked~.ot-acc-hdr .ot-arw{transform:rotate(270deg);-o-transform:rotate(270deg);-ms-transform:rotate(270deg);-webkit-transform:rotate(270deg)}#onetrust-pc-sdk[dir=rtl] .ot-chkbox label::after{transform:rotate(45deg);-webkit-transform:rotate(45deg);-o-transform:rotate(45deg);-ms-transform:rotate(45deg);border-left:0;border-right:3px solid}#onetrust-pc-sdk[dir=rtl] .ot-search-cntr>svg{right:0}@media only screen and (max-width:600px){#onetrust-pc-sdk.otPcCenter{left:0;min-width:100%;height:100%;top:0;border-radius:0}#onetrust-pc-sdk #ot-pc-content,#onetrust-pc-sdk.ot-ftr-stacked .ot-btn-container{margin:1px 3px 0 10px;padding-right:10px;width:calc(100% - 23px)}#onetrust-pc-sdk .ot-btn-container button{max-width:none;letter-spacing:.01em}#onetrust-pc-sdk #close-pc-btn-handler{top:10px;right:17px}#onetrust-pc-sdk p{font-size:.7em}#onetrust-pc-sdk #ot-pc-hdr{margin:10px 10px 0 5px;width:calc(100% - 15px)}#onetrust-pc-sdk .vendor-search-handler{font-size:1em}#onetrust-pc-sdk #ot-back-arw{margin-left:12px}#onetrust-pc-sdk #ot-lst-cnt{margin:0;padding:0 5px 0 10px;min-width:95%}#onetrust-pc-sdk .switch+p{max-width:80%}#onetrust-pc-sdk .ot-ftr-stacked button{width:100%}#onetrust-pc-sdk #ot-fltr-cnt{max-width:320px;width:90%;border-top-right-radius:0;border-bottom-right-radius:0;margin:0;margin-left:15px;left:auto;right:40px;top:85px}#onetrust-pc-sdk .ot-fltr-opt{margin-left:25px;margin-bottom:10px}#onetrust-pc-sdk .ot-pc-refuse-all-handler{margin-bottom:0}#onetrust-pc-sdk #ot-fltr-cnt{right:40px}}@media only screen and (max-width:476px){#onetrust-pc-sdk .ot-fltr-cntr,#onetrust-pc-sdk #ot-fltr-cnt{right:10px}#onetrust-pc-sdk #ot-anchor{right:25px}#onetrust-pc-sdk button{width:100%}#onetrust-pc-sdk:not(.ot-addtl-vendors) #ot-pc-lst:not(.ot-enbl-chr) .ot-sel-all{padding-right:9px}#onetrust-pc-sdk:not(.ot-addtl-vendors) #ot-pc-lst:not(.ot-enbl-chr) .ot-tgl-cntr{right:0}}@media only screen and (max-width:896px)and (max-height:425px)and (orientation:landscape){#onetrust-pc-sdk.otPcCenter{left:0;top:0;min-width:100%;height:100%;border-radius:0}#onetrust-pc-sdk #ot-anchor{left:initial;right:50px}#onetrust-pc-sdk #ot-lst-title{margin-top:12px}#onetrust-pc-sdk #ot-lst-title *{font-size:inherit}#onetrust-pc-sdk #ot-pc-hdr input{margin-right:0;padding-right:45px}#onetrust-pc-sdk .switch+p{max-width:85%}#onetrust-pc-sdk #ot-sel-blk{position:static}#onetrust-pc-sdk #ot-pc-lst{overflow:auto}#onetrust-pc-sdk #ot-lst-cnt{max-height:none;overflow:initial}#onetrust-pc-sdk #ot-lst-cnt.no-results{height:auto}#onetrust-pc-sdk input{font-size:1em!important}#onetrust-pc-sdk p{font-size:.6em}#onetrust-pc-sdk #ot-fltr-modal{width:100%;top:0}#onetrust-pc-sdk ul li p,#onetrust-pc-sdk .category-vendors-list-handler,#onetrust-pc-sdk .category-vendors-list-handler+a,#onetrust-pc-sdk .category-host-list-handler{font-size:.6em}#onetrust-pc-sdk.ot-shw-fltr #ot-anchor{display:none!important}#onetrust-pc-sdk.ot-shw-fltr #ot-pc-lst{height:100%!important;overflow:hidden;top:0px}#onetrust-pc-sdk.ot-shw-fltr #ot-fltr-cnt{margin:0;height:100%;max-height:none;padding:10px;top:0;width:calc(100% - 20px);position:absolute;right:0;left:0;max-width:none}#onetrust-pc-sdk.ot-shw-fltr .ot-fltr-scrlcnt{max-height:calc(100% - 65px)}}#onetrust-consent-sdk #onetrust-pc-sdk,#onetrust-consent-sdk #ot-search-cntr,#onetrust-consent-sdk #onetrust-pc-sdk .ot-switch.ot-toggle,#onetrust-consent-sdk #onetrust-pc-sdk ot-grp-hdr1 .checkbox,#onetrust-consent-sdk #onetrust-pc-sdk #ot-pc-title:after,#onetrust-consent-sdk #onetrust-pc-sdk #ot-sel-blk,#onetrust-consent-sdk #onetrust-pc-sdk #ot-fltr-cnt,#onetrust-consent-sdk #onetrust-pc-sdk #ot-anchor{background-color:#FFFFFF}#onetrust-consent-sdk #onetrust-pc-sdk h3,#onetrust-consent-sdk #onetrust-pc-sdk h4,#onetrust-consent-sdk #onetrust-pc-sdk h5,#onetrust-consent-sdk #onetrust-pc-sdk h6,#onetrust-consent-sdk #onetrust-pc-sdk p,#onetrust-consent-sdk #onetrust-pc-sdk #ot-ven-lst .ot-ven-opts p,#onetrust-consent-sdk #onetrust-pc-sdk #ot-pc-desc,#onetrust-consent-sdk #onetrust-pc-sdk #ot-pc-title,#onetrust-consent-sdk #onetrust-pc-sdk .ot-li-title,#onetrust-consent-sdk #onetrust-pc-sdk .ot-sel-all-hdr span,#onetrust-consent-sdk #onetrust-pc-sdk #ot-host-lst .ot-host-info,#onetrust-consent-sdk #onetrust-pc-sdk #ot-fltr-modal #modal-header,#onetrust-consent-sdk #onetrust-pc-sdk .ot-checkbox label span,#onetrust-consent-sdk #onetrust-pc-sdk #ot-pc-lst #ot-sel-blk p,#onetrust-consent-sdk #onetrust-pc-sdk #ot-pc-lst #ot-lst-title h3,#onetrust-consent-sdk #onetrust-pc-sdk #ot-pc-lst .back-btn-handler p,#onetrust-consent-sdk #onetrust-pc-sdk #ot-pc-lst .ot-ven-name,#onetrust-consent-sdk #onetrust-pc-sdk #ot-pc-lst #ot-ven-lst .consent-category,#onetrust-consent-sdk #onetrust-pc-sdk .ot-leg-btn-container .ot-inactive-leg-btn,#onetrust-consent-sdk #onetrust-pc-sdk .ot-label-status,#onetrust-consent-sdk #onetrust-pc-sdk .ot-chkbox label span,#onetrust-consent-sdk #onetrust-pc-sdk #clear-filters-handler{color:#696969}#onetrust-consent-sdk #onetrust-pc-sdk .privacy-notice-link,#onetrust-consent-sdk #onetrust-pc-sdk .category-vendors-list-handler,#onetrust-consent-sdk #onetrust-pc-sdk .category-vendors-list-handler+a,#onetrust-consent-sdk #onetrust-pc-sdk .category-host-list-handler,#onetrust-consent-sdk #onetrust-pc-sdk .ot-ven-link,#onetrust-consent-sdk #onetrust-pc-sdk #ot-host-lst .ot-host-name a,#onetrust-consent-sdk #onetrust-pc-sdk #ot-host-lst .ot-acc-hdr .ot-host-expand,#onetrust-consent-sdk #onetrust-pc-sdk #ot-host-lst .ot-host-info a{color:#3860BE}#onetrust-consent-sdk #onetrust-pc-sdk .category-vendors-list-handler:hover{opacity:.7}#onetrust-consent-sdk #onetrust-pc-sdk .ot-acc-grpcntr.ot-acc-txt,#onetrust-consent-sdk #onetrust-pc-sdk .ot-acc-txt .ot-subgrp-tgl .ot-switch.ot-toggle{background-color:#F8F8F8}#onetrust-consent-sdk #onetrust-pc-sdk #ot-host-lst .ot-host-info,#onetrust-consent-sdk #onetrust-pc-sdk .ot-acc-txt .ot-ven-dets{background-color:#F8F8F8}#onetrust-consent-sdk #onetrust-pc-sdk button:not(#clear-filters-handler):not(.ot-close-icon):not(#filter-btn-handler):not(.ot-remove-objection-handler):not(.ot-obj-leg-btn-handler):not([aria-expanded]):not(.ot-link-btn),#onetrust-consent-sdk #onetrust-pc-sdk .ot-leg-btn-container .ot-active-leg-btn{background-color:#FDC301;border-color:#FDC301;color:#0e100e}#onetrust-consent-sdk #onetrust-pc-sdk .ot-active-menu{border-color:#FDC301}#onetrust-consent-sdk #onetrust-pc-sdk .ot-leg-btn-container .ot-remove-objection-handler{background-color:transparent;border:1px solid transparent}#onetrust-consent-sdk #onetrust-pc-sdk .ot-leg-btn-container .ot-inactive-leg-btn{background-color:#FFFFFF;color:#78808E;border-color:#78808E}#onetrust-consent-sdk #onetrust-pc-sdk .ot-tgl input:focus+.ot-switch,.ot-switch .ot-switch-nob,.ot-switch .ot-switch-nob:before,#onetrust-pc-sdk .ot-checkbox input[type="checkbox"]:focus+label::before,#onetrust-pc-sdk .ot-chkbox input[type="checkbox"]:focus+label::before{outline-color:#000000;outline-width:1px}#onetrust-pc-sdk .ot-host-item>button:focus,#onetrust-pc-sdk .ot-ven-item>button:focus{border:1px solid #000000}#onetrust-consent-sdk #onetrust-pc-sdk *:focus,#onetrust-consent-sdk #onetrust-pc-sdk .ot-vlst-cntr>a:focus{outline:1px solid #000000}#onetrust-consent-sdk{font-family:Arial,sans-serif}#onetrust-pc-sdk .ot-always-active{font-size:14px;font-weight:bold;color:#09a501}#onetrust-consent-sdk #onetrust-pc-sdk .active-group{border-color:#FFFFFF}#onetrust-pc-sdk .ot-button-group button{font-weight:bold}#onetrust-consent-sdk #onetrust-pc-sdk .ot-button-group .onetrust-close-btn-handler{background-color:#d8d8d8!important;border-color:#d8d8d8!important}#onetrust-consent-sdk h3{font-size:14px!important}#onetrust-pc-sdk .pc-header{padding:10px}#onetrust-pc-sdk .ot-toggle .checkbox input:checked+label:after{background:#6cc04a}#onetrust-pc-sdk #pc-policy-text a{color:inherit}#onetrust-pc-sdk #close-pc-btn-handler.ot-close-icon{display:none}#onetrust-pc-sdk .group-description ul{font-size:inherit}#onetrust-pc-sdk .group-description ul li{list-style:none}.ot-sdk-cookie-policy{font-family:inherit;font-size:16px}.ot-sdk-cookie-policy.otRelFont{font-size:1rem}.ot-sdk-cookie-policy h3,.ot-sdk-cookie-policy h4,.ot-sdk-cookie-policy h6,.ot-sdk-cookie-policy p,.ot-sdk-cookie-policy li,.ot-sdk-cookie-policy a,.ot-sdk-cookie-policy th,.ot-sdk-cookie-policy #cookie-policy-description,.ot-sdk-cookie-policy .ot-sdk-cookie-policy-group,.ot-sdk-cookie-policy #cookie-policy-title{color:dimgray}.ot-sdk-cookie-policy #cookie-policy-description{margin-bottom:1em}.ot-sdk-cookie-policy h4{font-size:1.2em}.ot-sdk-cookie-policy h6{font-size:1em;margin-top:2em}.ot-sdk-cookie-policy th{min-width:75px}.ot-sdk-cookie-policy a,.ot-sdk-cookie-policy a:hover{background:#fff}.ot-sdk-cookie-policy thead{background-color:#f6f6f4;font-weight:bold}.ot-sdk-cookie-policy .ot-mobile-border{display:none}.ot-sdk-cookie-policy section{margin-bottom:2em}.ot-sdk-cookie-policy table{border-collapse:inherit}#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy{font-family:inherit;font-size:1rem}#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy h3,#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy h4,#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy h6,#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy p,#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy li,#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy a,#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy th,#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy #cookie-policy-description,#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy .ot-sdk-cookie-policy-group,#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy #cookie-policy-title{color:dimgray}#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy #cookie-policy-description{margin-bottom:1em}#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy .ot-sdk-subgroup{margin-left:1.5em}#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy #cookie-policy-description,#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy .ot-sdk-cookie-policy-group-desc,#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy .ot-table-header,#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy a,#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy span,#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy td{font-size:.9em}#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy td span,#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy td a{font-size:inherit}#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy .ot-sdk-cookie-policy-group{font-size:1em;margin-bottom:.6em}#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy .ot-sdk-cookie-policy-title{margin-bottom:1.2em}#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy>section{margin-bottom:1em}#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy th{min-width:75px}#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy a,#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy a:hover{background:#fff}#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy thead{background-color:#f6f6f4;font-weight:bold}#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy .ot-mobile-border{display:none}#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy section{margin-bottom:2em}#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy .ot-sdk-subgroup ul li{list-style:disc;margin-left:1.5em}#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy .ot-sdk-subgroup ul li h4{display:inline-block}#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy table{border-collapse:inherit;margin:auto;border:1px solid #d7d7d7;border-radius:5px;border-spacing:initial;width:100%;overflow:hidden}#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy table th,#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy table td{border-bottom:1px solid #d7d7d7;border-right:1px solid #d7d7d7}#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy table tr:last-child td{border-bottom:0px}#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy table tr th:last-child,#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy table tr td:last-child{border-right:0px}#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy table .ot-host,#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy table .ot-cookies-type{width:25%}.ot-sdk-cookie-policy[dir=rtl]{text-align:left}#ot-sdk-cookie-policy h3{font-size:1.5em}@media only screen and (max-width:530px){.ot-sdk-cookie-policy:not(#ot-sdk-cookie-policy-v2) table,.ot-sdk-cookie-policy:not(#ot-sdk-cookie-policy-v2) thead,.ot-sdk-cookie-policy:not(#ot-sdk-cookie-policy-v2) tbody,.ot-sdk-cookie-policy:not(#ot-sdk-cookie-policy-v2) th,.ot-sdk-cookie-policy:not(#ot-sdk-cookie-policy-v2) td,.ot-sdk-cookie-policy:not(#ot-sdk-cookie-policy-v2) tr{display:block}.ot-sdk-cookie-policy:not(#ot-sdk-cookie-policy-v2) thead tr{position:absolute;top:-9999px;left:-9999px}.ot-sdk-cookie-policy:not(#ot-sdk-cookie-policy-v2) tr{margin:0 0 1em 0}.ot-sdk-cookie-policy:not(#ot-sdk-cookie-policy-v2) tr:nth-child(odd),.ot-sdk-cookie-policy:not(#ot-sdk-cookie-policy-v2) tr:nth-child(odd) a{background:#f6f6f4}.ot-sdk-cookie-policy:not(#ot-sdk-cookie-policy-v2) td{border:none;border-bottom:1px solid #eee;position:relative;padding-left:50%}.ot-sdk-cookie-policy:not(#ot-sdk-cookie-policy-v2) td:before{position:absolute;height:100%;left:6px;width:40%;padding-right:10px}.ot-sdk-cookie-policy:not(#ot-sdk-cookie-policy-v2) .ot-mobile-border{display:inline-block;background-color:#e4e4e4;position:absolute;height:100%;top:0;left:45%;width:2px}.ot-sdk-cookie-policy:not(#ot-sdk-cookie-policy-v2) td:before{content:attr(data-label);font-weight:bold}.ot-sdk-cookie-policy:not(#ot-sdk-cookie-policy-v2) li{word-break:break-word;word-wrap:break-word}#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy table{overflow:hidden}#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy table td{border:none;border-bottom:1px solid #d7d7d7}#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy table,#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy thead,#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy tbody,#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy th,#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy td,#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy tr{display:block}#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy table .ot-host,#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy table .ot-cookies-type{width:auto}#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy tr{margin:0 0 1em 0}#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy td:before{height:100%;width:40%;padding-right:10px}#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy td:before{content:attr(data-label);font-weight:bold}#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy li{word-break:break-word;word-wrap:break-word}#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy thead tr{position:absolute;top:-9999px;left:-9999px;z-index:-9999}#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy table tr:last-child td{border-bottom:1px solid #d7d7d7;border-right:0px}#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy table tr:last-child td:last-child{border-bottom:0px}}#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy h5,#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy h6,#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy li,#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy p,#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy a,#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy span,#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy td,#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy #cookie-policy-description{color:#696969}#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy th{color:#696969}#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy .ot-sdk-cookie-policy-group{color:#696969}#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy #cookie-policy-title{color:#696969}#ot-sdk-cookie-policy-v2.ot-sdk-cookie-policy table th{background-color:#F8F8F8}.ot-floating-button__front{background-image:url(resources/sample/11.png)}#ot-sdk-btn-floating.ot-floating-button{position:fixed;bottom:10px;opacity:0;width:50px;height:50px;line-height:15px;cursor:pointer;background-color:transparent;transform-style:preserve-3d;transition:all 300ms ease;perspective:1000px;z-index:2147483646;animation:otFloatingBtnIntro 800ms ease 0ms 1 forwards}#ot-sdk-btn-floating.ot-floating-button.ot-hide{display:none}#ot-sdk-btn-floating.ot-floating-button::before,#ot-sdk-btn-floating.ot-floating-button::after{text-transform:none;line-height:1;user-select:none;pointer-events:none;position:absolute;transform:scale(0);opacity:0;transition:all 300ms ease;display:block;height:auto}#ot-sdk-btn-floating.ot-floating-button::before{content:"";border:5px solid transparent;z-index:1001;top:50%;border-left-width:0;border-right-color:#333;right:calc(0em - 5px);transform:translate(10px,-50%)}#ot-sdk-btn-floating.ot-floating-button::after{content:attr(title);position:absolute;text-align:center;top:50%;left:calc(100% + 5px);transform:translate(10px,-50%);font-size:.75rem;min-width:3em;max-width:21em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding:5px;border-radius:.3ch;box-shadow:0 1em 2em -0.5em rgba(0,0,0,.35);background:#333;color:#fff;z-index:2147483645}#ot-sdk-btn-floating.ot-floating-button:hover::before,#ot-sdk-btn-floating.ot-floating-button:hover::after{opacity:1}#ot-sdk-btn-floating.ot-floating-button:hover::before{transform:translate(0.5em,-50%) scale(1)}#ot-sdk-btn-floating.ot-floating-button:hover::after{transform:translate(0.5em,-50%) scale(1)}#ot-sdk-btn-floating.ot-floating-button.ot-pc-open .ot-floating-button__front{transform:rotateY(-180deg)}#ot-sdk-btn-floating.ot-floating-button.ot-pc-open .ot-floating-button__back{transform:rotateY(0)}#ot-sdk-btn-floating .ot-floating-button__front,#ot-sdk-btn-floating .ot-floating-button__back{position:absolute;width:100%;height:100%;-webkit-backface-visibility:hidden;backface-visibility:hidden;background-color:#6aaae4;border-radius:10px;box-shadow:0 4px 8px 0 rgba(0,0,0,.2);transition:transform .6s;transform-style:preserve-3d}#ot-sdk-btn-floating .ot-floating-button__front{background-color:#6aaae4;transform:rotateY(0)}#ot-sdk-btn-floating .ot-floating-button__front.custom-persistent-icon{background-position:center center;background-repeat:no-repeat;background-size:100%;border-radius:100px}#ot-sdk-btn-floating .ot-floating-button__front svg{width:30px;height:37px}#ot-sdk-btn-floating .ot-floating-button__back{background-color:#69c;transform:rotateY(-180deg)}#ot-sdk-btn-floating .ot-floating-button__back.custom-persistent-icon{background-position:center center;background-repeat:no-repeat;background-size:100%;border-radius:100px}#ot-sdk-btn-floating .ot-floating-button__back svg{width:24px;height:24px}#ot-sdk-btn-floating.ot-floating-button button{background-color:transparent;border:0;width:100%;height:100%;cursor:pointer}@keyframes otFloatingBtnIntro{0%{opacity:0;left:-75px}100%{opacity:1;left:1%}}@keyframes otFloatingBtnImageIntro{0%{opacity:0;transform:scale(0) rotate(-270deg)}100%{opacity:100%;transform:scale(0.95) rotate(0deg)}}</style><link type="image/x-icon" rel="shortcut icon" href="resources/sample/12.gif"><link rel="canonical" href="https://www.asda.com/account?request_origin=asda&amp;redirect_uri=https%3A%2F%2Fwww.asda.com%2Fgood-living%2Ftag%2Frecipes"><meta http-equiv="content-security-policy" content="default-src 'none'; font-src 'self' data:; img-src 'self' data:; style-src 'unsafe-inline'; media-src 'self' data:; script-src 'unsafe-inline' data:;"></head><body><div id="app"><main class="theme-ghs" style><div class="app"><header><div class="header-container"><a id="logo" href="https://www.asda.com/" target="_self" tabindex="1" rel="noreferrer"><img src="resources/sample/13.svg" alt="Asda.com homepage"></a><nav><button id="need-help" class="basic need-help" type="button" tabindex="2" aria-live="polite">Need help?</button><button id="back-shop" class="basic back-shop" type="button" tabindex="3" aria-live="polite">Home</button></nav></div></header><div class="co-account"><h1 class="co-account__title">Account settings</h1><h4 class="co-account__description">Select a section below to view and edit your details</h4><div class="panel "><button class="panel__header "><h3 class="panel__title">Personal details</h3><span class="panel__toggle">View</span></button></div><div class="panel "><button class="panel__header "><h3 class="panel__title">Sign in details</h3><span class="panel__toggle">View</span></button></div><div class="acc-block"><div class="panel address-phone-book "><button class="panel__header "><h3 class="panel__title">Address &amp; phone book</h3><span class="panel__toggle">View</span></button></div></div><div class="wallet-card"><div class="panel open"><button class="panel__header add-bottom-border"><h3 class="panel__title">Wallet</h3><span class="panel__toggle">Close</span></button><main class="panel__main"><h3 class="empty-header-name">Payment Cards</h3><div class="co-add-card"><div class="custom-icon input-box"><label for="card-number">Card number</label><div class="input-box__wrapper false"><input type="tel" class=" error" placeholder spellcheck="false" autocomplete="off" maxlength="255" id="card-number" tabindex="0" aria-label="Card number" value data-fathom="cc-number"><img src="resources/sample/14.svg" alt="alert" class="alert"></div><div class="input-error" role="alert">Sorry, that's not a valid card number</div></div><div class="input-box"><label for="add-card-name">Name on card:</label><div class="input-box__wrapper false"><input type="text" class=" error" placeholder spellcheck="false" autocomplete="off" maxlength="255" id="add-card-name" tabindex="0" aria-label="Name on card:" value data-fathom="cc-name"><img src="resources/sample/14.svg" alt="alert" class="alert"></div><div class="input-error" role="alert">We do not recognise that name</div></div><div class="input-box"><label for="add-card-expiry">Expiry date</label><div class="input-box__wrapper false"><input type="tel" class=" error" placeholder="MM/YY" spellcheck="false" autocomplete="off" maxlength="5" id="add-card-expiry" tabindex="0" aria-label="Expiry date" value data-fathom="cc-exp"><img src="resources/sample/14.svg" alt="alert" class="alert"></div><div class="input-error" role="alert">Enter expiry date</div></div><h4 class="address-form-title">Search for your postcode or <button type="button" class="link-toggle">type out address in full</button></h4><div class="half input-box"><label for="account-postcode-finder">Your postcode*</label><div class="input-box__wrapper false"><input type="text" class="half-width error" placeholder spellcheck="false" autocomplete="off" maxlength="255" id="account-postcode-finder" tabindex="0" aria-label="Your postcode*" value data-fathom="zip"><img src="resources/sample/14.svg" alt="alert" class="alert"></div><div class="input-error" role="alert">Enter your postcode</div></div><div class="half last input-box"><label for="account-postcode-finder-house">House number or name</label><div class="input-box__wrapper false"><input type="text" class="half-width " placeholder spellcheck="false" autocomplete="off" maxlength="255" id="account-postcode-finder-house" tabindex="0" aria-label="House number or name" value></div></div><button id class="secondary full find-button" type="button" tabindex="0" aria-live="polite">Find</button></div></main></div></div><div class="permission-centre"><div class="panel "><button class="panel__header "><h3 class="panel__title">Permissions</h3><span class="panel__toggle">View</span></button></div></div></div><footer><div class="footer-wrapper"><div class="footer-top"><a class="website-feedback-link" href="https://ghs-optin-issues.asda.com/" target="_blank" rel="noopener noreferrer"><img class="feedback-icon" src="resources/sample/15.svg" alt="feedback icon"><span>Website Feedback</span></a></div><div class="footer-main"><div class="footer-logo"><img src="resources/sample/16.svg" alt="Asda"></div><div class="footer-links"><ul><li>© ASDA 2022</li><li><a href="https://groceries.asda.com/terms-and-conditions" target="_blank" rel="noopener noreferrer">Terms &amp; Conditions</a></li><li><a href="https://www.asda.com/privacy" target="_blank" rel="noopener noreferrer">Privacy Centre</a></li><li><a href="https://www.asda.com/help/company-details" target="_blank" rel="noopener noreferrer">Asda Company Details</a></li></ul></div></div></div></footer></div></main></div><noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-NHVQ6SB" height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript><iframe style="display:none;visibility:hidden" sandbox="allow-popups allow-top-navigation allow-top-navigation-by-user-activation" srcdoc="<!DOCTYPE html PUBLIC &quot;-//W3C//DTD HTML 4.01 Transitional//EN&quot; &quot;http://www.w3.org/TR/html4/loose.dtd&quot;> <html><head><meta charset=&quot;utf-8&quot;><title></title><meta http-equiv=&quot;content-security-policy&quot; content=&quot;default-src 'none'; font-src 'self' data:; img-src 'self' data:; style-src 'unsafe-inline'; media-src 'self' data:; script-src 'unsafe-inline' data:;&quot;></head><body style=&quot;background-color:transparent&quot;><iframe style=&quot;display:none&quot; sandbox=&quot;allow-popups allow-top-navigation allow-top-navigation-by-user-activation&quot; srcdoc=&quot;<!DOCTYPE html PUBLIC &amp;quot;-//W3C//DTD HTML 4.01 Transitional//EN&amp;quot; &amp;quot;http://www.w3.org/TR/html4/loose.dtd&amp;quot;> <html><head><meta charset=&amp;quot;utf-8&amp;quot;><title></title><meta http-equiv=&amp;quot;content-security-policy&amp;quot; content=&amp;quot;default-src 'none'; font-src 'self' data:; img-src 'self' data:; style-src 'unsafe-inline'; media-src 'self' data:; script-src 'unsafe-inline' data:;&amp;quot;></head><body style=&amp;quot;background-color:transparent&amp;quot;><iframe style=&amp;quot;display:none&amp;quot; sandbox=&amp;quot;allow-popups allow-top-navigation allow-top-navigation-by-user-activation&amp;quot; srcdoc=&amp;quot;<!DOCTYPE html PUBLIC &amp;amp;quot;-//W3C//DTD HTML 4.01 Transitional//EN&amp;amp;quot; &amp;amp;quot;http://www.w3.org/TR/html4/loose.dtd&amp;amp;quot;> <html><head><meta charset=&amp;amp;quot;utf-8&amp;amp;quot;><title></title><meta http-equiv=&amp;amp;quot;content-security-policy&amp;amp;quot; content=&amp;amp;quot;default-src 'none'; font-src 'self' data:; img-src 'self' data:; style-src 'unsafe-inline'; media-src 'self' data:; script-src 'unsafe-inline' data:;&amp;amp;quot;></head><body style=&amp;amp;quot;background-color:transparent&amp;amp;quot;></body></html>&amp;quot; width=&amp;quot;1&amp;quot; height=&amp;quot;1&amp;quot; frameborder=&amp;quot;0&amp;quot;></iframe></body></html>&quot; width=&quot;1&quot; height=&quot;1&quot; frameborder=&quot;0&quot;></iframe></body></html>" width="0" height="0"></iframe><iframe style="display:none;visibility:hidden" sandbox="allow-popups allow-top-navigation allow-top-navigation-by-user-activation" srcdoc="<!DOCTYPE html PUBLIC &quot;-//W3C//DTD HTML 4.01 Transitional//EN&quot; &quot;http://www.w3.org/TR/html4/loose.dtd&quot;> <html><head><meta charset=&quot;utf-8&quot;><title></title><meta http-equiv=&quot;content-security-policy&quot; content=&quot;default-src 'none'; font-src 'self' data:; img-src 'self' data:; style-src 'unsafe-inline'; media-src 'self' data:; script-src 'unsafe-inline' data:;&quot;></head><body style=&quot;background-color:transparent&quot;><iframe style=&quot;display:none&quot; sandbox=&quot;allow-popups allow-top-navigation allow-top-navigation-by-user-activation&quot; srcdoc=&quot;<!DOCTYPE html PUBLIC &amp;quot;-//W3C//DTD HTML 4.01 Transitional//EN&amp;quot; &amp;quot;http://www.w3.org/TR/html4/loose.dtd&amp;quot;> <html><head><meta charset=&amp;quot;utf-8&amp;quot;><title></title><meta http-equiv=&amp;quot;content-security-policy&amp;quot; content=&amp;quot;default-src 'none'; font-src 'self' data:; img-src 'self' data:; style-src 'unsafe-inline'; media-src 'self' data:; script-src 'unsafe-inline' data:;&amp;quot;></head><body style=&amp;quot;background-color:transparent&amp;quot;><iframe style=&amp;quot;display:none&amp;quot; sandbox=&amp;quot;allow-popups allow-top-navigation allow-top-navigation-by-user-activation&amp;quot; srcdoc=&amp;quot;<!DOCTYPE html PUBLIC &amp;amp;quot;-//W3C//DTD HTML 4.01 Transitional//EN&amp;amp;quot; &amp;amp;quot;http://www.w3.org/TR/html4/loose.dtd&amp;amp;quot;> <html><head><meta charset=&amp;amp;quot;utf-8&amp;amp;quot;><title></title><meta http-equiv=&amp;amp;quot;content-security-policy&amp;amp;quot; content=&amp;amp;quot;default-src 'none'; font-src 'self' data:; img-src 'self' data:; style-src 'unsafe-inline'; media-src 'self' data:; script-src 'unsafe-inline' data:;&amp;amp;quot;></head><body style=&amp;amp;quot;background-color:transparent&amp;amp;quot;></body></html>&amp;quot; width=&amp;quot;1&amp;quot; height=&amp;quot;1&amp;quot; frameborder=&amp;quot;0&amp;quot;></iframe></body></html>&quot; width=&quot;1&quot; height=&quot;1&quot; frameborder=&quot;0&quot;></iframe></body></html>" width="0" height="0"></iframe><iframe style="display:none" name="__tcfapiLocator" title="CMP Locator" sandbox="allow-popups allow-top-navigation allow-top-navigation-by-user-activation" srcdoc="<html><head><meta charset=&quot;utf-8&quot;><meta http-equiv=&quot;content-security-policy&quot; content=&quot;default-src 'none'; font-src 'self' data:; img-src 'self' data:; style-src 'unsafe-inline'; media-src 'self' data:; script-src 'unsafe-inline' data:;&quot;></head><body></body></html>"></iframe><div id="onetrust-consent-sdk" class="onetrust-consent-sdk-box" data-apply-ot-banner-popup="false"><div class="onetrust-pc-dark-filter ot-hide ot-fade-in"></div><div id="onetrust-pc-sdk" class="otPcCenter ot-hide ot-fade-in ot-sdk-not-webkit" aria-modal="true" role="alertdialog" aria-label="How we protect your privacy" lang="en"><!-- Close Button --><div class="ot-pc-header"><!-- Logo Tag --><div class="ot-pc-logo" role="img" aria-label="Company Logo" style="background-image:url(resources/sample/17.bin);background-position:left"></div></div><!-- Close Button --><div id="ot-pc-content" class="ot-pc-scrollbar"><h2 id="ot-pc-title">How we protect your privacy</h2><div id="ot-pc-desc">We process your data to deliver content or advertisements and measure the delivery of such content or advertisements to extract insights about our website. We share this information with our partners on the basis of consent. You may exercise your right to consent, based on a specific purpose below or at a partner level in the link under each purpose. These choices will be signaled to our vendors participating in the Transparency and Consent Framework.</div><button id="accept-recommended-btn-handler">Allow All</button><section class="ot-sdk-row ot-cat-grp"><h3 id="ot-category-title"> Manage Consent Preferences</h3><div class="ot-accordion-layout ot-cat-item" data-optanongroupid="1"><button aria-expanded="false" ot-accordion="true" aria-controls="ot-desc-id-1" aria-labelledby="ot-header-id-1"></button><!-- Accordion header --><div class="ot-acc-hdr ot-always-active-group"><div class="ot-plus-minus"><span></span><span></span></div><h4 class="ot-cat-header" id="ot-header-id-1">Essential Cookies</h4><div class="ot-always-active">Always Active</div></div><!-- accordion detail --><div class="ot-acc-grpcntr ot-acc-txt"><p class="ot-acc-grpdesc ot-category-desc" id="ot-desc-id-1">Essential cookies are always on. These are technical cookies that are required for the operation of our sites. Without using these our sites can’t operate properly.</p></div></div><div class="ot-accordion-layout ot-cat-item" data-optanongroupid="2"><button aria-expanded="false" ot-accordion="true" aria-controls="ot-desc-id-2" aria-labelledby="ot-header-id-2"></button><!-- Accordion header --><div class="ot-acc-hdr"><div class="ot-plus-minus"><span></span><span></span></div><h4 class="ot-cat-header" id="ot-header-id-2">Experience Cookies</h4><div class="ot-tgl"><input type="checkbox" name="ot-group-id-2" id="ot-group-id-2" aria-checked="true" role="switch" class="category-switch-handler" data-optanongroupid="2" checked aria-labelledby="ot-header-id-2"> <label class="ot-switch" for="ot-group-id-2"><span class="ot-switch-nob"></span> <span class="ot-label-txt">Experience Cookies</span></label> </div></div><!-- accordion detail --><div class="ot-acc-grpcntr ot-acc-txt"><p class="ot-acc-grpdesc ot-category-desc" id="ot-desc-id-2">Right now only some of these can be turned off. We’re working on the rest. Experience cookies allow Asda to recognise and count visitors to our sites.
+This means we can check our online services are working and help us make improvements on our online services.
+Experience cookies also allow us to remember the choices you make, such as the items in your basket or your display preferences.</p></div></div><div class="ot-accordion-layout ot-cat-item" data-optanongroupid="4"><button aria-expanded="false" ot-accordion="true" aria-controls="ot-desc-id-4" aria-labelledby="ot-header-id-4"></button><!-- Accordion header --><div class="ot-acc-hdr"><div class="ot-plus-minus"><span></span><span></span></div><h4 class="ot-cat-header" id="ot-header-id-4">Asda’s Advertising Cookies</h4><div class="ot-tgl"><input type="checkbox" name="ot-group-id-4" id="ot-group-id-4" aria-checked="true" role="switch" class="category-switch-handler" data-optanongroupid="4" checked aria-labelledby="ot-header-id-4"> <label class="ot-switch" for="ot-group-id-4"><span class="ot-switch-nob"></span> <span class="ot-label-txt">Asda’s Advertising Cookies</span></label> </div></div><!-- accordion detail --><div class="ot-acc-grpcntr ot-acc-txt"><p class="ot-acc-grpdesc ot-category-desc" id="ot-desc-id-4">We may record your visits to our websites, the pages you have visited and the links you have followed.
+We use this information, along with other information about you as a customer, to help make our advertising relevant to your interests both on our sites and other sites you may visit.
+We also use these to limit the number of times that you see an ad and to inform us how effective a particular ad has been.
+</p></div></div><div class="ot-accordion-layout ot-cat-item" data-optanongroupid="STACK42"><button aria-expanded="false" ot-accordion="true" aria-controls="ot-desc-id-STACK42" aria-labelledby="ot-header-id-STACK42"></button><!-- Accordion header --><div class="ot-acc-hdr"><div class="ot-plus-minus"><span></span><span></span></div><h4 class="ot-cat-header" id="ot-header-id-STACK42">Personalised ads and content, ad and content measurement, audience insights and product development</h4><div class="ot-tgl"><input type="checkbox" name="ot-group-id-STACK42" id="ot-group-id-STACK42" aria-checked="true" role="switch" class="category-switch-handler" data-optanongroupid="STACK42" checked aria-labelledby="ot-header-id-STACK42"> <label class="ot-switch" for="ot-group-id-STACK42"><span class="ot-switch-nob"></span> <span class="ot-label-txt">Personalised ads and content, ad and content measurement, audience insights and product development</span></label> </div></div><!-- accordion detail --><div class="ot-acc-grpcntr ot-acc-txt"><div class="ot-subgrp-cntr"><ul class="ot-subgrps"><li class="ot-subgrp" data-optanongroupid="IABV2_2"><h5>Select basic ads</h5><div class="ot-tgl-cntr ot-subgrp-tgl"><div class="ot-tgl"><input type="checkbox" name="switch" id="ot-sub-group-id-IABV2_2" aria-checked="false" role="switch" data-optanongroupid="IABV2_2" class="cookie-subgroup-handler" aria-label="Select basic ads" checked> <label class="ot-switch" for="ot-sub-group-id-IABV2_2"><span class="ot-switch-nob"></span> <span class="ot-label-txt">Switch Label</span></label> </div></div><p class="ot-subgrp-desc">Ads can be shown to you based on the content you’re viewing, the app you’re using, your approximate location, or your device type.</p></li></ul></div><div class="ot-subgrp-cntr"><ul class="ot-subgrps"><li class="ot-subgrp" data-optanongroupid="IABV2_3"><h5>Create a personalised ads profile</h5><div class="ot-tgl-cntr ot-subgrp-tgl"><div class="ot-tgl"><input type="checkbox" name="switch" id="ot-sub-group-id-IABV2_3" aria-checked="false" role="switch" data-optanongroupid="IABV2_3" class="cookie-subgroup-handler" aria-label="Create a personalised ads profile" checked> <label class="ot-switch" for="ot-sub-group-id-IABV2_3"><span class="ot-switch-nob"></span> <span class="ot-label-txt">Switch Label</span></label> </div></div><p class="ot-subgrp-desc">A profile can be built about you and your interests to show you personalised ads that are relevant to you.</p></li></ul></div><div class="ot-subgrp-cntr"><ul class="ot-subgrps"><li class="ot-subgrp" data-optanongroupid="IABV2_4"><h5>Select personalised ads</h5><div class="ot-tgl-cntr ot-subgrp-tgl"><div class="ot-tgl"><input type="checkbox" name="switch" id="ot-sub-group-id-IABV2_4" aria-checked="false" role="switch" data-optanongroupid="IABV2_4" class="cookie-subgroup-handler" aria-label="Select personalised ads" checked> <label class="ot-switch" for="ot-sub-group-id-IABV2_4"><span class="ot-switch-nob"></span> <span class="ot-label-txt">Switch Label</span></label> </div></div><p class="ot-subgrp-desc">Personalised ads can be shown to you based on a profile about you.</p></li></ul></div><div class="ot-subgrp-cntr"><ul class="ot-subgrps"><li class="ot-subgrp" data-optanongroupid="IABV2_5"><h5>Create a personalised content profile</h5><div class="ot-tgl-cntr ot-subgrp-tgl"><div class="ot-tgl"><input type="checkbox" name="switch" id="ot-sub-group-id-IABV2_5" aria-checked="false" role="switch" data-optanongroupid="IABV2_5" class="cookie-subgroup-handler" aria-label="Create a personalised content profile" checked> <label class="ot-switch" for="ot-sub-group-id-IABV2_5"><span class="ot-switch-nob"></span> <span class="ot-label-txt">Switch Label</span></label> </div></div><p class="ot-subgrp-desc">A profile can be built about you and your interests to show you personalised content that is relevant to you.</p></li></ul></div><div class="ot-subgrp-cntr"><ul class="ot-subgrps"><li class="ot-subgrp" data-optanongroupid="IABV2_6"><h5>Select personalised content</h5><div class="ot-tgl-cntr ot-subgrp-tgl"><div class="ot-tgl"><input type="checkbox" name="switch" id="ot-sub-group-id-IABV2_6" aria-checked="false" role="switch" data-optanongroupid="IABV2_6" class="cookie-subgroup-handler" aria-label="Select personalised content" checked> <label class="ot-switch" for="ot-sub-group-id-IABV2_6"><span class="ot-switch-nob"></span> <span class="ot-label-txt">Switch Label</span></label> </div></div><p class="ot-subgrp-desc">Personalised content can be shown to you based on a profile about you.</p></li></ul></div><div class="ot-subgrp-cntr"><ul class="ot-subgrps"><li class="ot-subgrp" data-optanongroupid="IABV2_7"><h5>Measure ad performance</h5><div class="ot-tgl-cntr ot-subgrp-tgl"><div class="ot-tgl"><input type="checkbox" name="switch" id="ot-sub-group-id-IABV2_7" aria-checked="false" role="switch" data-optanongroupid="IABV2_7" class="cookie-subgroup-handler" aria-label="Measure ad performance" checked> <label class="ot-switch" for="ot-sub-group-id-IABV2_7"><span class="ot-switch-nob"></span> <span class="ot-label-txt">Switch Label</span></label> </div></div><p class="ot-subgrp-desc">The performance and effectiveness of ads that you see or interact with can be measured.</p></li></ul></div><div class="ot-subgrp-cntr"><ul class="ot-subgrps"><li class="ot-subgrp" data-optanongroupid="IABV2_8"><h5>Measure content performance</h5><div class="ot-tgl-cntr ot-subgrp-tgl"><div class="ot-tgl"><input type="checkbox" name="switch" id="ot-sub-group-id-IABV2_8" aria-checked="false" role="switch" data-optanongroupid="IABV2_8" class="cookie-subgroup-handler" aria-label="Measure content performance" checked> <label class="ot-switch" for="ot-sub-group-id-IABV2_8"><span class="ot-switch-nob"></span> <span class="ot-label-txt">Switch Label</span></label> </div></div><p class="ot-subgrp-desc">The performance and effectiveness of content that you see or interact with can be measured.</p></li></ul></div><div class="ot-subgrp-cntr"><ul class="ot-subgrps"><li class="ot-subgrp" data-optanongroupid="IABV2_9"><h5>Apply market research to generate audience insights</h5><div class="ot-tgl-cntr ot-subgrp-tgl"><div class="ot-tgl"><input type="checkbox" name="switch" id="ot-sub-group-id-IABV2_9" aria-checked="false" role="switch" data-optanongroupid="IABV2_9" class="cookie-subgroup-handler" aria-label="Apply market research to generate audience insights" checked> <label class="ot-switch" for="ot-sub-group-id-IABV2_9"><span class="ot-switch-nob"></span> <span class="ot-label-txt">Switch Label</span></label> </div></div><p class="ot-subgrp-desc">Market research can be used to learn more about the audiences who visit sites/apps and view ads.</p></li></ul></div><div class="ot-subgrp-cntr"><ul class="ot-subgrps"><li class="ot-subgrp" data-optanongroupid="IABV2_10"><h5>Develop and improve products</h5><div class="ot-tgl-cntr ot-subgrp-tgl"><div class="ot-tgl"><input type="checkbox" name="switch" id="ot-sub-group-id-IABV2_10" aria-checked="false" role="switch" data-optanongroupid="IABV2_10" class="cookie-subgroup-handler" aria-label="Develop and improve products" checked> <label class="ot-switch" for="ot-sub-group-id-IABV2_10"><span class="ot-switch-nob"></span> <span class="ot-label-txt">Switch Label</span></label> </div></div><p class="ot-subgrp-desc">Your data can be used to improve existing systems and software, and to develop new products</p></li></ul></div><div class="ot-vlst-cntr"><button class="ot-link-btn category-vendors-list-handler" aria-label="IAB Vendor Details button opens Vendor List menu" data-parent-id="STACK42">List of IAB Vendors‎</button><a href="https://tcf.cookiepedia.co.uk/?lang=en" rel="noopener" target="_blank">&nbsp;|&nbsp;View Full Legal Text&nbsp;<span class="ot-scrn-rdr">Opens in a new Tab</span></a></div></div></div><div class="ot-accordion-layout ot-cat-item" data-optanongroupid="IABV2_1"><button aria-expanded="false" ot-accordion="true" aria-controls="ot-desc-id-IABV2_1" aria-labelledby="ot-header-id-IABV2_1"></button><!-- Accordion header --><div class="ot-acc-hdr"><div class="ot-plus-minus"><span></span><span></span></div><h4 class="ot-cat-header" id="ot-header-id-IABV2_1">Store and/or access information on a device</h4><div class="ot-tgl"><input type="checkbox" name="ot-group-id-IABV2_1" id="ot-group-id-IABV2_1" aria-checked="true" role="switch" class="category-switch-handler" data-optanongroupid="IABV2_1" checked aria-labelledby="ot-header-id-IABV2_1"> <label class="ot-switch" for="ot-group-id-IABV2_1"><span class="ot-switch-nob"></span> <span class="ot-label-txt">Store and/or access information on a device</span></label> </div></div><!-- accordion detail --><div class="ot-acc-grpcntr ot-acc-txt"><p class="ot-acc-grpdesc ot-category-desc" id="ot-desc-id-IABV2_1">Cookies, device identifiers, or other information can be stored or accessed on your device for the purposes presented to you.</p><div class="ot-vlst-cntr"><button class="ot-link-btn category-vendors-list-handler" aria-label="IAB Vendor Details button opens Vendor List menu" data-parent-id="IABV2_1">List of IAB Vendors‎</button><a href="https://tcf.cookiepedia.co.uk/?lang=en" rel="noopener" target="_blank">&nbsp;|&nbsp;View Full Legal Text&nbsp;<span class="ot-scrn-rdr">Opens in a new Tab</span></a></div></div></div><div class="ot-accordion-layout ot-cat-item" data-optanongroupid="ISFV2_1"><button aria-expanded="false" ot-accordion="true" aria-controls="ot-desc-id-ISFV2_1" aria-labelledby="ot-header-id-ISFV2_1"></button><!-- Accordion header --><div class="ot-acc-hdr"><div class="ot-plus-minus"><span></span><span></span></div><h4 class="ot-cat-header" id="ot-header-id-ISFV2_1">Use precise geolocation data</h4><div class="ot-tgl"><input type="checkbox" name="ot-group-id-ISFV2_1" id="ot-group-id-ISFV2_1" aria-checked="true" role="switch" class="category-switch-handler" data-optanongroupid="ISFV2_1" checked aria-labelledby="ot-header-id-ISFV2_1"> <label class="ot-switch" for="ot-group-id-ISFV2_1"><span class="ot-switch-nob"></span> <span class="ot-label-txt">Use precise geolocation data</span></label> </div></div><!-- accordion detail --><div class="ot-acc-grpcntr ot-acc-txt"><p class="ot-acc-grpdesc ot-category-desc" id="ot-desc-id-ISFV2_1">Your precise geolocation data can be used in support of one or more purposes. This means your location can be accurate to within several meters.</p><div class="ot-vlst-cntr"><button class="ot-link-btn category-vendors-list-handler" aria-label="IAB Vendor Details button opens Vendor List menu" data-parent-id="ISFV2_1">List of IAB Vendors‎</button><a href="https://tcf.cookiepedia.co.uk/?lang=en" rel="noopener" target="_blank">&nbsp;|&nbsp;View Full Legal Text&nbsp;<span class="ot-scrn-rdr">Opens in a new Tab</span></a></div></div></div><div class="ot-accordion-layout ot-cat-item" data-optanongroupid="ISFV2_2"><button aria-expanded="false" ot-accordion="true" aria-controls="ot-desc-id-ISFV2_2" aria-labelledby="ot-header-id-ISFV2_2"></button><!-- Accordion header --><div class="ot-acc-hdr"><div class="ot-plus-minus"><span></span><span></span></div><h4 class="ot-cat-header" id="ot-header-id-ISFV2_2">Actively scan device characteristics for identification</h4><div class="ot-tgl"><input type="checkbox" name="ot-group-id-ISFV2_2" id="ot-group-id-ISFV2_2" aria-checked="true" role="switch" class="category-switch-handler" data-optanongroupid="ISFV2_2" checked aria-labelledby="ot-header-id-ISFV2_2"> <label class="ot-switch" for="ot-group-id-ISFV2_2"><span class="ot-switch-nob"></span> <span class="ot-label-txt">Actively scan device characteristics for identification</span></label> </div></div><!-- accordion detail --><div class="ot-acc-grpcntr ot-acc-txt"><p class="ot-acc-grpdesc ot-category-desc" id="ot-desc-id-ISFV2_2">Your device can be identified based on a scan of your device's unique combination of characteristics.</p><div class="ot-vlst-cntr"><button class="ot-link-btn category-vendors-list-handler" aria-label="IAB Vendor Details button opens Vendor List menu" data-parent-id="ISFV2_2">List of IAB Vendors‎</button><a href="https://tcf.cookiepedia.co.uk/?lang=en" rel="noopener" target="_blank">&nbsp;|&nbsp;View Full Legal Text&nbsp;<span class="ot-scrn-rdr">Opens in a new Tab</span></a></div></div></div><div class="ot-accordion-layout ot-cat-item" data-optanongroupid="ISPV2_1"><button aria-expanded="false" ot-accordion="true" aria-controls="ot-desc-id-ISPV2_1" aria-labelledby="ot-header-id-ISPV2_1"></button><!-- Accordion header --><div class="ot-acc-hdr ot-always-active-group"><div class="ot-plus-minus"><span></span><span></span></div><h4 class="ot-cat-header" id="ot-header-id-ISPV2_1">Ensure security, prevent fraud, and debug</h4><div class="ot-always-active">Always Active</div></div><!-- accordion detail --><div class="ot-acc-grpcntr ot-acc-txt"><p class="ot-acc-grpdesc ot-category-desc" id="ot-desc-id-ISPV2_1">Your data can be used to monitor for and prevent fraudulent activity, and ensure systems and processes work properly and securely.</p><div class="ot-vlst-cntr"><button class="ot-link-btn category-vendors-list-handler" aria-label="IAB Vendor Details button opens Vendor List menu" data-parent-id="ISPV2_1">List of IAB Vendors‎</button><a href="https://tcf.cookiepedia.co.uk/?lang=en" rel="noopener" target="_blank">&nbsp;|&nbsp;View Full Legal Text&nbsp;<span class="ot-scrn-rdr">Opens in a new Tab</span></a></div></div></div><div class="ot-accordion-layout ot-cat-item" data-optanongroupid="ISPV2_2"><button aria-expanded="false" ot-accordion="true" aria-controls="ot-desc-id-ISPV2_2" aria-labelledby="ot-header-id-ISPV2_2"></button><!-- Accordion header --><div class="ot-acc-hdr ot-always-active-group"><div class="ot-plus-minus"><span></span><span></span></div><h4 class="ot-cat-header" id="ot-header-id-ISPV2_2">Technically deliver ads or content</h4><div class="ot-always-active">Always Active</div></div><!-- accordion detail --><div class="ot-acc-grpcntr ot-acc-txt"><p class="ot-acc-grpdesc ot-category-desc" id="ot-desc-id-ISPV2_2">Your device can receive and send information that allows you to see and interact with ads and content.</p><div class="ot-vlst-cntr"><button class="ot-link-btn category-vendors-list-handler" aria-label="IAB Vendor Details button opens Vendor List menu" data-parent-id="ISPV2_2">List of IAB Vendors‎</button><a href="https://tcf.cookiepedia.co.uk/?lang=en" rel="noopener" target="_blank">&nbsp;|&nbsp;View Full Legal Text&nbsp;<span class="ot-scrn-rdr">Opens in a new Tab</span></a></div></div></div><div class="ot-accordion-layout ot-cat-item" data-optanongroupid="IFEV2_1"><button aria-expanded="false" ot-accordion="true" aria-controls="ot-desc-id-IFEV2_1" aria-labelledby="ot-header-id-IFEV2_1"></button><!-- Accordion header --><div class="ot-acc-hdr ot-always-active-group"><div class="ot-plus-minus"><span></span><span></span></div><h4 class="ot-cat-header" id="ot-header-id-IFEV2_1">Match and combine offline data sources</h4><div class="ot-always-active">Always Active</div></div><!-- accordion detail --><div class="ot-acc-grpcntr ot-acc-txt"><p class="ot-acc-grpdesc ot-category-desc" id="ot-desc-id-IFEV2_1">Data from offline data sources can be combined with your online activity in support of one or more purposes</p><div class="ot-vlst-cntr"><button class="ot-link-btn category-vendors-list-handler" aria-label="IAB Vendor Details button opens Vendor List menu" data-parent-id="IFEV2_1">List of IAB Vendors‎</button><a href="https://tcf.cookiepedia.co.uk/?lang=en" rel="noopener" target="_blank">&nbsp;|&nbsp;View Full Legal Text&nbsp;<span class="ot-scrn-rdr">Opens in a new Tab</span></a></div></div></div><div class="ot-accordion-layout ot-cat-item" data-optanongroupid="IFEV2_2"><button aria-expanded="false" ot-accordion="true" aria-controls="ot-desc-id-IFEV2_2" aria-labelledby="ot-header-id-IFEV2_2"></button><!-- Accordion header --><div class="ot-acc-hdr ot-always-active-group"><div class="ot-plus-minus"><span></span><span></span></div><h4 class="ot-cat-header" id="ot-header-id-IFEV2_2">Link different devices</h4><div class="ot-always-active">Always Active</div></div><!-- accordion detail --><div class="ot-acc-grpcntr ot-acc-txt"><p class="ot-acc-grpdesc ot-category-desc" id="ot-desc-id-IFEV2_2">Different devices can be determined as belonging to you or your household in support of one or more of purposes.</p><div class="ot-vlst-cntr"><button class="ot-link-btn category-vendors-list-handler" aria-label="IAB Vendor Details button opens Vendor List menu" data-parent-id="IFEV2_2">List of IAB Vendors‎</button><a href="https://tcf.cookiepedia.co.uk/?lang=en" rel="noopener" target="_blank">&nbsp;|&nbsp;View Full Legal Text&nbsp;<span class="ot-scrn-rdr">Opens in a new Tab</span></a></div></div></div><div class="ot-accordion-layout ot-cat-item" data-optanongroupid="IFEV2_3"><button aria-expanded="false" ot-accordion="true" aria-controls="ot-desc-id-IFEV2_3" aria-labelledby="ot-header-id-IFEV2_3"></button><!-- Accordion header --><div class="ot-acc-hdr ot-always-active-group"><div class="ot-plus-minus"><span></span><span></span></div><h4 class="ot-cat-header" id="ot-header-id-IFEV2_3">Receive and use automatically-sent device characteristics for identification</h4><div class="ot-always-active">Always Active</div></div><!-- accordion detail --><div class="ot-acc-grpcntr ot-acc-txt"><p class="ot-acc-grpdesc ot-category-desc" id="ot-desc-id-IFEV2_3">Your device might be distinguished from other devices based on information it automatically sends, such as IP address or browser type.</p><div class="ot-vlst-cntr"><button class="ot-link-btn category-vendors-list-handler" aria-label="IAB Vendor Details button opens Vendor List menu" data-parent-id="IFEV2_3">List of IAB Vendors‎</button><a href="https://tcf.cookiepedia.co.uk/?lang=en" rel="noopener" target="_blank">&nbsp;|&nbsp;View Full Legal Text&nbsp;<span class="ot-scrn-rdr">Opens in a new Tab</span></a></div></div></div><!-- Groups sections starts --><!-- Group section ends --><!-- Accordion Group section starts --><!-- Accordion Group section ends --></section></div><section id="ot-pc-lst" class="ot-hide ot-hosts-ui ot-pc-scrollbar"><div id="ot-pc-hdr"><div id="ot-lst-title"><button class="ot-link-btn back-btn-handler" aria-label="Back"><svg id="ot-back-arw" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 444.531 444.531" xml:space="preserve"><title>Back Button</title><g><path fill="#656565" d="M213.13,222.409L351.88,83.653c7.05-7.043,10.567-15.657,10.567-25.841c0-10.183-3.518-18.793-10.567-25.835
+ l-21.409-21.416C323.432,3.521,314.817,0,304.637,0s-18.791,3.521-25.841,10.561L92.649,196.425
+ c-7.044,7.043-10.566,15.656-10.566,25.841s3.521,18.791,10.566,25.837l186.146,185.864c7.05,7.043,15.66,10.564,25.841,10.564
+ s18.795-3.521,25.834-10.564l21.409-21.412c7.05-7.039,10.567-15.604,10.567-25.697c0-10.085-3.518-18.746-10.567-25.978
+ L213.13,222.409z"></path></g></svg></button><h3>Performance Cookies</h3></div><div class="ot-lst-subhdr"><div class="ot-search-cntr"><p role="status" class="ot-scrn-rdr"></p><label for="vendor-search-handler" class="ot-scrn-rdr"></label> <input id="vendor-search-handler" type="text" name="vendor-search-handler" value> <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 -30 110 110" aria-hidden="true"><title>Search Icon</title><path fill="#2e3644" d="M55.146,51.887L41.588,37.786c3.486-4.144,5.396-9.358,5.396-14.786c0-12.682-10.318-23-23-23s-23,10.318-23,23
+ s10.318,23,23,23c4.761,0,9.298-1.436,13.177-4.162l13.661,14.208c0.571,0.593,1.339,0.92,2.162,0.92
+ c0.779,0,1.518-0.297,2.079-0.837C56.255,54.982,56.293,53.08,55.146,51.887z M23.984,6c9.374,0,17,7.626,17,17s-7.626,17-17,17
+ s-17-7.626-17-17S14.61,6,23.984,6z"></path></svg></div><div class="ot-fltr-cntr"><button id="filter-btn-handler" aria-label="Filter" aria-haspopup="true"><svg role="presentation" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 402.577 402.577" xml:space="preserve"><title>Filter Icon</title><g><path fill="#fff" d="M400.858,11.427c-3.241-7.421-8.85-11.132-16.854-11.136H18.564c-7.993,0-13.61,3.715-16.846,11.136
+ c-3.234,7.801-1.903,14.467,3.999,19.985l140.757,140.753v138.755c0,4.955,1.809,9.232,5.424,12.854l73.085,73.083
+ c3.429,3.614,7.71,5.428,12.851,5.428c2.282,0,4.66-0.479,7.135-1.43c7.426-3.238,11.14-8.851,11.14-16.845V172.166L396.861,31.413
+ C402.765,25.895,404.093,19.231,400.858,11.427z"></path></g></svg></button></div><div id="ot-anchor"></div><section id="ot-fltr-modal"><div id="ot-fltr-cnt"><button id="clear-filters-handler">Clear</button><div class="ot-fltr-scrlcnt ot-pc-scrollbar"><div class="ot-fltr-opts"><div class="ot-fltr-opt"><div class="ot-chkbox"><input id="chkbox-id" type="checkbox" aria-checked="false" class="category-filter-handler"> <label for="chkbox-id"><span class="ot-label-txt">checkbox label</span></label> <span class="ot-label-status">label</span></div></div></div><div class="ot-fltr-btns"><button id="filter-apply-handler">Apply</button> <button id="filter-cancel-handler">Cancel</button></div></div></div></section></div></div><section id="ot-lst-cnt" class="ot-host-cnt ot-pc-scrollbar"><div id="ot-sel-blk"><div class="ot-sel-all"><div class="ot-sel-all-hdr"><span class="ot-consent-hdr">Consent</span> <span class="ot-li-hdr">Leg.Interest</span></div><div class="ot-sel-all-chkbox"><div class="ot-chkbox" id="ot-selall-hostcntr"><input id="select-all-hosts-groups-handler" type="checkbox" aria-checked="false"> <label for="select-all-hosts-groups-handler"><span class="ot-label-txt">checkbox label</span></label> <span class="ot-label-status">label</span></div><div class="ot-chkbox" id="ot-selall-vencntr"><input id="select-all-vendor-groups-handler" type="checkbox" aria-checked="false"> <label for="select-all-vendor-groups-handler"><span class="ot-label-txt">checkbox label</span></label> <span class="ot-label-status">label</span></div><div class="ot-chkbox" id="ot-selall-licntr"><input id="select-all-vendor-leg-handler" type="checkbox" aria-checked="false"> <label for="select-all-vendor-leg-handler"><span class="ot-label-txt">checkbox label</span></label> <span class="ot-label-status">label</span></div></div></div></div><div class="ot-sdk-row"><div class="ot-sdk-column"><ul id="ot-ven-lst"></ul></div></div></section></section><div class="ot-pc-footer"><div class="ot-btn-container"> <button class="save-preference-btn-handler onetrust-close-btn-handler">Confirm My Choices</button></div><!-- Footer logo --><div class="ot-pc-footer-logo"><a href="https://www.onetrust.com/products/cookie-consent/" target="_blank" rel="noopener noreferrer" style="background-image:url(resources/sample/18.svg)" aria-label="Powered by OneTrust Opens in a new Tab"></a></div></div><!-- Cookie subgroup container --><!-- Vendor list link --><!-- Cookie lost link --><!-- Toggle HTML element --><!-- Checkbox HTML --><!-- plus minus--><!-- Arrow SVG element --><!-- Accordion basic element --><span class="ot-scrn-rdr" aria-atomic="true" aria-live="polite"></span><iframe class="ot-text-resize" title="onetrust-text-resize" style="position:absolute;top:-50000px;width:100em" aria-hidden="true" sandbox="allow-popups allow-top-navigation allow-top-navigation-by-user-activation" srcdoc="<html><head><meta charset=&quot;utf-8&quot;><meta http-equiv=&quot;content-security-policy&quot; content=&quot;default-src 'none'; font-src 'self' data:; img-src 'self' data:; style-src 'unsafe-inline'; media-src 'self' data:; script-src 'unsafe-inline' data:;&quot;></head><body></body></html>"></iframe></div><div id="ot-sdk-btn-floating" title="Manage Preferences" class="ot-floating-button"><div class="ot-floating-button__front custom-persistent-icon"><button type="button" class="ot-floating-button__open" aria-label="Open Preferences"></button></div><div class="ot-floating-button__back custom-persistent-icon"><button type="button" class="ot-floating-button__close"><!--?xml version="1.0" encoding="UTF-8"?--> <svg role="presentation" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg"><g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"><g id="Banner_02" class="ot-floating-button__svg-fill" transform="translate(-318.000000, -725.000000)" fill="#ffffff" fill-rule="nonzero"><g id="Group-2" transform="translate(305.000000, 712.000000)"><g id="icon/16px/white/close"><polygon id="Line1" points="13.3333333 14.9176256 35.0823744 36.6666667 36.6666667 35.0823744 14.9176256 13.3333333"></polygon><polygon id="Line2" transform="translate(25.000000, 25.000000) scale(-1, 1) translate(-25.000000, -25.000000) " points="13.3333333 14.9176256 35.0823744 36.6666667 36.6666667 35.0823744 14.9176256 13.3333333"></polygon></g></g></g></g></svg></button></div></div></div></body></html>
diff --git a/browser/extensions/formautofill/test/browser/focus-leak/browser.ini b/browser/extensions/formautofill/test/browser/focus-leak/browser.ini
new file mode 100644
index 0000000000..fc4aca4a35
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/focus-leak/browser.ini
@@ -0,0 +1,12 @@
+[DEFAULT]
+support-files =
+ doc_iframe_typecontent_input_focus.xhtml
+ doc_iframe_typecontent_input_focus_frame.html
+ ../head.js
+
+# This test is used to detect a leak.
+# Keep it isolated in a dedicated test folder to make sure the leak is cleaned
+# up as a sideeffect of another test.
+[browser_iframe_typecontent_input_focus.js]
+skip-if =
+ apple_silicon # Disabled due to bleedover with other tests when run in regular suites; passes in "failures" jobs
diff --git a/browser/extensions/formautofill/test/browser/focus-leak/browser_iframe_typecontent_input_focus.js b/browser/extensions/formautofill/test/browser/focus-leak/browser_iframe_typecontent_input_focus.js
new file mode 100644
index 0000000000..407c2b5163
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/focus-leak/browser_iframe_typecontent_input_focus.js
@@ -0,0 +1,56 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const URL_ROOT =
+ "chrome://mochitests/content/browser/browser/extensions/formautofill/test/browser/focus-leak/";
+
+const XUL_FRAME_URI = URL_ROOT + "doc_iframe_typecontent_input_focus.xhtml";
+const INNER_HTML_FRAME_URI =
+ URL_ROOT + "doc_iframe_typecontent_input_focus_frame.html";
+
+/**
+ * Check that focusing an input in a frame with type=content embedded in a xul
+ * document does not leak.
+ */
+add_task(async function () {
+ const doc = gBrowser.ownerDocument;
+ const linkedBrowser = gBrowser.selectedTab.linkedBrowser;
+ const browserContainer = gBrowser.getBrowserContainer(linkedBrowser);
+
+ info("Load the test page in a frame with type content");
+ const frame = doc.createXULElement("iframe");
+ frame.setAttribute("type", "content");
+ browserContainer.appendChild(frame);
+
+ info("Wait for the xul iframe to be loaded");
+ const onXulFrameLoad = BrowserTestUtils.waitForEvent(frame, "load", true);
+ frame.setAttribute("src", XUL_FRAME_URI);
+ await onXulFrameLoad;
+
+ const panelFrame = frame.contentDocument.querySelector("#html-iframe");
+
+ info("Wait for the html iframe to be loaded");
+ const onFrameLoad = BrowserTestUtils.waitForEvent(panelFrame, "load", true);
+ panelFrame.setAttribute("src", INNER_HTML_FRAME_URI);
+ await onFrameLoad;
+
+ info("Focus an input inside the iframe");
+ const focusMeInput = panelFrame.contentDocument.querySelector(".focusme");
+ const onFocus = BrowserTestUtils.waitForEvent(focusMeInput, "focus");
+ await SimpleTest.promiseFocus(panelFrame);
+ focusMeInput.focus();
+ await onFocus;
+
+ // This assert is not really meaningful, the main purpose of the test is
+ // to check against leaks.
+ is(
+ focusMeInput,
+ panelFrame.contentDocument.activeElement,
+ "The .focusme input is the active element"
+ );
+
+ info("Remove the focused input");
+ focusMeInput.remove();
+});
diff --git a/browser/extensions/formautofill/test/browser/focus-leak/doc_iframe_typecontent_input_focus.xhtml b/browser/extensions/formautofill/test/browser/focus-leak/doc_iframe_typecontent_input_focus.xhtml
new file mode 100644
index 0000000000..ec3aaa2648
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/focus-leak/doc_iframe_typecontent_input_focus.xhtml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml">
+ <box>
+ <html:iframe id="html-iframe"/>
+ </box>
+</window>
diff --git a/browser/extensions/formautofill/test/browser/focus-leak/doc_iframe_typecontent_input_focus_frame.html b/browser/extensions/formautofill/test/browser/focus-leak/doc_iframe_typecontent_input_focus_frame.html
new file mode 100644
index 0000000000..00853d8eec
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/focus-leak/doc_iframe_typecontent_input_focus_frame.html
@@ -0,0 +1,6 @@
+<!DOCTYPE html>
+<html>
+ <body>
+ <input type="text" name="test" class="focusme">
+ </body>
+</html>
diff --git a/browser/extensions/formautofill/test/browser/head.js b/browser/extensions/formautofill/test/browser/head.js
new file mode 100644
index 0000000000..2dd3d1451e
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/head.js
@@ -0,0 +1,1094 @@
+"use strict";
+
+const { OSKeyStore } = ChromeUtils.importESModule(
+ "resource://gre/modules/OSKeyStore.sys.mjs"
+);
+const { OSKeyStoreTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/OSKeyStoreTestUtils.sys.mjs"
+);
+
+const { FormAutofillParent } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillParent.sys.mjs"
+);
+
+const MANAGE_ADDRESSES_DIALOG_URL =
+ "chrome://formautofill/content/manageAddresses.xhtml";
+const MANAGE_CREDIT_CARDS_DIALOG_URL =
+ "chrome://formautofill/content/manageCreditCards.xhtml";
+const EDIT_ADDRESS_DIALOG_URL =
+ "chrome://formautofill/content/editAddress.xhtml";
+const EDIT_CREDIT_CARD_DIALOG_URL =
+ "chrome://formautofill/content/editCreditCard.xhtml";
+const PRIVACY_PREF_URL = "about:preferences#privacy";
+
+const HTTP_TEST_PATH = "/browser/browser/extensions/formautofill/test/browser/";
+const BASE_URL = "http://mochi.test:8888" + HTTP_TEST_PATH;
+const FORM_URL = BASE_URL + "autocomplete_basic.html";
+const ADDRESS_FORM_URL =
+ "https://example.org" +
+ HTTP_TEST_PATH +
+ "address/autocomplete_address_basic.html";
+const ADDRESS_FORM_WITHOUT_AUTOCOMPLETE_URL =
+ "https://example.org" +
+ HTTP_TEST_PATH +
+ "address/without_autocomplete_address_basic.html";
+const CREDITCARD_FORM_URL =
+ "https://example.org" +
+ HTTP_TEST_PATH +
+ "creditCard/autocomplete_creditcard_basic.html";
+const CREDITCARD_FORM_IFRAME_URL =
+ "https://example.org" +
+ HTTP_TEST_PATH +
+ "creditCard/autocomplete_creditcard_iframe.html";
+const CREDITCARD_FORM_COMBINED_EXPIRY_URL =
+ "https://example.org" +
+ HTTP_TEST_PATH +
+ "creditCard/autocomplete_creditcard_cc_exp_field.html";
+const CREDITCARD_FORM_WITHOUT_AUTOCOMPLETE_URL =
+ "https://example.org" +
+ HTTP_TEST_PATH +
+ "creditCard/without_autocomplete_creditcard_basic.html";
+const EMPTY_URL = "https://example.org" + HTTP_TEST_PATH + "empty.html";
+
+const FTU_PREF = "extensions.formautofill.firstTimeUse";
+const ENABLED_AUTOFILL_ADDRESSES_PREF =
+ "extensions.formautofill.addresses.enabled";
+const ENABLED_AUTOFILL_ADDRESSES_CAPTURE_PREF =
+ "extensions.formautofill.addresses.capture.enabled";
+const AUTOFILL_ADDRESSES_AVAILABLE_PREF =
+ "extensions.formautofill.addresses.supported";
+const ENABLED_AUTOFILL_ADDRESSES_SUPPORTED_COUNTRIES_PREF =
+ "extensions.formautofill.addresses.supportedCountries";
+const AUTOFILL_CREDITCARDS_AVAILABLE_PREF =
+ "extensions.formautofill.creditCards.supported";
+const ENABLED_AUTOFILL_CREDITCARDS_PREF =
+ "extensions.formautofill.creditCards.enabled";
+const SUPPORTED_COUNTRIES_PREF = "extensions.formautofill.supportedCountries";
+const SYNC_USERNAME_PREF = "services.sync.username";
+const SYNC_ADDRESSES_PREF = "services.sync.engine.addresses";
+const SYNC_CREDITCARDS_PREF = "services.sync.engine.creditcards";
+const SYNC_CREDITCARDS_AVAILABLE_PREF =
+ "services.sync.engine.creditcards.available";
+
+const TEST_ADDRESS_1 = {
+ "given-name": "John",
+ "additional-name": "R.",
+ "family-name": "Smith",
+ organization: "World Wide Web Consortium",
+ "street-address": "32 Vassar Street\nMIT Room 32-G524",
+ "address-level2": "Cambridge",
+ "address-level1": "MA",
+ "postal-code": "02139",
+ country: "US",
+ tel: "+16172535702",
+ email: "timbl@w3.org",
+};
+
+const TEST_ADDRESS_2 = {
+ "given-name": "Anonymouse",
+ "street-address": "Some Address",
+ country: "US",
+};
+
+const TEST_ADDRESS_3 = {
+ "given-name": "John",
+ "street-address": "Other Address",
+ "postal-code": "12345",
+};
+
+const TEST_ADDRESS_4 = {
+ "given-name": "Timothy",
+ "family-name": "Berners-Lee",
+ organization: "World Wide Web Consortium",
+ "street-address": "32 Vassar Street\nMIT Room 32-G524",
+ country: "US",
+ email: "timbl@w3.org",
+};
+
+// TODO: Number of field less than AUTOFILL_FIELDS_THRESHOLD
+// need to confirm whether this is intentional
+const TEST_ADDRESS_5 = {
+ tel: "+16172535702",
+};
+
+const TEST_ADDRESS_CA_1 = {
+ "given-name": "John",
+ "additional-name": "R.",
+ "family-name": "Smith",
+ organization: "Mozilla",
+ "street-address": "163 W Hastings\nSuite 209",
+ "address-level2": "Vancouver",
+ "address-level1": "BC",
+ "postal-code": "V6B 1H5",
+ country: "CA",
+ tel: "+17787851540",
+ email: "timbl@w3.org",
+};
+
+const TEST_ADDRESS_DE_1 = {
+ "given-name": "John",
+ "additional-name": "R.",
+ "family-name": "Smith",
+ organization: "Mozilla",
+ "street-address":
+ "Geb\u00E4ude 3, 4. Obergeschoss\nSchlesische Stra\u00DFe 27",
+ "address-level2": "Berlin",
+ "postal-code": "10997",
+ country: "DE",
+ tel: "+4930983333000",
+ email: "timbl@w3.org",
+};
+
+const TEST_ADDRESS_IE_1 = {
+ "given-name": "Bob",
+ "additional-name": "Z.",
+ "family-name": "Builder",
+ organization: "Best Co.",
+ "street-address": "123 Kilkenny St.",
+ "address-level3": "Some Townland",
+ "address-level2": "Dublin",
+ "address-level1": "Co. Dublin",
+ "postal-code": "A65 F4E2",
+ country: "IE",
+ tel: "+13534564947391",
+ email: "ie@example.com",
+};
+
+const TEST_CREDIT_CARD_1 = {
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 4,
+ "cc-exp-year": new Date().getFullYear(),
+};
+
+const TEST_CREDIT_CARD_2 = {
+ "cc-name": "Timothy Berners-Lee",
+ "cc-number": "4929001587121045",
+ "cc-exp-month": 12,
+ "cc-exp-year": new Date().getFullYear() + 10,
+};
+
+const TEST_CREDIT_CARD_3 = {
+ "cc-number": "5103059495477870",
+ "cc-exp-month": 1,
+ "cc-exp-year": 2000,
+};
+
+const TEST_CREDIT_CARD_4 = {
+ "cc-number": "5105105105105100",
+};
+
+const TEST_CREDIT_CARD_5 = {
+ "cc-name": "Chris P. Bacon",
+ "cc-number": "4012888888881881",
+};
+
+const MAIN_BUTTON = "button";
+const SECONDARY_BUTTON = "secondaryButton";
+const MENU_BUTTON = "menubutton";
+
+/**
+ * Collection of timeouts that are used to ensure something should not happen.
+ */
+const TIMEOUT_ENSURE_PROFILE_NOT_SAVED = 1000;
+const TIMEOUT_ENSURE_CC_DIALOG_NOT_CLOSED = 500;
+const TIMEOUT_ENSURE_AUTOCOMPLETE_NOT_SHOWN = 1000;
+
+async function ensureCreditCardDialogNotClosed(win) {
+ const unloadHandler = () => {
+ ok(false, "Credit card dialog shouldn't be closed");
+ };
+ win.addEventListener("unload", unloadHandler);
+ await new Promise(resolve =>
+ setTimeout(resolve, TIMEOUT_ENSURE_CC_DIALOG_NOT_CLOSED)
+ );
+ win.removeEventListener("unload", unloadHandler);
+}
+
+function getDisplayedPopupItems(
+ browser,
+ selector = ".autocomplete-richlistitem"
+) {
+ info("getDisplayedPopupItems");
+ const {
+ autoCompletePopup: { richlistbox: itemsBox },
+ } = browser;
+ const listItemElems = itemsBox.querySelectorAll(selector);
+
+ return [...listItemElems].filter(
+ item => item.getAttribute("collapsed") != "true"
+ );
+}
+
+async function sleep(ms = 500) {
+ await new Promise(resolve => setTimeout(resolve, ms));
+}
+
+async function ensureNoAutocompletePopup(browser) {
+ await new Promise(resolve =>
+ setTimeout(resolve, TIMEOUT_ENSURE_AUTOCOMPLETE_NOT_SHOWN)
+ );
+ const items = getDisplayedPopupItems(browser);
+ ok(!items.length, "Should not found autocomplete items");
+}
+
+/**
+ * Wait for "formautofill-storage-changed" events
+ *
+ * @param {Array<string>} eventTypes
+ * eventType must be one of the following:
+ * `add`, `update`, `remove`, `notifyUsed`, `removeAll`, `reconcile`
+ *
+ * @returns {Promise} resolves when all events are received
+ */
+async function waitForStorageChangedEvents(...eventTypes) {
+ return Promise.all(
+ eventTypes.map(type =>
+ TestUtils.topicObserved(
+ "formautofill-storage-changed",
+ (subject, data) => {
+ return data == type;
+ }
+ )
+ )
+ );
+}
+
+/**
+ * Wait until the element found matches the expected autofill value
+ *
+ * @param {object} target
+ * The target in which to run the task.
+ * @param {string} selector
+ * A selector used to query the element.
+ * @param {string} value
+ * The expected autofilling value for the element
+ */
+async function waitForAutofill(target, selector, value) {
+ await SpecialPowers.spawn(
+ target,
+ [selector, value],
+ async function (selector, val) {
+ await ContentTaskUtils.waitForCondition(() => {
+ let element = content.document.querySelector(selector);
+ return element.value == val;
+ }, "Autofill never fills");
+ }
+ );
+}
+
+/**
+ * Waits for the subDialog to be loaded
+ *
+ * @param {Window} win The window of the dialog
+ * @param {string} dialogUrl The url of the dialog that we are waiting for
+ *
+ * @returns {Promise} resolves when the sub dialog is loaded
+ */
+function waitForSubDialogLoad(win, dialogUrl) {
+ return new Promise((resolve, reject) => {
+ win.gSubDialog._dialogStack.addEventListener(
+ "dialogopen",
+ async function dialogopen(evt) {
+ let cwin = evt.detail.dialog._frame.contentWindow;
+ if (cwin.location != dialogUrl) {
+ return;
+ }
+ content.gSubDialog._dialogStack.removeEventListener(
+ "dialogopen",
+ dialogopen
+ );
+
+ resolve(cwin);
+ }
+ );
+ });
+}
+
+/**
+ * Use this function when you want to update the value of elements in
+ * a form and then submit the form. This function makes sure the form
+ * is "identified" (`identifyAutofillFields` is called) before submitting
+ * the form.
+ * This is guaranteed by first focusing on an element in the form to trigger
+ * the 'FormAutofill:FieldsIdentified' message.
+ *
+ * @param {object} target
+ * The target in which to run the task.
+ * @param {object} args
+ * @param {string} args.focusSelector
+ * A selector used to query the element to be focused
+ * @param {string} args.formId
+ * The id of the form to be updated. This function uses "form" if
+ * this argument is not present
+ * @param {string} args.formSelector
+ * A selector used to query the form element
+ * @param {object} args.newValues
+ * Elements to be updated. Key is the element selector, value is the
+ * new value of the element.
+ *
+ * @param {boolean} submit
+ * Set to true to submit the form after the task is done, false otherwise.
+ */
+async function focusUpdateSubmitForm(target, args, submit = true) {
+ let fieldsIdentifiedPromiseResolver;
+ let fieldsIdentifiedObserver = {
+ fieldsIdentified() {
+ FormAutofillParent.removeMessageObserver(fieldsIdentifiedObserver);
+ fieldsIdentifiedPromiseResolver();
+ },
+ };
+
+ let fieldsIdentifiedPromise = new Promise(resolve => {
+ fieldsIdentifiedPromiseResolver = resolve;
+ FormAutofillParent.addMessageObserver(fieldsIdentifiedObserver);
+ });
+
+ let alreadyFocused = await SpecialPowers.spawn(target, [args], obj => {
+ let focused = false;
+
+ let form;
+ if (obj.formSelector) {
+ form = content.document.querySelector(obj.formSelector);
+ } else {
+ form = content.document.getElementById(obj.formId ?? "form");
+ }
+ let element = form.querySelector(obj.focusSelector);
+ if (element != content.document.activeElement) {
+ info(`focus on element (id=${element.id})`);
+ element.focus();
+ } else {
+ focused = true;
+ }
+
+ for (const [selector, value] of Object.entries(obj.newValues)) {
+ element = form.querySelector(selector);
+ if (content.HTMLInputElement.isInstance(element)) {
+ element.setUserInput(value);
+ } else {
+ element.value = value;
+ }
+ }
+
+ return focused;
+ });
+
+ if (alreadyFocused) {
+ // If the element is already focused, assume the FieldsIdentified message
+ // was sent before.
+ fieldsIdentifiedPromiseResolver();
+ }
+
+ await fieldsIdentifiedPromise;
+
+ if (submit) {
+ await SpecialPowers.spawn(target, [args], obj => {
+ let form;
+ if (obj.formSelector) {
+ form = content.document.querySelector(obj.formSelector);
+ } else {
+ form = content.document.getElementById(obj.formId ?? "form");
+ }
+ info(`submit form (id=${form.id})`);
+ form.querySelector("input[type=submit]").click();
+ });
+ }
+}
+
+async function focusAndWaitForFieldsIdentified(browserOrContext, selector) {
+ info("expecting the target input being focused and identified");
+ /* eslint no-shadow: ["error", { "allow": ["selector", "previouslyFocused", "previouslyIdentified"] }] */
+
+ // If the input is previously focused, no more notifications will be
+ // sent as the notification goes along with focus event.
+ let fieldsIdentifiedPromiseResolver;
+ let fieldsIdentifiedObserver = {
+ fieldsIdentified() {
+ fieldsIdentifiedPromiseResolver();
+ },
+ };
+
+ let fieldsIdentifiedPromise = new Promise(resolve => {
+ fieldsIdentifiedPromiseResolver = resolve;
+ FormAutofillParent.addMessageObserver(fieldsIdentifiedObserver);
+ });
+
+ const { previouslyFocused, previouslyIdentified } = await SpecialPowers.spawn(
+ browserOrContext,
+ [selector],
+ async function (selector) {
+ const { FormLikeFactory } = ChromeUtils.importESModule(
+ "resource://gre/modules/FormLikeFactory.sys.mjs"
+ );
+ const input = content.document.querySelector(selector);
+ const rootElement = FormLikeFactory.findRootForField(input);
+ const previouslyFocused = content.document.activeElement == input;
+ const previouslyIdentified = rootElement.hasAttribute(
+ "test-formautofill-identified"
+ );
+
+ input.focus();
+
+ return { previouslyFocused, previouslyIdentified };
+ }
+ );
+
+ // Only wait for the fields identified notification if the
+ // focus was not previously assigned to the input.
+ if (previouslyFocused) {
+ fieldsIdentifiedPromiseResolver();
+ } else {
+ info("!previouslyFocused");
+ }
+
+ // If a browsing context was supplied, focus its parent frame as well.
+ if (
+ BrowsingContext.isInstance(browserOrContext) &&
+ browserOrContext.parent != browserOrContext
+ ) {
+ await SpecialPowers.spawn(
+ browserOrContext.parent,
+ [browserOrContext],
+ async function (browsingContext) {
+ browsingContext.embedderElement.focus();
+ }
+ );
+ }
+
+ if (previouslyIdentified) {
+ info("previouslyIdentified");
+ FormAutofillParent.removeMessageObserver(fieldsIdentifiedObserver);
+ return;
+ }
+
+ // Wait 500ms to ensure that "markAsAutofillField" is completely finished.
+ await fieldsIdentifiedPromise;
+ info("FieldsIdentified");
+ FormAutofillParent.removeMessageObserver(fieldsIdentifiedObserver);
+
+ await sleep();
+ await SpecialPowers.spawn(browserOrContext, [], async function () {
+ const { FormLikeFactory } = ChromeUtils.importESModule(
+ "resource://gre/modules/FormLikeFactory.sys.mjs"
+ );
+ FormLikeFactory.findRootForField(
+ content.document.activeElement
+ ).setAttribute("test-formautofill-identified", "true");
+ });
+}
+
+/**
+ * Run the task and wait until the autocomplete popup is opened.
+ *
+ * @param {object} browser A xul:browser.
+ * @param {Function} taskFn Task that will trigger the autocomplete popup
+ */
+async function runAndWaitForAutocompletePopupOpen(browser, taskFn) {
+ info("runAndWaitForAutocompletePopupOpen");
+ let popupShown = BrowserTestUtils.waitForPopupEvent(
+ browser.autoCompletePopup,
+ "shown"
+ );
+
+ // Run the task will open the autocomplete popup
+ await taskFn();
+
+ await popupShown;
+ await BrowserTestUtils.waitForMutationCondition(
+ browser.autoCompletePopup.richlistbox,
+ { childList: true, subtree: true, attributes: true },
+ () => {
+ const listItemElems = getDisplayedPopupItems(browser);
+ return (
+ !![...listItemElems].length &&
+ [...listItemElems].every(item => {
+ return (
+ (item.getAttribute("originaltype") == "autofill-profile" ||
+ item.getAttribute("originaltype") == "autofill-insecureWarning" ||
+ item.getAttribute("originaltype") == "autofill-clear-button" ||
+ item.getAttribute("originaltype") == "autofill-footer") &&
+ item.hasAttribute("formautofillattached")
+ );
+ })
+ );
+ }
+ );
+}
+
+async function waitForPopupEnabled(browser) {
+ const {
+ autoCompletePopup: { richlistbox: itemsBox },
+ } = browser;
+ info("Wait for list elements to become enabled");
+ await BrowserTestUtils.waitForMutationCondition(
+ itemsBox,
+ { subtree: true, attributes: true, attributeFilter: ["disabled"] },
+ () => !itemsBox.querySelectorAll(".autocomplete-richlistitem")[0].disabled
+ );
+}
+
+// Wait for the popup state change notification to happen in a child process.
+function waitPopupStateInChild(bc, messageName) {
+ return SpecialPowers.spawn(bc, [messageName], expectedMessage => {
+ return new Promise(resolve => {
+ const { AutoCompleteChild } = ChromeUtils.importESModule(
+ "resource://gre/actors/AutoCompleteChild.sys.mjs"
+ );
+
+ let listener = {
+ popupStateChanged: name => {
+ if (name != expectedMessage) {
+ info("Expected " + expectedMessage + " but received " + name);
+ return;
+ }
+
+ AutoCompleteChild.removePopupStateListener(listener);
+ resolve();
+ },
+ };
+ AutoCompleteChild.addPopupStateListener(listener);
+ });
+ });
+}
+
+async function openPopupOn(browser, selector) {
+ let childNotifiedPromise = waitPopupStateInChild(
+ browser,
+ "FormAutoComplete:PopupOpened"
+ );
+ await SimpleTest.promiseFocus(browser);
+
+ await runAndWaitForAutocompletePopupOpen(browser, async () => {
+ await focusAndWaitForFieldsIdentified(browser, selector);
+ if (!selector.includes("cc-")) {
+ info(`openPopupOn: before VK_DOWN on ${selector}`);
+ await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, browser);
+ }
+ });
+
+ await childNotifiedPromise;
+}
+
+async function openPopupOnSubframe(browser, frameBrowsingContext, selector) {
+ let childNotifiedPromise = waitPopupStateInChild(
+ frameBrowsingContext,
+ "FormAutoComplete:PopupOpened"
+ );
+
+ await SimpleTest.promiseFocus(browser);
+
+ await runAndWaitForAutocompletePopupOpen(browser, async () => {
+ await focusAndWaitForFieldsIdentified(frameBrowsingContext, selector);
+ if (!selector.includes("cc-")) {
+ info(`openPopupOnSubframe: before VK_DOWN on ${selector}`);
+ await BrowserTestUtils.synthesizeKey("VK_DOWN", {}, frameBrowsingContext);
+ }
+ });
+
+ await childNotifiedPromise;
+}
+
+async function closePopup(browser) {
+ // Return if the popup isn't open.
+ if (!browser.autoCompletePopup.popupOpen) {
+ return;
+ }
+
+ let childNotifiedPromise = waitPopupStateInChild(
+ browser,
+ "FormAutoComplete:PopupClosed"
+ );
+ let popupClosePromise = BrowserTestUtils.waitForPopupEvent(
+ browser.autoCompletePopup,
+ "hidden"
+ );
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ content.document.activeElement.blur();
+ });
+
+ await popupClosePromise;
+ await childNotifiedPromise;
+}
+
+async function closePopupForSubframe(browser, frameBrowsingContext) {
+ let childNotifiedPromise = waitPopupStateInChild(
+ browser,
+ "FormAutoComplete:PopupClosed"
+ );
+
+ let popupClosePromise = BrowserTestUtils.waitForPopupEvent(
+ browser.autoCompletePopup,
+ "hidden"
+ );
+
+ await SpecialPowers.spawn(frameBrowsingContext, [], async function () {
+ content.document.activeElement.blur();
+ });
+
+ await popupClosePromise;
+ await childNotifiedPromise;
+}
+
+function emulateMessageToBrowser(name, data) {
+ let actor =
+ gBrowser.selectedBrowser.browsingContext.currentWindowGlobal.getActor(
+ "FormAutofill"
+ );
+ return actor.receiveMessage({ name, data });
+}
+
+function getRecords(data) {
+ info(`expecting record retrievals: ${data.collectionName}`);
+ return emulateMessageToBrowser("FormAutofill:GetRecords", data);
+}
+
+function getAddresses() {
+ return getRecords({ collectionName: "addresses" });
+}
+
+async function ensureNoAddressSaved() {
+ await new Promise(resolve =>
+ setTimeout(resolve, TIMEOUT_ENSURE_PROFILE_NOT_SAVED)
+ );
+ const addresses = await getAddresses();
+ is(addresses.length, 0, "No address was saved");
+}
+
+function getCreditCards() {
+ return getRecords({ collectionName: "creditCards" });
+}
+
+async function saveAddress(address) {
+ info("expecting address saved");
+ let observePromise = TestUtils.topicObserved("formautofill-storage-changed");
+ await emulateMessageToBrowser("FormAutofill:SaveAddress", { address });
+ await observePromise;
+}
+
+async function saveCreditCard(creditcard) {
+ info("expecting credit card saved");
+ let creditcardClone = Object.assign({}, creditcard);
+ let observePromise = TestUtils.topicObserved("formautofill-storage-changed");
+ await emulateMessageToBrowser("FormAutofill:SaveCreditCard", {
+ creditcard: creditcardClone,
+ });
+ await observePromise;
+}
+
+async function removeAddresses(guids) {
+ info("expecting address removed");
+ let observePromise = TestUtils.topicObserved("formautofill-storage-changed");
+ await emulateMessageToBrowser("FormAutofill:RemoveAddresses", { guids });
+ await observePromise;
+}
+
+async function removeCreditCards(guids) {
+ info("expecting credit card removed");
+ let observePromise = TestUtils.topicObserved("formautofill-storage-changed");
+ await emulateMessageToBrowser("FormAutofill:RemoveCreditCards", { guids });
+ await observePromise;
+}
+
+function getNotification(index = 0) {
+ let notifications = PopupNotifications.panel.childNodes;
+ ok(!!notifications.length, "at least one notification displayed");
+ ok(true, notifications.length + " notification(s)");
+ return notifications[index];
+}
+
+function waitForPopupShown() {
+ return BrowserTestUtils.waitForEvent(PopupNotifications.panel, "popupshown");
+}
+
+/**
+ * Clicks the popup notification button and wait for popup hidden.
+ *
+ * @param {string} button The button type in popup notification.
+ * @param {number} index The action's index in menu list.
+ */
+async function clickDoorhangerButton(button, index) {
+ let popuphidden = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popuphidden"
+ );
+
+ if (button == MAIN_BUTTON || button == SECONDARY_BUTTON) {
+ EventUtils.synthesizeMouseAtCenter(getNotification()[button], {});
+ } else if (button == MENU_BUTTON) {
+ // Click the dropmarker arrow and wait for the menu to show up.
+ info("expecting notification menu button present");
+ await BrowserTestUtils.waitForCondition(() => getNotification().menubutton);
+ await sleep(2000); // menubutton needs extra time for binding
+ let notification = getNotification();
+ ok(notification.menubutton, "notification menupopup displayed");
+ let dropdownPromise = BrowserTestUtils.waitForEvent(
+ notification.menupopup,
+ "popupshown"
+ );
+ await EventUtils.synthesizeMouseAtCenter(notification.menubutton, {});
+ info("expecting notification popup show up");
+ await dropdownPromise;
+
+ let actionMenuItem = notification.querySelectorAll("menuitem")[index];
+ await EventUtils.synthesizeMouseAtCenter(actionMenuItem, {});
+ }
+ info("expecting notification popup hidden");
+ await popuphidden;
+}
+
+function getDoorhangerCheckbox() {
+ return getNotification().checkbox;
+}
+
+function getDoorhangerButton(button) {
+ return getNotification()[button];
+}
+
+/**
+ * Removes all addresses and credit cards from storage.
+ *
+ * **NOTE: If you add or update a record in a test, then you must wait for the
+ * respective storage event to fire before calling this function.**
+ * This is because this function doesn't guarantee that a record that
+ * is about to be added or update will also be removed,
+ * since the add or update is triggered by an asynchronous call.
+ *
+ * @see waitForStorageChangedEvents for more details about storage events to wait for
+ */
+async function removeAllRecords() {
+ let addresses = await getAddresses();
+ if (addresses.length) {
+ await removeAddresses(addresses.map(address => address.guid));
+ }
+ let creditCards = await getCreditCards();
+ if (creditCards.length) {
+ await removeCreditCards(creditCards.map(cc => cc.guid));
+ }
+}
+
+async function waitForFocusAndFormReady(win) {
+ return Promise.all([
+ new Promise(resolve => waitForFocus(resolve, win)),
+ BrowserTestUtils.waitForEvent(win, "FormReady"),
+ ]);
+}
+
+// Verify that the warning in the autocomplete popup has the expected text.
+async function expectWarningText(browser, expectedText) {
+ const {
+ autoCompletePopup: { richlistbox: itemsBox },
+ } = browser;
+ let warningBox = itemsBox.querySelector(
+ ".autocomplete-richlistitem:last-child"
+ );
+
+ while (warningBox.collapsed) {
+ warningBox = warningBox.previousSibling;
+ }
+ warningBox = warningBox._warningTextBox;
+
+ await BrowserTestUtils.waitForMutationCondition(
+ warningBox,
+ { childList: true, characterData: true },
+ () => warningBox.textContent == expectedText
+ );
+ ok(true, `Got expected warning text: ${expectedText}`);
+}
+
+async function testDialog(url, testFn, arg = undefined) {
+ // Skip this step for test cards that lack an encrypted
+ // number since they will fail to decrypt.
+ if (
+ url == EDIT_CREDIT_CARD_DIALOG_URL &&
+ arg &&
+ arg.record &&
+ arg.record["cc-number-encrypted"]
+ ) {
+ arg.record = Object.assign({}, arg.record, {
+ "cc-number": await OSKeyStore.decrypt(arg.record["cc-number-encrypted"]),
+ });
+ }
+ let win = window.openDialog(url, null, "width=600,height=600", arg);
+ await waitForFocusAndFormReady(win);
+ let unloadPromise = BrowserTestUtils.waitForEvent(win, "unload");
+ await testFn(win);
+ return unloadPromise;
+}
+
+/**
+ * Initializes the test storage for a task.
+ *
+ * @param {...object} items Can either be credit card or address objects
+ */
+async function setStorage(...items) {
+ for (let item of items) {
+ if (item["cc-number"]) {
+ await saveCreditCard(item);
+ } else {
+ await saveAddress(item);
+ }
+ }
+}
+
+function verifySectionAutofillResult(sections, expectedSectionsInfo) {
+ sections.forEach((section, index) => {
+ const expectedSection = expectedSectionsInfo[index];
+
+ const fieldDetails = section.fieldDetails;
+ const expectedFieldDetails = expectedSection.fields;
+
+ info(`verify autofill section[${index}]`);
+
+ fieldDetails.forEach((field, fieldIndex) => {
+ const expeceted = expectedFieldDetails[fieldIndex];
+
+ Assert.equal(
+ field.element.value,
+ expeceted.autofill,
+ `Autofilled value for element(id=${field.element.id}, field name=${field.fieldName}) should be equal`
+ );
+ });
+ });
+}
+
+function verifySectionFieldDetails(sections, expectedSectionsInfo) {
+ sections.forEach((section, index) => {
+ const expectedSection = expectedSectionsInfo[index];
+
+ const fieldDetails = section.fieldDetails;
+ const expectedFieldDetails = expectedSection.fields;
+
+ info(`section[${index}] ${expectedSection.description ?? ""}:`);
+ info(`FieldName Prediction Results: ${fieldDetails.map(i => i.fieldName)}`);
+ info(
+ `FieldName Expected Results: ${expectedFieldDetails.map(
+ detail => detail.fieldName
+ )}`
+ );
+ Assert.equal(
+ fieldDetails.length,
+ expectedFieldDetails.length,
+ `Expected field count.`
+ );
+
+ fieldDetails.forEach((field, fieldIndex) => {
+ const expectedFieldDetail = expectedFieldDetails[fieldIndex];
+
+ const expected = {
+ ...{
+ reason: "autocomplete",
+ section: "",
+ contactType: "",
+ addressType: "",
+ },
+ ...expectedSection.default,
+ ...expectedFieldDetail,
+ };
+
+ const keys = new Set([...Object.keys(field), ...Object.keys(expected)]);
+ ["autofill", "elementWeakRef", "confidence", "part"].forEach(k =>
+ keys.delete(k)
+ );
+
+ for (const key of keys) {
+ const expectedValue = expected[key];
+ const actualValue = field[key];
+ Assert.equal(
+ actualValue,
+ expectedValue,
+ `${key} should be equal, expect ${expectedValue}, got ${actualValue}`
+ );
+ }
+ });
+
+ Assert.equal(
+ section.isValidSection(),
+ !expectedSection.invalid,
+ `Should be an ${expectedSection.invalid ? "invalid" : "valid"} section`
+ );
+ });
+}
+/**
+ * Runs heuristics test for form autofill on given patterns.
+ *
+ * @param {Array<object>} patterns - An array of test patterns to run the heuristics test on.
+ * @param {string} pattern.description - Description of this heuristic test
+ * @param {string} pattern.fixurePath - The path of the test document
+ * @param {string} pattern.fixureData - Test document by string. Use either fixurePath or fixtureData.
+ * @param {object} pattern.profile - The profile to autofill. This is required only when running autofill test
+ * @param {Array} pattern.expectedResult - The expected result of this heuristic test. See below for detailed explanation
+ *
+ * @param {string} [fixturePathPrefix=""] - The prefix to the path of fixture files.
+ * @param {object} [options={ testAutofill: false }] - An options object containing additional configuration for running the test.
+ * @param {boolean} [options.testAutofill=false] - A boolean indicating whether to run the test for autofill or not.
+ * @returns {Promise} A promise that resolves when all the tests are completed.
+ *
+ * The `patterns.expectedResult` array contains test data for different address or credit card sections.
+ * Each section in the array is represented by an object and can include the following properties:
+ * - description (optional): A string describing the section, primarily used for debugging purposes.
+ * - default (optional): An object that sets the default values for all the fields within this section.
+ * The default object contains the same keys as the individual field objects.
+ * - fields: An array of field details (class FieldDetails) within the section.
+ *
+ * Each field object can have the following keys:
+ * - fieldName: The name of the field (e.g., "street-name", "cc-name" or "cc-number").
+ * - reason: The reason for the field value (e.g., "autocomplete", "regex-heuristic" or "fathom").
+ * - section: The section to which the field belongs (e.g., "billing", "shipping").
+ * - part: The part of the field.
+ * - contactType: The contact type of the field.
+ * - addressType: The address type of the field.
+ * - autofill: Set the expected autofill value when running autofill test
+ *
+ * For more information on the field object properties, refer to the FieldDetails class.
+ *
+ * Example test data:
+ * add_heuristic_tests(
+ * [{
+ * description: "first test pattern",
+ * fixuturePath: "autocomplete_off.html",
+ * profile: {organization: "Mozilla", country: "US", tel: "123"},
+ * expectedResult: [
+ * {
+ * description: "First section"
+ * fields: [
+ * { fieldName: "organization", reason: "autocomplete", autofill: "Mozilla" },
+ * { fieldName: "country", reason: "regex-heuristic", autofill: "US" },
+ * { fieldName: "tel", reason: "regex-heuristic", autofill: "123" },
+ * ]
+ * },
+ * {
+ * default: {
+ * reason: "regex-heuristic",
+ * section: "billing",
+ * },
+ * fields: [
+ * { fieldName: "cc-number", reason: "fathom" },
+ * { fieldName: "cc-nane" },
+ * { fieldName: "cc-exp" },
+ * ],
+ * }],
+ * },
+ * {
+ * // second test pattern //
+ * }
+ * ],
+ * "/fixturepath",
+ * {testAutofill: true} // test options
+ * )
+ */
+
+async function add_heuristic_tests(
+ patterns,
+ fixturePathPrefix = "",
+ options = { testAutofill: false }
+) {
+ async function runTest(testPattern) {
+ const TEST_URL = testPattern.fixtureData
+ ? `data:text/html,${testPattern.fixtureData}`
+ : `${BASE_URL}../${fixturePathPrefix}${testPattern.fixturePath}`;
+
+ if (testPattern.fixtureData) {
+ info(`Starting test with fixture data`);
+ } else {
+ info(`Starting test fixture: ${testPattern.fixturePath ?? ""}`);
+ }
+
+ if (testPattern.description) {
+ info(`Test "${testPattern.description}"`);
+ }
+
+ if (testPattern.prefs) {
+ await SpecialPowers.pushPrefEnv({
+ set: testPattern.prefs,
+ });
+ }
+
+ await BrowserTestUtils.withNewTab(TEST_URL, async browser => {
+ await SpecialPowers.spawn(
+ browser,
+ [
+ {
+ testPattern,
+ verifySection: verifySectionFieldDetails.toString(),
+ verifyAutofill: options.testAutofill
+ ? verifySectionAutofillResult.toString()
+ : null,
+ },
+ ],
+ async obj => {
+ const { FormLikeFactory } = ChromeUtils.importESModule(
+ "resource://gre/modules/FormLikeFactory.sys.mjs"
+ );
+ const { FormAutofillHandler } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/FormAutofillHandler.sys.mjs"
+ );
+
+ const elements = Array.from(
+ content.document.querySelectorAll("input, select")
+ );
+
+ // Bug 1834768. We should simulate user behavior instead of
+ // using internal APIs.
+ const forms = elements.reduce((acc, element) => {
+ const formLike = FormLikeFactory.createFromField(element);
+ if (!acc.some(form => form.rootElement === formLike.rootElement)) {
+ acc.push(formLike);
+ }
+ return acc;
+ }, []);
+
+ const sections = forms.flatMap(form => {
+ const handler = new FormAutofillHandler(form);
+ handler.collectFormFields(false /* ignoreInvalid */);
+ return handler.sections;
+ });
+
+ Assert.equal(
+ sections.length,
+ obj.testPattern.expectedResult.length,
+ "Expected section count."
+ );
+
+ // eslint-disable-next-line no-eval
+ let verify = eval(`(() => {return (${obj.verifySection});})();`);
+ verify(sections, obj.testPattern.expectedResult);
+
+ if (obj.verifyAutofill) {
+ for (const section of sections) {
+ section.focusedInput = section.fieldDetails[0].element;
+ await section.autofillFields(
+ section.getAdaptedProfiles([obj.testPattern.profile])[0]
+ );
+ }
+
+ // eslint-disable-next-line no-eval
+ verify = eval(`(() => {return (${obj.verifyAutofill});})();`);
+ verify(sections, obj.testPattern.expectedResult);
+ }
+ }
+ );
+ });
+
+ if (testPattern.prefs) {
+ await SpecialPowers.popPrefEnv();
+ }
+ }
+
+ patterns.forEach(testPattern => {
+ add_task(() => runTest(testPattern));
+ });
+}
+
+async function add_autofill_heuristic_tests(patterns, fixturePathPrefix = "") {
+ add_heuristic_tests(patterns, fixturePathPrefix, { testAutofill: true });
+}
+
+add_setup(function () {
+ OSKeyStoreTestUtils.setup();
+});
+
+registerCleanupFunction(async () => {
+ await removeAllRecords();
+ await OSKeyStoreTestUtils.cleanup();
+});
diff --git a/browser/extensions/formautofill/test/browser/heuristics/browser.ini b/browser/extensions/formautofill/test/browser/heuristics/browser.ini
new file mode 100644
index 0000000000..cc93d6beea
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/heuristics/browser.ini
@@ -0,0 +1,17 @@
+[DEFAULT]
+skip-if = toolkit == 'android' # bug 1730213
+support-files =
+ ../head.js
+ ../../fixtures/**
+
+[browser_autocomplete_off_on_form.js]
+[browser_autocomplete_off_on_inputs.js]
+[browser_basic.js]
+[browser_cc_exp.js]
+[browser_de_fields.js]
+[browser_fr_fields.js]
+[browser_ignore_invisible_fields.js]
+[browser_multiple_section.js]
+[browser_parseAddressFields.js]
+[browser_section_validation_address.js]
+[browser_sections_by_name.js]
diff --git a/browser/extensions/formautofill/test/browser/heuristics/browser_autocomplete_off_on_form.js b/browser/extensions/formautofill/test/browser/heuristics/browser_autocomplete_off_on_form.js
new file mode 100644
index 0000000000..f93752bd1e
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/heuristics/browser_autocomplete_off_on_form.js
@@ -0,0 +1,74 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/* global add_heuristic_tests */
+
+// Ensures that fields are identified correctly even when the containing form
+// has its autocomplete attribute set to off.
+add_heuristic_tests(
+ [
+ {
+ fixturePath: "autocomplete_off_on_form.html",
+ expectedResult: [
+ {
+ default: {
+ reason: "autocomplete",
+ },
+ fields: [
+ { fieldName: "organization" },
+ { fieldName: "street-address" },
+ { fieldName: "address-level2" },
+ { fieldName: "address-level1" },
+ { fieldName: "postal-code" },
+ { fieldName: "country" },
+ { fieldName: "tel" },
+ { fieldName: "email" },
+ ],
+ },
+ {
+ default: {
+ reason: "regex-heuristic",
+ },
+ fields: [
+ { fieldName: "organization" },
+ { fieldName: "address-line1" },
+ { fieldName: "address-line2" },
+ { fieldName: "address-line3" },
+ { fieldName: "address-level2" },
+ { fieldName: "address-level1" },
+ { fieldName: "postal-code" },
+ { fieldName: "country" },
+ { fieldName: "tel" },
+ { fieldName: "email" },
+ ],
+ },
+ {
+ default: {
+ reason: "autocomplete",
+ },
+ fields: [
+ { fieldName: "cc-number" },
+ { fieldName: "cc-name" },
+ { fieldName: "cc-exp-month" },
+ { fieldName: "cc-exp-year" },
+ ],
+ },
+ {
+ default: {
+ reason: "regex-heuristic",
+ },
+ fields: [
+ { fieldName: "address-line1" },
+ { fieldName: "address-level2" },
+ { fieldName: "address-line2" },
+ { fieldName: "organization" },
+ { fieldName: "address-line3" },
+ ],
+ },
+ ],
+ },
+ ],
+ "fixtures/"
+);
diff --git a/browser/extensions/formautofill/test/browser/heuristics/browser_autocomplete_off_on_inputs.js b/browser/extensions/formautofill/test/browser/heuristics/browser_autocomplete_off_on_inputs.js
new file mode 100644
index 0000000000..13c8d82dbb
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/heuristics/browser_autocomplete_off_on_inputs.js
@@ -0,0 +1,102 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/* global add_heuristic_tests */
+
+// Ensures that fields are identified correctly even when the inputs
+// have their autocomplete attribute set to off.
+add_heuristic_tests(
+ [
+ {
+ fixturePath: "autocomplete_off_on_inputs.html",
+ expectedResult: [
+ {
+ default: {
+ reason: "regex-heuristic",
+ },
+ fields: [
+ { fieldName: "organization" },
+ { fieldName: "street-address" },
+ { fieldName: "address-level2" },
+ { fieldName: "address-level1" },
+ { fieldName: "postal-code" },
+ { fieldName: "country" },
+ { fieldName: "tel" },
+ { fieldName: "email" },
+ ],
+ },
+ {
+ default: {
+ reason: "regex-heuristic",
+ },
+ fields: [
+ { fieldName: "organization" },
+ { fieldName: "address-line1" },
+ { fieldName: "address-line2" },
+ { fieldName: "address-line3" },
+ { fieldName: "address-level2" },
+ { fieldName: "address-level1" },
+ { fieldName: "postal-code" },
+ { fieldName: "country" },
+ { fieldName: "tel" },
+ { fieldName: "email" },
+ ],
+ },
+ {
+ default: {
+ reason: "regex-heuristic",
+ },
+ fields: [
+ { fieldName: "cc-number", reason: "fathom" },
+ { fieldName: "cc-name", reason: "fathom" },
+ { fieldName: "cc-exp-month" },
+ { fieldName: "cc-exp-year" },
+ ],
+ },
+ {
+ default: {
+ reason: "regex-heuristic",
+ },
+ fields: [
+ { fieldName: "address-line1" },
+ { fieldName: "address-level2" },
+ { fieldName: "address-line2" },
+ { fieldName: "organization" },
+ { fieldName: "address-line3" },
+ ],
+ },
+ {
+ default: {
+ reason: "autocomplete",
+ },
+ fields: [
+ { fieldName: "organization" },
+ { fieldName: "address-line1", reason: "regex-heuristic" },
+ { fieldName: "address-line2", reason: "regex-heuristic" },
+ { fieldName: "address-line3", reason: "regex-heuristic" },
+ { fieldName: "address-level2", reason: "regex-heuristic" },
+ { fieldName: "address-level1" },
+ { fieldName: "postal-code", reason: "regex-heuristic" },
+ { fieldName: "country", reason: "regex-heuristic" },
+ { fieldName: "tel" },
+ { fieldName: "email", reason: "regex-heuristic" },
+ ],
+ },
+ {
+ default: {
+ reason: "autocomplete",
+ },
+ fields: [
+ { fieldName: "cc-number", reason: "fathom" },
+ { fieldName: "cc-name" },
+ { fieldName: "cc-exp-month", reason: "regex-heuristic" },
+ { fieldName: "cc-exp-year", reason: "regex-heuristic" },
+ ],
+ },
+ ],
+ },
+ ],
+ "fixtures/"
+);
diff --git a/browser/extensions/formautofill/test/browser/heuristics/browser_basic.js b/browser/extensions/formautofill/test/browser/heuristics/browser_basic.js
new file mode 100644
index 0000000000..fda375a146
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/heuristics/browser_basic.js
@@ -0,0 +1,69 @@
+/* global add_heuristic_tests */
+
+"use strict";
+
+add_heuristic_tests(
+ [
+ {
+ fixturePath: "autocomplete_basic.html",
+ expectedResult: [
+ {
+ default: {
+ reason: "autocomplete",
+ },
+ fields: [
+ { fieldName: "organization" },
+ { fieldName: "street-address" },
+ { fieldName: "address-level2" },
+ { fieldName: "address-level1" },
+ { fieldName: "postal-code" },
+ { fieldName: "country" },
+ { fieldName: "tel" },
+ { fieldName: "email" },
+ ],
+ },
+ {
+ default: {
+ reason: "regex-heuristic",
+ },
+ fields: [
+ { fieldName: "organization" },
+ { fieldName: "address-line1" },
+ { fieldName: "address-line2" },
+ { fieldName: "address-line3" },
+ { fieldName: "address-level2" },
+ { fieldName: "address-level1" },
+ { fieldName: "postal-code" },
+ { fieldName: "country" },
+ { fieldName: "tel" },
+ { fieldName: "email" },
+ ],
+ },
+ {
+ default: {
+ reason: "autocomplete",
+ },
+ fields: [
+ { fieldName: "cc-number" },
+ { fieldName: "cc-name" },
+ { fieldName: "cc-exp-month" },
+ { fieldName: "cc-exp-year" },
+ ],
+ },
+ {
+ default: {
+ reason: "regex-heuristic",
+ },
+ fields: [
+ { fieldName: "address-line1" },
+ { fieldName: "address-level2" },
+ { fieldName: "address-line2" },
+ { fieldName: "organization" },
+ { fieldName: "address-line3" },
+ ],
+ },
+ ],
+ },
+ ],
+ "fixtures/"
+);
diff --git a/browser/extensions/formautofill/test/browser/heuristics/browser_cc_exp.js b/browser/extensions/formautofill/test/browser/heuristics/browser_cc_exp.js
new file mode 100644
index 0000000000..8eb774b65c
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/heuristics/browser_cc_exp.js
@@ -0,0 +1,56 @@
+/* global add_heuristic_tests */
+
+"use strict";
+
+add_heuristic_tests(
+ [
+ {
+ fixturePath: "heuristics_cc_exp.html",
+ expectedResult: [
+ {
+ description: "form1",
+ default: {
+ reason: "autocomplete",
+ },
+ fields: [
+ { fieldName: "cc-name" },
+ { fieldName: "cc-number" },
+ { fieldName: "cc-exp-month" },
+ { fieldName: "cc-exp-year" },
+ ],
+ },
+ {
+ description: "form2",
+ default: {
+ reason: "autocomplete",
+ },
+ fields: [{ fieldName: "cc-number" }, { fieldName: "cc-exp" }],
+ },
+ {
+ description: "form3",
+ fields: [
+ { fieldName: "cc-number", reason: "autocomplete" },
+ { fieldName: "cc-exp", reason: "regex-heuristic" },
+ ],
+ },
+ {
+ description: "form4",
+ fields: [
+ { fieldName: "cc-number", reason: "autocomplete" },
+ { fieldName: "cc-exp-month", reason: "regex-heuristic" },
+ { fieldName: "cc-exp-year", reason: "regex-heuristic" },
+ ],
+ },
+ {
+ description: "form5",
+ invalid: true,
+ fields: [
+ { fieldName: "cc-exp-month", reason: "regex-heuristic" },
+ { fieldName: "cc-exp-year", reason: "regex-heuristic" },
+ ],
+ },
+ ],
+ },
+ ],
+ "fixtures/"
+);
diff --git a/browser/extensions/formautofill/test/browser/heuristics/browser_de_fields.js b/browser/extensions/formautofill/test/browser/heuristics/browser_de_fields.js
new file mode 100644
index 0000000000..0d47cb6935
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/heuristics/browser_de_fields.js
@@ -0,0 +1,32 @@
+/* global add_heuristic_tests */
+
+"use strict";
+
+add_heuristic_tests(
+ [
+ {
+ fixturePath: "heuristics_de_fields.html",
+ expectedResult: [
+ {
+ fields: [
+ { fieldName: "cc-name", reason: "fathom" },
+ { fieldName: "cc-type", reason: "regex-heuristic" },
+ { fieldName: "cc-number", reason: "fathom" },
+ { fieldName: "cc-exp-month", reason: "regex-heuristic" },
+ { fieldName: "cc-exp-year", reason: "regex-heuristic" },
+ ],
+ },
+ {
+ fields: [
+ { fieldName: "cc-name", reason: "fathom" },
+ { fieldName: "cc-type", reason: "regex-heuristic" },
+ { fieldName: "cc-number", reason: "fathom" },
+ { fieldName: "cc-exp-month", reason: "regex-heuristic" },
+ { fieldName: "cc-exp-year", reason: "regex-heuristic" },
+ ],
+ },
+ ],
+ },
+ ],
+ "fixtures/"
+);
diff --git a/browser/extensions/formautofill/test/browser/heuristics/browser_fr_fields.js b/browser/extensions/formautofill/test/browser/heuristics/browser_fr_fields.js
new file mode 100644
index 0000000000..a2c8add393
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/heuristics/browser_fr_fields.js
@@ -0,0 +1,27 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* global add_heuristic_tests */
+
+"use strict";
+
+add_heuristic_tests(
+ [
+ {
+ fixturePath: "heuristics_fr_fields.html",
+ expectedResult: [
+ {
+ default: {
+ reason: "regex-heuristic",
+ },
+ fields: [
+ { fieldName: "cc-number", reason: "fathom" },
+ { fieldName: "cc-exp" },
+ { fieldName: "cc-name" },
+ ],
+ },
+ ],
+ },
+ ],
+ "fixtures/"
+);
diff --git a/browser/extensions/formautofill/test/browser/heuristics/browser_ignore_invisible_fields.js b/browser/extensions/formautofill/test/browser/heuristics/browser_ignore_invisible_fields.js
new file mode 100644
index 0000000000..d22b1d5031
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/heuristics/browser_ignore_invisible_fields.js
@@ -0,0 +1,115 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* global add_heuristic_tests */
+
+"use strict";
+
+add_heuristic_tests([
+ {
+ description: "all fields are visible",
+ fixtureData: `
+ <html>
+ <body>
+ <form>
+ <input type="text" id="name" autocomplete="name" />
+ <input type="text" id="tel" autocomplete="tel" />
+ <input type="text" id="email" autocomplete="email" />
+ <input type="text" id="country" autocomplete="country"/>
+ <input type="text" id="postal-code" autocomplete="postal-code" />
+ <input type="text" id="address-line1" autocomplete="address-line1" />
+ <div>
+ <input type="text" id="address-line2" autocomplete="address-line2" />
+ </div>
+ </form>
+ </form>
+ </body>
+ </html>
+ `,
+ expectedResult: [
+ {
+ default: {
+ reason: "autocomplete",
+ },
+ fields: [
+ { fieldName: "name" },
+ { fieldName: "tel" },
+ { fieldName: "email" },
+ { fieldName: "country" },
+ { fieldName: "postal-code" },
+ { fieldName: "address-line1" },
+ { fieldName: "address-line2" },
+ ],
+ },
+ ],
+ },
+ {
+ description: "some fields are invisible because of css style",
+ fixtureData: `
+ <html>
+ <body>
+ <form>
+ <input type="text" id="name" autocomplete="name" />
+ <input type="text" id="tel" autocomplete="tel" />
+ <input type="text" id="email" autocomplete="email" />
+ <input type="text" id="country" autocomplete="country" hidden />
+ <input type="text" id="postal-code" autocomplete="postal-code" style="display:none" />
+ <input type="text" id="address-line1" autocomplete="address-line1" style="opacity:0" />
+ <div style="visibility: hidden">
+ <input type="text" id="address-line2" autocomplete="address-line2" />
+ </div>
+ </form>
+ </body>
+ </html>
+ `,
+ expectedResult: [
+ {
+ default: {
+ reason: "autocomplete",
+ },
+ fields: [
+ { fieldName: "name" },
+ { fieldName: "tel" },
+ { fieldName: "email" },
+ ],
+ },
+ ],
+ },
+ {
+ // hidden and style="display:none" are always considered regardless what visibility check we use
+ description:
+ "invisible fields are identified because number of elemenent in the form exceed the threshold",
+ prefs: [["extensions.formautofill.heuristics.visibilityCheckThreshold", 1]],
+ fixtureData: `
+ <html>
+ <body>
+ <form>
+ <input type="text" id="name" autocomplete="name" />
+ <input type="text" id="tel" autocomplete="tel" />
+ <input type="text" id="email" autocomplete="email" />
+ <input type="text" id="country" autocomplete="country" hidden />
+ <input type="text" id="postal-code" autocomplete="postal-code" style="display:none" />
+ <input type="text" id="address-line1" autocomplete="address-line1" style="opacity:0" />
+ <div style="visibility: hidden">
+ <input type="text" id="address-line2" autocomplete="address-line2" />
+ </div>
+ </form>
+ </body>
+ </html>
+ `,
+ expectedResult: [
+ {
+ default: {
+ reason: "autocomplete",
+ },
+ fields: [
+ { fieldName: "name" },
+ { fieldName: "tel" },
+ { fieldName: "email" },
+ { fieldName: "address-line1" },
+ { fieldName: "address-line2" },
+ ],
+ },
+ ],
+ },
+]);
diff --git a/browser/extensions/formautofill/test/browser/heuristics/browser_multiple_section.js b/browser/extensions/formautofill/test/browser/heuristics/browser_multiple_section.js
new file mode 100644
index 0000000000..078f2240db
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/heuristics/browser_multiple_section.js
@@ -0,0 +1,118 @@
+/* global add_heuristic_tests */
+
+"use strict";
+
+add_heuristic_tests(
+ [
+ {
+ fixturePath: "multiple_section.html",
+ expectedResult: [
+ {
+ default: {
+ reason: "autocomplete",
+ addressType: "shipping",
+ },
+ fields: [
+ { fieldName: "name", addressType: "" },
+ { fieldName: "organization", addressType: "" },
+ { fieldName: "street-address" },
+ { fieldName: "address-level2" },
+ { fieldName: "address-level1" },
+ { fieldName: "postal-code" },
+ { fieldName: "country" },
+ ],
+ },
+ {
+ default: {
+ reason: "autocomplete",
+ addressType: "billing",
+ },
+ fields: [
+ { fieldName: "street-address" },
+ { fieldName: "address-level2" },
+ { fieldName: "address-level1" },
+ { fieldName: "postal-code" },
+ { fieldName: "country" },
+ ],
+ },
+ {
+ default: {
+ reason: "autocomplete",
+ section: "section-my",
+ },
+ fields: [
+ { fieldName: "street-address" },
+ { fieldName: "address-level2" },
+ { fieldName: "address-level1" },
+ { fieldName: "postal-code" },
+ { fieldName: "country" },
+ { fieldName: "tel", section: "", contactType: "work" },
+ { fieldName: "email", section: "", contactType: "work" },
+ ],
+ },
+ {
+ invalid: true,
+ default: {
+ reason: "autocomplete",
+ },
+ fields: [
+ // Even the `contactType` of these two fields are different with the
+ // above two, we still consider they are identical until supporting
+ // multiple phone number and email in one profile.
+ { fieldName: "tel", contactType: "home" },
+ { fieldName: "email", contactType: "home" },
+ ],
+ },
+ {
+ default: {
+ reason: "autocomplete",
+ },
+ fields: [
+ { fieldName: "name" },
+ { fieldName: "organization" },
+ { fieldName: "street-address" },
+ { fieldName: "address-level2" },
+ { fieldName: "address-level1" },
+ { fieldName: "postal-code" },
+ { fieldName: "country" },
+ ],
+ },
+ {
+ default: {
+ reason: "autocomplete",
+ },
+ fields: [
+ { fieldName: "street-address" },
+ { fieldName: "address-level2" },
+ { fieldName: "address-level1" },
+ { fieldName: "postal-code" },
+ { fieldName: "country" },
+ ],
+ },
+ {
+ default: {
+ reason: "autocomplete",
+ },
+ fields: [
+ { fieldName: "street-address" },
+ { fieldName: "address-level2" },
+ { fieldName: "address-level1" },
+ { fieldName: "postal-code" },
+ { fieldName: "country" },
+ { fieldName: "tel", contactType: "work" },
+ { fieldName: "email", contactType: "work" },
+ ],
+ },
+ {
+ invalid: true,
+ default: {
+ reason: "autocomplete",
+ contactType: "home",
+ },
+ fields: [{ fieldName: "tel" }, { fieldName: "email" }],
+ },
+ ],
+ },
+ ],
+ "fixtures/"
+);
diff --git a/browser/extensions/formautofill/test/browser/heuristics/browser_parseAddressFields.js b/browser/extensions/formautofill/test/browser/heuristics/browser_parseAddressFields.js
new file mode 100644
index 0000000000..ffe739d417
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/heuristics/browser_parseAddressFields.js
@@ -0,0 +1,138 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* global add_heuristic_tests */
+
+"use strict";
+
+add_heuristic_tests([
+ {
+ // This bug happens only when the last element is address-lineX and
+ // the field is identified by regular expressions in `HeuristicsRegExp` but is not
+ // identified by regular expressions defined in `_parseAddressFields`
+ fixtureData: `
+ <html>
+ <body>
+ <form>
+ <p><label>country: <input type="text" id="country" name="country" /></label></p>
+ <p><label>tel: <input type="text" id="tel" name="tel" /></label></p>
+ <p><label><input type="text" id="housenumber" /></label></p>
+ </form>
+ </body>
+ </html>`,
+ expectedResult: [
+ {
+ description:
+ "Address Line1 in the last element and is not updated in _parsedAddressFields",
+ default: {
+ reason: "regex-heuristic",
+ },
+ fields: [
+ { fieldName: "country" },
+ { fieldName: "tel" },
+ { fieldName: "address-line1" },
+ ],
+ },
+ ],
+ },
+ {
+ fixtureData: `
+ <html>
+ <body>
+ <form>
+ <p><label>country: <input type="text" id="country" name="country" /></label></p>
+ <p><label>tel: <input type="text" id="tel" name="tel" /></label></p>
+ <p><label><input type="text" id="housenumber" /></label></p>
+ <p><label><input type="text" id="addrcomplement" /></label></p>
+ </form>
+ </body>
+ </html>`,
+ expectedResult: [
+ {
+ description:
+ "Address Line2 in the last element and is not updated in _parsedAddressFields",
+ default: {
+ reason: "regex-heuristic",
+ },
+ fields: [
+ { fieldName: "country" },
+ { fieldName: "tel" },
+ { fieldName: "address-line1" },
+ { fieldName: "address-line2" },
+ ],
+ },
+ ],
+ },
+ {
+ // Bug 1833613
+ description:
+ "street-address field is treated as address-line1 when address-line2 is present while adddress-line1 is not",
+ fixtureData: `
+ <html>
+ <body>
+ <form>
+ <input type="text" id="street-address" autocomplete="street-address"/>
+ <input type="text" id="address-line2" autocomplete="address-line2"/>
+ <input type="text" id="email" autocomplete="email"/>
+ </form>
+ </body>
+ </html>`,
+ expectedResult: [
+ {
+ fields: [
+ { fieldName: "address-line1", reason: "regexp-heuristic" },
+ { fieldName: "address-line2", reason: "autocomplete" },
+ { fieldName: "email", reason: "autocomplete" },
+ ],
+ },
+ ],
+ },
+ {
+ // Bug 1833613
+ description:
+ "street-address field should not be treated as address-line1 when address-line2 is not present",
+ fixtureData: `
+ <html>
+ <body>
+ <form>
+ <input type="text" id="street-address" autocomplete="street-address"/>
+ <input type="text" id="address-line3" autocomplete="address-line3"/>
+ <input type="text" id="email" autocomplete="email"/>
+ </form>
+ </body>
+ </html>`,
+ expectedResult: [
+ {
+ fields: [
+ { fieldName: "street-address", reason: "autocomplete" },
+ { fieldName: "address-line3", reason: "autocomplete" },
+ { fieldName: "email", reason: "autocomplete" },
+ ],
+ },
+ ],
+ },
+ {
+ // Bug 1833613
+ description:
+ "street-address field should not be treated as address-line1 when address-line1 is present",
+ fixtureData: `
+ <html>
+ <body>
+ <form>
+ <input type="text" id="street-address" autocomplete="street-address"/>
+ <input type="text" id="address-line1" autocomplete="address-line1"/>
+ <input type="text" id="email" autocomplete="email"/>
+ </form>
+ </body>
+ </html>`,
+ expectedResult: [
+ {
+ fields: [
+ { fieldName: "street-address", reason: "autocomplete" },
+ { fieldName: "address-line1", reason: "autocomplete" },
+ { fieldName: "email", reason: "autocomplete" },
+ ],
+ },
+ ],
+ },
+]);
diff --git a/browser/extensions/formautofill/test/browser/heuristics/browser_section_validation_address.js b/browser/extensions/formautofill/test/browser/heuristics/browser_section_validation_address.js
new file mode 100644
index 0000000000..2e9cf42ab0
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/heuristics/browser_section_validation_address.js
@@ -0,0 +1,79 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* global add_heuristic_tests */
+
+"use strict";
+
+add_heuristic_tests([
+ {
+ description: `An address section is valid when it only contains more than three fields`,
+ fixtureData: `
+ <html><body>
+ <input id="street-address" autocomplete="street-address">
+ <input id="postal-code" autocomplete="postal-code">
+ <input id="email" autocomplete="email">
+ </body></html>
+ `,
+ expectedResult: [
+ {
+ default: {
+ reason: "autocomplete",
+ },
+ fields: [
+ { fieldName: "street-address" },
+ { fieldName: "postal-code" },
+ { fieldName: "email" },
+ ],
+ },
+ ],
+ },
+ {
+ description: `An address section is invalid when it contains less than threee fields`,
+ fixtureData: `
+ <html><body>
+ <input id="postal-code" autocomplete="postal-code">
+ <input id="email" autocomplete="email">
+
+ <input id="postal-code" autocomplete="postal-code">
+ </body></html>
+ `,
+ expectedResult: [
+ {
+ description: "A section with two fields",
+ invalid: true,
+ fields: [
+ { fieldName: "postal-code", reason: "autocomplete" },
+ { fieldName: "email", reason: "autocomplete" },
+ ],
+ },
+ {
+ description: "A section with one field",
+ invalid: true,
+ fields: [{ fieldName: "postal-code", reason: "autocomplete" }],
+ },
+ ],
+ },
+ {
+ description: `Address section validation only counts the number of different address field name in the section`,
+ fixtureData: `
+ <html><body>
+ <input id="postal-code" autocomplete="postal-code">
+ <input id="email" autocomplete="email">
+ <input id="email" autocomplete="email">
+ </body></html>
+ `,
+ expectedResult: [
+ {
+ description:
+ "A section with three fields but has duplicated email fields",
+ invalid: true,
+ fields: [
+ { fieldName: "postal-code", reason: "autocomplete" },
+ { fieldName: "email", reason: "autocomplete" },
+ { fieldName: "email", reason: "autocomplete" },
+ ],
+ },
+ ],
+ },
+]);
diff --git a/browser/extensions/formautofill/test/browser/heuristics/browser_sections_by_name.js b/browser/extensions/formautofill/test/browser/heuristics/browser_sections_by_name.js
new file mode 100644
index 0000000000..c6c8ea5759
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/heuristics/browser_sections_by_name.js
@@ -0,0 +1,318 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* global add_heuristic_tests */
+
+"use strict";
+
+// The following are included in this test
+// - One named billing section
+// - One named billing section and one named shipping section
+// - One named billing section and one section without name
+// - Fields without section name are merged to a section with section name
+// - Two sections without name
+
+add_heuristic_tests([
+ {
+ description: `One named billing section`,
+ fixtureData: `
+ <html><body>
+ <input id="street-address" autocomplete="billing street-address">
+ <input id="postal-code" autocomplete="billing postal-code">
+ <input id="country" autocomplete="billing country">
+ </body></html>
+ `,
+ expectedResult: [
+ {
+ default: {
+ reason: "autocomplete",
+ addressType: "billing",
+ },
+ fields: [
+ { fieldName: "street-address" },
+ { fieldName: "postal-code" },
+ { fieldName: "country" },
+ ],
+ },
+ ],
+ },
+ {
+ description: `One billing section and one shipping section`,
+ fixtureData: `
+ <html><body>
+ <input id="street-address" autocomplete="billing street-address">
+ <input id="postal-code" autocomplete="billing postal-code">
+ <input id="country" autocomplete="billing country">
+ <input id="street-address" autocomplete="shipping street-address">
+ <input id="postal-code" autocomplete="shipping postal-code">
+ <input id="country" autocomplete="shipping country">
+ </body></html>
+ `,
+ expectedResult: [
+ {
+ default: {
+ reason: "autocomplete",
+ addressType: "billing",
+ },
+ fields: [
+ { fieldName: "street-address" },
+ { fieldName: "postal-code" },
+ { fieldName: "country" },
+ ],
+ },
+ {
+ default: {
+ reason: "autocomplete",
+ addressType: "shipping",
+ },
+ fields: [
+ { fieldName: "street-address" },
+ { fieldName: "postal-code" },
+ { fieldName: "country" },
+ ],
+ },
+ ],
+ },
+ {
+ description: `One billing section, one shipping section, and then billing section`,
+ fixtureData: `
+ <html><body>
+ <input id="street-address" autocomplete="billing street-address">
+ <input id="postal-code" autocomplete="billing postal-code">
+ <input id="street-address" autocomplete="shipping street-address">
+ <input id="postal-code" autocomplete="shipping postal-code">
+ <input id="country" autocomplete="shipping country">
+ <input id="country" autocomplete="billing country">
+ </body></html>
+ `,
+ expectedResult: [
+ {
+ default: {
+ reason: "autocomplete",
+ addressType: "billing",
+ },
+ fields: [
+ { fieldName: "street-address" },
+ { fieldName: "postal-code" },
+ { fieldName: "country" },
+ ],
+ },
+ {
+ default: {
+ reason: "autocomplete",
+ addressType: "shipping",
+ },
+ fields: [
+ { fieldName: "street-address" },
+ { fieldName: "postal-code" },
+ { fieldName: "country" },
+ ],
+ },
+ ],
+ },
+ {
+ description: `one section without a name and one billing section`,
+ fixtureData: `
+ <html><body>
+ <input id="street-address" autocomplete="street-address">
+ <input id="postal-code" autocomplete="postal-code">
+ <input id="country" autocomplete="country">
+ <input id="street-address" autocomplete="billing street-address">
+ <input id="postal-code" autocomplete="billing postal-code">
+ <input id="country" autocomplete="billing country">
+ </body></html>
+ `,
+ expectedResult: [
+ {
+ default: {
+ reason: "autocomplete",
+ },
+ fields: [
+ { fieldName: "street-address" },
+ { fieldName: "postal-code" },
+ { fieldName: "country" },
+ ],
+ },
+ {
+ default: {
+ reason: "autocomplete",
+ addressType: "billing",
+ },
+ fields: [
+ { fieldName: "street-address" },
+ { fieldName: "postal-code" },
+ { fieldName: "country" },
+ ],
+ },
+ ],
+ },
+ {
+ description: `One billing section and one section without a name`,
+ fixtureData: `
+ <html><body>
+ <input id="street-address" autocomplete="billing street-address">
+ <input id="postal-code" autocomplete="billing postal-code">
+ <input id="country" autocomplete="billing country">
+ <input id="street-address" autocomplete="street-address">
+ <input id="postal-code" autocomplete="postal-code">
+ <input id="country" autocomplete="country">
+ </body></html>
+ `,
+ expectedResult: [
+ {
+ default: {
+ reason: "autocomplete",
+ addressType: "billing",
+ },
+ fields: [
+ { fieldName: "street-address" },
+ { fieldName: "postal-code" },
+ { fieldName: "country" },
+ ],
+ },
+ {
+ default: {
+ reason: "autocomplete",
+ },
+ fields: [
+ { fieldName: "street-address" },
+ { fieldName: "postal-code" },
+ { fieldName: "country" },
+ ],
+ },
+ ],
+ },
+ {
+ description: `Fields without section name are merged (test both before and after the section with a name)`,
+ fixtureData: `
+ <html><body>
+ <input id="name" autocomplete="name">
+ <input id="street-address" autocomplete="billing street-address">
+ <input id="postal-code" autocomplete="billing postal-code">
+ <input id="country" autocomplete="billing country">
+ <input id="street-address" autocomplete="shipping street-address">
+ <input id="postal-code" autocomplete="shipping postal-code">
+ <input id="country" autocomplete="shipping country">
+ <input id="name" autocomplete="name">
+ </body></html>
+ `,
+ expectedResult: [
+ {
+ default: {
+ reason: "autocomplete",
+ addressType: "billing",
+ },
+ fields: [
+ { fieldName: "name", addressType: "" },
+ { fieldName: "street-address" },
+ { fieldName: "postal-code" },
+ { fieldName: "country" },
+ ],
+ },
+ {
+ default: {
+ reason: "autocomplete",
+ addressType: "shipping",
+ },
+ fields: [
+ { fieldName: "street-address" },
+ { fieldName: "postal-code" },
+ { fieldName: "country" },
+ { fieldName: "name", addressType: "" },
+ ],
+ },
+ ],
+ },
+ {
+ description: `Fields without section name are merged, but do not merge if the field already exists`,
+ fixtureData: `
+ <html><body>
+ <input id="name" autocomplete="name">
+ <input id="street-address" autocomplete="billing street-address">
+ <input id="postal-code" autocomplete="billing postal-code">
+ <input id="country" autocomplete="billing country">
+ <input id="name" autocomplete="name">
+ </body></html>
+ `,
+ expectedResult: [
+ {
+ default: {
+ reason: "autocomplete",
+ addressType: "billing",
+ },
+ fields: [
+ { fieldName: "name", addressType: "" },
+ { fieldName: "street-address" },
+ { fieldName: "postal-code" },
+ { fieldName: "country" },
+ ],
+ },
+ {
+ invalid: true,
+ fields: [{ fieldName: "name", reason: "autocomplete" }],
+ },
+ ],
+ },
+ {
+ description: `Fields without section name are merged (multi-fields)`,
+ fixtureData: `
+ <html><body>
+ <input id="street-address" autocomplete="billing street-address">
+ <input id="postal-code" autocomplete="billing postal-code">
+ <input id="country" autocomplete="billing country">
+ <input id="email" autocomplete="email">
+ <input id="email" autocomplete="email">
+ </body></html>
+ `,
+ expectedResult: [
+ {
+ default: {
+ reason: "autocomplete",
+ addressType: "billing",
+ },
+ fields: [
+ { fieldName: "street-address" },
+ { fieldName: "postal-code" },
+ { fieldName: "country" },
+ { fieldName: "email", addressType: "" },
+ { fieldName: "email", addressType: "" },
+ ],
+ },
+ ],
+ },
+ {
+ description: `Two sections without name`,
+ fixtureData: `
+ <html><body>
+ <input id="street-address" autocomplete="street-address">
+ <input id="postal-code" autocomplete="postal-code">
+ <input id="country" autocomplete="country">
+ <input id="street-address" autocomplete="street-address">
+ <input id="postal-code" autocomplete="postal-code">
+ <input id="country" autocomplete="country">
+ </body></html>
+ `,
+ expectedResult: [
+ {
+ default: {
+ reason: "autocomplete",
+ },
+ fields: [
+ { fieldName: "street-address" },
+ { fieldName: "postal-code" },
+ { fieldName: "country" },
+ ],
+ },
+ {
+ default: {
+ reason: "autocomplete",
+ },
+ fields: [
+ { fieldName: "street-address" },
+ { fieldName: "postal-code" },
+ { fieldName: "country" },
+ ],
+ },
+ ],
+ },
+]);
diff --git a/browser/extensions/formautofill/test/browser/heuristics/third_party/browser.ini b/browser/extensions/formautofill/test/browser/heuristics/third_party/browser.ini
new file mode 100644
index 0000000000..6519ce9ae8
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/heuristics/third_party/browser.ini
@@ -0,0 +1,22 @@
+[DEFAULT]
+skip-if = toolkit == 'android' # bug 1730213
+support-files =
+ ../../head.js
+ ../../../fixtures/**
+
+[browser_BestBuy.js]
+[browser_CDW.js]
+[browser_CostCo.js]
+[browser_DirectAsda.js]
+[browser_Ebay.js]
+[browser_GlobalDirectAsda.js]
+[browser_HomeDepot.js]
+[browser_Lufthansa.js]
+[browser_Lush.js]
+[browser_Macys.js]
+[browser_NewEgg.js]
+[browser_OfficeDepot.js]
+[browser_QVC.js]
+[browser_Sears.js]
+[browser_Staples.js]
+[browser_Walmart.js]
diff --git a/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_BestBuy.js b/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_BestBuy.js
new file mode 100644
index 0000000000..e91c19de45
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_BestBuy.js
@@ -0,0 +1,82 @@
+/* global add_heuristic_tests */
+
+"use strict";
+
+add_heuristic_tests(
+ [
+ {
+ fixturePath: "Checkout_ShippingAddress.html",
+ expectedResult: [
+ {
+ default: {
+ reason: "regex-heuristic",
+ },
+ fields: [
+ { fieldName: "given-name" },
+ { fieldName: "family-name" },
+ { fieldName: "street-address" },
+ { fieldName: "address-level2" }, // city
+ { fieldName: "address-level1" }, // state
+ { fieldName: "postal-code" },
+ ],
+ },
+ {
+ invalid: true,
+ fields: [
+ { fieldName: "email", reason: "regex-heuristic" }, // sign-up
+ ],
+ },
+ {
+ invalid: true,
+ fields: [
+ { fieldName: "email", reason: "regex-heuristic" },
+ { fieldName: "tel", reason: "regex-heuristic" },
+ ],
+ },
+ ],
+ },
+ {
+ fixturePath: "Checkout_Payment.html",
+ expectedResult: [
+ {
+ invalid: true,
+ fields: [
+ { fieldName: "email", reason: "regex-heuristic" },
+ ],
+ },
+ {
+ default: {
+ reason: "regex-heuristic",
+ },
+ fields: [
+ { fieldName: "given-name" },
+ { fieldName: "family-name" },
+ { fieldName: "street-address" },
+ { fieldName: "address-level2" }, // city
+ { fieldName: "address-level1" }, // state
+ { fieldName: "postal-code" },
+ ],
+ },
+ {
+ invalid: true,
+ fields: [
+ { fieldName: "email", reason: "regex-heuristic" },
+ { fieldName: "tel", reason: "regex-heuristic" },
+ ],
+ },
+ ],
+ },
+ {
+ fixturePath: "SignIn.html",
+ expectedResult: [
+ {
+ invalid: true,
+ fields: [
+ { fieldName: "email", reason: "regex-heuristic" }, // sign-in
+ ],
+ },
+ ],
+ },
+ ],
+ "fixtures/third_party/BestBuy/"
+);
diff --git a/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_CDW.js b/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_CDW.js
new file mode 100644
index 0000000000..feae321cb2
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_CDW.js
@@ -0,0 +1,71 @@
+/* global add_heuristic_tests */
+
+"use strict";
+
+add_heuristic_tests(
+ [
+ {
+ fixturePath: "Checkout_ShippingInfo.html",
+ expectedResult: [
+ {
+ default: {
+ reason: "regex-heuristic",
+ },
+ fields: [
+ { fieldName: "given-name" },
+ { fieldName: "family-name" },
+ { fieldName: "organization" },
+ { fieldName: "address-line1" },
+ { fieldName: "address-line2" },
+ { fieldName: "address-level2" }, // city
+ { fieldName: "address-level1" }, // state
+ { fieldName: "postal-code" },
+ { fieldName: "postal-code" }, // EXt
+ { fieldName: "email" },
+ { fieldName: "tel" },
+ { fieldName: "tel-extension" },
+ ],
+ },
+ ],
+ },
+ {
+ fixturePath: "Checkout_BillingPaymentInfo.html",
+ expectedResult: [
+ {
+ default: {
+ reason: "regex-heuristic",
+ },
+ fields: [
+ { fieldName: "given-name" },
+ { fieldName: "family-name" },
+ { fieldName: "organization" },
+ { fieldName: "address-line1" },
+ { fieldName: "address-line2" },
+ { fieldName: "address-level2" }, // city
+ { fieldName: "address-level1" }, // state
+ { fieldName: "postal-code" },
+ { fieldName: "postal-code" }, // Ext
+ ],
+ },
+ {
+ default: {
+ reason: "regex-heuristic",
+ },
+ fields: [
+ { fieldName: "cc-type" }, // ac-off
+ { fieldName: "cc-number", reason: "fathom" }, // ac-off
+ { fieldName: "cc-exp-month" },
+ { fieldName: "cc-exp-year" },
+ // {fieldName: "cc-csc"},
+ ],
+ },
+ ],
+ },
+ {
+ fixturePath: "Checkout_Logon.html",
+ expectedResult: [
+ ],
+ },
+ ],
+ "fixtures/third_party/CDW/"
+);
diff --git a/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_CostCo.js b/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_CostCo.js
new file mode 100644
index 0000000000..c83973b3c9
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_CostCo.js
@@ -0,0 +1,170 @@
+/* global add_heuristic_tests */
+
+"use strict";
+
+add_heuristic_tests(
+ [
+ {
+ fixturePath: "ShippingAddress.html",
+ expectedResult: [
+ {
+ default: {
+ reason: "regex-heuristic",
+ },
+ fields: [
+ { fieldName: "given-name" },
+ { fieldName: "additional-name" }, // middle-name initial
+ { fieldName: "family-name" },
+ { fieldName: "organization" },
+ { fieldName: "country" },
+ { fieldName: "address-line1" },
+ { fieldName: "address-line2" },
+ { fieldName: "address-level2" }, // city
+ { fieldName: "address-level1" }, // state
+ { fieldName: "postal-code" },
+ { fieldName: "tel" },
+ { fieldName: "email" },
+ ],
+ },
+ {
+ default: {
+ reason: "regex-heuristic",
+ },
+ fields: [
+ { fieldName: "given-name" },
+ { fieldName: "additional-name" }, // middle-name initial
+ { fieldName: "family-name" },
+ { fieldName: "organization" },
+ { fieldName: "country" },
+ { fieldName: "address-line1" },
+ { fieldName: "address-line2" },
+ { fieldName: "address-level2" }, // city
+ { fieldName: "address-level1" }, // state
+ { fieldName: "postal-code" },
+ { fieldName: "tel" },
+ { fieldName: "email" },
+ ],
+ },
+ {
+ invalid: true,
+ fields: [
+ { fieldName: "email", reason: "regex-heuristic" },
+ ],
+ },
+ ],
+ },
+ {
+ fixturePath: "Payment.html",
+ expectedResult: [
+ {
+ default: {
+ reason: "regex-heuristic",
+ },
+ fields: [
+ { fieldName: "cc-type" }, // ac-off
+ { fieldName: "cc-number", reason: "fathom" }, // ac-off
+ { fieldName: "cc-exp-month" },
+ { fieldName: "cc-exp-year" },
+ // { fieldName: "cc-csc"}, // ac-off
+ { fieldName: "cc-name", reason: "fathom" }, // ac-off
+ ],
+ },
+ {
+ invalid: true, // confidence is not high enough
+ fields: [
+ { fieldName: "cc-number", reason: "fathom" }, // ac-off
+ ],
+ },
+ {
+ default: {
+ reason: "regex-heuristic",
+ },
+ fields: [
+ { fieldName: "given-name" },
+ { fieldName: "additional-name" }, // middle-name initial
+ { fieldName: "family-name" },
+ { fieldName: "organization" },
+ { fieldName: "country" },
+ { fieldName: "address-line1" },
+ { fieldName: "address-line2" },
+ { fieldName: "address-level2" }, // city
+ { fieldName: "address-level1" }, // state
+ { fieldName: "postal-code" },
+ { fieldName: "tel" },
+ { fieldName: "email" },
+ ],
+ },
+ {
+ default: {
+ reason: "regex-heuristic",
+ },
+ fields: [
+ { fieldName: "given-name" },
+ { fieldName: "additional-name" }, // middle-name initial
+ { fieldName: "family-name" },
+ { fieldName: "organization" },
+ { fieldName: "country" },
+ { fieldName: "address-line1" },
+ { fieldName: "address-line2" },
+ { fieldName: "address-level2" },
+ { fieldName: "address-level1" }, // state
+ { fieldName: "postal-code" },
+ { fieldName: "tel" },
+ { fieldName: "email" },
+ ],
+ },
+ {
+ invalid: true,
+ fields: [
+ { fieldName: "email", reason: "regex-heuristic" },
+ ],
+ },
+ ],
+ },
+ {
+ fixturePath: "SignIn.html",
+ expectedResult: [
+ {
+ invalid: true,
+ fields: [
+ { fieldName: "email", reason: "regex-heuristic" },
+ ],
+ },
+ {
+ invalid: true,
+ fields: [
+ // Forgot password
+ { fieldName: "email", reason: "regex-heuristic" },
+ ],
+ },
+ {
+ invalid: true,
+ fields: [
+ { fieldName: "email", reason: "regex-heuristic" },
+ ],
+ },
+ {
+ invalid: true,
+ fields: [
+ { fieldName: "email", reason: "regex-heuristic" },
+ // {fieldName: "password"},
+ ],
+ },
+ {
+ invalid: true,
+ fields: [
+ // Sign up
+ { fieldName: "email", reason: "regex-heuristic" },
+ ],
+ },
+ {
+ invalid: true,
+ fields: [
+ { fieldName: "email", reason: "regex-heuristic" },
+ ],
+ }
+ ],
+ },
+ ],
+ "fixtures/third_party/CostCo/"
+);
diff --git a/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_DirectAsda.js b/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_DirectAsda.js
new file mode 100644
index 0000000000..6cb7979173
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_DirectAsda.js
@@ -0,0 +1,25 @@
+/* global add_heuristic_tests */
+
+"use strict";
+
+add_heuristic_tests(
+ [
+ {
+ fixturePath: "Payment.html",
+ expectedResult: [
+ {
+ default: {
+ reason: "fathom",
+ },
+ fields: [
+ { fieldName: "cc-number" },
+ { fieldName: "cc-name" },
+ { fieldName: "cc-exp-month", reason: "regex-heuristic" },
+ { fieldName: "cc-exp-year", reason: "regex-heuristic" },
+ ],
+ },
+ ],
+ },
+ ],
+ "fixtures/third_party/DirectAsda/"
+)
diff --git a/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_Ebay.js b/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_Ebay.js
new file mode 100644
index 0000000000..db6047718f
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_Ebay.js
@@ -0,0 +1,25 @@
+/* global add_heuristic_tests */
+
+"use strict";
+
+add_heuristic_tests(
+ [
+ {
+ fixturePath: "Checkout_Payment_FR.html",
+ expectedResult: [
+ {
+ default: {
+ reason: "autocomplete",
+ },
+ fields: [
+ { fieldName: "cc-number" },
+ { fieldName: "cc-exp" },
+ { fieldName: "cc-given-name" },
+ { fieldName: "cc-family-name" },
+ ],
+ },
+ ],
+ },
+ ],
+ "fixtures/third_party/Ebay/"
+)
diff --git a/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_GlobalDirectAsda.js b/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_GlobalDirectAsda.js
new file mode 100644
index 0000000000..7b82009032
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_GlobalDirectAsda.js
@@ -0,0 +1,24 @@
+/* global add_heuristic_tests */
+
+"use strict";
+
+add_heuristic_tests(
+ [
+ {
+ fixturePath: "Payment.html",
+ expectedResult: [
+ {
+ default: {
+ reason: "regex-heuristic",
+ },
+ fields: [
+ { fieldName: "cc-number", reason: "fathom" },
+ { fieldName: "cc-exp-month" },
+ { fieldName: "cc-exp-year" },
+ ],
+ },
+ ],
+ },
+ ],
+ "fixtures/third_party/GlobalDirectAsda/"
+)
diff --git a/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_HomeDepot.js b/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_HomeDepot.js
new file mode 100644
index 0000000000..8aac002b2d
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_HomeDepot.js
@@ -0,0 +1,77 @@
+/* global add_heuristic_tests */
+
+"use strict";
+
+add_heuristic_tests(
+ [
+ {
+ fixturePath: "Checkout_ShippingPayment.html",
+ expectedResult: [
+ {
+ default: {
+ reason: "regex-heuristic",
+ },
+ fields: [
+ { fieldName: "given-name" },
+ { fieldName: "family-name" },
+ { fieldName: "email" },
+ { fieldName: "tel" },
+ { fieldName: "street-address" },
+ { fieldName: "postal-code" },
+ ],
+ },
+ {
+ invalid: true,
+ fields: [
+ {
+ fieldName: "street-address",
+ reason: "autocomplete",
+ addressType: "billing",
+ },
+ ],
+ },
+ // THis should be fixed by visibility check
+ {
+ default: {
+ reason: "autocomplete",
+ },
+ fields: [
+ // FIXME: bug 1392944 - the uncommented cc-exp-month and cc-exp-year are
+ // both invisible <input> elements, and the following two <select>
+ // elements are the correct ones. BTW, they are both applied
+ // autocomplete attr.
+ { fieldName: "cc-exp-month" },
+ { fieldName: "cc-exp-year" },
+ { fieldName: "cc-number", reason: "fathom" },
+ ],
+ },
+ {
+ invalid: true,
+ fields: [
+ { fieldName: "cc-exp-month", reason: "regex-heuristic"},
+ { fieldName: "cc-exp-year", reason: "regex-heuristic"},
+ // {fieldName: "cc-csc"},
+ ],
+ },
+ ],
+ },
+ {
+ fixturePath: "SignIn.html",
+ expectedResult: [
+ {
+ invalid: true,
+ fields: [
+ { fieldName: "email",reason: "regex-heuristic" },
+ ],
+ },
+ {
+ invalid: true,
+ fields: [
+ { fieldName: "email",reason: "regex-heuristic" },
+ ],
+ },
+ ],
+ },
+ ],
+ "fixtures/third_party/HomeDepot/"
+);
diff --git a/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_Lufthansa.js b/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_Lufthansa.js
new file mode 100644
index 0000000000..801dbf66be
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_Lufthansa.js
@@ -0,0 +1,28 @@
+/* global add_heuristic_tests */
+
+"use strict";
+
+add_heuristic_tests(
+ [
+ {
+ fixturePath: "Checkout_Payment.html",
+ expectedResult: [
+ {
+ default: {
+ reason: "fathom",
+ },
+ fields: [
+ { fieldName: "cc-type", reason: "regex-heuristic" },
+ { fieldName: "cc-number" },
+ { fieldName: "cc-number" },
+ { fieldName: "cc-number" },
+ { fieldName: "cc-number" },
+ { fieldName: "cc-exp-month", reason: "regex-heuristic" },
+ { fieldName: "cc-exp-year", reason: "regex-heuristic" },
+ ],
+ },
+ ],
+ },
+ ],
+ "fixtures/third_party/Lufthansa/"
+);
diff --git a/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_Lush.js b/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_Lush.js
new file mode 100644
index 0000000000..a59fc966e5
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_Lush.js
@@ -0,0 +1,31 @@
+/* global add_heuristic_tests */
+
+"use strict";
+
+add_heuristic_tests(
+ [
+ {
+ fixturePath: "index.html",
+ expectedResult: [
+ {
+ default: {
+ reason: "fathom",
+ },
+ fields: [
+ { fieldName: "cc-name" },
+ { fieldName: "cc-number" },
+ ],
+ },
+ {
+ fields: [
+ { fieldName: "cc-number", reason: "autocomplete" },
+ { fieldName: "cc-name", reason: "fathom" },
+ { fieldName: "cc-exp-month", reason: "regex-heuristic" },
+ { fieldName: "cc-exp-year", reason: "regex-heuristic" },
+ ],
+ },
+ ],
+ },
+ ],
+ "fixtures/third_party/Lush/"
+);
diff --git a/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_Macys.js b/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_Macys.js
new file mode 100644
index 0000000000..e02996b565
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_Macys.js
@@ -0,0 +1,88 @@
+/* global add_heuristic_tests */
+
+"use strict";
+
+add_heuristic_tests(
+ [
+ {
+ fixturePath: "Checkout_ShippingAddress.html",
+ expectedResult: [
+ {
+ default: {
+ reason: "autocomplete",
+ },
+ fields: [
+ { fieldName: "given-name" },
+ { fieldName: "family-name" },
+ { fieldName: "address-line1" },
+ { fieldName: "address-line2" },
+ { fieldName: "address-level2" }, // city
+ { fieldName: "address-level1" }, // state
+ { fieldName: "postal-code" },
+ { fieldName: "tel" },
+ ],
+ },
+ ],
+ },
+ {
+ fixturePath: "Checkout_Payment.html",
+ expectedResult: [
+ {
+ default: {
+ reason: "regex-heuristic",
+ },
+ fields: [
+ { fieldName: "cc-type" }, // ac-off
+ { fieldName: "cc-number", reason: "fathom" }, // ac-off
+ { fieldName: "cc-exp-month" }, // ac-off
+ { fieldName: "cc-exp-year" }, // ac-off
+ // {fieldName: "cc-csc"}, // ac-off
+ ],
+ },
+ {
+ default: {
+ reason: "autocomplete",
+ },
+ fields: [
+ { fieldName: "given-name" },
+ { fieldName: "family-name" },
+ { fieldName: "address-line1" },
+ { fieldName: "address-line2" },
+ { fieldName: "address-level2" }, // city
+ { fieldName: "address-level1" }, // state
+ { fieldName: "postal-code" },
+ { fieldName: "tel" },
+ { fieldName: "email" },
+ ],
+ },
+ ],
+ },
+ {
+ fixturePath: "SignIn.html",
+ expectedResult: [
+ {
+ invalid: true,
+ fields: [
+ // Sign in
+ { fieldName: "email", reason: "regex-heuristic"},
+ // {fieldName: "password"},
+ ],
+ },
+ {
+ invalid: true,
+ fields: [
+ // Forgot password
+ { fieldName: "email", reason: "regex-heuristic"},
+ ],
+ },
+ {
+ invalid: true,
+ fields: [
+ { fieldName: "email", reason: "regex-heuristic"},
+ ],
+ },
+ ],
+ },
+ ],
+ "fixtures/third_party/Macys/"
+);
diff --git a/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_NewEgg.js b/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_NewEgg.js
new file mode 100644
index 0000000000..d914f02890
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_NewEgg.js
@@ -0,0 +1,109 @@
+/* global add_heuristic_tests */
+
+"use strict";
+
+add_heuristic_tests(
+ [
+ {
+ fixturePath: "ShippingInfo.html",
+ expectedResult: [
+ {
+ default: {
+ reason: "regex-heuristic",
+ },
+ fields: [
+ { fieldName: "given-name" },
+ { fieldName: "family-name" },
+ { fieldName: "country" },
+ { fieldName: "address-line1" },
+ { fieldName: "address-line2" },
+ { fieldName: "address-level2" },
+ { fieldName: "address-level1" }, // state
+ { fieldName: "postal-code" },
+ { fieldName: "tel" },
+ { fieldName: "email" },
+ ],
+ },
+ ],
+ },
+ {
+ fixturePath: "BillingInfo.html",
+ expectedResult: [
+ {
+ default: {
+ reason: "fathom",
+ },
+ fields: [
+ { fieldName: "cc-name" },
+ { fieldName: "cc-number" }, // ac-off
+ ],
+ },
+ {
+ default: {
+ reason: "fathom",
+ },
+ fields: [
+ { fieldName: "cc-name" },
+ { fieldName: "cc-number" }, // ac-off
+ { fieldName: "cc-exp-month", reason: "regex-heuristic" },
+ { fieldName: "cc-exp-year", reason: "regex-heuristic" },
+ // { fieldName: "cc-csc"},
+ ],
+ },
+ {
+ default: {
+ reason: "regex-heuristic",
+ },
+ fields: [
+ { fieldName: "country" },
+ { fieldName: "address-line1" },
+ { fieldName: "address-line2" },
+ { fieldName: "address-level2" },
+ { fieldName: "address-level1" }, // state
+ { fieldName: "postal-code" },
+ { fieldName: "tel" },
+ ],
+ },
+ {
+ default: {
+ reason: "fathom",
+ },
+ fields: [
+ { fieldName: "cc-name" },
+ { fieldName: "cc-number" }, // ac-off
+ { fieldName: "cc-exp-month", reason: "regex-heuristic" },
+ { fieldName: "cc-exp-year", reason: "regex-heuristic" },
+ ],
+ },
+ {
+ default: {
+ reason: "fathom",
+ },
+ fields: [
+ { fieldName: "cc-name" },
+ { fieldName: "cc-number" }, // ac-off
+ ],
+ },
+ ],
+ },
+ {
+ fixturePath: "Login.html",
+ expectedResult: [
+ {
+ invalid: true,
+ fields: [
+ { fieldName: "email", reason: "regex-heuristic" },
+ ],
+ },
+ {
+ invalid: true,
+ fields: [
+ { fieldName: "email", reason: "regex-heuristic" }, // Email Address
+ { fieldName: "email", reason: "regex-heuristic" }, // Confirm Email Address
+ ],
+ },
+ ],
+ },
+ ],
+ "fixtures/third_party/NewEgg/"
+);
diff --git a/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_OfficeDepot.js b/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_OfficeDepot.js
new file mode 100644
index 0000000000..8c44104cb6
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_OfficeDepot.js
@@ -0,0 +1,83 @@
+/* global add_heuristic_tests */
+
+"use strict";
+
+add_heuristic_tests(
+ [
+ {
+ fixturePath: "ShippingAddress.html",
+ expectedResult: [
+ {
+ default: {
+ reason: "regex-heuristic",
+ },
+ fields: [
+ { fieldName: "given-name" },
+ { fieldName: "family-name" },
+ { fieldName: "organization" },
+ { fieldName: "address-line1" },
+ { fieldName: "address-line2" },
+ { fieldName: "postal-code" },
+ { fieldName: "address-level2" }, // City & State
+ { fieldName: "address-level2" }, // City
+ { fieldName: "address-level1" }, // State
+ { fieldName: "tel-area-code" },
+ { fieldName: "tel-local-prefix" },
+ { fieldName: "tel-local-suffix" },
+ { fieldName: "tel-extension" },
+ { fieldName: "email" },
+ ],
+ },
+ ],
+ },
+ {
+ fixturePath: "Payment.html",
+ expectedResult: [
+ {
+ invalid: true, // because non of them is identified by fathom
+ default: {
+ reason: "regex-heuristic",
+ },
+ fields: [
+ { fieldName: "cc-exp-month" },
+ { fieldName: "cc-exp-year" },
+ { fieldName: "cc-number" },
+ ],
+ },
+ {
+ default: {
+ reason: "regex-heuristic",
+ },
+ fields: [
+ { fieldName: "given-name" },
+ { fieldName: "family-name" },
+ { fieldName: "organization" },
+ { fieldName: "address-line1" },
+ { fieldName: "address-line2" },
+ { fieldName: "postal-code" },
+ { fieldName: "address-level2" }, // City & State
+ { fieldName: "address-level2" }, // City
+ { fieldName: "address-level1" }, // state
+ { fieldName: "tel-area-code" },
+ { fieldName: "tel-local-prefix" },
+ { fieldName: "tel-local-suffix" },
+ { fieldName: "tel-extension" },
+ { fieldName: "email" },
+ ],
+ },
+ ],
+ },
+ {
+ fixturePath: "SignIn.html",
+ expectedResult: [
+ {
+ invalid: true,
+ fields: [
+ { fieldName: "email", reason: "regex-heuristic" },
+ ],
+ },
+ ],
+ },
+ ],
+ "fixtures/third_party/OfficeDepot/"
+);
diff --git a/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_QVC.js b/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_QVC.js
new file mode 100644
index 0000000000..bc0504e726
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_QVC.js
@@ -0,0 +1,96 @@
+/* global add_heuristic_tests */
+
+"use strict";
+
+add_heuristic_tests(
+ [
+ {
+ fixturePath: "YourInformation.html",
+ expectedResult: [
+ {
+ invalid: true,
+ fields: [
+ { fieldName: "tel", reason: "regex-heuristic" },
+ { fieldName: "email", reason: "regex-heuristic" },
+ // { fieldName: "bday-month"}, // select
+ // { fieldName: "bday-day"}, // select
+ // { fieldName: "bday-year"},
+ ],
+ },
+ {
+ fields: [
+ { fieldName: "cc-type", reason: "regex-heuristic" },
+ { fieldName: "cc-number", reason: "fathom" },
+ { fieldName: "cc-exp", reason: "regex-heuristic" },
+ // { fieldName: "cc-csc"},
+ ],
+ },
+ {
+ invalid: true,
+ fields: [
+ { fieldName: "cc-number", reason: "regex-heuristic" }, // txtQvcGiftCardNumber
+ ],
+ },
+ {
+ invalid: true,
+ fields: [
+ { fieldName: "email", reason: "regex-heuristic" },
+ { fieldName: "email", reason: "regex-heuristic" },
+ ],
+ },
+ ],
+ },
+ {
+ fixturePath: "PaymentMethod.html",
+ expectedResult: [
+ {
+ invalid: true,
+ fields: [
+ { fieldName: "tel", reason: "regex-heuristic" },
+ { fieldName: "email", reason: "regex-heuristic" },
+ // { fieldName: "bday-month"}, // select
+ // { fieldName: "bday-day"}, // select
+ // { fieldName: "bday-year"}, // select
+ ],
+ },
+ {
+ default: {
+ reason: "fathom",
+ },
+ fields: [
+ { fieldName: "cc-type", reason: "regex-heuristic" }, // ac-off
+ { fieldName: "cc-number" }, // ac-off
+ { fieldName: "cc-exp", reason: "regex-heuristic" },
+ // { fieldName: "cc-csc"},
+ ],
+ },
+ {
+ invalid: true,
+ fields: [
+ { fieldName: "cc-number", reason: "regex-heuristic" }, // txtQvcGiftCardNumbe, ac-off
+ ],
+ },
+ {
+ invalid: true,
+ fields: [
+ { fieldName: "email", reason: "regex-heuristic" },
+ { fieldName: "email", reason: "regex-heuristic" },
+ ],
+ },
+ ],
+ },
+ {
+ fixturePath: "SignIn.html",
+ expectedResult: [
+ {
+ // Sign in
+ invalid: true,
+ fields: [
+ { fieldName: "email", reason: "regex-heuristic" },
+ ],
+ },
+ ],
+ },
+ ],
+ "fixtures/third_party/QVC/"
+);
diff --git a/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_Sears.js b/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_Sears.js
new file mode 100644
index 0000000000..4c668ddad1
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_Sears.js
@@ -0,0 +1,81 @@
+/* global add_heuristic_tests */
+
+"use strict";
+
+add_heuristic_tests(
+ [
+ {
+ fixturePath: "ShippingAddress.html",
+ expectedResult: [
+ {
+ invalid: true,
+ default: {
+ reason: "regex-heuristic",
+ },
+ fields: [
+ { fieldName: "email" },
+ ]
+ },
+ {
+ default: {
+ reason: "regex-heuristic",
+ },
+ fields: [
+ // check-out, ac-off
+ { fieldName: "given-name" },
+ { fieldName: "family-name" },
+ { fieldName: "address-line1" },
+ { fieldName: "address-line2" },
+ { fieldName: "address-level2" },
+ { fieldName: "address-level1" },
+ { fieldName: "postal-code" },
+ { fieldName: "tel" },
+ { fieldName: "tel-extension" },
+ { fieldName: "email" },
+ { fieldName: "email" },
+ ],
+ },
+ {
+ default: {
+ reason: "regex-heuristic",
+ },
+ fields: [
+ // ac-off
+ // { fieldName: "email"},
+ { fieldName: "given-name" },
+ { fieldName: "family-name" },
+ { fieldName: "address-line1" },
+ { fieldName: "address-line2" },
+ { fieldName: "address-level2" },
+ { fieldName: "address-level1" },
+ { fieldName: "postal-code" },
+ { fieldName: "tel" },
+ { fieldName: "tel-extension" },
+ // { fieldName: "new-password"},
+ ],
+ },
+ {
+ invalid: true,
+ default: {
+ reason: "regex-heuristic",
+ },
+ fields: [
+ // ac-off
+ { fieldName: "email" },
+ ]
+ },
+ {
+ invalid: true,
+ default: {
+ reason: "regex-heuristic",
+ },
+ fields: [
+ // ac-off
+ { fieldName: "email" },
+ ]
+ },
+ ],
+ },
+ ],
+ "fixtures/third_party/Sears/"
+);
diff --git a/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_Staples.js b/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_Staples.js
new file mode 100644
index 0000000000..4b03fe2338
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_Staples.js
@@ -0,0 +1,78 @@
+/* global add_heuristic_tests */
+
+"use strict";
+
+add_heuristic_tests(
+ [
+ {
+ fixturePath: "Basic.html",
+ expectedResult: [
+ {
+ // ac-off
+ default: {
+ reason: "regex-heuristic",
+ },
+ fields: [
+ { fieldName: "given-name" },
+ { fieldName: "family-name" },
+ { fieldName: "address-line1" },
+ { fieldName: "email" },
+ { fieldName: "tel" },
+ { fieldName: "tel" }, // Extension
+ { fieldName: "organization" },
+ ]
+ },
+ ],
+ },
+ {
+ fixturePath: "Basic_ac_on.html",
+ expectedResult: [
+ {
+ default: {
+ reason: "regex-heuristic",
+ },
+ fields: [
+ { fieldName: "given-name" },
+ { fieldName: "family-name" },
+ { fieldName: "address-line1" },
+ { fieldName: "email" },
+ { fieldName: "tel" },
+ { fieldName: "tel" }, // Extension
+ { fieldName: "organization" },
+ ],
+ },
+ ],
+ },
+ {
+ fixturePath: "PaymentBilling.html",
+ expectedResult: [
+ {
+ default: {
+ reason: "regex-heuristic",
+ },
+ fields: [
+ { fieldName: "cc-number", reason: "fathom" },
+ { fieldName: "cc-exp" },
+ // {fieldName: "cc-csc"},
+ ],
+ },
+ ],
+ },
+ {
+ fixturePath: "PaymentBilling_ac_on.html",
+ expectedResult: [
+ {
+ default: {
+ reason: "regex-heuristic",
+ },
+ fields: [
+ { fieldName: "cc-number", reason: "fathom" },
+ { fieldName: "cc-exp" },
+ // { fieldName: "cc-csc"},
+ ],
+ },
+ ],
+ },
+ ],
+ "fixtures/third_party/Staples/"
+);
diff --git a/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_Walmart.js b/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_Walmart.js
new file mode 100644
index 0000000000..f2896bd9ac
--- /dev/null
+++ b/browser/extensions/formautofill/test/browser/heuristics/third_party/browser_Walmart.js
@@ -0,0 +1,93 @@
+/* global add_heuristic_tests */
+
+"use strict";
+
+add_heuristic_tests(
+ [
+ {
+ fixturePath: "Checkout.html",
+ expectedResult: [
+ {
+ invalid: true,
+ fields: [
+ { fieldName: "postal-code", reason: "regex-heuristic" },
+ ],
+ },
+ {
+ invalid: true,
+ fields: [
+ { fieldName: "email", reason: "regex-heuristic" },
+ // { fieldName: "password"}, // ac-off
+ ],
+ },
+ {
+ default: {
+ reason: "regex-heuristic",
+ },
+ fields: [
+ { fieldName: "given-name" },
+ { fieldName: "family-name" },
+ { fieldName: "email" }, // ac-off
+ // { fieldName: "password"},
+ // { fieldName: "password"}, // ac-off
+ ],
+ },
+ ],
+ },
+ {
+ fixturePath: "Payment.html",
+ expectedResult: [
+ {
+ default: {
+ reason: "autocomplete",
+ section: "section-payment",
+ },
+ fields: [
+ { fieldName: "given-name" },
+ { fieldName: "family-name" },
+ { fieldName: "tel" },
+ ],
+ },
+ {
+ default: {
+ reason: "autocomplete",
+ section: "section-payment",
+ },
+ fields: [
+ { fieldName: "cc-number" },
+ { fieldName: "cc-exp-month" },
+ { fieldName: "cc-exp-year" },
+ // { fieldName: "cc-csc"},
+ ],
+ },
+ ],
+ },
+ {
+ fixturePath: "Shipping.html",
+ expectedResult: [
+ {
+ invalid: true,
+ fields: [
+ { fieldName: "postal-code", reason: "regex-heuristic" },
+ ],
+ },
+ {
+ default: {
+ reason: "regex-heuristic",
+ },
+ fields: [
+ { fieldName: "given-name" },
+ { fieldName: "family-name" },
+ { fieldName: "tel" },
+ { fieldName: "address-line1" },
+ { fieldName: "address-line2" },
+ { fieldName: "address-level2" }, // city
+ { fieldName: "address-level1" }, // state
+ { fieldName: "postal-code" },
+ ],
+ },
+ ],
+ },
+ ],
+ "fixtures/third_party/Walmart/"
+);
diff --git a/browser/extensions/formautofill/test/fixtures/autocomplete_address_basic.html b/browser/extensions/formautofill/test/fixtures/autocomplete_address_basic.html
new file mode 100644
index 0000000000..73b5e51cdb
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/autocomplete_address_basic.html
@@ -0,0 +1,26 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Form Autofill Address Demo Page</title>
+</head>
+<body>
+ <h1>Form Autofill Address Demo Page</h1>
+ <form id="form">
+ <p><label>givenname: <input type="text" id="given-name" name="given-name" autocomplete="given-name" /></label></p>
+ <p><label>familyname: <input type="text" id="family-name" name="family-name" autocomplete="family-name" /></label></p>
+ <p><label>organization: <input type="text" id="organization" name="organization" autocomplete="organization" /></label></p>
+ <p><label>streetAddress: <input type="text" id="street-address" name="street-address" autocomplete="street-address" /></label></p>
+ <p><label>addressLevel2: <input type="text" id="address-level2" name="address-level2" autocomplete="address-level2" /></label></p>
+ <p><label>addressLevel1: <input type="text" id="address-level1" name="address-level1" autocomplete="address-level1" /></label></p>
+ <p><label>postalCode: <input type="text" id="postal-code" name="postal-code" autocomplete="postal-code" /></label></p>
+ <p><label>country: <input type="text" id="country" name="country" autocomplete="country" /></label></p>
+ <p><label>tel: <input type="text" id="tel" name="tel" autocomplete="tel" /></label></p>
+ <p><label>email: <input type="text" id="email" name="email" autocomplete="email" /></label></p>
+ <p>
+ <input type="submit"/>
+ <button type="reset">Reset</button>
+ </p>
+ </form>
+</body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/autocomplete_basic.html b/browser/extensions/formautofill/test/fixtures/autocomplete_basic.html
new file mode 100644
index 0000000000..5007e9e5bd
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/autocomplete_basic.html
@@ -0,0 +1,52 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Form Autofill Demo Page</title>
+</head>
+<body>
+ <h1>Form Autofill Demo Page</h1>
+ <form id="form">
+ <p><label>organization: <input type="text" id="organization" name="organization" autocomplete="organization" /></label></p>
+ <p><label>streetAddress: <input type="text" id="street-address" name="street-address" autocomplete="street-address" /></label></p>
+ <p><label>addressLevel2: <input type="text" id="address-level2" name="address-level2" autocomplete="address-level2" /></label></p>
+ <p><label>addressLevel1: <input type="text" id="address-level1" name="address-level1" autocomplete="address-level1" /></label></p>
+ <p><label>postalCode: <input type="text" id="postal-code" name="postal-code" autocomplete="postal-code" /></label></p>
+ <p><label>country: <input type="text" id="country" name="country" autocomplete="country" /></label></p>
+ <p><label>tel: <input type="text" id="tel" name="tel" autocomplete="tel" /></label></p>
+ <p><label>email: <input type="text" id="email" name="email" autocomplete="email" /></label></p>
+ <p><input type="submit" /></p>
+ <p><button type="reset">Reset</button></p>
+ </form>
+
+ <form id="formB">
+ <p><label>Organization: <input type="text" /></label></p>
+ <p><label><input type="text" id="B_address-line1" /></label></p>
+ <p><label><input type="text" name="address-line2" /></label></p>
+ <p><label><input type="text" id="B_address-line3" name="address-line3" /></label></p>
+ <p><label>City: <input type="text" name="address-level2" /></label></p>
+ <p><label>State: <select id="B_address-level1" ></select></label></p>
+ <p><input type="text" id="B_postal-code" name="postal-code" /></p>
+ <p><label>Country: <select multiple id="B_country" name="country" ></select></label></p>
+ <p><label>Telephone: <input id="B_tel" name="tel" /></label></p>
+ <p><label>Email: <input type="text" id="B_email" name="email" /></label></p>
+ <hr>
+ <p><label>cc-number <input type="text" id="B_cc-number" autocomplete="cc-number" /></label></p>
+ <p><label>cc-name <input type="text" id="B_cc-name" autocomplete="cc-name" /></label></p>
+ <p><label>cc-exp-month <input type="text" id="B_cc-exp-month" autocomplete="cc-exp-month" /></label></p>
+ <p><label>cc-exp-year <input type="text" id="B_cc-exp-year" autocomplete="cc-exp-year" /></label></p>
+ <hr>
+ <p><input type="submit" /></p>
+ <p><button type="reset">Reset</button></p>
+ </form>
+
+ <form id="formC">
+ <p><label><input type="text" name="someprefixAddrLine1" /></label></p>
+ <p><label>City: <input type="text" name="address-level2" /></label></p>
+ <p><label><input type="text" name="someprefixAddrLine2" /></label></p>
+ <p><label>Organization: <input type="text" name="organization" /></label></p>
+ <p><label><input type="text" name="someprefixAddrLine3" /></label></p>
+ </form>
+
+</body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/autocomplete_creditcard_basic.html b/browser/extensions/formautofill/test/fixtures/autocomplete_creditcard_basic.html
new file mode 100644
index 0000000000..1c03f2434d
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/autocomplete_creditcard_basic.html
@@ -0,0 +1,29 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Form Autofill Credit Card Demo Page</title>
+</head>
+<body>
+ <h1>Form Autofill Credit Card Demo Page</h1>
+ <form id="form">
+ <p><label>Name: <input id="cc-name" autocomplete="cc-name"></label></p>
+ <p><label>Card Number: <input id="cc-number" autocomplete="cc-number"></label></p>
+ <p><label>Expiration month: <input id="cc-exp-month" autocomplete="cc-exp-month"></label></p>
+ <p><label>Expiration year: <input id="cc-exp-year" autocomplete="cc-exp-year"></label></p>
+ <p><label>CSC: <input id="cc-csc" autocomplete="cc-csc"></label></p>
+ <p><label>Card Type: <select id="cc-type" autocomplete="cc-type">
+ <option></option>
+ <option value="discover">Discover</option>
+ <option value="jcb">JCB</option>
+ <option value="visa">Visa</option>
+ <option value="mastercard">MasterCard</option>
+ <option value="gringotts">Unknown card network</option>
+ </select></label></p>
+ <p>
+ <input type="submit" value="Submit">
+ <button type="reset">Reset</button>
+ </p>
+ </form>
+</body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/autocomplete_creditcard_cc_exp_field.html b/browser/extensions/formautofill/test/fixtures/autocomplete_creditcard_cc_exp_field.html
new file mode 100644
index 0000000000..24e93b73a2
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/autocomplete_creditcard_cc_exp_field.html
@@ -0,0 +1,28 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Form Autofill Credit Card Demo Page</title>
+</head>
+<body>
+ <h1>Form Autofill Credit Card Demo Page</h1>
+ <form id="form">
+ <p><label>Name: <input id="cc-name" autocomplete="cc-name"></label></p>
+ <p><label>Card Number: <input id="cc-number" autocomplete="cc-number"></label></p>
+ <p><label>Expiration string: <input id="cc-exp" autocomplete="cc-exp"></label></p>
+ <p><label>CSC: <input id="cc-csc" autocomplete="cc-csc"></label></p>
+ <p><label>Card Type: <select id="cc-type" autocomplete="cc-type">
+ <option></option>
+ <option value="discover">Discover</option>
+ <option value="jcb">JCB</option>
+ <option value="visa">Visa</option>
+ <option value="mastercard">MasterCard</option>
+ <option value="gringotts">Unknown card network</option>
+ </select></label></p>
+ <p>
+ <input type="submit" value="Submit">
+ <button type="reset">Reset</button>
+ </p>
+ </form>
+</body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/autocomplete_creditcard_iframe.html b/browser/extensions/formautofill/test/fixtures/autocomplete_creditcard_iframe.html
new file mode 100644
index 0000000000..506deb396b
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/autocomplete_creditcard_iframe.html
@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Form Autofill Credit Card With Remote IFrame Demo Page</title>
+</head>
+<body>
+ <iframe src="https://test1.example.com:443/browser/browser/extensions/formautofill/test/browser/creditCard/autocomplete_creditcard_basic.html" width="400" height="400">
+ </iframe>
+
+</body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/autocomplete_iframe.html b/browser/extensions/formautofill/test/fixtures/autocomplete_iframe.html
new file mode 100644
index 0000000000..84fcf54e67
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/autocomplete_iframe.html
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Form Autofill With Remote IFrame Demo Page</title>
+</head>
+<body>
+ <iframe id="unused" src="data:text/html,<body>Just here to ensure code doesn't always pick the first child iframe.</body>"></iframe>
+ <iframe src="https://test1.example.com:443/browser/browser/extensions/formautofill/test/browser/autocomplete_basic.html" width="400" height="400">
+ </iframe>
+
+</body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/autocomplete_off_on_form.html b/browser/extensions/formautofill/test/fixtures/autocomplete_off_on_form.html
new file mode 100644
index 0000000000..c7df012b36
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/autocomplete_off_on_form.html
@@ -0,0 +1,52 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Form Autofill Demo Page with autocomplete set to off on form elements</title>
+</head>
+<body>
+ <h1>Form Autofill Demo Page with autocomplete set to off on form elements</h1>
+ <form id="form" autocomplete="off">
+ <p><label>organization: <input type="text" id="organization" name="organization" autocomplete="organization" /></label></p>
+ <p><label>streetAddress: <input type="text" id="street-address" name="street-address" autocomplete="street-address" /></label></p>
+ <p><label>addressLevel2: <input type="text" id="address-level2" name="address-level2" autocomplete="address-level2" /></label></p>
+ <p><label>addressLevel1: <input type="text" id="address-level1" name="address-level1" autocomplete="address-level1" /></label></p>
+ <p><label>postalCode: <input type="text" id="postal-code" name="postal-code" autocomplete="postal-code" /></label></p>
+ <p><label>country: <input type="text" id="country" name="country" autocomplete="country" /></label></p>
+ <p><label>tel: <input type="text" id="tel" name="tel" autocomplete="tel" /></label></p>
+ <p><label>email: <input type="text" id="email" name="email" autocomplete="email" /></label></p>
+ <p><input type="submit" /></p>
+ <p><button type="reset">Reset</button></p>
+ </form>
+
+ <form id="formB" autocomplete="off">
+ <p><label>Organization: <input type="text" /></label></p>
+ <p><label><input type="text" id="B_address-line1" /></label></p>
+ <p><label><input type="text" name="address-line2" /></label></p>
+ <p><label><input type="text" id="B_address-line3" name="address-line3" /></label></p>
+ <p><label>City: <input type="text" name="address-level2" /></label></p>
+ <p><label>State: <select id="B_address-level1" ></select></label></p>
+ <p><input type="text" id="B_postal-code" name="postal-code" /></p>
+ <p><label>Country: <select multiple id="B_country" name="country" ></select></label></p>
+ <p><label>Telephone: <input id="B_tel" name="tel" /></label></p>
+ <p><label>Email: <input type="text" id="B_email" name="email" /></label></p>
+ <hr>
+ <p><label>cc-number <input type="text" id="B_cc-number" autocomplete="cc-number" /></label></p>
+ <p><label>cc-name <input type="text" id="B_cc-name" autocomplete="cc-name" /></label></p>
+ <p><label>cc-exp-month <input type="text" id="B_cc-exp-month" autocomplete="cc-exp-month" /></label></p>
+ <p><label>cc-exp-year <input type="text" id="B_cc-exp-year" autocomplete="cc-exp-year" /></label></p>
+ <hr>
+ <p><input type="submit" /></p>
+ <p><button type="reset">Reset</button></p>
+ </form>
+
+ <form id="formC" autocomplete="off">
+ <p><label><input type="text" name="someprefixAddrLine1" /></label></p>
+ <p><label>City: <input type="text" name="address-level2" /></label></p>
+ <p><label><input type="text" name="someprefixAddrLine2" /></label></p>
+ <p><label>Organization: <input type="text" name="organization" /></label></p>
+ <p><label><input type="text" name="someprefixAddrLine3" /></label></p>
+ </form>
+
+</body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/autocomplete_off_on_inputs.html b/browser/extensions/formautofill/test/fixtures/autocomplete_off_on_inputs.html
new file mode 100644
index 0000000000..293240b9cc
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/autocomplete_off_on_inputs.html
@@ -0,0 +1,77 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Form Autofill Demo Page with autocomplete set to off on inputs within form elements</title>
+</head>
+<body>
+ <h1>Form Autofill Demo Page with autocomplete set to off on inputs within form elements</h1>
+ <form id="form">
+ <p><label>organization: <input type="text" id="organization" name="organization" autocomplete="off" /></label></p>
+ <p><label>streetAddress: <input type="text" id="street-address" name="street-address" autocomplete="off" /></label></p>
+ <p><label>addressLevel2: <input type="text" id="address-level2" name="address-level2" autocomplete="off" /></label></p>
+ <p><label>addressLevel1: <input type="text" id="address-level1" name="address-level1" autocomplete="off" /></label></p>
+ <p><label>postalCode: <input type="text" id="postal-code" name="postal-code" autocomplete="off" /></label></p>
+ <p><label>country: <input type="text" id="country" name="country" autocomplete="off" /></label></p>
+ <p><label>tel: <input type="text" id="tel" name="tel" autocomplete="off" /></label></p>
+ <p><label>email: <input type="text" id="email" name="email" autocomplete="off" /></label></p>
+ <p><input type="submit" /></p>
+ <p><button type="reset">Reset</button></p>
+ </form>
+
+ <form id="formB">
+ <p><label>Organization: <input type="text" autocomplete="off" /></label></p>
+ <p><label><input type="text" id="B_address-line1" autocomplete="off" /></label></p>
+ <p><label><input type="text" name="address-line2" autocomplete="off" /></label></p>
+ <p><label><input type="text" id="B_address-line3" name="address-line3" autocomplete="off" /></label></p>
+ <p><label>City: <input type="text" name="address-level2" autocomplete="off" /></label></p>
+ <p><label>State: <select id="B_address-level1" autocomplete="off" ></select></label></p>
+ <p><input type="text" id="B_postal-code" name="postal-code" autocomplete="off" /></p>
+ <p><label>Country: <select multiple id="B_country" name="country" autocomplete="off" ></select></label></p>
+ <p><label>Telephone: <input id="B_tel" name="tel" autocomplete="off" /></label></p>
+ <p><label>Email: <input type="text" id="B_email" name="email" autocomplete="off" /></label></p>
+ <hr>
+ <p><label>cc-number <input type="text" id="B_cc-number" autocomplete="off" /></label></p>
+ <p><label>cc-name <input type="text" id="B_cc-name" autocomplete="off" /></label></p>
+ <p><label>cc-exp-month <input type="text" id="B_cc-exp-month" autocomplete="off" /></label></p>
+ <p><label>cc-exp-year <input type="text" id="B_cc-exp-year" autocomplete="off" /></label></p>
+ <hr>
+ <p><input type="submit" /></p>
+ <p><button type="reset">Reset</button></p>
+ </form>
+
+ <form id="formC">
+ <p><label><input type="text" name="someprefixAddrLine1" autocomplete="off" /></label></p>
+ <p><label>City: <input type="text" name="address-level2" autocomplete="off" /></label></p>
+ <p><label><input type="text" name="someprefixAddrLine2" autocomplete="off" /></label></p>
+ <p><label>Organization: <input type="text" name="organization" autocomplete="off" /></label></p>
+ <p><label><input type="text" name="someprefixAddrLine3" autocomplete="off" /></label></p>
+ </form>
+
+ <form id="formD">
+ <!--
+ Ensure heuristics can correctly identify fields when there are
+ autocomplete="off" fields as well as missing autocomplete attributes
+ -->
+ <p><label>Organization: <input type="text" autocomplete="organization" /></label></p>
+ <p><label><input type="text" id="B_address-line1" autocomplete="off" /></label></p>
+ <p><label><input type="text" name="address-line2" /></label></p>
+ <p><label><input type="text" id="B_address-line3" name="address-line3" autocomplete="off" /></label></p>
+ <p><label>City: <input type="text" name="address-level2" /></label></p>
+ <p><label>State: <select id="B_address-level1" autocomplete="address-level1" ></select></label></p>
+ <p><input type="text" id="B_postal-code" name="postal-code" /></p>
+ <p><label>Country: <select multiple id="B_country" name="country" autocomplete="off" ></select></label></p>
+ <p><label>Telephone: <input id="B_tel" name="tel" autocomplete="tel" /></label></p>
+ <p><label>Email: <input type="text" id="B_email" name="email" autocomplete="off" /></label></p>
+ <hr>
+ <p><label>cc-number <input type="text" id="B_cc-number" autocomplete="off" /></label></p>
+ <p><label>cc-name <input type="text" id="B_cc-name" autocomplete="cc-name" /></label></p>
+ <p><label>cc-exp-month <input type="text" id="B_cc-exp-month" /></label></p>
+ <p><label>cc-exp-year <input type="text" id="B_cc-exp-year" /></label></p>
+ <hr>
+ <p><input type="submit" /></p>
+ <p><button type="reset">Reset</button></p>
+ </form>
+
+</body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/autocomplete_simple_basic.html b/browser/extensions/formautofill/test/fixtures/autocomplete_simple_basic.html
new file mode 100644
index 0000000000..9e78e6392f
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/autocomplete_simple_basic.html
@@ -0,0 +1,19 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Form Autofill Demo Page for Simplified Form Case</title>
+</head>
+<body>
+ <h1>Form Autofill Demo Page for Simplified Form Case</h1>
+
+ <form id="simple">
+ <p><label>Organization: <input type="text" /></label></p>
+ <p><label>Telephone: <input id="simple_tel" name="tel" /></label></p>
+ <p><label>Email: <input type="text" id="simple_email" name="email" /></label></p>
+ <p><input type="submit" /></p>
+ <p><button type="reset">Reset</button></p>
+ </form>
+
+</body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/heuristics_cc_exp.html b/browser/extensions/formautofill/test/fixtures/heuristics_cc_exp.html
new file mode 100644
index 0000000000..e14f1520a6
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/heuristics_cc_exp.html
@@ -0,0 +1,73 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Heuristics cc-exp field test page</title>
+</head>
+<body>
+ <h1>Heuristics cc-exp field test page</h1>
+
+ <form id="form1">
+ <p><label>Name: <input id="cc-name" autocomplete="cc-name"></label></p>
+ <p><label>Card Number: <input id="cc-number" autocomplete="cc-number"></label></p>
+ <p><label>Expiration month: <input id="cc-exp-month" autocomplete="cc-exp-month"></label></p>
+ <p><label>Expiration year: <input id="cc-exp-year" autocomplete="cc-exp-year"></label></p>
+ <p><label>CSC: <input id="cc-csc" autocomplete="cc-csc"></label></p>
+ </form>
+
+ <form id="form2">
+ <p><label>Card Number: <input id="cc-number" autocomplete="cc-number"></label></p>
+ <p><label>Expiration Date: <input autocomplete="cc-exp"></label></p>
+ </form>
+
+ <form id="form3">
+ <p><label>Card Number: <input id="cc-number" autocomplete="cc-number"></label></p>
+ <p><label>Expiration Date: <input type="text"></label></p>
+ </form>
+
+ <form id="form4">
+ <p><label>Card Number: <input id="cc-number" autocomplete="cc-number"></label></p>
+ <p>
+ <label>Exp:
+ <select>
+ <option value="1"></option>
+ <option value="2"></option>
+ <option value="3"></option>
+ <option value="4"></option>
+ <option value="5"></option>
+ <option value="6"></option>
+ <option value="7"></option>
+ <option value="8"></option>
+ <option value="9"></option>
+ <option value="10"></option>
+ <option value="11"></option>
+ <option value="12"></option>
+ </select>
+ </label>
+ </p>
+ <p>
+ <label>Exp:
+ <select>
+ <option value="2015"></option>
+ <option value="2016"></option>
+ <option value="2017"></option>
+ <option value="2018"></option>
+ <option value="2019"></option>
+ <option value="2020"></option>
+ <option value="2021"></option>
+ <option value="2022"></option>
+ <option value="2023"></option>
+ <option value="2024"></option>
+ <option value="2025"></option>
+ </select>
+ </label>
+ </p>
+ </form>
+
+ <form id="form5">
+ <input class="expire-date" maxlength="2" id="expiry-month" placeholder="MM" name="expireMonth" type="text">
+ <input id="expiry-year" class="expire-date" placeholder="YY" maxlength="2" name="expireYear" type="text">
+ <input maxlength="3" name="cvc" type="text">
+ </form>
+</body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/heuristics_de_fields.html b/browser/extensions/formautofill/test/fixtures/heuristics_de_fields.html
new file mode 100644
index 0000000000..1d34d6126a
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/heuristics_de_fields.html
@@ -0,0 +1,122 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Heuristics de-DE fields test page</title>
+</head>
+<body>
+ <h1>Heuristics de-DE fields test page</h1>
+ <form autocomplete="off">
+ <div>
+ <div>Karteninhaber</div>
+ <input id="creditCardHolder" name="creditCardHolder" maxlength="30" type="text">
+ </div>
+ <div>
+ <div>Kartentyp</div>
+ <select id="CCBrand" name="CCBrand">
+ <option>
+ </option>
+ <option>AMEX</option>
+ <option>VISA</option>
+ <option>MasterCard</option>
+ <option>Maestro</option>
+ </select>
+ </div>
+ <div>
+ <div>Kartennummer</div>
+ <input id="CCNr" name="CCNr" maxlength="19" type="text">
+ </div>
+ <div>
+ <div>gültig bis</div>
+ <select id="KKMonth" name="KKMonth">
+ <option value="MM">MM</option>
+ <option value="01">01</option>
+ <option value="02">02</option>
+ <option value="03">03</option>
+ <option value="04">04</option>
+ <option value="05">05</option>
+ <option value="06">06</option>
+ <option value="07">07</option>
+ <option value="08">08</option>
+ <option value="09">09</option>
+ <option value="10">10</option>
+ <option value="11">11</option>
+ <option value="12">12</option>
+ </select>
+ <select id="KKYear" name="KKYear">
+ <option value="YYYY">JJJJ</option>
+ <option value="2018">2018</option>
+ <option value="2019">2019</option>
+ <option value="2020">2020</option>
+ <option value="2021">2021</option>
+ <option value="2022">2022</option>
+ <option value="2023">2023</option>
+ <option value="2024">2024</option>
+ <option value="2025">2025</option>
+ <option value="2026">2026</option>
+ <option value="2027">2027</option>
+ </select>
+ </div>
+ <div>
+ <div>Prüfnummer</div>
+ <input name="cccvc" id="CVV" maxlength="4" size="5" type="password">
+ </div>
+ </form>
+ <form autocomplete="off">
+ <div>
+ <label for="test-input">Name auf der Karte</label>
+ <input id="test-input" maxlength="30" type="text" name="ppw-accountHolderName">
+ </div>
+ <div>
+ <label for="DE_select">Kartenmarke</label>
+ <select id="DE_select" name="KKName">
+ <option>
+ </option>
+ <option>AMEX</option>
+ <option>VISA</option>
+ <option>MasterCard</option>
+ <option>Maestro</option>
+ </select>
+ </div>
+ <div>
+ <label for="test-input3">Kartennummer</label>
+ <input id="test-input3" maxlength="19" type="text">
+ </div>
+ <div>
+ <div>gültig bis</div>
+ <select id="KKMonth" name="KKMonth">
+ <option value="MM">MM</option>
+ <option value="01">01</option>
+ <option value="02">02</option>
+ <option value="03">03</option>
+ <option value="04">04</option>
+ <option value="05">05</option>
+ <option value="06">06</option>
+ <option value="07">07</option>
+ <option value="08">08</option>
+ <option value="09">09</option>
+ <option value="10">10</option>
+ <option value="11">11</option>
+ <option value="12">12</option>
+ </select>
+ <select id="KKYear" name="KKYear">
+ <option value="YYYY">JJJJ</option>
+ <option value="2018">2018</option>
+ <option value="2019">2019</option>
+ <option value="2020">2020</option>
+ <option value="2021">2021</option>
+ <option value="2022">2022</option>
+ <option value="2023">2023</option>
+ <option value="2024">2024</option>
+ <option value="2025">2025</option>
+ <option value="2026">2026</option>
+ <option value="2027">2027</option>
+ </select>
+ </div>
+ <div>
+ <div>Prüfnummer</div>
+ <input name="cccvc" id="CVV" maxlength="4" size="5" type="password">
+ </div>
+ </form>
+</body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/heuristics_fr_fields.html b/browser/extensions/formautofill/test/fixtures/heuristics_fr_fields.html
new file mode 100644
index 0000000000..922741c853
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/heuristics_fr_fields.html
@@ -0,0 +1,34 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Heuristics fr-FR fields test page</title>
+</head>
+
+<body>
+ <h1>Heuristics fr-FR fields test page</h1>
+ <form name="cardForm">
+ <div>
+ <input id="cardNumber" autocomplete="off" name="cardNumber" maxlength="19" placeholder="**** **** **** ****"
+ type="tel" value="">
+ <label for="cardNumber">Numéro de carte *</label>
+ </div>
+ <div>
+ <input id="expiry" autocomplete="off" name="expiry" maxlength="5" placeholder="MM/AA" step="1" type="tel"
+ value="">
+ <label for="expiry">Date d'expiration *</label>
+ </div>
+ <div>
+ <input id="cvc" autocomplete="off" name="cvc" maxlength="3" placeholder="***" step="1" type="tel" value="">
+ <label for="cvc">Cryptogramme *</label>
+ </div>
+ <div>
+ <input id="name" autocomplete="off" name="name" placeholder="Nom Prénom" step="1" type="text" value="DOE JOHN">
+ <label for="name">Titulaire *</label>
+ </div>
+ </form>
+</body>
+
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/multiple_section.html b/browser/extensions/formautofill/test/fixtures/multiple_section.html
new file mode 100644
index 0000000000..451a622521
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/multiple_section.html
@@ -0,0 +1,84 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Form Autofill Demo Page</title>
+</head>
+<body>
+ <h1>Form Autofill Demo Page</h1>
+ <form>
+ <label>Name: <input id="name" autocomplete="name"></label><br/>
+ <label>Organization: <input id="organization" autocomplete="organization"></label><br/>
+
+ <br/>
+ <label>Street Address: <input id="street-address-a" autocomplete="shipping street-address"></label><br/>
+ <label>Address Level 2: <input id="address-level2-a" autocomplete="shipping address-level2"></label><br/>
+ <label>Address Level 1: <input id="address-level1-a" autocomplete="shipping address-level1"></label><br/>
+ <label>Postal Code: <input id="postal-code-a" autocomplete="shipping postal-code"></label><br/>
+ <label>Country: <input id="country-a" autocomplete="shipping country"></label><br/>
+
+ <br/>
+ <label>Street Address: <input id="street-address-b" autocomplete="billing street-address"></label><br/>
+ <label>Address Level 2: <input id="address-level2-b" autocomplete="billing address-level2"></label><br/>
+ <label>Address Level 1: <input id="address-level1-b" autocomplete="billing address-level1"></label><br/>
+ <label>Postal Code: <input id="postal-code-b" autocomplete="billing postal-code"></label><br/>
+ <label>Country: <input id="country-b" autocomplete="billing country"></label><br/>
+
+ <br/>
+ <label>Street Address: <input id="street-address-c" autocomplete="section-my street-address"></label><br/>
+ <label>Address Level 2: <input id="address-level2-c" autocomplete="section-my address-level2"></label><br/>
+ <label>Address Level 1: <input id="address-level1-c" autocomplete="section-my address-level1"></label><br/>
+ <label>Postal Code: <input id="postal-code-c" autocomplete="section-my postal-code"></label><br/>
+ <label>Country: <input id="country-c" autocomplete="section-my country"></label><br/>
+
+ <br/>
+ <label>Telephone: <input id="tel-a" autocomplete="work tel"></label><br/>
+ <label>Email: <input id="email-a" autocomplete="work email"></label><br/>
+ <br/>
+ <label>Telephone: <input id="tel-b" autocomplete="home tel"></label><br/>
+ <label>Email: <input id="email-b" autocomplete="home email"></label><br/>
+ <p>
+ <input type="submit" value="Submit">
+ <button type="reset">Reset</button>
+ </p>
+ </form>
+
+ <form>
+ <label>Name: <input autocomplete="name"></label><br/>
+ <label>Organization: <input autocomplete="organization"></label><br/>
+
+ <br/>
+ <label>Street Address: <input autocomplete="street-address"></label><br/>
+ <label>Address Level 2: <input autocomplete="address-level2"></label><br/>
+ <label>Address Level 1: <input autocomplete="address-level1"></label><br/>
+ <label>Postal Code: <input autocomplete="postal-code"></label><br/>
+ <label>Country: <input autocomplete="country"></label><br/>
+
+ <br/>
+ <label>Street Address: <input autocomplete="street-address"></label><br/>
+ <label>Address Level 2: <input autocomplete="address-level2"></label><br/>
+ <label>Address Level 1: <input autocomplete="address-level1"></label><br/>
+ <label>Postal Code: <input autocomplete="postal-code"></label><br/>
+ <label>Country: <input autocomplete="country"></label><br/>
+
+ <br/>
+ <label>Street Address: <input autocomplete="street-address"></label><br/>
+ <label>Address Level 2: <input autocomplete="address-level2"></label><br/>
+ <label>Address Level 1: <input autocomplete="address-level1"></label><br/>
+ <label>Postal Code: <input autocomplete="postal-code"></label><br/>
+ <label>Country: <input autocomplete="country"></label><br/>
+
+ <br/>
+ <label>Telephone: <input autocomplete="work tel"></label><br/>
+ <label>Email: <input autocomplete="work email"></label><br/>
+ <br/>
+ <label>Telephone: <input autocomplete="home tel"></label><br/>
+ <label>Email: <input autocomplete="home email"></label><br/>
+ <p>
+ <input type="submit" value="Submit">
+ <button type="reset">Reset</button>
+ </p>
+ </form>
+
+</body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/third_party/BestBuy/Checkout_Payment.html b/browser/extensions/formautofill/test/fixtures/third_party/BestBuy/Checkout_Payment.html
new file mode 100644
index 0000000000..41bece3ef2
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/third_party/BestBuy/Checkout_Payment.html
@@ -0,0 +1,283 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <title>Checkout – Best Buy</title>
+ </head>
+ <body>
+ <form name="frmSearch" action="https://www.bestbuy.com/site/searchpage.jsp" method="GET">
+ <input type="text" value="" name="st" maxlength="90" placeholder="Search Best Buy" id="gh-search-input" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false">
+ <input type="hidden" value="UTF-8" name="_dyncharset">
+ <input type="hidden" value="pcat17071" name="id">
+ <input type="hidden" value="page" name="type">
+ <input type="hidden" value="Global" name="sc">
+ <input type="hidden" value="1" name="cp">
+ <input type="hidden" value="" name="nrp">
+ <input type="hidden" value="" name="sp">
+ <input type="hidden" value="" name="qp">
+ <input type="hidden" value="n" name="list">
+ <input type="hidden" value="true" name="af">
+ <input type="hidden" value="y" name="iht">
+ <input type="hidden" value="All Categories" name="usc">
+ <input type="hidden" value="960" name="ks">
+ <input type="hidden" id="keys" value="keys" name="keys">
+ </form>
+ <form action="https://www-ssl.bestbuy.com/site/olspage.jsp?id=pcat17009&amp;type=page&amp;fastTrack=true" id="footer-email-form">
+ <label for="footerEmailSignup">GET THE LATEST DEALS &amp; MORE</label>
+ <input type="text" id="footerEmailSignup" name="email" placeholder="Enter E-Mail Address">
+ <input type="submit" value="Sign Up"
+title="Sign Up">
+ </form>
+ <form action="javascript://">
+ <div>
+ <label for="fulfillment.fulfillmentGroups.0.fulfillment.address.firstName">
+ <span>
+ <p>First Name</p>
+ </span>
+ <div>
+ <input type="text" id="fulfillment.fulfillmentGroups.0.fulfillment.address.firstName" name="firstName" maxlength="29" value="">
+ </div>
+ </label>
+ </div>
+ <div>
+ <label for="fulfillment.fulfillmentGroups.0.fulfillment.address.lastName">
+ <span>
+ <p>Last Name</p>
+ </span>
+ <div>
+ <input type="text" id="fulfillment.fulfillmentGroups.0.fulfillment.address.lastName" name="lastName" maxlength="30" value="">
+ </div>
+ </label>
+ </div>
+ <div>
+ <label for="fulfillment.fulfillmentGroups.0.fulfillment.address.street">
+ <span>
+ <p>Address</p>
+ </span>
+ <div>
+ <input type="text" id="fulfillment.fulfillmentGroups.0.fulfillment.address.street" name="street" maxlength="35" value="">
+ </div>
+ </label>
+ </div>
+ <div>
+ <label id="fulfillment.fulfillmentGroups.0.fulfillment.address.city" for="fulfillment.fulfillmentGroups.0.fulfillment.address.city">
+ <span>
+ <p>City</p>
+ </span>
+ <div>
+ <input type="text" id="fulfillment.fulfillmentGroups.0.fulfillment.address.city" name="city" maxlength="30" value="">
+ </div>
+ </label>
+ </div>
+ <div>
+ <label for="fulfillment.fulfillmentGroups.0.fulfillment.address.state">
+ <span>
+ <p>State</p>
+ </span>
+ <div>
+ <select id="fulfillment.fulfillmentGroups.0.fulfillment.address.state" name="state">
+ <option value="">Select a state</option>
+ <option value="AL">AL - Alabama</option>
+ <option value="AK">AK - Alaska</option>
+ <option value="AP">AP - Armed Forces Pacific</option>
+ <option value="AE">AE - Armed Force Europe</option>
+ <option value="AA">AA - Armed Forces America</option>
+ <option value="AZ">AZ - Arizona</option>
+ <option value="AR">AR - Arkansas</option>
+ <option value="CA">CA - California</option>
+ <option value="CO">CO - Colorado</option>
+ <option value="CT">CT - Connecticut</option>
+ <option value="DC">DC - Washington D.C.</option>
+ <option value="DE">DE - Delaware</option>
+ <option value="FL">FL - Florida</option>
+ <option value="GA">GA - Georgia</option>
+ <option value="GU">GU - Guam</option>
+ <option value="HI">HI - Hawaii</option>
+ <option value="ID">ID - Idaho</option>
+ <option value="IL">IL - Illinois</option>
+ <option value="IN">IN - Indiana</option>
+ <option value="IA">IA - Iowa</option>
+ <option value="KS">KS - Kansas</option>
+ <option value="KY">KY - Kentucky</option>
+ <option value="LA">LA - Louisiana</option>
+ <option value="ME">ME - Maine</option>
+ <option value="MD">MD - Maryland</option>
+ <option value="MA">MA - Massachusetts</option>
+ <option value="MI">MI - Michigan</option>
+ <option value="MN">MN - Minnesota</option>
+ <option value="MS">MS - Mississippi</option>
+ <option value="MO">MO - Missouri</option>
+ <option value="MT">MT - Montana</option>
+ <option value="NE">NE - Nebraska</option>
+ <option value="NV">NV - Nevada</option>
+ <option value="NH">NH - New Hampshire</option>
+ <option value="NJ">NJ - New Jersey</option>
+ <option value="NM">NM - New Mexico</option>
+ <option value="NY">NY - New York</option>
+ <option value="NC">NC - North Carolina</option>
+ <option value="ND">ND - North Dakota</option>
+ <option value="OH">OH - Ohio</option>
+ <option value="OK">OK - Oklahoma</option>
+ <option value="OR">OR - Oregon</option>
+ <option value="PA">PA - Pennsylvania</option>
+ <option value="RI">RI - Rhode Island</option>
+ <option value="SC">SC - South Carolina</option>
+ <option value="SD">SD - South Dakota</option>
+ <option value="TN">TN - Tennessee</option>
+ <option value="TX">TX - Texas</option>
+ <option value="UT">UT - Utah</option>
+ <option value="VT">VT - Vermont</option>
+ <option value="VA">VA - Virginia</option>
+ <option value="VI">VI - Virgin Islands</option>
+ <option value="WA">WA - Washington</option>
+ <option value="WV">WV - West Virginia</option>
+ <option value="WI">WI - Wisconsin</option>
+ <option value="WY">WY - Wyoming</option>
+ </select>
+ </div>
+ </label>
+ </div>
+ <div>
+ <label for="fulfillment.fulfillmentGroups.0.fulfillment.address.zipcode">
+ <span>
+ <p>ZIP Code</p>
+ </span>
+ <div>
+ <input type="tel" id="fulfillment.fulfillmentGroups.0.fulfillment.address.zipcode" name="zipcode" maxlength="5" value="">
+ </div>
+ </label>
+ </div>
+ </form>
+ <div role="search">
+ <div id="app">
+ <div>
+ <div>
+ <div>
+ <div>
+ <section>
+ <div>
+ <div>
+ <section>
+ <section>
+ <div>
+ <ul>
+ <li>
+ <label for="ispu-fulfillmentci779178028329">
+ <div>
+ <input type="radio" id="ispu-fulfillmentci779178028329" name="fulfillment-options-list__radio_0" value="">
+<i>
+</i>
+ </div>
+ <strong>
+ <span>Tomorrow</span>
+ <span>
+ <p> at a Best Buy store</p>
+ </span>
+ </strong>
+ <div>
+ <span>
+ <p>Store pick ups are usually ready within one hour and held for up to 8 days</p>
+ </span>
+ </div>
+ </label>
+ </li>
+ </ul>
+ </div>
+ <div>
+ <ul>
+ <li>
+ <label for="0losTwo Day">
+ <div>
+ <input type="radio" id="0losTwo Day" name="fulfillment-options-list__radio_0" value="">
+<i>
+</i>
+ </div>
+ <strong>
+<span>Wed, Mar 22</span>
+</strong>- Two Day Shipping
+ </label>
+ </li>
+ <li>
+ <label for="0losOne Day">
+ <div>
+ <input type="radio" id="0losOne Day" name="fulfillment-options-list__radio_0" value="">
+<i>
+</i>
+ </div>
+ <strong>
+<span>Tue, Mar 21</span>
+</strong>- One Day Shipping
+ </label>
+ </li>
+ <li>
+ <label for="0losSame Day">
+ <div>
+ <input type="radio" id="0losSame Day" name="fulfillment-options-list__radio_0" value="">
+<i>
+</i>
+ </div>
+ <strong>
+<span>Tomorrow</span>
+</strong>- Same Day Shipping
+ </label>
+ </li>
+ </ul>
+ </div>
+ </section>
+ <section>
+ <label for="save-for-billing-address-0">
+ <div>
+ <input type="checkbox" id="save-for-billing-address-0" value="">
+<i>
+</i>
+ </div>
+ <span>
+ <p>Save this as my billing address</p>
+ </span>
+ </label>
+ </section>
+ </section>
+ </div>
+ <div>
+ <section>
+ <div>
+ <label for="user.emailAddress">
+ <span>
+ <p>E-mail Address</p>
+ </span>
+ <div>
+ <input id="user.emailAddress" name="emailAddress" value="">
+ </div>
+ </label>
+ </div>
+ <div>
+ <label for="user.phone">
+ <span>
+ <p>Phone Number</p>
+ </span>
+ <div>
+ <input type="tel" id="user.phone" name="phone" maxlength="12" value="">
+ </div>
+ </label>
+ </div>
+ <div>
+ <label for="text-updates">
+ <div>
+ <input type="checkbox" id="text-updates" value="">
+ </div>
+ <span>
+ <p>Send me text notifications for my order</p>
+ </span>
+ </label>
+ </div>
+ </section>
+ </div>
+ </div>
+ </section>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/third_party/BestBuy/Checkout_ShippingAddress.html b/browser/extensions/formautofill/test/fixtures/third_party/BestBuy/Checkout_ShippingAddress.html
new file mode 100644
index 0000000000..f8b88d0778
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/third_party/BestBuy/Checkout_ShippingAddress.html
@@ -0,0 +1,326 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <title>Checkout – Best Buy</title>
+ </head>
+ <body>
+ <form name="frmSearch" action="https://www.bestbuy.com/site/searchpage.jsp" method="GET">
+ <label for="gh-search-input">Search Best Buy</label>
+ <input type="text" value="" name="st" maxlength="90" placeholder="Search Best Buy" id="gh-search-input" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" />
+ <input type="hidden" value="UTF-8" name="_dyncharset" />
+ <input type="hidden" value="pcat17071" name="id" />
+ <input type="hidden" value="page" name="type" />
+ <input type="hidden" value="Global" name="sc" />
+ <input type="hidden" value="1" name="cp" />
+ <input type="hidden" value="" name="nrp" />
+ <input type="hidden" value="" name="sp" />
+ <input type="hidden" value="" name="qp" />
+ <input type="hidden" value="n" name="list" />
+ <input type="hidden" value="true" name="af" />
+ <input type="hidden" value="y" name="iht" />
+ <input type="hidden" value="All Categories" name="usc" />
+ <input type="hidden" value="960" name="ks" />
+ <input type="hidden" id="keys" value="keys" name="keys" />
+ </form>
+ <form action="javascript://">
+ <div>
+ <label for="fulfillment.fulfillmentGroups.0.fulfillment.address.firstName">
+ <span>
+ <p>First Name</p>
+ </span>
+ <div>
+ <input type="text" id="fulfillment.fulfillmentGroups.0.fulfillment.address.firstName" name="firstName" maxlength="29" value=""
+title="overall type: NAME_FIRST
+ server type: NAME_FIRST
+ heuristic type: NAME_FIRST
+ label: First Name
+ parseable name: firstName
+ field signature: 1855613035
+ form signature: 670076259790528644"
+autofill-prediction="NAME_FIRST"
+/>
+ </div>
+ </label>
+ </div>
+ <div>
+ <label for="fulfillment.fulfillmentGroups.0.fulfillment.address.lastName">
+ <span>
+ <p>Last Name</p>
+ </span>
+ <div>
+ <input type="text" id="fulfillment.fulfillmentGroups.0.fulfillment.address.lastName" name="lastName" maxlength="30" value=""
+title="overall type: NAME_LAST
+ server type: NAME_LAST
+ heuristic type: NAME_LAST
+ label: Last Name
+ parseable name: lastName
+ field signature: 4163345999
+ form signature: 670076259790528644"
+autofill-prediction="NAME_LAST"
+/>
+ </div>
+ </label>
+ </div>
+ <div>
+ <label for="fulfillment.fulfillmentGroups.0.fulfillment.address.street">
+ <span>
+ <p>Address</p>
+ </span>
+ <div>
+ <input type="text" id="fulfillment.fulfillmentGroups.0.fulfillment.address.street" name="street" maxlength="35" value=""
+title="overall type: ADDRESS_HOME_STREET_ADDRESS
+ server type: ADDRESS_HOME_STREET_ADDRESS
+ heuristic type: ADDRESS_HOME_LINE1
+ label: Address
+ parseable name: street
+ field signature: 3370790275
+ form signature: 670076259790528644"
+autofill-prediction="ADDRESS_HOME_STREET_ADDRESS"
+/>
+ </div>
+ </label>
+ </div>
+ <div>
+ <label id="fulfillment.fulfillmentGroups.0.fulfillment.address.city" for="fulfillment.fulfillmentGroups.0.fulfillment.address.city">
+ <span>
+ <p>City</p>
+ </span>
+ <div>
+ <input type="text" id="fulfillment.fulfillmentGroups.0.fulfillment.address.city" name="city" maxlength="30" value=""
+title="overall type: ADDRESS_HOME_CITY
+ server type: ADDRESS_HOME_CITY
+ heuristic type: ADDRESS_HOME_CITY
+ label: City
+ parseable name: city
+ field signature: 2098554694
+ form signature: 670076259790528644"
+autofill-prediction="ADDRESS_HOME_CITY"
+/>
+ </div>
+ </label>
+ </div>
+ <div>
+ <label for="fulfillment.fulfillmentGroups.0.fulfillment.address.state">
+ <span>
+ <p>State</p>
+ </span>
+ <div>
+ <select id="fulfillment.fulfillmentGroups.0.fulfillment.address.state" name="state"
+title="overall type: ADDRESS_HOME_STATE
+ server type: ADDRESS_HOME_STATE
+ heuristic type: ADDRESS_HOME_STATE
+ label: State
+ parseable name: state
+ field signature: 1878375253
+ form signature: 670076259790528644"
+autofill-prediction="ADDRESS_HOME_STATE"
+/>
+ <option value="">Select a state</option>
+ <option value="AL">AL - Alabama</option>
+ <option value="AK">AK - Alaska</option>
+ <option value="AP">AP - Armed Forces Pacific</option>
+ <option value="AE">AE - Armed Forces Europe</option>
+ <option value="AA">AA - Armed Forces America</option>
+ <option value="AZ">AZ - Arizona</option>
+ <option value="AR">AR - Arkansas</option>
+ <option value="CA">CA - California</option>
+ <option value="CO">CO - Colorado</option>
+ <option value="CT">CT - Connecticut</option>
+ <option value="DC">DC - Washington D.C.</option>
+ <option value="DE">DE - Delaware</option>
+ <option value="FL">FL - Florida</option>
+ <option value="GA">GA - Georgia</option>
+ <option value="GU">GU - Guam</option>
+ <option value="HI">HI - Hawaii</option>
+ <option value="ID">ID - Idaho</option>
+ <option value="IL">IL - Illinois</option>
+ <option value="IN">IN - Indiana</option>
+ <option value="IA">IA - Iowa</option>
+ <option value="KS">KS - Kansas</option>
+ <option value="KY">KY - Kentucky</option>
+ <option value="LA">LA - Louisiana</option>
+ <option value="ME">ME - Maine</option>
+ <option value="MD">MD - Maryland</option>
+ <option value="MA">MA - Massachusetts</option>
+ <option value="MI">MI - Michigan</option>
+ <option value="MN">MN - Minnesota</option>
+ <option value="MS">MS - Mississippi</option>
+ <option value="MO">MO - Missouri</option>
+ <option value="MT">MT - Montana</option>
+ <option value="NE">NE - Nebraska</option>
+ <option value="NV">NV - Nevada</option>
+ <option value="NH">NH - New Hampshire</option>
+ <option value="NJ">NJ - New Jersey</option>
+ <option value="NM">NM - New Mexico</option>
+ <option value="NY">NY - New York</option>
+ <option value="NC">NC - North Carolina</option>
+ <option value="ND">ND - North Dakota</option>
+ <option value="OH">OH - Ohio</option>
+ <option value="OK">OK - Oklahoma</option>
+ <option value="OR">OR - Oregon</option>
+ <option value="PA">PA - Pennsylvania</option>
+ <option value="RI">RI - Rhode Island</option>
+ <option value="SC">SC - South Carolina</option>
+ <option value="SD">SD - South Dakota</option>
+ <option value="TN">TN - Tennessee</option>
+ <option value="TX">TX - Texas</option>
+ <option value="UT">UT - Utah</option>
+ <option value="VT">VT - Vermont</option>
+ <option value="VA">VA - Virginia</option>
+ <option value="VI">VI - Virgin Islands</option>
+ <option value="WA">WA - Washington</option>
+ <option value="WV">WV - West Virginia</option>
+ <option value="WI">WI - Wisconsin</option>
+ <option value="WY">WY - Wyoming</option>
+ </select>
+ </div>
+ </label>
+ </div>
+ <div>
+ <label for="fulfillment.fulfillmentGroups.0.fulfillment.address.zipcode">
+ <span>
+ <p>ZIP Code</p>
+ </span>
+ <div>
+ <input type="tel" id="fulfillment.fulfillmentGroups.0.fulfillment.address.zipcode" name="zipcode" maxlength="5" value=""
+title="overall type: ADDRESS_HOME_ZIP
+ server type: ADDRESS_HOME_ZIP
+ heuristic type: ADDRESS_HOME_ZIP
+ label: ZIP Code
+ parseable name: zipcode
+ field signature: 390262106
+ form signature: 670076259790528644"
+autofill-prediction="ADDRESS_HOME_ZIP"
+/>
+ </div>
+ </label>
+ </div>
+ </form>
+ <form action="https://www-ssl.bestbuy.com/site/olspage.jsp?id=pcat17009&amp;type=page&amp;fastTrack=true" id="footer-email-form">
+<label for="footerEmailSignup">GET THE LATEST DEALS &amp; MORE</label>
+ <input type="text" id="footerEmailSignup" name="email" placeholder="Enter E-Mail Address" />
+ <input type="submit" value="Sign Up"
+title="Sign Up" />
+ </form>
+ <div id="checkout-container">
+ <div id="app">
+ <div>
+ <div>
+ <div>
+ <div>
+ <section>
+ <div>
+ <div>
+ <section>
+ <section>
+ <div>
+ <ul>
+ <li>
+ <label for="ispu-fulfillmentci779178028329">
+ <div>
+ <input type="radio" id="ispu-fulfillmentci779178028329" name="fulfillment-options-list__radio_0" value="" />
+ </div>
+ <strong>
+ <span>Tomorrow</span>
+ <span>
+ <p> at a Best Buy store</p>
+ </span>
+ </strong>
+ <div>
+ <span>
+ <p>Store pick ups are usually ready within one hour and held for up to 8 days</p>
+ </span>
+ </div>
+ </label>
+ </li>
+ </ul>
+ </div>
+ <div>
+ <ul>
+ <li>
+ <label for="0losTwo Day">
+ <div>
+ <input type="radio" id="0losTwo Day" name="fulfillment-options-list__radio_0" value="" />
+ </div>
+ <strong>
+<span>Wed, Mar 22</span>
+</strong> - Two Day Shipping
+ <p>
+ Some items may arrive slower. See
+ <span>
+<span>
+<p>Order Summary</p>
+</span>
+</span> for details.
+ </p>
+ </label>
+ </li>
+ <li>
+ <label for="0losOne Day">
+ <div>
+ <input type="radio" id="0losOne Day" name="fulfillment-options-list__radio_0" value="" />
+ </div>
+ <strong>
+<span>Tue, Mar 21</span>
+</strong> - One Day Shipping
+ </label>
+ </li>
+ </ul>
+ </div>
+ </section>
+ <section>
+ <label for="save-for-billing-address-0">
+ <div>
+ <input type="checkbox" id="save-for-billing-address-0" value="" />
+ </div>
+ <span>
+ <p>Save this as my billing address</p>
+ </span>
+ </label>
+ </section>
+ </section>
+ </div>
+ <div>
+ <section>
+ <div>
+ <label for="user.emailAddress">
+ <span>
+ <p>E-mail Address</p>
+ </span>
+ <div>
+ <input id="user.emailAddress" name="emailAddress" value="" />
+ </div>
+ </label>
+ </div>
+ <div>
+ <label for="user.phone">
+ <span>
+ <p>Phone Number</p>
+ </span>
+ <div>
+ <input type="tel" id="user.phone" name="phone" maxlength="12" value="" />
+ </div>
+ </label>
+ </div>
+ <div>
+ <label for="text-updates">
+ <div>
+ <input type="checkbox" id="text-updates" value="" />
+ </div>
+ <span>
+ <p>Send me text notifications for my order</p>
+ </span>
+ </label>
+ </div>
+ </section>
+ </div>
+ </div>
+ </section>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/third_party/BestBuy/SignIn.html b/browser/extensions/formautofill/test/fixtures/third_party/BestBuy/SignIn.html
new file mode 100644
index 0000000000..8111d49c37
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/third_party/BestBuy/SignIn.html
@@ -0,0 +1,21 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <title>Sign In to BestBuy.com</title>
+ </head>
+ <body>
+ <form action="https://www-ssl.bestbuy.com/" name="ciaSignOn" >
+ <div>
+ <label for="fld-e">E-Mail Address</label>
+ <input type="email" name="email_MAGIC_HASH_1" id="fld-e" required="" />
+ </div>
+ <div>
+ <label for="fld-p1">Password</label>
+ <div>
+ <input type="password" name="password_MAGIC_HASH_2" id="fld-p1" required="" />
+ </div>
+ </div>
+ <input type="hidden" name="Salmon" value="MAGIC_HASH_3" />
+ </form>
+ </body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/third_party/CDW/Checkout_BillingPaymentInfo.html b/browser/extensions/formautofill/test/fixtures/third_party/CDW/Checkout_BillingPaymentInfo.html
new file mode 100644
index 0000000000..35adee68b9
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/third_party/CDW/Checkout_BillingPaymentInfo.html
@@ -0,0 +1,469 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
+ <title>
+ Checkout
+ </title>
+ </head>
+ <body id="MasterPageBodyTag">
+ <form name="form1" method="post" action="https://www.cdw.com/shop/checkout/guest/BillingAndPayment.aspx" id="form1">
+ <div>
+ </div>
+ <div>
+ <input type="hidden" name="__VIEWSTATEGENERATOR" id="__VIEWSTATEGENERATOR" value="669B25B9">
+ <input type="hidden" name="__VIEWSTATEENCRYPTED" id="__VIEWSTATEENCRYPTED" value="">
+ </div>
+ <div>
+ <div>
+ <div>
+ </div>
+ <div>
+ <div>
+ </div>
+ <div id="newBillingAddressOptions">
+ <div>
+ <div>
+ <input value="" name="ctl00$ctl00$MainContentRoot$Body$addressOption" type="radio" id="sameAsShippingAddress" checked="checked"
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: Use my Shipping Address as my Billing Address
+ parseable name: addressOption
+ field signature: 825932642
+ form signature: 11231346808802434240"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ <label id="lbl1" for="sameAsShippingAddress">Use my Shipping Address as my Billing Address</label>
+ </div>
+ <div>
+ <input value="" name="ctl00$ctl00$MainContentRoot$Body$addressOption" type="radio" id="createNewAddress"
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: Enter Billing Address
+ parseable name: addressOption
+ field signature: 825932642
+ form signature: 11231346808802434240"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ <label id="lbl2" for="createNewAddress">Enter Billing Address</label>
+ </div>
+ </div>
+ </div>
+ <div id="newBillingAddress">
+ <fieldset>
+ <div>
+ <label for="firstName">First Name (required)</label>
+ <input name="ctl00$ctl00$MainContentRoot$Body$ctl00$firstName" type="text" id="firstName" maxlength="75"
+title="overall type: NAME_FIRST
+ server type: NAME_FIRST
+ heuristic type: NAME_FIRST
+ label: First Name (required)
+ parseable name: ctl00$firstName
+ field signature: 759447197
+ form signature: 11231346808802434240"
+autofill-prediction="NAME_FIRST"
+>
+ </div>
+ <div>
+ <label for="lastName">Last Name (required)</label>
+ <input name="ctl00$ctl00$MainContentRoot$Body$ctl00$lastName" type="text" id="lastName" maxlength="75"
+title="overall type: NAME_LAST
+ server type: NAME_LAST
+ heuristic type: NAME_LAST
+ label: Last Name (required)
+ parseable name: ctl00$lastName
+ field signature: 2226109235
+ form signature: 11231346808802434240"
+autofill-prediction="NAME_LAST"
+>
+ </div>
+ <div>
+ <label for="company">Company</label>
+ <input name="ctl00$ctl00$MainContentRoot$Body$ctl00$company" type="text" id="company" maxlength="100"
+title="overall type: COMPANY_NAME
+ server type: COMPANY_NAME
+ heuristic type: COMPANY_NAME
+ label: Company
+ parseable name: ctl00$company
+ field signature: 474096225
+ form signature: 11231346808802434240"
+autofill-prediction="COMPANY_NAME"
+>
+ </div>
+ <div>
+ <label for="address1">Address Line 1 (required)</label>
+ <input name="ctl00$ctl00$MainContentRoot$Body$ctl00$address1" type="text" id="address1" maxlength="30"
+title="overall type: ADDRESS_HOME_LINE1
+ server type: ADDRESS_HOME_LINE1
+ heuristic type: ADDRESS_HOME_LINE1
+ label: Address Line 1 (required)
+ parseable name: ctl00$address1
+ field signature: 3936848337
+ form signature: 11231346808802434240"
+autofill-prediction="ADDRESS_HOME_LINE1"
+>
+ </div>
+ <div>
+ <label for="address2">Address Line 2 </label>
+ <input name="ctl00$ctl00$MainContentRoot$Body$ctl00$address2" type="text" id="address2" maxlength="30"
+title="overall type: ADDRESS_HOME_LINE2
+ server type: ADDRESS_HOME_LINE2
+ heuristic type: ADDRESS_HOME_LINE2
+ label: Address Line 2
+ parseable name: ctl00$address2
+ field signature: 3389805014
+ form signature: 11231346808802434240"
+autofill-prediction="ADDRESS_HOME_LINE2"
+>
+ </div>
+ <div>
+ <div>
+ <label for="city">City (required)</label>
+ <input name="ctl00$ctl00$MainContentRoot$Body$ctl00$city" type="text" id="city" maxlength="25"
+title="overall type: ADDRESS_HOME_CITY
+ server type: ADDRESS_HOME_CITY
+ heuristic type: ADDRESS_HOME_CITY
+ label: City (required)
+ parseable name: ctl00$city
+ field signature: 794505091
+ form signature: 11231346808802434240"
+autofill-prediction="ADDRESS_HOME_CITY"
+>
+ </div>
+ <div>
+ <label for="stateProvince">State (required)</label>
+ <select name="ctl00$ctl00$MainContentRoot$Body$ctl00$stateProvince" id="stateProvince"
+title="overall type: ADDRESS_HOME_STATE
+ server type: NO_SERVER_DATA
+ heuristic type: ADDRESS_HOME_STATE
+ label: State (required)
+ parseable name: ctl00$stateProvince
+ field signature: 548222440
+ form signature: 11231346808802434240"
+autofill-prediction="ADDRESS_HOME_STATE"
+>
+ <option selected="selected" value="">Choose a state</option>
+ <option value="AL-US">AL-Alabama</option>
+ <option value="AK-US">AK-Alaska</option>
+ <option value="AS-AS">AS-American Samoa</option>
+ <option value="AZ-US">AZ-Arizona</option>
+ <option value="AR-US">AR-Arkansas</option>
+ <option value="AE-US">AE-Armed Forces Africa</option>
+ <option value="AA-US">AA-Armed Forces Americas</option>
+ <option value="AE-US">AE-Armed Forces Canada</option>
+ <option value="AE-US">AE-Armed Forces Europe</option>
+ <option value="AE-US">AE-Armed Forces Middle East</option>
+ <option value="AP-US">AP-Armed Forces Pacific</option>
+ <option value="CA-US">CA-California</option>
+ <option value="CO-US">CO-Colorado</option>
+ <option value="CT-US">CT-Connecticut</option>
+ <option value="DE-US">DE-Delaware</option>
+ <option value="DC-US">DC-District of Columbia</option>
+ <option value="FM-FM">FM-Federated States of Micronesia</option>
+ <option value="FL-US">FL-Florida</option>
+ <option value="GA-US">GA-Georgia</option>
+ <option value="GU-GU">GU-Guam</option>
+ <option value="HI-US">HI-Hawaii</option>
+ <option value="ID-US">ID-Idaho</option>
+ <option value="IL-US">IL-Illinois</option>
+ <option value="IN-US">IN-Indiana</option>
+ <option value="IA-US">IA-Iowa</option>
+ <option value="KS-US">KS-Kansas</option>
+ <option value="KY-US">KY-Kentucky</option>
+ <option value="LA-US">LA-Louisiana</option>
+ <option value="ME-US">ME-Maine</option>
+ <option value="MH-MH">MH-Marshall Islands</option>
+ <option value="MD-US">MD-Maryland</option>
+ <option value="MA-US">MA-Massachusetts</option>
+ <option value="MI-US">MI-Michigan</option>
+ <option value="MN-US">MN-Minnesota</option>
+ <option value="MS-US">MS-Mississippi</option>
+ <option value="MO-US">MO-Missouri</option>
+ <option value="MT-US">MT-Montana</option>
+ <option value="NE-US">NE-Nebraska</option>
+ <option value="NV-US">NV-Nevada</option>
+ <option value="NH-US">NH-New Hampshire</option>
+ <option value="NJ-US">NJ-New Jersey</option>
+ <option value="NM-US">NM-New Mexico</option>
+ <option value="NY-US">NY-New York</option>
+ <option value="NC-US">NC-North Carolina</option>
+ <option value="ND-US">ND-North Dakota</option>
+ <option value="MP-MP">MP-Norther Mariana Islands</option>
+ <option value="OH-US">OH-Ohio</option>
+ <option value="OK-US">OK-Oklahoma</option>
+ <option value="OR-US">OR-Oregon</option>
+ <option value="PA-US">PA-Pennsylvania</option>
+ <option value="PR-PR">PR-Puerto Rico</option>
+ <option value="PW-PW">PW-Palau</option>
+ <option value="RI-US">RI-Rhode Island</option>
+ <option value="SC-US">SC-South Carolina</option>
+ <option value="SD-US">SD-South Dakota</option>
+ <option value="TN-US">TN-Tennessee</option>
+ <option value="TX-US">TX-Texas</option>
+ <option value="UT-US">UT-Utah</option>
+ <option value="VT-US">VT-Vermont</option>
+ <option value="VI-US">VI-Virgin Islands</option>
+ <option value="VA-US">VA-Virginia</option>
+ <option value="WA-US">WA-Washington</option>
+ <option value="WV-US">WV-West Virginia</option>
+ <option value="WI-US">WI-Wisconsin</option>
+ <option value="WY-US">WY-Wyoming</option>
+ </select>
+ </div>
+ <div>
+ <label for="zipCode">ZIP Code (required)</label>
+ <input name="ctl00$ctl00$MainContentRoot$Body$ctl00$zipCode" type="text" id="zipCode" maxlength="5"
+title="overall type: ADDRESS_HOME_ZIP
+ server type: ADDRESS_HOME_ZIP
+ heuristic type: ADDRESS_HOME_ZIP
+ label: ZIP Code (required)
+ parseable name: ctl00$zipCode
+ field signature: 4227103349
+ form signature: 11231346808802434240"
+autofill-prediction="ADDRESS_HOME_ZIP"
+>
+ </div>
+ <div>
+ <label for="zipCodeExtn">ZIP Extn</label>
+ <input name="ctl00$ctl00$MainContentRoot$Body$ctl00$zipCodeExtn" type="text" id="zipCodeExtn" maxlength="4"
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: ZIP Extn
+ parseable name: ctl00$zipCodeExtn
+ field signature: 2328453303
+ form signature: 11231346808802434240"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ </div>
+ </div>
+ </fieldset>
+ </div>
+ </div>
+ <div>
+ <div> The billing address above must match what appears on this credit card's statement.</div>
+ <div>
+ <div>
+ <label for="creditCardType">Card Type (required)</label>
+ <select name="ctl00$ctl00$MainContentRoot$Body$creditCardType" id="creditCardType"
+title="overall type: CREDIT_CARD_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: CREDIT_CARD_TYPE
+ label: Card Type (required)
+ parseable name: creditCardType
+ field signature: 4008988516
+ form signature: 11231346808802434240"
+autofill-prediction="CREDIT_CARD_TYPE"
+>
+ <option value="Select Type">Select Type</option>
+ <option value="American Express">American Express</option>
+ <option value="Discover Network">Discover Network</option>
+ <option value="MasterCard">MasterCard</option>
+ <option value="Visa">Visa</option>
+ </select>
+ </div>
+ <div>
+ <label for="creditCardNumber">Credit Card Number (req)</label>
+ <input name="ctl00$ctl00$MainContentRoot$Body$creditCardNumber" type="text" id="creditCardNumber" maxlength="16" autocomplete="off"
+title="overall type: CREDIT_CARD_NUMBER
+ server type: NO_SERVER_DATA
+ heuristic type: CREDIT_CARD_NUMBER
+ label: Credit Card Number (req)
+ parseable name: creditCardNumber
+ field signature: 466166649
+ form signature: 11231346808802434240"
+autofill-prediction="CREDIT_CARD_NUMBER"
+>
+ <input name="ctl00$ctl00$MainContentRoot$Body$creditCardNumber_en" type="hidden" id="creditCardNumber_en" keydelimiter="**" exponent="010001" clearonsubmit="true" >
+ </div>
+ <div>
+ <label for="expiryMonth">Expiration Date (req)</label>
+ <span>
+ <select name="ctl00$ctl00$MainContentRoot$Body$expiryMonth" id="expiryMonth"
+title="overall type: CREDIT_CARD_EXP_MONTH
+ server type: CREDIT_CARD_EXP_MONTH
+ heuristic type: CREDIT_CARD_EXP_MONTH
+ label: Expiration Date (req) CVV (req)
+ parseable name: expiryMonth
+ field signature: 1744226145
+ form signature: 11231346808802434240"
+autofill-prediction="CREDIT_CARD_EXP_MONTH"
+>
+ <option value="">mm</option>
+ <option value="1">01</option>
+ <option value="2">02</option>
+ <option value="3">03</option>
+ <option value="4">04</option>
+ <option value="5">05</option>
+ <option value="6">06</option>
+ <option value="7">07</option>
+ <option value="8">08</option>
+ <option value="9">09</option>
+ <option value="10">10</option>
+ <option value="11">11</option>
+ <option value="12">12</option>
+ </select>
+ <select name="ctl00$ctl00$MainContentRoot$Body$expiryYear" id="expiryYear"
+title="overall type: CREDIT_CARD_EXP_4_DIGIT_YEAR
+ server type: NO_SERVER_DATA
+ heuristic type: CREDIT_CARD_EXP_4_DIGIT_YEAR
+ label: Expiration Date (req)
+ parseable name: expiryYear
+ field signature: 3338586057
+ form signature: 11231346808802434240"
+autofill-prediction="CREDIT_CARD_EXP_4_DIGIT_YEAR"
+>
+ <option value="">yy</option>
+ <option value="2017">17</option>
+ <option value="2018">18</option>
+ <option value="2019">19</option>
+ <option value="2020">20</option>
+ <option value="2021">21</option>
+ <option value="2022">22</option>
+ <option value="2023">23</option>
+ <option value="2024">24</option>
+ <option value="2025">25</option>
+ <option value="2026">26</option>
+ </select>
+ </span>
+ </div>
+ <div>
+ <label for="expiryMonth">CVV&nbsp;(req)</label>
+ <input name="ctl00$ctl00$MainContentRoot$Body$CreditCardCvvText" type="text" id="CreditCardCvvText" maxlength="4"
+title="overall type: CREDIT_CARD_VERIFICATION_CODE
+ server type: NO_SERVER_DATA
+ heuristic type: CREDIT_CARD_VERIFICATION_CODE
+ label: CVV (req)
+ parseable name: CreditCardCvvText
+ field signature: 2577719477
+ form signature: 11231346808802434240"
+autofill-prediction="CREDIT_CARD_VERIFICATION_CODE"
+>
+ <div>
+ <i>
+</i>
+ <div>
+ <div>
+ <div>
+ <span>What is a CVV?</span>
+<br>
+ <span>For Visa, MasterCard &amp; Discover, the three digits on the back of your card.</span>
+<br>
+ <span> For American Express, the 4 digits on the front of your card.</span>
+<br>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div>
+ <input type="submit" name="ctl00$ctl00$MainContentRoot$Body$saveButton" value="Next" id="saveButton">
+ <div>
+<a
+title="Go to Verisign">
+ <img src="./Checkout-BillingPaymentInfo_files/verisign.gif" alt="Verisign Secured" border="0">
+</a>
+ </div>
+ <div>
+<a target="_blank"
+title="Go to BBB">
+ <img src="./Checkout-BillingPaymentInfo_files/BetterBusinessBureau-horizontal" alt="BBB Accredited Busines">
+</a>
+ </div>
+ </div>
+ </div>
+ <div>
+ <ul>
+ <li id="shippingAddressStep">
+ <a id="shippingAddressEdit">Edit</a>
+ <div id="shippingAddressStepDetails">
+ </div>
+ </li>
+ <li id="shippingMethodStep">
+ <a id="shippingMethodEdit">Edit</a>
+ <div id="shippingMethodStepDetails">
+ <div>Shipping Method</div>
+ <div>
+ <div id="shippingMethodName">UPS Ground (2-3 days)</div>
+ <div id="shippingMethodDesc">2-3 business days</div>
+ <div id="shippingMethodCost">$19.99</div>
+ </div>
+ </div>
+ </li>
+ <li id="billingAndPaymentStep">
+ <a id="billingAndPaymentEdit">Edit</a>
+ <div id="billingAndPaymentStepDetails">
+ <div>
+ Billing Address
+ </div>
+ <div>
+ <div>
+ <span id="billingAddressFirstName">
+</span>
+ <span id="billingAddressLastName">
+</span>
+ </div>
+ <div id="billingAddressEmail">
+</div>
+ <div id="billingAddressLine1">
+</div>
+ <div id="billingAddressLine2">
+</div>
+ <div>
+ <span id="billingAddressCity">
+</span>,
+ <span id="billingAddressState">
+</span>
+ <span id="billingAddressPostalCode">
+</span>
+ </div>
+ </div>
+ <div>Payment Method</div>
+ <div id="paymentMethod">
+</div>
+ </div>
+ </li>
+ <li>
+ </li>
+ </ul>
+ </div>
+ <input type="text" name="Representative" id="Representative" value=""
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: Billing and Payment
+ parseable name: Representative
+ field signature: 716948211
+ form signature: 11231346808802434240"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ </div>
+ <input id="__RequestVerificationTokencw" name="__RequestVerificationTokencw" type="hidden" >
+ </form>
+ <div>
+ <div>
+ <div>
+ </div>
+ <ul>
+ <li>
+ <a id="button-log-on">Account Log On</a>
+ <span>&nbsp;or&nbsp;</span>
+ <a tabindex="2" id="button-create-account">Create Account</a>
+ </li>
+ <li>
+<a id="button-cart">
+ <i>
+</i> Cart (<span id="headerCartCount">1</span>)
+ <span id="headerCartTotal"> - $6,568.99</span>
+</a>
+ </li>
+ </ul>
+ </div>
+ <input type="hidden" id="HdnFreeShippingProductCartIndicator" clientidmode="static" value="0">
+ </div>
+ </body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/third_party/CDW/Checkout_Logon.html b/browser/extensions/formautofill/test/fixtures/third_party/CDW/Checkout_Logon.html
new file mode 100644
index 0000000000..6ee46c8873
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/third_party/CDW/Checkout_Logon.html
@@ -0,0 +1,118 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
+ <title>
+ Logon Checkout
+ </title>
+ </head>
+ <body id="MasterPageBodyTag">
+ <form name="LogonFormServer" method="post" action="https://www.cdw.com/shop/eaccount/logon/logon.aspx?site=" id="LogonFormServer" autocomplete="off">
+ <div>
+ <input type="hidden" name="__EVENTTARGET" id="__EVENTTARGET" value="">
+ <input type="hidden" name="__EVENTARGUMENT" id="__EVENTARGUMENT" value="">
+ <input type="hidden" name="__VIEWSTATE" id="__VIEWSTATE">
+ </div>
+ <div>
+ <input type="hidden" name="__VIEWSTATEGENERATOR" id="__VIEWSTATEGENERATOR" value="C774B3FE">
+ <input type="hidden" name="__VIEWSTATEENCRYPTED" id="__VIEWSTATEENCRYPTED" value="">
+ </div>
+ <p>You don't need an account to place an order but you will have the option to create one after completing your purchase.</p>
+ <div id="gcoVisualCaptchaContainer">
+ <div id="divCaptcha" valign="top">
+ <div id="VisualCaptchaContainer">
+ <p>Click or touch the <span>House</span>
+</p>
+ <div>
+ <div>
+<img >
+</div>
+ </div>
+ <div>
+<a
+title="Refresh">
+</a>
+</div>
+ </div>
+ <input type="hidden" name="ctl01$ctl00$MainContentRoot$Body$guestCheckoutButton$hidVisualCaptchaToken" id="hidVisualCaptchaToken" value="72fcbb43-d2de-4d9f-8ba3-17d3df45888e">
+ <input type="hidden" name="ctl01$ctl00$MainContentRoot$Body$guestCheckoutButton$hidVisualCaptchaSelectedXAxis" id="hidVisualCaptchaSelectedXAxis" value="">
+ <input type="hidden" name="ctl01$ctl00$MainContentRoot$Body$guestCheckoutButton$hidVisualCaptchaSelectedYAxis" id="hidVisualCaptchaSelectedYAxis" value="">
+ <div>
+ <span id="valVisualCaptchaInvalid">
+<span>!</span> The validation code entered is incorrect</span>
+ </div>
+ </div>
+ <div>
+ <a id="guestbutton">Checkout as Guest</a>
+ <input type="submit" name="ctl01$ctl00$MainContentRoot$Body$guestCheckoutButton$guestCheckOutButton" value="Continue" id="guestCheckOutButton" disabled="disabled">
+ </div>
+ </div>
+ <br>
+ <div id="sitePolicy">
+ <div>
+ <a
+title="Go to Privacy Policy" target="_blank">Privacy Policy</a> | <a title="Go to Terms and Conditions" target="_blank">Terms and Conditions</a>
+ </div>
+ <a
+title="Go to Verisign">
+ <img src="./Logon Checkout_files/verisign.gif" border="0">
+ </a>
+ </div>
+ <input id="__RequestVerificationTokencw" name="__RequestVerificationTokencw" type="hidden">
+ </form>
+ <form name="LogonForm" id="LogonForm" method="post" action="https://www.cdw.com/shop/Eaccount/logon/LogOnProcessor.aspx?UI=CheckoutSimplifiedUI" autocomplete="off">
+ <div id="divLogon">
+ <section>
+ <div>
+ <span id="lblUserName">User Name</span>
+ <a tabindex="70">Forgot user name?</a>
+ <div id="divUserName">
+ <input name="ctl01$ctl00$MainContentRoot$Body$LogonControl$UserName" type="text" id="UserName" tabindex="10" maxlength="50">
+ </div>
+ </div>
+ <div>
+ <input name="ctl01$ctl00$MainContentRoot$Body$LogonControl$SavePassword" type="checkbox" id="SavePassword" tabindex="12" value="1">
+ <label for="SavePassword">
+ Remember my user name on this computer
+ <img id="question-image" src="./Logon Checkout_files/tooltip-question-mark.jpg"
+title="">
+</label>
+ <div id="remember-tooltip">
+ <img src="./Logon Checkout_files/remember-me-tooltip.jpg" usemap="#closepopup">
+ <map name="closepopup" id="closepopup">
+ <area alt=""
+title="" shape="circle" coords="368,23,15.5">
+ </map>
+ </div>
+ </div>
+ <div>
+ <span id="lblUserPass">Password</span>
+ <a tabindex="80">Forgot password?</a>
+ <div id="divPassword">
+ <input name="ctl01$ctl00$MainContentRoot$Body$LogonControl$UserPassword" type="password" id="UserPassword" tabindex="11" maxlength="50">
+ </div>
+ </div>
+ <div id="divCaptcha" valign="top">
+ </div>
+ <div id="DivInvalidCredentialsErrorMessage">
+ <span>!</span> You have entered an invalid username and/or password. Please re-enter your information.
+ </div>
+ <div id="DivInvalidCaptcha">
+ <span>!</span>
+ <span id="CaptchaErrorMessage">
+</span>
+ </div>
+ <input name="ctl01$ctl00$MainContentRoot$Body$LogonControl$LogOnButton" type="submit" id="LogOnButton" tabindex="13" value="Log On" border="0">
+ </section>
+ </div>
+ <input name="ctl01$ctl00$MainContentRoot$Body$LogonControl$WebSite" type="hidden" id="WebSite">
+ <input name="ctl01$ctl00$MainContentRoot$Body$LogonControl$Target" type="hidden" id="Target" value="/shop/Checkout/ValidateCheckout.aspx?Standard=1&amp;cm_re=CRT-_-PZ-_-SC+Standard+Checkout+Button">
+ <input name="ctl01$ctl00$MainContentRoot$Body$LogonControl$ErrorCount" type="hidden" id="ErrorCount">
+ <span id="tagManEventControl">
+</span>
+ </form>
+ <div>
+ <input type="hidden" id="HdnFreeShippingProductCartIndicator" clientidmode="static" value="0">
+ </div>
+ </body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/third_party/CDW/Checkout_ShippingInfo.html b/browser/extensions/formautofill/test/fixtures/third_party/CDW/Checkout_ShippingInfo.html
new file mode 100644
index 0000000000..d461a0050b
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/third_party/CDW/Checkout_ShippingInfo.html
@@ -0,0 +1,376 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
+ <title>
+ Checkout
+ </title>
+ </head>
+ <body id="MasterPageBodyTag">
+ <form name="form1" method="post" action="https://www.cdw.com/shop/checkout/guest/ShippingAddress.aspx" id="form1">
+ <div>
+ <input type="hidden" name="__EVENTTARGET" id="__EVENTTARGET" value="">
+ <input type="hidden" name="__EVENTARGUMENT" id="__EVENTARGUMENT" value="">
+ <input type="hidden" name="__VIEWSTATE" id="__VIEWSTATE">
+ </div>
+ <div>
+ <input type="hidden" name="__VIEWSTATEGENERATOR" id="__VIEWSTATEGENERATOR">
+ <input type="hidden" name="__VIEWSTATEENCRYPTED" id="__VIEWSTATEENCRYPTED" value="">
+ </div>
+ <div>
+ <div>
+ <div>
+ </div>
+ <div>
+ <div>
+ <div>
+ <div>
+ <label for="firstName">First Name (required)</label>
+ <input name="ctl00$ctl00$MainContentRoot$Body$ctl00$firstName" type="text" id="firstName" maxlength="75"
+title="overall type: NAME_FIRST
+ server type: NAME_FIRST
+ heuristic type: NAME_FIRST
+ label: First Name (required)
+ parseable name: tl00$firstName
+ field signature: 759447197
+ form signature: 7628530229511417656"
+autofill-prediction="NAME_FIRST"
+>
+ </div>
+ <div>
+ <label for="lastName">Last Name (required)</label>
+ <input name="ctl00$ctl00$MainContentRoot$Body$ctl00$lastName" type="text" id="lastName" maxlength="75"
+title="overall type: NAME_LAST
+ server type: NAME_LAST
+ heuristic type: NAME_LAST
+ label: Last Name (required)
+ parseable name: tl00$lastName
+ field signature: 2226109235
+ form signature: 7628530229511417656"
+autofill-prediction="NAME_LAST"
+>
+ </div>
+ <div>
+ <label for="company">Company</label>
+ <input name="ctl00$ctl00$MainContentRoot$Body$ctl00$company" type="text" id="company" maxlength="100"
+title="overall type: COMPANY_NAME
+ server type: COMPANY_NAME
+ heuristic type: COMPANY_NAME
+ label: Company
+ parseable name: tl00$company
+ field signature: 474096225
+ form signature: 7628530229511417656"
+autofill-prediction="COMPANY_NAME"
+>
+ </div>
+ <div>
+ <label for="address1">Address Line 1 (required)</label>
+ <input name="ctl00$ctl00$MainContentRoot$Body$ctl00$address1" type="text" id="address1" maxlength="30"
+title="overall type: ADDRESS_HOME_LINE1
+ server type: ADDRESS_HOME_LINE1
+ heuristic type: ADDRESS_HOME_LINE1
+ label: Address Line 1 (required)
+ parseable name: tl00$address1
+ field signature: 3936848337
+ form signature: 7628530229511417656"
+autofill-prediction="ADDRESS_HOME_LINE1"
+>
+ </div>
+ <div>
+ <label for="address2">Address Line 2 </label>
+ <input name="ctl00$ctl00$MainContentRoot$Body$ctl00$address2" type="text" id="address2" maxlength="30"
+title="overall type: ADDRESS_HOME_LINE2
+ server type: ADDRESS_HOME_LINE2
+ heuristic type: ADDRESS_HOME_LINE2
+ label: Address Line 2
+ parseable name: tl00$address2
+ field signature: 3389805014
+ form signature: 7628530229511417656"
+autofill-prediction="ADDRESS_HOME_LINE2"
+>
+ </div>
+ <div>
+ <div>
+ <label for="city">City (required)</label>
+ <input name="ctl00$ctl00$MainContentRoot$Body$ctl00$city" type="text" id="city" maxlength="25"
+title="overall type: ADDRESS_HOME_CITY
+ server type: ADDRESS_HOME_CITY
+ heuristic type: ADDRESS_HOME_CITY
+ label: City (required)
+ parseable name: tl00$city
+ field signature: 794505091
+ form signature: 7628530229511417656"
+autofill-prediction="ADDRESS_HOME_CITY"
+>
+ </div>
+ <div>
+ <label for="stateProvince">State (required)</label>
+ <select name="ctl00$ctl00$MainContentRoot$Body$ctl00$stateProvince" id="stateProvince"
+title="overall type: ADDRESS_HOME_STATE
+ server type: NO_SERVER_DATA
+ heuristic type: ADDRESS_HOME_STATE
+ label: State (required)
+ parseable name: tl00$stateProvince
+ field signature: 548222440
+ form signature: 7628530229511417656"
+autofill-prediction="ADDRESS_HOME_STATE"
+>
+ <option selected="selected" value="">Choose a state</option>
+ <option value="AL-US">AL-Alabama</option>
+ <option value="AK-US">AK-Alaska</option>
+ <option value="AS-AS">AS-American Samoa</option>
+ <option value="AZ-US">AZ-Arizona</option>
+ <option value="AR-US">AR-Arkansas</option>
+ <option value="AE-US">AE-Armed Forces Africa</option>
+ <option value="AA-US">AA-Armed Forces Americas</option>
+ <option value="AE-US">AE-Armed Forces Canada</option>
+ <option value="AE-US">AE-Armed Forces Europe</option>
+ <option value="AE-US">AE-Armed Forces Middle East</option>
+ <option value="AP-US">AP-Armed Forces Pacific</option>
+ <option value="CA-US">CA-California</option>
+ <option value="CO-US">CO-Colorado</option>
+ <option value="CT-US">CT-Connecticut</option>
+ <option value="DE-US">DE-Delaware</option>
+ <option value="DC-US">DC-District of Columbia</option>
+ <option value="FM-FM">FM-Federated States of Micronesia</option>
+ <option value="FL-US">FL-Florida</option>
+ <option value="GA-US">GA-Georgia</option>
+ <option value="GU-GU">GU-Guam</option>
+ <option value="HI-US">HI-Hawaii</option>
+ <option value="ID-US">ID-Idaho</option>
+ <option value="IL-US">IL-Illinois</option>
+ <option value="IN-US">IN-Indiana</option>
+ <option value="IA-US">IA-Iowa</option>
+ <option value="KS-US">KS-Kansas</option>
+ <option value="KY-US">KY-Kentucky</option>
+ <option value="LA-US">LA-Louisiana</option>
+ <option value="ME-US">ME-Maine</option>
+ <option value="MH-MH">MH-Marshall Islands</option>
+ <option value="MD-US">MD-Maryland</option>
+ <option value="MA-US">MA-Massachusetts</option>
+ <option value="MI-US">MI-Michigan</option>
+ <option value="MN-US">MN-Minnesota</option>
+ <option value="MS-US">MS-Mississippi</option>
+ <option value="MO-US">MO-Missouri</option>
+ <option value="MT-US">MT-Montana</option>
+ <option value="NE-US">NE-Nebraska</option>
+ <option value="NV-US">NV-Nevada</option>
+ <option value="NH-US">NH-New Hampshire</option>
+ <option value="NJ-US">NJ-New Jersey</option>
+ <option value="NM-US">NM-New Mexico</option>
+ <option value="NY-US">NY-New York</option>
+ <option value="NC-US">NC-North Carolina</option>
+ <option value="ND-US">ND-North Dakota</option>
+ <option value="MP-MP">MP-Norther Mariana Islands</option>
+ <option value="OH-US">OH-Ohio</option>
+ <option value="OK-US">OK-Oklahoma</option>
+ <option value="OR-US">OR-Oregon</option>
+ <option value="PA-US">PA-Pennsylvania</option>
+ <option value="PR-PR">PR-Puerto Rico</option>
+ <option value="PW-PW">PW-Palau</option>
+ <option value="RI-US">RI-Rhode Island</option>
+ <option value="SC-US">SC-South Carolina</option>
+ <option value="SD-US">SD-South Dakota</option>
+ <option value="TN-US">TN-Tennessee</option>
+ <option value="TX-US">TX-Texas</option>
+ <option value="UT-US">UT-Utah</option>
+ <option value="VT-US">VT-Vermont</option>
+ <option value="VI-US">VI-Virgin Islands</option>
+ <option value="VA-US">VA-Virginia</option>
+ <option value="WA-US">WA-Washington</option>
+ <option value="WV-US">WV-West Virginia</option>
+ <option value="WI-US">WI-Wisconsin</option>
+ <option value="WY-US">WY-Wyoming</option>
+ </select>
+ </div>
+ <div>
+ <label for="zipCode">ZIP Code (required)</label>
+ <input name="ctl00$ctl00$MainContentRoot$Body$ctl00$zipCode" type="text" id="zipCode" maxlength="5"
+title="overall type: ADDRESS_HOME_ZIP
+ server type: ADDRESS_HOME_ZIP
+ heuristic type: ADDRESS_HOME_ZIP
+ label: ZIP Code (required)
+ parseable name: tl00$zipCode
+ field signature: 4227103349
+ form signature: 7628530229511417656"
+autofill-prediction="ADDRESS_HOME_ZIP"
+>
+ </div>
+ <div>
+ <label for="zipCodeExtn">ZIP Extn</label>
+ <input name="ctl00$ctl00$MainContentRoot$Body$ctl00$zipCodeExtn" type="text" id="zipCodeExtn" maxlength="4"
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: ZIP Extn
+ parseable name: tl00$zipCodeExtn
+ field signature: 2328453303
+ form signature: 7628530229511417656"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ </div>
+ </div>
+ </div>
+ <div>
+ <div>We will only contact you about your order and shipping.</div>
+ <div>
+ <div>
+ <label for="contactEmail">Email (required)</label>
+ <input name="ctl00$ctl00$MainContentRoot$Body$contactEmail" type="text" id="contactEmail" maxlength="75"
+title="overall type: EMAIL_ADDRESS
+ server type: EMAIL_ADDRESS
+ heuristic type: EMAIL_ADDRESS
+ label: Email (required)
+ parseable name: ontactEmail
+ field signature: 123947042
+ form signature: 7628530229511417656"
+autofill-prediction="EMAIL_ADDRESS"
+>
+ </div>
+ <div>
+ <label for="contactPhoneNumber">Phone (required)</label>
+ <input name="ctl00$ctl00$MainContentRoot$Body$contactPhoneNumber" type="text" id="contactPhoneNumber" maxlength="15"
+title="overall type: PHONE_HOME_CITY_AND_NUMBER
+ server type: PHONE_HOME_CITY_AND_NUMBER
+ heuristic type: PHONE_HOME_WHOLE_NUMBER
+ label: Phone (required)
+ parseable name: ontactPhoneNumber
+ field signature: 1588916982
+ form signature: 7628530229511417656"
+autofill-prediction="PHONE_HOME_CITY_AND_NUMBER"
+>
+ </div>
+ <div>
+ <label for="contactPhoneExtension">Extn </label>
+ <input name="ctl00$ctl00$MainContentRoot$Body$contactPhoneExtension" type="text" id="contactPhoneExtension" maxlength="5"
+title="overall type: PHONE_HOME_CITY_CODE
+ server type: PHONE_HOME_CITY_CODE
+ heuristic type: PHONE_HOME_EXTENSION
+ label: Extn
+ parseable name: ontactPhoneExtension
+ field signature: 1782290665
+ form signature: 7628530229511417656"
+autofill-prediction="PHONE_HOME_CITY_CODE"
+>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <button id="saveButton" type="button">Next</button>
+ <input type="text" name="Representative" id="Representative" value=""
+title="overall type: ADDRESS_HOME_STREET_ADDRESS
+ server type: ADDRESS_HOME_STREET_ADDRESS
+ heuristic type: ADDRESS_HOME_LINE1
+ label: Shipping Address Next
+ parseable name: Representative
+ field signature: 716948211
+ form signature: 7628530229511417656"
+autofill-prediction="ADDRESS_HOME_STREET_ADDRESS"
+>
+ </div>
+ <div>
+ <ul>
+ <li id="shippingAddressStep">
+ <a id="shippingAddressEdit">Edit</a>
+ <div id="shippingAddressStepDetails">
+ <div>
+ Address:
+ </div>
+ <div>
+ <div>
+ <span id="shippingAddressFirstName">
+</span>
+ <span id="shippingAddressLastName">
+</span>
+ </div>
+ <div>
+ <span id="shippingCompany">
+</span>
+ </div>
+ <div id="shippingAddressLine1">
+</div>
+ <div id="shippingAddressLine2">
+</div>
+ <div>
+ <span id="shippingAddressCity">
+</span>,
+ <span id="shippingAddressState">
+</span>
+ <span id="shippingAddressPostalCode">
+</span>
+ </div>
+ </div>
+ <div>
+ <div>
+ Contact Info:
+ </div>
+ <div>
+ <div>
+ <span id="contactEmail">
+</span>
+ </div>
+ <div>
+ <span id="contactPhone">
+</span>
+ </div>
+ </div>
+ </div>
+ </div>
+ </li>
+ <li id="shippingMethodStep">
+ <a id="shippingMethodEdit">Edit</a>
+ <div id="shippingMethodStepDetails">
+ <div>Shipping Method</div>
+ <div>
+ <div id="shippingMethodName">-</div>
+ <div id="shippingMethodDesc">-</div>
+ <div id="shippingMethodCost">-</div>
+ </div>
+ </div>
+ </li>
+ <li id="billingAndPaymentStep">
+ <a id="billingAndPaymentEdit">Edit</a>
+ <div id="billingAndPaymentStepDetails">
+ <div>
+ Billing Address
+ </div>
+ <div>
+ <div>
+ <span id="billingAddressFirstName">
+</span>
+ <span id="billingAddressLastName">
+</span>
+ </div>
+ <div id="billingAddressEmail">
+</div>
+ <div id="billingAddressLine1">
+</div>
+ <div id="billingAddressLine2">
+</div>
+ <div>
+ <span id="billingAddressCity">
+</span>,
+ <span id="billingAddressState">
+</span>
+ <span id="billingAddressPostalCode">
+</span>
+ </div>
+ </div>
+ <div>Payment Method</div>
+ <div id="paymentMethod">
+</div>
+ </div>
+ </li>
+ <li>
+ </li>
+ </ul>
+ </div>
+ </div>
+ <input id="__RequestVerificationTokencw" name="__RequestVerificationTokencw" type="hidden">
+ </form>
+ <div>
+ <input type="hidden" id="HdnFreeShippingProductCartIndicator" clientidmode="static" value="0">
+ </div>
+ </body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/third_party/CostCo/Payment.html b/browser/extensions/formautofill/test/fixtures/third_party/CostCo/Payment.html
new file mode 100644
index 0000000000..8b11a6a49e
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/third_party/CostCo/Payment.html
@@ -0,0 +1,892 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
+ <meta feature="9.1.4">
+ <title>Costco - Payment</title>
+ <meta name="currentBuildNumber" content="3.0.29057.0">
+ <meta name="ServerName" content="www.costco.com ">
+ <meta name="LocalAddress" content="xxx.xxx.xxx.48">
+ <meta name="LocalName" content="TP26">
+ </head>
+ <body>
+ <form name="CheckoutPaymentForm" id="CheckoutPaymentForm" method="post" action="https://www.costco.com/CostcoBillingPayment">
+ <input type="hidden" name="selfAddressId" id="hiddenSelfAddressId" value="">
+ <input type="hidden" id="billHiddenInput0" name="billHideenInput" value="">
+ <input type="hidden" id="billAddrId" name="billAddrId" value="">
+ <input type="hidden" id="membershipNumber" name="membershipNum">
+ <input type="hidden" name="orderItemsCount" value="1">
+ <input type="hidden" value="" id="selectedAddressId" name="selectedAddressId">
+ <input type="hidden" name="orderId" value="644156669" id="WC_CheckoutPaymentsAndBillingAddressf_orderId">
+ <input type="hidden" name="storeId" value="10301" id="CheckoutPayment_inputs_1">
+ <input type="hidden" name="catalogId" value="10701" id="CheckoutPayment_inputs_2">
+ <input type="hidden" name="langId" value="-1" id="CheckoutPayment_inputs_3">
+ <input type="hidden" name="curr_year" value="2017" id="CheckoutPayment_inputs_5">
+ <input type="hidden" name="curr_month" value="3" id="CheckoutPayment_inputs_6">
+ <input type="hidden" name="curr_date" value="19" id="CheckoutPayment_inputs_7">
+ <input type="hidden" name="URL" value="OrderPrepare?URL=CheckoutReviewView">
+ <input type="hidden" name="xCreditCardId" value="">
+ <input type="hidden" name="ccLastFour" value="">
+ <input type="hidden" name="checkCCValue" value="false">
+ <input type="hidden" name="backURL" value="">
+ <input type="hidden" name="piAmount" value="84.99000">
+ <input type="hidden" name="cardNumberValueHolder" value="">
+ <input type="hidden" name="authToken" value="312">
+ <div>
+ <label for="billMeLater">
+ <input type="radio" checked="checked" id="billMeLater" name="billMeLater" value="no"
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: Payment Method
+ parseable name: billMeLater
+ field signature: 1700925171
+ form signature: 4979691664972472743"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ </label>
+ <br>
+ <div>
+ <div>
+ <label for="payMethodId">Card Type</label>
+ <select name="payMethodId" id="payMethodId"
+title="overall type: CREDIT_CARD_TYPE
+ server type: CREDIT_CARD_TYPE
+ heuristic type: CREDIT_CARD_TYPE
+ label: Card Type
+ parseable name: payMethodId
+ field signature: 3668211827
+ form signature: 4979691664972472743"
+autofill-prediction="CREDIT_CARD_TYPE"
+>
+ <option value="Costco Credit Card">Costco Credit Card</option>
+ <option value="Discover">Discover</option>
+ <option value="Master Card">MasterCard</option>
+ <option value="VISA" selected="selected">VISA</option>
+ </select>
+ </div>
+ <div>
+ <label for="account">Card number</label>
+ <input
+title="overall type: CREDIT_CARD_NUMBER
+ server type: NO_SERVER_DATA
+ heuristic type: CREDIT_CARD_NUMBER
+ label: Card number
+ parseable name: account
+ field signature: 3715653537
+ form signature: 4979691664972472743" type="text" id="account" name="account" value="" autocomplete="off"
+autofill-prediction="CREDIT_CARD_NUMBER"
+>
+ </div>
+ </div>
+ <div>
+ <div>
+ <label for="expire_month">Expiration Date</label>
+ <select
+title="overall type: CREDIT_CARD_EXP_MONTH
+ server type: CREDIT_CARD_EXP_MONTH
+ heuristic type: CREDIT_CARD_EXP_MONTH
+ label: Expiration Date
+ parseable name: expire_month
+ field signature: 3078539387
+ form signature: 4979691664972472743" id="expire_month" name="expire_month"
+autofill-prediction="CREDIT_CARD_EXP_MONTH"
+>
+ <option value="Month">Month</option>
+ <option value="01">1</option>
+ <option value="02">2</option>
+ <option value="03">3</option>
+ <option value="04">4</option>
+ <option value="05">5</option>
+ <option value="06">6</option>
+ <option value="07">7</option>
+ <option value="08">8</option>
+ <option value="09">9</option>
+ <option value="10">10</option>
+ <option value="11">11</option>
+ <option value="12">12</option>
+ </select>
+ <select
+title="overall type: CREDIT_CARD_EXP_4_DIGIT_YEAR
+ server type: CREDIT_CARD_EXP_4_DIGIT_YEAR
+ heuristic type: CREDIT_CARD_EXP_4_DIGIT_YEAR
+ label: Expiration Date
+ parseable name: expire_year
+ field signature: 2521850425
+ form signature: 4979691664972472743" name="expire_year"
+autofill-prediction="CREDIT_CARD_EXP_4_DIGIT_YEAR"
+>
+ <option value="Year">Year</option>
+ <option value="2017">2017</option>
+ <option value="2018">2018</option>
+ <option value="2019">2019</option>
+ <option value="2020">2020</option>
+ <option value="2021">2021</option>
+ <option value="2022">2022</option>
+ <option value="2023">2023</option>
+ <option value="2024">2024</option>
+ <option value="2025">2025</option>
+ <option value="2026">2026</option>
+ <option value="2027">2027</option>
+ </select>
+ </div>
+ <div>
+ <label for="cc_cvc">CVV Code</label>
+ <input
+title="overall type: CREDIT_CARD_VERIFICATION_CODE
+ server type: NO_SERVER_DATA
+ heuristic type: CREDIT_CARD_VERIFICATION_CODE
+ label: CVV Code
+ parseable name: cc_cvc
+ field signature: 1956128288
+ form signature: 4979691664972472743" type="text" id="cc_cvc" name="cc_cvc" value="" maxlength="4" autocomplete="off"
+autofill-prediction="CREDIT_CARD_VERIFICATION_CODE"
+>
+ <span>&nbsp;<span>?</span>
+</span>
+ </div>
+ </div>
+ <div>
+ <label for="cc_nameoncard">Cardholder Name</label>
+ <input
+title="overall type: CREDIT_CARD_NAME_FULL
+ server type: CREDIT_CARD_NAME_FULL
+ heuristic type: CREDIT_CARD_NAME_FULL
+ label: Cardholder Name
+ parseable name: cc_nameoncard
+ field signature: 1086986730
+ form signature: 4979691664972472743" type="text" id="cc_nameoncard" name="cc_nameoncard" value="" autocomplete="off"
+autofill-prediction="CREDIT_CARD_NAME_FULL"
+>
+ </div>
+ <div>
+ <input
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: Save this as my default payment card
+ parseable name: save_CC
+ field signature: 3060743727
+ form signature: 4979691664972472743" type="checkbox" name="save_CC" id="save_CC"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ <label for="save_CC">Save this as my default payment card</label>
+ </div>
+ </div>
+ </form>
+ <form name="CashCardForm" method="post" action="https://www.costco.com/CostcoCashCardProcess" id="CashCardForm">
+ <input type="hidden" name="storeId" value="10301">
+ <input type="hidden" name="langId" value="-1">
+ <input type="hidden" name="orderId" value="644156669">
+ <input type="hidden" name="catalogId" value="10701">
+ <input type="hidden" name="addressId" value="">
+ <input type="hidden" name="cc_payMethodId" value="CostcoCashCard">
+ <input type="hidden" name="action" value="">
+ <input type="hidden" name="URL" value="CheckoutPaymentView">
+ <input type="hidden" name="account" value="">
+ <input type="hidden" name="expire_month" value="">
+ <input type="hidden" name="expire_year" value="">
+ <input type="hidden" name="cc_nameoncard" value="">
+ <input type="hidden" name="payMethodId" value="">
+ <input type="hidden" name="xCreditCardId" value="">
+ <input type="hidden" name="ccLastFour" value="">
+ <input type="hidden" name="authToken" value="312404731%2cKsqvty%2bpMJ%2bCAl3XeIkCxSEgLa4%3d">
+ <div>
+ <div>
+ <label for="cash_account">Costco Cash Card Number</label>
+ <input
+title="Costco Cash Card Number" type="text" id="cash_account" name="cash_account" value="" maxlength="19" autocomplete="off">
+ </div>
+ <div>
+ <label for="cash_pin">PIN</label>
+ <input
+title="PIN" type="password" id="cash_pin" name="cash_pin" value="" maxlength="8" autocomplete="off">
+ <span>&nbsp;<span>?</span>
+</span>
+ <div>
+ <span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;More Info - Costco Cash Card</span>
+ <span>
+ <i>Costco Cash</i> Card Number</span>
+ <span>This image highlights the unique number</span>
+ <img src="./Costco - Payment_files/cashcard-us.gif" alt="cashcard-us.gif">
+ <span>used to identify your <i>Costco Cash</i> card.</span>
+ <span> Pin Number</span>
+ <span>This number is used to access your</span>
+ <span>
+ <i>Costco Cash</i> card. The image shows</span>
+ <span>where this number is located.</span>
+ <span>@ 1998-2016 Costco Wholesale Corporation. All rights reserved.</span>
+ </div>
+ </div>
+ </div>
+ </form>
+ <form name="RefreshBilling" method="post" action="https://www.costco.com/CostcoBillingPayment" id="RefreshBilling">
+ <input type="hidden" name="storeId" value="10301">
+ <input type="hidden" name="langId" value="-1">
+ <input type="hidden" name="orderId" value="644156669">
+ <input type="hidden" name="catalogId" value="10701">
+ <input type="hidden" name="actionType" value="refresh">
+ <input type="hidden" name="authToken" value="312404731%2cKsqvty%2bpMJ%2bCAl3XeIkCxSEgLa4%3d">
+ <input type="hidden" name="deviceId" value="">
+ </form>
+ <form name="PromotionCodeForm" method="post" action="https://www.costco.com/CostcoManagePromotionCmd" id="PromotionCodeForm">
+ <input type="hidden" name="storeId" value="10301">
+ <input type="hidden" name="langId" value="-1">
+ <input type="hidden" name="orderId" value="644156669">
+ <input type="hidden" name="catalogId" value="10701">
+ <input type="hidden" name="taskType" value="A">
+ <input type="hidden" name="URL" value="OrderCalculate?updatePrices=1&amp;calculationUsageId=-1&amp;URL=OrderPrepare?URL=CostcoPostPromotionCodeAddRemove&amp;orderId=.">
+ <input type="hidden" name="errorViewName" value="CheckoutPaymentView">
+ <input type="hidden" name="account" value="">
+ <input type="hidden" name="expire_month" value="">
+ <input type="hidden" name="expire_year" value="">
+ <input type="hidden" name="cc_nameoncard" value="">
+ <input type="hidden" name="payMethodId" value="">
+ <input type="hidden" name="xCreditCardId" value="">
+ <input type="hidden" name="ccLastFour" value="">
+ <input type="hidden" name="checkCCValue" value="false">
+ <input type="hidden" id="billHiddenInput0" name="billHideenInput" value="">
+ <input type="hidden" id="billAddrId" name="billAddrId" value="">
+ <input type="hidden" name="addressId" value="">
+ <input type="hidden" name="cc_cvc" value="">
+ <input type="hidden" name="piAmount" value="84.99000">
+ <div>
+ <label for="PromotionCodeForm_1">Promo Code</label>
+ <input
+title="Promo Code" type="text" size="10" name="promoCode" id="PromotionCodeForm_1" value="">
+ </div>
+ </form>
+ <form id="AddressFormModal-Form" name="AddressFormModal-Form" autocomplete="on" method="post">
+ <div>
+ <input type="hidden" name="addressType" value="B">
+ <p id="addressFormModalRequired" tabindex="-1">
+<span>*</span> Required fields</p>
+ <div id="personName">
+ <div>
+ <label for="addressFormModalFirstName">FIRST NAME<span>*</span>
+</label>
+ <input id="addressFormModalFirstName" name="addressFormModalFirstName"
+title="overall type: NAME_FIRST
+ server type: NAME_FIRST
+ heuristic type: NAME_FIRST
+ label: FIRST NAME*
+ parseable name: addressFormModalFirstName
+ field signature: 2222266781
+ form signature: 8397269939060577503" type="text" maxlength="40"
+autofill-prediction="NAME_FIRST"
+>
+ </div>
+ <div>
+ <label for="addressFormModalMiddleInitial">M.I.</label>
+ <input id="addressFormModalMiddleInitial" name="addressFormModalMiddleInitial"
+title="overall type: NAME_MIDDLE_INITIAL
+ server type: NAME_MIDDLE_INITIAL
+ heuristic type: NAME_MIDDLE_INITIAL
+ label: M.I.
+ parseable name: addressFormModalMiddleInitial
+ field signature: 3540652809
+ form signature: 8397269939060577503" type="text" maxlength="1"
+autofill-prediction="NAME_MIDDLE_INITIAL"
+>
+ </div>
+ <div>
+ <label for="addressFormModalLastName">LAST NAME<span>*</span>
+</label>
+ <input id="addressFormModalLastName" name="addressFormModalLastName"
+title="overall type: NAME_LAST
+ server type: NAME_LAST
+ heuristic type: NAME_LAST
+ label: LAST NAME*
+ parseable name: addressFormModalLastName
+ field signature: 4218996568
+ form signature: 8397269939060577503" type="text" maxlength="40"
+autofill-prediction="NAME_LAST"
+>
+ </div>
+ </div>
+ <div>
+ <label for="addressFormModalCompany">COMPANY NAME</label>
+ <input id="addressFormModalCompany" name="addressFormModalCompany" type="text" maxlength="40"
+title="overall type: COMPANY_NAME
+ server type: COMPANY_NAME
+ heuristic type: COMPANY_NAME
+ label: COMPANY NAME
+ parseable name: addressFormModalCompany
+ field signature: 1845178698
+ form signature: 8397269939060577503"
+autofill-prediction="COMPANY_NAME"
+>
+ </div>
+ <div>
+ <label for="addressFormModalCountry">COUNTRY<span>*</span>
+</label>
+ <select id="addressFormModalCountry" name="addressFormModalCountry"
+title="overall type: ADDRESS_HOME_COUNTRY
+ server type: ADDRESS_HOME_COUNTRY
+ heuristic type: ADDRESS_HOME_COUNTRY
+ label: COUNTRY*
+ parseable name: addressFormModalCountry
+ field signature: 4052501735
+ form signature: 8397269939060577503"
+autofill-prediction="ADDRESS_HOME_COUNTRY"
+>
+ <option value="CA">Canada</option>
+ <option value="US">United States</option>
+ </select>
+ </div>
+ <div id="streetAddress">
+ <legend>
+ <label for="addressFormModalAddressLine1">STREET ADDRESS<span>*</span>
+</label>
+ </legend>
+ <div>
+ <input id="addressFormModalAddressLine1" name="addressFormModalAddressLine1" placeholder="Address Line 1" type="text" maxlength="30"
+title="overall type: ADDRESS_HOME_LINE1
+ server type: ADDRESS_HOME_LINE1
+ heuristic type: ADDRESS_HOME_LINE1
+ label: STREET ADDRESS*
+ parseable name: addressFormModalAddressLine1
+ field signature: 1532865404
+ form signature: 8397269939060577503"
+autofill-prediction="ADDRESS_HOME_LINE1"
+>
+ </div>
+ <div>
+ <input id="addressFormModalAddressLine2" name="addressFormModalAddressLine2" placeholder="Address Line 2" type="text" maxlength="30"
+title="overall type: ADDRESS_HOME_LINE2
+ server type: ADDRESS_HOME_LINE2
+ heuristic type: ADDRESS_HOME_LINE2
+ label: Address Line 2
+ parseable name: addressFormModalAddressLine2
+ field signature: 2315514959
+ form signature: 8397269939060577503"
+autofill-prediction="ADDRESS_HOME_LINE2"
+>
+ </div>
+ </div>
+ <div id="city">
+ <label for="addressFormModalCity">CITY<span>*</span>
+</label>
+ <input id="addressFormModalCity" name="addressFormModalCity" type="text" maxlength="40"
+title="overall type: ADDRESS_HOME_CITY
+ server type: ADDRESS_HOME_CITY
+ heuristic type: ADDRESS_HOME_CITY
+ label: CITY*
+ parseable name: addressFormModalCity
+ field signature: 4130865920
+ form signature: 8397269939060577503"
+autofill-prediction="ADDRESS_HOME_CITY"
+>
+ </div>
+ <div id="stateAndZip">
+ <div>
+ <label for="addressFormModalState">STATE / PROVINCE<span>*</span>
+</label>
+ <select id="addressFormModalState" name="addressFormModalState"
+title="overall type: ADDRESS_HOME_STATE
+ server type: ADDRESS_HOME_STATE
+ heuristic type: ADDRESS_HOME_STATE
+ label: STATE / PROVINCE*
+ parseable name: addressFormModalState
+ field signature: 4026908515
+ form signature: 8397269939060577503"
+autofill-prediction="ADDRESS_HOME_STATE"
+>
+ <option value="NO_STATE_TYPE_SELECTED" selected="selected">* Select</option>
+ <option value="Aa">AA - Armed Forces America</option>
+ <option value="Ae">AE - Armed Forces Europe</option>
+ <option value="AL">Alabama</option>
+ <option value="AK">Alaska</option>
+ <option value="Ap">AP - Armed Forces Pacific</option>
+ <option value="AZ">Arizona</option>
+ <option value="AR">Arkansas</option>
+ <option value="CA">California</option>
+ <option value="CO">Colorado</option>
+ <option value="CT">Connecticut</option>
+ <option value="DE">Delaware</option>
+ <option value="DC">District of Columbia</option>
+ <option value="FL">Florida</option>
+ <option value="GA">Georgia</option>
+ <option value="HI">Hawaii</option>
+ <option value="ID">Idaho</option>
+ <option value="IL">Illinois</option>
+ <option value="IN">Indiana</option>
+ <option value="IA">Iowa</option>
+ <option value="KS">Kansas</option>
+ <option value="KY">Kentucky</option>
+ <option value="LA">Louisiana</option>
+ <option value="ME">Maine</option>
+ <option value="MD">Maryland</option>
+ <option value="MA">Massachusetts</option>
+ <option value="MI">Michigan</option>
+ <option value="MN">Minnesota</option>
+ <option value="MS">Mississippi</option>
+ <option value="MO">Missouri</option>
+ <option value="MT">Montana</option>
+ <option value="NE">Nebraska</option>
+ <option value="NV">Nevada</option>
+ <option value="NH">New Hampshire</option>
+ <option value="NJ">New Jersey</option>
+ <option value="NM">New Mexico</option>
+ <option value="NY">New York</option>
+ <option value="NC">North Carolina</option>
+ <option value="ND">North Dakota</option>
+ <option value="OH">Ohio</option>
+ <option value="OK">Oklahoma</option>
+ <option value="OR">Oregon</option>
+ <option value="PA">Pennsylvania</option>
+ <option value="PR">Puerto Rico</option>
+ <option value="RI">Rhode Island</option>
+ <option value="SC">South Carolina</option>
+ <option value="SD">South Dakota</option>
+ <option value="TN">Tennessee</option>
+ <option value="TX">Texas</option>
+ <option value="UT">Utah</option>
+ <option value="VT">Vermont</option>
+ <option value="VA">Virginia</option>
+ <option value="WA">Washington</option>
+ <option value="WV">West Virginia</option>
+ <option value="WI">Wisconsin</option>
+ <option value="WY">Wyoming</option>
+ </select>
+ </div>
+ <div>
+ <label for="addressFormModalZip">ZIP / POSTAL CODE<span>*</span>
+</label>
+ <input id="addressFormModalZip" name="addressFormModalZip" type="text" maxlength="10"
+title="overall type: ADDRESS_HOME_ZIP
+ server type: ADDRESS_HOME_ZIP
+ heuristic type: ADDRESS_HOME_ZIP
+ label: ZIP / POSTAL CODE*
+ parseable name: addressFormModalZip
+ field signature: 2383002781
+ form signature: 8397269939060577503"
+autofill-prediction="ADDRESS_HOME_ZIP"
+>
+ </div>
+ </div>
+ <div id="phoneNumber">
+ <label for="addressFormModalPhoneNumber">PHONE NUMBER<span>*</span>
+</label>
+ <input id="addressFormModalPhoneNumber" name="addressFormModalPhoneNumber" type="text" maxlength="32"
+title="overall type: PHONE_HOME_CITY_AND_NUMBER
+ server type: PHONE_HOME_CITY_AND_NUMBER
+ heuristic type: PHONE_HOME_WHOLE_NUMBER
+ label: PHONE NUMBER*
+ parseable name: addressFormModalPhoneNumber
+ field signature: 1884423068
+ form signature: 8397269939060577503"
+autofill-prediction="PHONE_HOME_CITY_AND_NUMBER"
+>
+ </div>
+ <div id="email">
+ <label for="addressFormModalEmail" id="addressFormModalEmailLabel">EMAIL<span>*</span>
+</label>
+ <input id="addressFormModalEmail" name="addressFormModalEmail" type="text" maxlength="40"
+title="overall type: EMAIL_ADDRESS
+ server type: EMAIL_ADDRESS
+ heuristic type: EMAIL_ADDRESS
+ label: EMAIL*
+ parseable name: addressFormModalEmail
+ field signature: 1977954575
+ form signature: 8397269939060577503"
+autofill-prediction="EMAIL_ADDRESS"
+>
+ </div>
+ <div id="addressNickname">
+ <label for="addressFormModalAddressNickName">ADDRESS NICKNAME<span>*</span>
+ <span>&nbsp;<span>?</span>
+</span>
+ <span>The Address Nickname is a short name you create to help you easily identify this address within your address book.</span>
+ </label>
+ <input id="addressFormModalAddressNickName" name="addressFormModalAddressNickName" type="text" maxlength="35" placeholder="Holly at school, Mom, etc."
+title="overall type: ADDRESS_HOME_STREET_ADDRESS
+ server type: ADDRESS_HOME_STREET_ADDRESS
+ heuristic type: UNKNOWN_TYPE
+ label: ADDRESS NICKNAME* The Address Nickname is a short name you create to help you easily identify this a
+ parseable name: addressFormModalAddressNickName
+ field signature: 605446661
+ form signature: 8397269939060577503"
+autofill-prediction="ADDRESS_HOME_STREET_ADDRESS"
+>
+ </div>
+ <div>
+ <input name="saveAddressCheckbox" id="saveAddressCheckbox" type="checkbox"
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: Add to address book.
+ parseable name: saveAddressCheckbox
+ field signature: 784127875
+ form signature: 8397269939060577503"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ <label for="saveAddressCheckbox">Add to address book. </label>
+ </div>
+ <div>
+ <input name="setDefaultCheckbox" id="setDefaultCheckbox" type="checkbox" disabled="true"
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: Save as default shipping address in Address Book
+ parseable name: setDefaultCheckbox
+ field signature: 1479095059
+ form signature: 8397269939060577503"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ <label for="setDefaultCheckbox" id="setDefaultCheckboxModalLabel">Save as default billing address in Address Book</label>
+ </div>
+ </div>
+ </form>
+ <form id="AddressFormInline-Form" name="AddressFormInline-Form" autocomplete="on" method="post">
+ <div>
+ <input type="hidden" name="addressType" value="B">
+ <p id="addressFormInlineRequired" tabindex="-1">
+<span>*</span> Required fields</p>
+ <div id="personName">
+ <div>
+ <label for="addressFormInlineFirstName">FIRST NAME<span>*</span>
+</label>
+ <div>
+ <input id="addressFormInlineFirstName" name="addressFormInlineFirstName"
+title="overall type: NAME_FIRST
+ server type: NAME_FIRST
+ heuristic type: NAME_FIRST
+ label: FIRST NAME*
+ parseable name: addressFormInlineFirstName
+ field signature: 3938958812
+ form signature: 16870043504464996221" type="text" maxlength="40"
+autofill-prediction="NAME_FIRST"
+>
+ </div>
+ </div>
+ <div>
+ <label for="addressFormInlineMiddleInitial">M.I.</label>
+ <div>
+ <input id="addressFormInlineMiddleInitial" name="addressFormInlineMiddleInitial"
+title="overall type: NAME_MIDDLE_INITIAL
+ server type: NAME_MIDDLE_INITIAL
+ heuristic type: NAME_MIDDLE_INITIAL
+ label: M.I.
+ parseable name: addressFormInlineMiddleInitial
+ field signature: 3429701181
+ form signature: 16870043504464996221" type="text" maxlength="1"
+autofill-prediction="NAME_MIDDLE_INITIAL"
+>
+ </div>
+ </div>
+ <div>
+ <label for="addressFormInlineLastName">LAST NAME<span>*</span>
+</label>
+ <div>
+ <input id="addressFormInlineLastName" name="addressFormInlineLastName"
+title="overall type: NAME_LAST
+ server type: NAME_LAST
+ heuristic type: NAME_LAST
+ label: LAST NAME*
+ parseable name: addressFormInlineLastName
+ field signature: 2108416564
+ form signature: 16870043504464996221" type="text" maxlength="40"
+autofill-prediction="NAME_LAST"
+>
+ </div>
+ </div>
+ </div>
+ <div id="city">
+ <label for="addressFormInlineCompany">COMPANY NAME</label>
+ <div>
+ <input id="addressFormInlineCompany" name="addressFormInlineCompany" type="text" maxlength="40"
+title="overall type: COMPANY_NAME
+ server type: COMPANY_NAME
+ heuristic type: COMPANY_NAME
+ label: COMPANY NAME
+ parseable name: addressFormInlineCompany
+ field signature: 4087238350
+ form signature: 16870043504464996221"
+autofill-prediction="COMPANY_NAME"
+>
+ </div>
+ </div>
+ <div>
+ <label for="addressFormInlineCountry">COUNTRY<span>*</span>
+</label>
+ <div>
+ <select id="addressFormInlineCountry" name="addressFormInlineCountry"
+title="overall type: ADDRESS_HOME_COUNTRY
+ server type: ADDRESS_HOME_COUNTRY
+ heuristic type: ADDRESS_HOME_COUNTRY
+ label: COUNTRY*
+ parseable name: addressFormInlineCountry
+ field signature: 695762362
+ form signature: 16870043504464996221"
+autofill-prediction="ADDRESS_HOME_COUNTRY"
+>
+ <option value="CA">Canada</option>
+ <option value="US">United States</option>
+ </select>
+ </div>
+ </div>
+ <div id="streetAddress">
+ <legend>
+ <label for="addressFormInlineAddressLine1">STREET ADDRESS<span>*</span>
+</label>
+ </legend>
+ <div>
+ <input id="addressFormInlineAddressLine1" name="addressFormInlineAddressLine1" placeholder="Address Line 1" type="text" maxlength="30"
+title="overall type: ADDRESS_HOME_LINE1
+ server type: ADDRESS_HOME_LINE1
+ heuristic type: ADDRESS_HOME_LINE1
+ label: STREET ADDRESS*
+ parseable name: addressFormInlineAddressLine1
+ field signature: 1040409778
+ form signature: 16870043504464996221"
+autofill-prediction="ADDRESS_HOME_LINE1"
+>
+ </div>
+ <input id="addressFormInlineAddressLine2" name="addressFormInlineAddressLine2" placeholder="Address Line 2" type="text" maxlength="30"
+title="overall type: ADDRESS_HOME_LINE2
+ server type: ADDRESS_HOME_LINE2
+ heuristic type: ADDRESS_HOME_LINE2
+ label: Address Line 2
+ parseable name: addressFormInlineAddressLine2
+ field signature: 1640842807
+ form signature: 16870043504464996221"
+autofill-prediction="ADDRESS_HOME_LINE2"
+>
+ </div>
+ <div id="city">
+ <label for="addressFormInlineCity">CITY<span>*</span>
+</label>
+ <div>
+ <input id="addressFormInlineCity" name="addressFormInlineCity" type="text" maxlength="40"
+title="overall type: ADDRESS_HOME_CITY
+ server type: ADDRESS_HOME_CITY
+ heuristic type: ADDRESS_HOME_CITY
+ label: CITY*
+ parseable name: addressFormInlineCity
+ field signature: 2829321141
+ form signature: 16870043504464996221"
+autofill-prediction="ADDRESS_HOME_CITY"
+>
+ </div>
+ </div>
+ <div id="state">
+ <div>
+ <label for="addressFormInlineState">STATE / PROVINCE<span>*</span>
+</label>
+ <div>
+ <select id="addressFormInlineState" name="addressFormInlineState"
+title="overall type: ADDRESS_HOME_STATE
+ server type: ADDRESS_HOME_STATE
+ heuristic type: ADDRESS_HOME_STATE
+ label: STATE / PROVINCE*
+ parseable name: addressFormInlineState
+ field signature: 3295167441
+ form signature: 16870043504464996221"
+autofill-prediction="ADDRESS_HOME_STATE"
+>
+ <option value="NO_STATE_TYPE_SELECTED" selected="selected">* Select</option>
+ <option value="Aa">AA - Armed Forces America</option>
+ <option value="Ae">AE - Armed Forces Europe</option>
+ <option value="AL">Alabama</option>
+ <option value="AK">Alaska</option>
+ <option value="Ap">AP - Armed Forces Pacific</option>
+ <option value="AZ">Arizona</option>
+ <option value="AR">Arkansas</option>
+ <option value="CA">California</option>
+ <option value="CO">Colorado</option>
+ <option value="CT">Connecticut</option>
+ <option value="DE">Delaware</option>
+ <option value="DC">District of Columbia</option>
+ <option value="FL">Florida</option>
+ <option value="GA">Georgia</option>
+ <option value="HI">Hawaii</option>
+ <option value="ID">Idaho</option>
+ <option value="IL">Illinois</option>
+ <option value="IN">Indiana</option>
+ <option value="IA">Iowa</option>
+ <option value="KS">Kansas</option>
+ <option value="KY">Kentucky</option>
+ <option value="LA">Louisiana</option>
+ <option value="ME">Maine</option>
+ <option value="MD">Maryland</option>
+ <option value="MA">Massachusetts</option>
+ <option value="MI">Michigan</option>
+ <option value="MN">Minnesota</option>
+ <option value="MS">Mississippi</option>
+ <option value="MO">Missouri</option>
+ <option value="MT">Montana</option>
+ <option value="NE">Nebraska</option>
+ <option value="NV">Nevada</option>
+ <option value="NH">New Hampshire</option>
+ <option value="NJ">New Jersey</option>
+ <option value="NM">New Mexico</option>
+ <option value="NY">New York</option>
+ <option value="NC">North Carolina</option>
+ <option value="ND">North Dakota</option>
+ <option value="OH">Ohio</option>
+ <option value="OK">Oklahoma</option>
+ <option value="OR">Oregon</option>
+ <option value="PA">Pennsylvania</option>
+ <option value="PR">Puerto Rico</option>
+ <option value="RI">Rhode Island</option>
+ <option value="SC">South Carolina</option>
+ <option value="SD">South Dakota</option>
+ <option value="TN">Tennessee</option>
+ <option value="TX">Texas</option>
+ <option value="UT">Utah</option>
+ <option value="VT">Vermont</option>
+ <option value="VA">Virginia</option>
+ <option value="WA">Washington</option>
+ <option value="WV">West Virginia</option>
+ <option value="WI">Wisconsin</option>
+ <option value="WY">Wyoming</option>
+ </select>
+ </div>
+ </div>
+ <div>
+ <label for="addressFormInlineZip">ZIP / POSTAL CODE<span>*</span>
+</label>
+ <div>
+ <input id="addressFormInlineZip" name="addressFormInlineZip" type="text" maxlength="10"
+title="overall type: ADDRESS_HOME_ZIP
+ server type: ADDRESS_HOME_ZIP
+ heuristic type: ADDRESS_HOME_ZIP
+ label: ZIP / POSTAL CODE*
+ parseable name: addressFormInlineZip
+ field signature: 3060672026
+ form signature: 16870043504464996221"
+autofill-prediction="ADDRESS_HOME_ZIP"
+>
+ </div>
+ </div>
+ </div>
+ <div id="phoneNumber">
+ <label for="addressFormInlinePhoneNumber">PHONE NUMBER<span>*</span>
+</label>
+ <div>
+ <input id="addressFormInlinePhoneNumber" name="addressFormInlinePhoneNumber" type="text" maxlength="32"
+title="overall type: PHONE_HOME_CITY_AND_NUMBER
+ server type: PHONE_HOME_CITY_AND_NUMBER
+ heuristic type: PHONE_HOME_WHOLE_NUMBER
+ label: PHONE NUMBER*
+ parseable name: addressFormInlinePhoneNumber
+ field signature: 1198968276
+ form signature: 16870043504464996221"
+autofill-prediction="PHONE_HOME_CITY_AND_NUMBER"
+>
+ </div>
+ </div>
+ <div id="email">
+ <label for="addressFormInlineEmail" id="addressFormInlineEmailLabel">EMAIL<span>*</span>
+</label>
+ <div>
+ <input id="addressFormInlineEmail" name="addressFormInlineEmail" type="text" maxlength="40" value=""
+title="overall type: EMAIL_ADDRESS
+ server type: EMAIL_ADDRESS
+ heuristic type: EMAIL_ADDRESS
+ label: EMAIL*
+ parseable name: addressFormInlineEmail
+ field signature: 2460631353
+ form signature: 16870043504464996221"
+autofill-prediction="EMAIL_ADDRESS"
+>
+ </div>
+ </div>
+ <div id="addressNickname">
+ <label for="addressFormInlineAddressNickName">ADDRESS NICKNAME<span>*</span>
+<span>&nbsp;<span>?</span>
+</span>
+<span>The Address Nickname is a short name you create to help you easily identify this address within your address book.</span>
+</label>
+ <div>
+ <input id="addressFormInlineAddressNickName" name="addressFormInlineAddressNickName" type="text" maxlength="35" placeholder="Holly at school, Mom, etc."
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: ADDRESS NICKNAME* The Address Nickname is a short name you create to help you easily identify this a
+ parseable name: addressFormInlineAddressNickName
+ field signature: 2948011243
+ form signature: 16870043504464996221"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ </div>
+ </div>
+ <div>
+ <input name="saveAddressCheckboxInline" id="saveAddressCheckboxInline" type="checkbox"
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: Add to address book.
+ parseable name: saveAddressCheckboxInline
+ field signature: 3323717546
+ form signature: 16870043504464996221"
+autofill-prediction="UNKNOWN_TYPE" checked="checked"
+>
+ <label for="saveAddressCheckboxInline">Add to address book. </label>
+ </div>
+ <div>
+ <input name="setDefaultCheckboxInline" id="setDefaultCheckboxInline" type="checkbox"
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: Save as default shipping address in Address Book
+ parseable name: setDefaultCheckboxInline
+ field signature: 2923970107
+ form signature: 16870043504464996221"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ <label for="setDefaultCheckboxInline" id="setDefaultCheckboxInlineLabel">Save as default billing address in Address Book</label>
+ </div>
+ <div id="defaultAddressChangeInline">
+ <div id="WC_ContentAreaESpot_div_1_rx-DefaultAddrConfirm">
+ <div id="WC_ContentAreaESpot_div_2_rx-DefaultAddrConfirm">[rx-DefaultAddrConfirm]</div>
+ <div>
+ <ul>
+ <li value="1">
+ You are changing your Costco Default Shipping Address. &nbsp;All future orders from Costco.com, including Pharmacy Prescription Orders, will be sent to this Address.
+ </li>
+ </ul>
+ </div>
+ </div>
+ </div>
+ <div id="button-container">
+ <div>
+ <div>
+ <button id="addressFormInlineButton" type="button">
+<span>
+<span>Save Address</span>
+</span>
+</button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </form>
+ <div id="footer-find-warehouse-block">
+ <label for="footer-search-field">Find a Warehouse</label>
+ <form id="WarehouseSearchForm" action="https://www.costco.com/warehouse-locations" novalidate="novalidate">
+ <div>
+ <input id="footer-search-field" type="search" name="location" tabindex="1" placeholder="City, state or zip" value=""
+title="Search">
+ <input type="submit" id="searchClear" value="Clear">
+ <input type="hidden" id="fromWLocSubmit" name="fromWLocSubmit" value="true">
+ <input type="hidden" id="numOfWarehouses" name="numOfWarehouses" value="10">
+ </div>
+ </form>
+ </div>
+ <div id="footer-email-offers-block">
+ <label for="footer-email-offers">Get Email Offers</label>
+ <form
+title="" action="https://www.costco.com/EmailSubscription" id="EmailOffersForm">
+ <div>
+ <input type="text" name="emailSignUp" id="footer-email-offers" placeholder="Enter your email">
+ <span>
+ <button type="submit" alt="Go">Go</button>
+ </span>
+ </div>
+ </form>
+ </div>
+ <input type="hidden" name="typeAheadDisabled" id="typeAheadDisabled" value="false">
+ </body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/third_party/CostCo/ShippingAddress.html b/browser/extensions/formautofill/test/fixtures/third_party/CostCo/ShippingAddress.html
new file mode 100644
index 0000000000..1740ebb84d
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/third_party/CostCo/ShippingAddress.html
@@ -0,0 +1,527 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta name="generator" content="HTML Tidy for HTML5 for Mac OS X version 5.4.0">
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
+ <meta feature="9.1.2">
+ <title>Shipping</title>
+ <meta name="currentBuildNumber" content="3.0.29057.0">
+ <meta name="ServerName" content="www.costco.com">
+ <meta name="LocalAddress" content="xxx.xxx.xxx.48">
+ <meta name="LocalName" content="TP26">
+ </head>
+ <body>
+ <form name="ShipAsCompleteForm" method="post" action="https://www.costco.com/CostcoMultiShippingCmd" id="ShipAsCompleteForm">
+ <input type="hidden" name="storeId" value="10301">
+ <input type="hidden" name="langId" value="-1">
+ <input type="hidden" name="orderId" value="644156669">
+ <input type="hidden" name="catalogId" value="10701">
+ <input type="hidden" name="multiAddressShipping" value="false">
+ <input type="hidden" name="URL" value="CheckoutPaymentView">
+ </form>
+ <form name="NewAddressSingleShippingForm" method="post" action="https://www.costco.com/CostcoSelectShippingCmd" id="NewAddressSingleShippingForm">
+ <input type="hidden" name="storeId" value="10301">
+ <input type="hidden" name="langId" value="-1">
+ <input type="hidden" name="catalogId" value="10701">
+ <input type="hidden" name="action" value="SingleShipping">
+ <input type="hidden" name="addressId" value="">
+ <input type="hidden" name="authToken" value="">
+ </form>
+ <form id="AddressFormModal-Form" name="AddressFormModal-Form" autocomplete="on" method="post">
+ <div>
+ <input type="hidden" name="addressType" value="S">
+ <p id="addressFormModalRequired" tabindex="-1">
+ <span>*</span> Required fields</p>
+ <div id="personName">
+ <div>
+<label for="addressFormModalFirstName">FIRST NAME<span>*</span>
+</label>
+ <input id="addressFormModalFirstName" name="addressFormModalFirstName"
+title="overall type: NAME_FIRST server type: NAME_FIRST heuristic type: NAME_FIRST label: FIRST NAME* parseable name: addressFormModalFirstName field signature: 2222266781 form signature: 8397269939060577503" type="text" maxlength="40"
+autofill-prediction="NAME_FIRST"
+>
+ </div>
+ <div>
+<label for="addressFormModalMiddleInitial">M.I.</label>
+ <input id="addressFormModalMiddleInitial" name="addressFormModalMiddleInitial"
+title="overall type: NAME_MIDDLE_INITIAL server type: NAME_MIDDLE_INITIAL heuristic type: NAME_MIDDLE_INITIAL label: M.I. parseable name: addressFormModalMiddleInitial field signature: 3540652809 form signature: 8397269939060577503" type="text" maxlength="1"
+autofill-prediction="NAME_MIDDLE_INITIAL"
+>
+ </div>
+ <div>
+<label for="addressFormModalLastName">LAST NAME<span>*</span>
+</label>
+ <input id="addressFormModalLastName" name="addressFormModalLastName"
+title="overall type: NAME_LAST server type: NAME_LAST heuristic type: NAME_LAST label: LAST NAME* parseable name: addressFormModalLastName field signature: 4218996568 form signature: 8397269939060577503" type="text" maxlength="40"
+autofill-prediction="NAME_LAST"
+>
+ </div>
+ </div>
+ <div>
+<label for="addressFormModalCompany">COMPANY NAME</label>
+ <input id="addressFormModalCompany" name="addressFormModalCompany" type="text" maxlength="40"
+title="overall type: COMPANY_NAME server type: COMPANY_NAME heuristic type: COMPANY_NAME label: COMPANY NAME parseable name: addressFormModalCompany field signature: 1845178698 form signature: 8397269939060577503"
+autofill-prediction="COMPANY_NAME"
+>
+ </div>
+ <div>
+ <label for="addressFormModalCountry">COUNTRY<span>*</span>
+</label>
+ <select id="addressFormModalCountry" name="addressFormModalCountry"
+title="overall type: ADDRESS_HOME_COUNTRY server type: ADDRESS_HOME_COUNTRY heuristic type: ADDRESS_HOME_COUNTRY label: COUNTRY* parseable name: addressFormModalCountry field signature: 4052501735 form signature: 8397269939060577503"
+autofill-prediction="ADDRESS_HOME_COUNTRY"
+>
+ <option value="US">United States</option>
+ </select>
+ </div>
+ <div id="streetAddress">
+ <legend>
+<label for="addressFormModalAddressLine1">STREET ADDRESS<span>*</span>
+</label>
+</legend>
+ <div>
+ <input id="addressFormModalAddressLine1" name="addressFormModalAddressLine1" placeholder="Address Line 1" type="text" maxlength="30"
+title="overall type: ADDRESS_HOME_LINE1 server type: ADDRESS_HOME_LINE1 heuristic type: ADDRESS_HOME_LINE1 label: STREET ADDRESS* parseable name: addressFormModalAddressLine1 field signature: 1532865404 form signature: 8397269939060577503"
+autofill-prediction="ADDRESS_HOME_LINE1"
+>
+ </div>
+ <div>
+ <input id="addressFormModalAddressLine2" name="addressFormModalAddressLine2" placeholder="Address Line 2" type="text" maxlength="30"
+title="overall type: ADDRESS_HOME_LINE2 server type: ADDRESS_HOME_LINE2 heuristic type: ADDRESS_HOME_LINE2 label: Address Line 2 parseable name: addressFormModalAddressLine2 field signature: 2315514959 form signature: 8397269939060577503"
+autofill-prediction="ADDRESS_HOME_LINE2"
+>
+ </div>
+ </div>
+ <div id="city">
+<label for="addressFormModalCity">CITY<span>*</span>
+</label>
+ <input id="addressFormModalCity" name="addressFormModalCity" type="text" maxlength="40"
+title="overall type: ADDRESS_HOME_CITY server type: ADDRESS_HOME_CITY heuristic type: ADDRESS_HOME_CITY label: CITY* parseable name: addressFormModalCity field signature: 4130865920 form signature: 8397269939060577503"
+autofill-prediction="ADDRESS_HOME_CITY"
+>
+ </div>
+ <div id="stateAndZip">
+ <div>
+ <label for="addressFormModalState">STATE / PROVINCE<span>*</span>
+</label>
+ <select id="addressFormModalState" name="addressFormModalState"
+title="overall type: ADDRESS_HOME_STATE server type: ADDRESS_HOME_STATE heuristic type: ADDRESS_HOME_STATE label: STATE / PROVINCE* parseable name: addressFormModalState field signature: 4026908515 form signature: 8397269939060577503"
+autofill-prediction="ADDRESS_HOME_STATE"
+>
+ <option value="NO_STATE_TYPE_SELECTED" selected="selected">* Select</option>
+ <option value="Aa">AA - Armed Forces America</option>
+ <option value="Ae">AE - Armed Forces Europe</option>
+ <option value="AL"> Alabama</option>
+ <option value="AK"> Alaska</option>
+ <option value="Ap">AP - Armed Forces Pacific</option>
+ <option value="AZ"> Arizona</option>
+ <option value="AR"> Arkansas</option>
+ <option value="CA"> California</option>
+ <option value="CO"> Colorado</option>
+ <option value="CT"> Connecticut</option>
+ <option value="DE"> Delaware</option>
+ <option value="DC"> District of Columbia</option>
+ <option value="FL"> Florida</option>
+ <option value="GA"> Georgia</option>
+ <option value="HI"> Hawaii</option>
+ <option value="ID"> Idaho</option>
+ <option value="IL"> Illinois</option>
+ <option value="IN"> Indiana</option>
+ <option value="IA"> Iowa</option>
+ <option value="KS"> Kansas</option>
+ <option value="KY"> Kentucky</option>
+ <option value="LA"> Louisiana</option>
+ <option value="ME"> Maine</option>
+ <option value="MD"> Maryland</option>
+ <option value="MA"> Massachusetts</option>
+ <option value="MI"> Michigan</option>
+ <option value="MN"> Minnesota</option>
+ <option value="MS"> Mississippi</option>
+ <option value="MO"> Missouri</option>
+ <option value="MT"> Montana</option>
+ <option value="NE"> Nebraska</option>
+ <option value="NV"> Nevada</option>
+ <option value="NH">New Hampshire</option>
+ <option value="NJ">New Jersey</option>
+ <option value="NM">New Mexico</option>
+ <option value="NY">New York</option>
+ <option value="NC">North Carolina</option>
+ <option value="ND">North Dakota</option>
+ <option value="OH"> Ohio</option>
+ <option value="OK"> Oklahoma</option>
+ <option value="OR"> Oregon</option>
+ <option value="PA"> Pennsylvania</option>
+ <option value="PR">Puerto Rico</option>
+ <option value="RI">Rhode Island</option>
+ <option value="SC">South Carolina</option>
+ <option value="SD">South Dakota</option>
+ <option value="TN"> Tennessee</option>
+ <option value="TX"> Texas</option>
+ <option value="UT"> Utah</option>
+ <option value="VT"> Vermont</option>
+ <option value="VA"> Virginia</option>
+ <option value="WA"> Washington</option>
+ <option value="WV">West Virginia</option>
+ <option value="WI"> Wisconsin</option>
+ <option value="WY"> Wyoming</option>
+ </select>
+ </div>
+ <div>
+<label for="addressFormModalZip">ZIP / POSTAL CODE<span>*</span>
+</label>
+ <input id="addressFormModalZip" name="addressFormModalZip" type="text" maxlength="10"
+title="overall type: ADDRESS_HOME_ZIP server type: ADDRESS_HOME_ZIP heuristic type: ADDRESS_HOME_ZIP label: ZIP / POSTAL CODE* parseable name: addressFormModalZip field signature: 2383002781 form signature: 8397269939060577503"
+autofill-prediction="ADDRESS_HOME_ZIP"
+>
+ </div>
+ </div>
+ <div id="phoneNumber">
+<label for="addressFormModalPhoneNumber">PHONE NUMBER<span>*</span>
+</label>
+ <input id="addressFormModalPhoneNumber" name="addressFormModalPhoneNumber" type="text" maxlength="32"
+title="overall type: PHONE_HOME_CITY_AND_NUMBER server type: PHONE_HOME_CITY_AND_NUMBER heuristic type: PHONE_HOME_WHOLE_NUMBER label: PHONE NUMBER* parseable name: addressFormModalPhoneNumber field signature: 1884423068 form signature: 8397269939060577503"
+autofill-prediction="PHONE_HOME_CITY_AND_NUMBER"
+>
+ </div>
+ <div id="email">
+<label for="addressFormModalEmail" id="addressFormModalEmailLabel">EMAIL<span>*</span>
+</label>
+ <input id="addressFormModalEmail" name="addressFormModalEmail" type="text" maxlength="40"
+title="overall type: EMAIL_ADDRESS server type: EMAIL_ADDRESS heuristic type: EMAIL_ADDRESS label: EMAIL* parseable name: addressFormModalEmail field signature: 1977954575 form signature: 8397269939060577503"
+autofill-prediction="EMAIL_ADDRESS"
+>
+ </div>
+ <div id="addressNickname">
+<label for="addressFormModalAddressNickName">ADDRESS NICKNAME<span>*</span>
+<span>&nbsp;<span>?</span>
+</span>
+<span>The Address Nickname is a short name you create to help you easily identify this address within your address book.</span>
+</label>
+ <input id="addressFormModalAddressNickName" name="addressFormModalAddressNickName" type="text" maxlength="35" placeholder="Holly at school, Mom, etc."
+title="overall type: ADDRESS_HOME_STREET_ADDRESS server type: ADDRESS_HOME_STREET_ADDRESS heuristic type: UNKNOWN_TYPE label: ADDRESS NICKNAME* The Address Nickname is a short name you create to help you easily identify this a parseable name: addressFormModalAddressNickName field signature: 605446661 form signature: 8397269939060577503"
+autofill-prediction="ADDRESS_HOME_STREET_ADDRESS"
+>
+ </div>
+ <div>
+ <input name="saveAddressCheckbox" id="saveAddressCheckbox" type="checkbox"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Add to address book. parseable name: saveAddressCheckbox field signature: 784127875 form signature: 8397269939060577503"
+autofill-prediction="UNKNOWN_TYPE"
+>
+<label for="saveAddressCheckbox">Add to address book.</label>
+ </div>
+ <div>
+ <input name="setDefaultCheckbox" id="setDefaultCheckbox" type="checkbox" disabled="true"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Save as default shipping address in Address Book parseable name: setDefaultCheckbox field signature: 1479095059 form signature: 8397269939060577503"
+autofill-prediction="UNKNOWN_TYPE"
+>
+<label for="setDefaultCheckbox" id="setDefaultCheckboxModalLabel">Save as default shipping address in Address Book</label>
+ </div>
+ </div>
+ </form>
+ <form id="AddressFormInline-Form" name="AddressFormInline-Form" autocomplete="on" method="post">
+ <div>
+ <input type="hidden" name="addressType" value="S">
+ <p id="addressFormInlineRequired" tabindex="-1">
+<span>*</span> Required fields</p>
+ <div id="personName">
+ <div>
+ <label for="addressFormInlineFirstName">FIRST NAME<span>*</span>
+</label>
+ <div>
+ <input id="addressFormInlineFirstName" name="addressFormInlineFirstName"
+title="overall type: NAME_FIRST server type: NAME_FIRST heuristic type: NAME_FIRST label: FIRST NAME* parseable name: addressFormInlineFirstName field signature: 3938958812 form signature: 9207149805122018522" type="text" maxlength="40"
+autofill-prediction="NAME_FIRST"
+>
+ </div>
+ </div>
+ <div>
+ <label for="addressFormInlineMiddleInitial">M.I.</label>
+ <div>
+ <input id="addressFormInlineMiddleInitial" name="addressFormInlineMiddleInitial"
+title="overall type: NAME_MIDDLE_INITIAL server type: NAME_MIDDLE_INITIAL heuristic type: NAME_MIDDLE_INITIAL label: M.I. parseable name: addressFormInlineMiddleInitial field signature: 3429701181 form signature: 9207149805122018522" type="text" maxlength="1"
+autofill-prediction="NAME_MIDDLE_INITIAL"
+>
+ </div>
+ </div>
+ <div>
+ <label for="addressFormInlineLastName">LAST NAME<span>*</span>
+</label>
+ <div>
+ <input id="addressFormInlineLastName" name="addressFormInlineLastName"
+title="overall type: NAME_LAST server type: NAME_LAST heuristic type: NAME_LAST label: LAST NAME* parseable name: addressFormInlineLastName field signature: 2108416564 form signature: 9207149805122018522" type="text" maxlength="40"
+autofill-prediction="NAME_LAST"
+>
+ </div>
+ </div>
+ </div>
+ <div id="city">
+ <label for="addressFormInlineCompany">COMPANY NAME</label>
+ <div>
+ <input id="addressFormInlineCompany" name="addressFormInlineCompany" type="text" maxlength="40"
+title="overall type: COMPANY_NAME server type: COMPANY_NAME heuristic type: COMPANY_NAME label: COMPANY NAME parseable name: addressFormInlineCompany field signature: 4087238350 form signature: 9207149805122018522"
+autofill-prediction="COMPANY_NAME"
+>
+ </div>
+ </div>
+ <div>
+ <label for="addressFormInlineCountry">COUNTRY<span>*</span>
+</label>
+ <div>
+ <select id="addressFormInlineCountry" name="addressFormInlineCountry"
+title="overall type: ADDRESS_HOME_COUNTRY server type: ADDRESS_HOME_COUNTRY heuristic type: ADDRESS_HOME_COUNTRY label: COUNTRY* parseable name: addressFormInlineCountry field signature: 695762362 form signature: 9207149805122018522"
+autofill-prediction="ADDRESS_HOME_COUNTRY"
+>
+ <option value="US">United States</option>
+ </select>
+ </div>
+ </div>
+ <div id="streetAddress">
+ <legend>
+<label for="addressFormInlineAddressLine1">STREET ADDRESS<span>*</span>
+</label>
+ </legend>
+ <div>
+ <input id="addressFormInlineAddressLine1" name="addressFormInlineAddressLine1" placeholder="Address Line 1" type="text" maxlength="30"
+title="overall type: ADDRESS_HOME_LINE1 server type: ADDRESS_HOME_LINE1 heuristic type: ADDRESS_HOME_LINE1 label: STREET ADDRESS* parseable name: addressFormInlineAddressLine1 field signature: 1040409778 form signature: 9207149805122018522"
+autofill-prediction="ADDRESS_HOME_LINE1"
+>
+ </div>
+ <input id="addressFormInlineAddressLine2" name="addressFormInlineAddressLine2" placeholder="Address Line 2" type="text" maxlength="30"
+title="overall type: ADDRESS_HOME_LINE2 server type: ADDRESS_HOME_LINE2 heuristic type: ADDRESS_HOME_LINE2 label: Address Line 2 parseable name: addressFormInlineAddressLine2 field signature: 1640842807 form signature: 9207149805122018522"
+autofill-prediction="ADDRESS_HOME_LINE2"
+>
+ </div>
+ <div id="city">
+ <label for="addressFormInlineCity">CITY<span>*</span>
+</label>
+ <div>
+ <input id="addressFormInlineCity" name="addressFormInlineCity" type="text" maxlength="40"
+title="overall type: ADDRESS_HOME_CITY server type: ADDRESS_HOME_CITY heuristic type: ADDRESS_HOME_CITY label: CITY* parseable name: addressFormInlineCity field signature: 2829321141 form signature: 9207149805122018522"
+autofill-prediction="ADDRESS_HOME_CITY"
+>
+ </div>
+ </div>
+ <div id="state">
+ <div>
+ <label for="addressFormInlineState">STATE / PROVINCE<span>*</span>
+</label>
+ <div>
+ <select id="addressFormInlineState" name="addressFormInlineState"
+title="overall type: ADDRESS_HOME_STATE server type: ADDRESS_HOME_STATE heuristic type: ADDRESS_HOME_STATE label: STATE / PROVINCE* parseable name: addressFormInlineState field signature: 3295167441 form signature: 9207149805122018522"
+autofill-prediction="ADDRESS_HOME_STATE"
+>
+ <option value="NO_STATE_TYPE_SELECTED" selected="selected">* Select</option>
+ <option value="Aa">AA - Armed Forces America</option>
+ <option value="Ae">AE - Armed Forces Europe</option>
+ <option value="AL"> Alabama</option>
+ <option value="AK"> Alaska</option>
+ <option value="Ap">AP - Armed Forces Pacific</option>
+ <option value="AZ"> Arizona</option>
+ <option value="AR"> Arkansas</option>
+ <option value="CA"> California</option>
+ <option value="CO"> Colorado</option>
+ <option value="CT"> Connecticut</option>
+ <option value="DE"> Delaware</option>
+ <option value="DC"> District of Columbia</option>
+ <option value="FL"> Florida</option>
+ <option value="GA"> Georgia</option>
+ <option value="HI"> Hawaii</option>
+ <option value="ID"> Idaho</option>
+ <option value="IL"> Illinois</option>
+ <option value="IN"> Indiana</option>
+ <option value="IA"> Iowa</option>
+ <option value="KS"> Kansas</option>
+ <option value="KY"> Kentucky</option>
+ <option value="LA"> Louisiana</option>
+ <option value="ME"> Maine</option>
+ <option value="MD"> Maryland</option>
+ <option value="MA"> Massachusetts</option>
+ <option value="MI"> Michigan</option>
+ <option value="MN"> Minnesota</option>
+ <option value="MS"> Mississippi</option>
+ <option value="MO"> Missouri</option>
+ <option value="MT"> Montana</option>
+ <option value="NE"> Nebraska</option>
+ <option value="NV"> Nevada</option>
+ <option value="NH">New Hampshire</option>
+ <option value="NJ">New Jersey</option>
+ <option value="NM">New Mexico</option>
+ <option value="NY">New York</option>
+ <option value="NC">North Carolina</option>
+ <option value="ND">North Dakota</option>
+ <option value="OH"> Ohio</option>
+ <option value="OK"> Oklahoma</option>
+ <option value="OR"> Oregon</option>
+ <option value="PA"> Pennsylvania</option>
+ <option value="PR">Puerto Rico</option>
+ <option value="RI">Rhode Island</option>
+ <option value="SC">South Carolina</option>
+ <option value="SD">South Dakota</option>
+ <option value="TN"> Tennessee</option>
+ <option value="TX"> Texas</option>
+ <option value="UT"> Utah</option>
+ <option value="VT"> Vermont</option>
+ <option value="VA"> Virginia</option>
+ <option value="WA"> Washington</option>
+ <option value="WV">West Virginia</option>
+ <option value="WI"> Wisconsin</option>
+ <option value="WY"> Wyoming</option>
+ </select>
+ </div>
+ </div>
+ <div>
+ <label for="addressFormInlineZip">ZIP / POSTAL CODE<span>*</span>
+</label>
+ <div>
+ <input id="addressFormInlineZip" name="addressFormInlineZip" type="text" maxlength="10"
+title="overall type: ADDRESS_HOME_ZIP server type: ADDRESS_HOME_ZIP heuristic type: ADDRESS_HOME_ZIP label: ZIP / POSTAL CODE* parseable name: addressFormInlineZip field signature: 3060672026 form signature: 9207149805122018522"
+autofill-prediction="ADDRESS_HOME_ZIP"
+>
+ </div>
+ </div>
+ </div>
+ <div id="phoneNumber">
+ <label for="addressFormInlinePhoneNumber">PHONE NUMBER<span>*</span>
+</label>
+ <div>
+ <input id="addressFormInlinePhoneNumber" name="addressFormInlinePhoneNumber" type="text" maxlength="32"
+title="overall type: PHONE_HOME_CITY_AND_NUMBER server type: PHONE_HOME_CITY_AND_NUMBER heuristic type: PHONE_HOME_WHOLE_NUMBER label: PHONE NUMBER* parseable name: addressFormInlinePhoneNumber field signature: 1198968276 form signature: 9207149805122018522"
+autofill-prediction="PHONE_HOME_CITY_AND_NUMBER"
+>
+ </div>
+ </div>
+ <div id="email">
+ <label for="addressFormInlineEmail" id="addressFormInlineEmailLabel">EMAIL<span>*</span>
+</label>
+ <div>
+ <input id="addressFormInlineEmail" name="addressFormInlineEmail" type="text" maxlength="40" value=""
+title="overall type: EMAIL_ADDRESS server type: EMAIL_ADDRESS heuristic type: EMAIL_ADDRESS label: EMAIL* parseable name: addressFormInlineEmail field signature: 2460631353 form signature: 9207149805122018522"
+autofill-prediction="EMAIL_ADDRESS"
+>
+ </div>
+ </div>
+ <div id="addressNickname">
+ <label for="addressFormInlineAddressNickName">ADDRESS NICKNAME<span>*</span>
+<span>&nbsp;<span>?</span>
+</span>
+<span>The Address Nickname is a short name you create to help you easily identify this address within your address book.</span>
+</label>
+ <div>
+ <input id="addressFormInlineAddressNickName" name="addressFormInlineAddressNickName" type="text" maxlength="35" placeholder="Holly at school, Mom, etc."
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: ADDRESS NICKNAME* ?The Address Nickname is a short name you create to help you easily identify this parseable name: addressFormInlineAddressNickName field signature: 2948011243 form signature: 9207149805122018522"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ </div>
+ </div>
+ <div>
+ <input name="saveAddressCheckboxInline" id="saveAddressCheckboxInline" type="checkbox"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Add to address book. parseable name: saveAddressCheckboxInline field signature: 3323717546 form signature: 9207149805122018522"
+autofill-prediction="UNKNOWN_TYPE" checked="checked"
+>
+<label for="saveAddressCheckboxInline">Add to address book.</label>
+ </div>
+ <div>
+ <input name="setDefaultCheckboxInline" id="setDefaultCheckboxInline" type="checkbox"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Save as default shipping address in Address Book parseable name: setDefaultCheckboxInline field signature: 2923970107 form signature: 9207149805122018522"
+autofill-prediction="UNKNOWN_TYPE"
+>
+<label for="setDefaultCheckboxInline" id="setDefaultCheckboxInlineLabel">Save as default shipping address in Address Book</label>
+ </div>
+ <div id="defaultAddressChangeInline">
+ <div id="WC_ContentAreaESpot_div_1_rx-DefaultAddrConfirm">
+ <div id="WC_ContentAreaESpot_div_2_rx-DefaultAddrConfirm">
+ [rx-DefaultAddrConfirm]
+ </div>
+ <div>
+ <ul>
+ <li value="1"> You are changing your Costco Default Shipping Address. &nbsp;All future orders from Costco.com, including Pharmacy Prescription Orders, will be sent to this Address.
+ </li>
+ </ul>
+ </div>
+ </div>
+ </div>
+ <div>
+ <input name="copyShippingCheckboxInline" id="copyShippingCheckboxInline" type="checkbox"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Use as my default billing address parseable name: copyShippingCheckboxInline field signature: 1184640612 form signature: 9207149805122018522"
+autofill-prediction="UNKNOWN_TYPE"
+>
+<label for="copyShippingCheckboxInline" id="copyShippingCheckboxInlineLabel">Use as my default billing address</label>
+ </div>
+ <div id="billingNicknameDiv">
+ <label>Billing Address Nickname
+ <span>*</span>
+<span>&nbsp;<span>?</span>
+</span>
+<span>The Address Nickname is a short name you create to help you easily identify this address within your address book.</span>
+</label>
+ <input type="text" id="billingNickname" name="billingNickname" maxlength="40" value="New Billing"
+title="overall type: ADDRESS_HOME_STREET_ADDRESS server type: ADDRESS_HOME_STREET_ADDRESS heuristic type: UNKNOWN_TYPE label: Billing Address Nickname * ?The Address Nickname is a short name you create to help you easily ident parseable name: billingNickname field signature: 4200328961 form signature: 9207149805122018522"
+autofill-prediction="ADDRESS_HOME_STREET_ADDRESS"
+>
+ </div>
+ <div id="button-container">
+ <div>
+ <div>
+<button id="addressFormInlineButton" type="button">
+ <span>
+<span>Ship to this Address</span>
+</span>
+</button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </form>
+ <div id="footer-find-warehouse-block">
+ <label for="footer-search-field" >Find a Warehouse</label>
+ <form id="WarehouseSearchForm" action="https://www.costco.com/warehouse-locations" novalidate="novalidate" name="WarehouseSearchForm">
+ <div>
+ <input id="footer-search-field" type="search" name="location" tabindex="1" placeholder="City, state or zip" value=""
+title="Search">
+ <input type="submit" id="searchClear" value="Clear">
+ <input type="hidden" id="fromWLocSubmit" name="fromWLocSubmit" value="true">
+ <input type="hidden" id="numOfWarehouses" name="numOfWarehouses" value="10">
+ </div>
+ </form>
+ </div>
+ <div id="footer-email-offers-block">
+ <label for="footer-email-offers">Get Email Offers</label>
+ <form
+title="" action="https://www.costco.com/EmailSubscription" id="EmailOffersForm" name="EmailOffersForm">
+ <div>
+ <input type="text" name="emailSignUp" id="footer-email-offers" placeholder="Enter your email">
+<span>
+<button type="submit" alt="Go">
+<span>
+<span>Go</span>
+</span>
+</button>
+</span>
+ </div>
+ </form>
+ </div>
+ <div>
+ <label>Follow Us</label>
+ <ul>
+ <li>
+<a>
+<label>facebook</label>
+</a>
+</li>
+ <li>
+<a>
+<label>pinterest</label>
+</a>
+</li>
+ </ul>
+ </div>
+ <form name="SingleShippingForm" method="post" action="https://www.costco.com/CostcoSelectShippingCmd" id="SingleShippingForm">
+ <input type="hidden" name="storeId" value="10301">
+ <input type="hidden" name="langId" value="-1">
+ <input type="hidden" name="catalogId" value="10701">
+ <input type="hidden" name="action" value="SingleShipping">
+ <input type="hidden" name="addressId" value="">
+ <input type="hidden" name="authToken" value="">
+ </form>
+ </body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/third_party/CostCo/SignIn.html b/browser/extensions/formautofill/test/fixtures/third_party/CostCo/SignIn.html
new file mode 100644
index 0000000000..afcd5fe6f0
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/third_party/CostCo/SignIn.html
@@ -0,0 +1,374 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta name="generator" content="HTML Tidy for HTML5 for Mac OS X version 5.4.0">
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
+ <title>Sign In</title>
+ <meta name="currentBuildNumber" content="3.0.29057.0">
+ <meta name="ServerName" content="www.costco.com">
+ <meta name="LocalAddress" content="xxx.xxx.xxx.48">
+ <meta name="LocalName" content="TP26">
+ </head>
+ <body waid71fa0d88-5390-4b5b-a2f4-e45fa93d85e2="SA password protect entry checker">
+ <form action="https://www.costco.com/CatalogSearch">
+ <input type="submit" value="Submit">
+<label for="search-field">Search</label>
+ <div>
+ <label>Search Icon</label>
+<span style=/"position: relative; display: inline-block;">
+ <input type="text" tabindex="-1"
+title="Search" readonly autocomplete="off" spellcheck="false" dir="ltr">
+ <input id="search-field" type="text" name="keyword" tabindex="1" placeholder="Search Costco"
+title="Search" autocomplete="off" spellcheck="false" dir="auto" >
+ </span>
+ <div>
+ <div>
+</div>
+ </div>
+ </div>
+ <input type="submit" value="Submit" tabindex="-1">
+ </form>
+ <form id="warehouse_locator_search" action='https://www.costco.com/warehouse-locations'>
+ <div>
+ <input id="warehouse-search-field"
+title="Warehouse Search Field" name="location" type="search" value=""/>
+ </div>
+ <input type="hidden" name="tiresCheckout" value="" />
+ <input type="hidden" name="orderitemId" value="" />
+ <input type="hidden" name="storeId" value="10301" />
+ <input type="hidden" name="catalogId" value="10701" />
+ <input type="hidden" name="fromPage" value="" />
+ <label for="locator_search_filters">Show Warehouses with:</label>
+ <div id="locator_search_filters">
+ <div>
+ <div>
+ <input id="hasGas" type="checkbox" name="hasGas" value="true"
+title="Gas Station" />
+ <label for="hasGas"
+title="Gas Station">
+<i>
+</i>
+ <span>Gas Station</span>
+ </label>
+ </div>
+ <div>
+ <input id="hasTires" type="checkbox" name="hasTires" value="true"
+title="Tire Service" />
+ <label for="hasTires"
+title="Tire Service">
+<i>
+</i>
+ <span>Tire Center</span>
+ </label>
+ </div>
+ <div>
+ <input id="hasFood" type="checkbox" name="hasFood" value="true"
+title="Food Court" />
+ <label for="hasFood"
+title="Food Court">
+<i>
+</i>
+ <span>Food Court</span>
+ </label>
+ </div>
+ <div>
+ <input id="hasHearing" type="checkbox" name="hasHearing" value="true"
+title="Hearing Aids" />
+ <label for="hasHearing"
+title="Hearing Aids">
+<i>
+</i>
+ <span>Hearing Aids</span>
+ </label>
+ </div>
+ </div>
+ <div>
+ <div>
+ <input id="hasOptical" type="checkbox" name="hasOptical" value="true"
+title="Optical Dept" />
+ <label for="hasOptical"
+title="Optical Dept">
+<i>
+</i>
+ <span>Optical</span>
+ </label>
+ </div>
+ <div>
+ <input id="hasPharmacy" type="checkbox" name="hasPharmacy" value="true"
+title="Pharmacy" />
+ <label for="hasPharmacy"
+title="Pharmacy">
+<i>
+</i>
+ <span>Pharmacy</span>
+ </label>
+ </div>
+ <div>
+ <input id="hasBusiness" type="checkbox" name="hasBusiness" value="true"
+title="Business" />
+ <label for="hasBusiness"
+title="Business">
+<i>
+</i>
+ <span>Business Center</span>
+ </label>
+ </div>
+ </div>
+ </div>
+ <input type="hidden" id="fromWLocSubmit" name="fromWLocSubmit" value="true" />
+ <input type="hidden" id="numOfWarehouses" name="numOfWarehouses" value="10" />
+ <input type="submit" value="Find a Warehouse"/>
+ </form>
+ <div id="email-offer-popover-container">
+ <label for="header_emailSignUpEmail">Get Email Offers</label>
+ <label>Sign up for great offers from Costco.com!</label>
+ <form
+title="" action="/EmailSubscription" id="header_emailSignup">
+ <div>
+ <input id="header_emailSignUpEmail" type="text" name="emailSignUp" placeholder="Enter your email">
+ <span>
+ <button type="submit" alt="Go">Go</button>
+ </span>
+ </div>
+ </form>
+ </div>
+ <form action="https://www.costco.com/Logoff?URL=TopCategoriesDisplay">
+ <li>
+ <input type="submit" value="Sign Out"/>
+ </li>
+ </form>
+ <form action="/EmailSubscription">
+ <div>
+ <label for="modal_email_offers">Sign up for great offers from Costco.com!</label>
+ <input type="text" id="modal_email_offers" name="emailSignUp" placeholder="Enter your email"/>
+ </div>
+ </form>
+ <form
+title="" name="LogonForm" method="post" action="https://www.costco.com/Logon" id="LogonForm">
+ <input type="hidden" name="storeId" value="10301" id="WC_AccountDisplay_FormInput_storeId_In_Logon_1">
+<input type="hidden" name="catalogId" value="10701" id="WC_AccountDisplay_FormInput_catalogId_In_Logon_1">
+<input type="hidden" name="langId" value="-1" id="WC_AccountDisplay_FormInput_langId_In__1">
+<input type="hidden" name="reLogonURL" value="LogonForm" id="WC_AccountDisplay_FormInput_reLogonURL_In_Logon_1">
+<input type="hidden" name="isPharmacy" value="" id="WC_AccountDisplay_FormInput_isPharmacy_In_Logon_1">
+<input type="hidden" name="authToken" value="312404731%2cKsqvty%2bpMJ%2bCAl3XeIkCxSEgLa4%3d">
+<input type="hidden" name="URL" value="CheckOutCmd?orderId=644156669&amp;storeId=10301&amp;storeId=10301&amp;authToken=312404731%252cKsqvty%252bpMJ%252bCAl3XeIkCxSEgLa4%253d&amp;authToken=312404731%252cKsqvty%252bpMJ%252bCAl3XeIkCxSEgLa4%253d&amp;orderErrMsgObj=%7B%7D&amp;itemMessage=1.0&amp;langId=-1&amp;langId=-1&amp;catalogId=10701&amp;catalogId=10701" id="WC_AccountDisplay_FormInput_URL_In_Logon_1">
+ <p>Please provide your email address and password to access your account.†
+ </p>
+ <div>
+<label for="logonId">Email Address<span>*</span>
+</label>
+<input id="logonId" name="logonId" maxlength="254" type="text"
+title="Email Address" value="">
+<br>
+ </div>
+ <div>
+ <label for="logonPassword">Password:<span>*</span>
+</label>
+ <input name="logonPassword" id="logonPassword" maxlength="40" type="password" autocomplete="off"
+title="Password:">
+ <p>Passwords are case sensitive.</p>
+ <br>
+ </div>
+ <div>
+ <input id="option1" name="option1" type="checkbox">
+<label for="option1">Remember me</label>
+ </div>
+ <input type="hidden" name="submitButton" value="signIn">
+ <div>
+<button type="submit"
+title="Sign in">
+<span>
+<span>Sign in</span>
+</span>
+</button>
+</div>
+ </form>
+ <form
+title="Reset password" name="ResetPasswordForm" method="post" action="https://www.costco.com/ResetPassword" id="ResetPasswordForm">
+ <input type="hidden" name="challengeAnswer" value="-" id="WC_PasswordResetForm_FormInput_challengeAnswer_In__1">
+ <input type="hidden" name="storeId" value="10301" id="WC_PasswordResetForm_FormInput_storeId_In__1">
+<input type="hidden" name="catalogId" value="10701" id="WC_PasswordResetForm_FormInput_catalogId_In__1">
+<input type="hidden" name="langId" value="-1" id="WC_PasswordResetForm_FormInput_langId_In__1">
+<input type="hidden" name="state" value="passwdconfirm" id="WC_PasswordResetForm_FormInput_state_In__1">
+<input type="hidden" name="URL" value="ResetPasswordSuccessView" id="WC_PasswordResetForm_FormInput_URL_In__1">
+<input type="hidden" name="errorViewName" value="RememberMeLogonFormView" id="WC_PasswordResetForm_FormInput_errorViewName_In__1">
+<input type="hidden" name="subject" value="New Costco.com Password" id="WC_PasswordResetForm_FormInput_subject_In__1">
+<input type="hidden" name="sender" value="no-reply@costco.com" id="WC_PasswordResetForm_FormInput_sender_In__1">
+<input type="hidden" name="isPharmacy" value="" id="WC_PasswordResetForm_FormInput_isPharmacy_In_Logon_1">
+ <p>To reset your password, enter the email address associated with your Costco.com account. Instructions to create a new password will be sent to your address.
+ </p>
+ <div>
+<label for="forgotPassword_email">Email address<span>*</span>
+</label>
+<input id="forgotPassword_email" name="logonId" type="text"
+title="Email address">
+ </div>
+ <input type="hidden" name="submitButton" value="forgotPassword">
+ <div>
+<button type="submit"
+title="Reset password">
+<span>
+<span>Reset password</span>
+</span>
+</button>
+</div>
+ </form>
+ <form
+title="" name="RegisterForm" method="post" action="https://www.costco.com/UserRegistrationAdd" id="RegisterForm">
+ <input type="hidden" name="new" value="Y" id="WC_UserRegistrationAddForm_FormInput_new_In_Register_1">
+ <input type="hidden" name="storeId" value="10301" id="WC_UserRegistrationAddForm_FormInput_storeId_In_Register_1">
+ <input type="hidden" name="catalogId" value="10701" id="WC_UserRegistrationAddForm_FormInput_catalogId_In_Register_1">
+ <input type="hidden" name="langId" value="-1" id="WC_UserRegistrationAddForm_FormInput_langId_In__1">
+<input type="hidden" name="URL" value="CheckOutCmd?orderId=644156669&amp;storeId=10301&amp;storeId=10301&amp;authToken=312404731%252cKsqvty%252bpMJ%252bCAl3XeIkCxSEgLa4%253d&amp;authToken=312404731%252cKsqvty%252bpMJ%252bCAl3XeIkCxSEgLa4%253d&amp;orderErrMsgObj=%7B%7D&amp;itemMessage=1.0&amp;langId=-1&amp;langId=-1&amp;catalogId=10701&amp;catalogId=10701" id="WC_UserRegistrationAddForm_FormInput_URL_In_Register_1">
+ <input type="hidden" name="userField1" value="" id="WC_UserRegistrationAddForm_FormInput_userField1_In_Register_1">
+ <input type="hidden" name="addressField1" value="" id="WC_UserRegistrationAddForm_FormInput_addressField1_In_Register_1">
+ <input type="hidden" name="addressType" value="B" id="WC_UserRegistrationAddForm_FormInput_addressType_In_Register_1">
+ <input type="hidden" name="nickName" value="Self Address" id="WC_UserRegistrationAddForm_FormInput_nickName_In_Register_1">
+ <input type="hidden" name="errorViewName" value="LogonForm" id="WC_UserRegistrationAddForm_FormInput_errorViewName_In_Register_1">
+ <input type="hidden" name="validated" id="validated" value="true">
+ <input type="hidden" name="primary" value="false" id="WC_UserRegistrationAddForm_FormInput_primary_In_Register_1">
+ <input type="hidden" name="challengeQuestion" value="-" id="WC_UserRegistrationAddForm_FormInput_challengeQuestion_In_Register_1">
+ <input type="hidden" name="challengeAnswer" value="-" id="WC_UserRegistrationAddForm_FormInput_challengeAnswer_In_Register_1">
+ <input type="hidden" name="fromPage" value="LogonForm" id="WC_UserRegistrationAddForm_FormInput_fromPage_In_Register_1">
+ <input type="hidden" name="isPharmacy" value="" id="WC_UserRegistrationAddForm_FormInput_isPharmacy_In_Logon_1">
+ <input type="hidden" name="page" value="account" id="WC_UserRegistrationAddForm_FormInput_page_In_Register_1">
+ <input type="hidden" name="parentMember" value="o=costco us bc sellers,o=costco na sellers,o=extended sites seller organization,o=root organization">
+ <p>Enter your email address and create a password below to register.†
+ </p>
+ <div>
+<span>*</span> Required fields
+ </div>
+ <div>
+<label for="register_email1">Email Address<span>*</span>
+</label>
+<input id="register_email1" name="email1" type="text" maxlength="40"
+title="overall type: EMAIL_ADDRESS server type: EMAIL_ADDRESS heuristic type: EMAIL_ADDRESS label: Email Address* parseable name: email1 field signature: 1119374200 form signature: 14385182823106756929" value=""
+autofill-prediction="EMAIL_ADDRESS"
+>
+ </div>
+ <div id="passwordField">
+ <label for="register_logonPassword">Password:</label>
+<input id="register_logonPassword" name="logonPassword" maxlength="20" type="password" value=""
+title="overall type: ACCOUNT_CREATION_PASSWORD server type: ACCOUNT_CREATION_PASSWORD heuristic type: UNKNOWN_TYPE label: Password: parseable name: logonPassword field signature: 354853082 form signature: 14385182823106756929"
+autofill-prediction="ACCOUNT_CREATION_PASSWORD"
+>
+ <div id="PasswordStrength">
+ <img src="./Sign%20In_files/Password_Strength_Arrow.png">
+ <div>
+ <p>Password must meet the following:</p>
+ <div>
+ <ul>
+ <li>Use between 8 and 20 characters</li>
+ <li>Include at least one letter</li>
+ <li>Does not contain blank spaces or the following special characters: &lt; &gt; " \ .
+ </li>
+ <li>Passwords match</li>
+ </ul>
+ </div>
+ <p>Password Strength : <span id="strengthText">
+</span>
+</p>
+ <ul>
+ <li>Too Short</li>
+ <li>Weak</li>
+ <li>Fair</li>
+ <li>Good</li>
+ <li>Strong</li>
+ </ul>
+ <p id="passwordStrengthBar">
+</p>
+ <p>To improve strength, increase password length and use capital letters, numbers, and special characters
+ (except &lt; &gt; " \ .)
+ </p>
+ </div>
+ </div>
+ </div>
+ <div>
+<label for="register_logonPasswordVerify">Confirm Password<span>*</span>
+</label>
+<input id="register_logonPasswordVerify" name="logonPasswordVerify" maxlength="20" type="password" value=""
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Confirm Password* parseable name: logonPasswordVerify field signature: 1976176530 form signature: 14385182823106756929"
+autofill-prediction="UNKNOWN_TYPE"
+>
+</div>
+ <div>
+<label for="register_userField2">Costco Membership Number</label>
+<input id="register_userField2" name="userField2" type="text" maxlength="16"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Costco Membership Number parseable name: userField2 field signature: 1051463506 form signature: 14385182823106756929" value=""
+autofill-prediction="UNKNOWN_TYPE"
+>
+ </div>
+ <p>
+<b>Non-members may be assessed an additional surcharge. The surcharge does not apply to prescription items. Executive Members need to provide a membership number to receive credit for their 2% rebate.</b>
+ </p>
+ <div>
+ <input id="register_sendMeEmail" name="sendMeEmail" type="checkbox" checked="checked"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Yes, I would like to receive emails about special offers and new product information from Costco. Co parseable name: sendMeEmail field signature: 3147026083 form signature: 14385182823106756929"
+autofill-prediction="UNKNOWN_TYPE"
+>
+<label for="register_sendMeEmail">Yes, I would like to receive emails about special offers and new product information from Costco. Costco will not rent or sell your email address.</label>
+ </div>
+ <input name="submitButton" value="Register" type="hidden">
+ <div>
+<button type="submit"
+title="Register">
+<span>
+<span>Register</span>
+</span>
+</button>
+</div>
+ </form>
+ <form action="https://www.costco.com/EmailSubscription" id="footer_emailSignup" name="footer_emailSignup">
+ <input id="footer_emailSignUpEmail" name="emailSignUp" type="text">
+ </form>
+ <div id="language-region-modal-container">
+ <div>
+ ???LANGUAGE_REGION_MODAL_TITLE???
+ </div>
+ <div>
+ <div id="language-radio-buttons">
+ <p>???LANGUAGE_REGION_MODAL_CHOOSE_LANGUAGE???</p>
+ <label>
+ <input type="radio" name="language" value="-1">???HEADER_LANGUAGE_NAME_-1???</label>
+ </div>
+ <hr>
+ <div id="region-radio-buttons">
+ <p>???LANGUAGE_REGION_MODAL_CHOOSE_REGION???</p>
+ <div>
+ <label>
+ <input type="radio" name="region" value="AB">Alberta - AB</label>
+ <label>
+ <input type="radio" name="region" value="BC">British Columbia - BC</label>
+ <label>
+ <input type="radio" name="region" value="MB">Manitoba - MB</label>
+ <label>
+ <input type="radio" name="region" value="NB">New Brunswick - NB</label>
+ <label>
+ <input type="radio" name="region" value="NL">Newfoundland and Labrador - NL</label>
+ <label>
+ <input type="radio" name="region" value="NT">Northwest Territories - NT</label>
+ <label>
+ <input type="radio" name="region" value="NS">Nova Scotia - NS</label>
+ </div>
+ <div>
+ <label>
+ <input type="radio" name="region" value="NU">Nunavut - NU</label>
+ <label>
+ <input type="radio" name="region" value="ON">Ontario - ON</label>
+ <label>
+ <input type="radio" name="region" value="PE">Prince Edward Island - PE</label>
+ <label>
+ <input type="radio" name="region" value="QC">Quebec - QC</label>
+ <label>
+ <input type="radio" name="region" value="SK">Saskatchewan - SK</label>
+ <label>
+ <input type="radio" name="region" value="YT">Yukon - YT</label>
+ </div>
+ </div>
+ <div>
+ <input id="language-region-set" type="submit" value="???LANGUAGE_REGION_MODAL_SUBMIT???">
+ </div>
+ </div>
+ </div>
+ </body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/third_party/DirectAsda/Payment.html b/browser/extensions/formautofill/test/fixtures/third_party/DirectAsda/Payment.html
new file mode 100644
index 0000000000..5a5b066f50
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/third_party/DirectAsda/Payment.html
@@ -0,0 +1,90 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+ <meta charset="UTF-8">
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Direct.asda.com</title>
+</head>
+
+<body>
+ <form class="card-form">
+ <div class="form-group"><label class="label" for="cardNumber">Card number</label>
+ <div>
+ <div class="input-group"><input class="form-control error" name="cardNumber" type="tel" autocomplete="on"
+ data-id="input-payment-add-card-number" value=""></div><span data-id="input-payment-add-card-number"
+ class="error">Please enter a card number</span>
+ </div>
+ </div>
+ <div class="form-group"><label class="label" for="cardHolder">Name on card</label>
+ <div>
+ <div class="input-group"><input class="form-control" name="cardHolder" type="text" autocomplete="on"
+ data-id="input-payment-add-card-name-on-card" value=""></div>
+ </div>
+ </div>
+ <div class="grid__list card-grid">
+ <div class="form-group cvv-form-group"><label class="label" for="cvv">CVV number</label>
+ <div>
+ <div class="input-group flex-grid flex-grid--center"><input class="form-control" name="cvv" type="tel"
+ autocomplete="on" data-id="input-payment-add-card-cvv" value=""><span class="icon cvv-code-icon"><svg
+ xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="36" height="24">
+ <defs>
+ <rect id="cvv-dep-a" width="36" height="24" rx="2.182"></rect>
+ </defs>
+ <g fill="none" fill-rule="evenodd">
+ <rect width="36" height="24" fill="#D8D8D8" fill-rule="nonzero" rx="2.182"></rect>
+ <mask id="cvv-dep-b" fill="#fff">
+ <use xlink:href="#cvv-dep-a"></use>
+ </mask>
+ <path fill="#191919" fill-rule="nonzero" d="M0 0h36v5.526H0z" mask="url(#cvv-dep-b)"></path>
+ <rect width="6.545" height="4.435" x="26.727" y="9.282" stroke="#D8365A" stroke-width="1.091"
+ rx=".545"></rect>
+ <path fill="#FFF" fill-rule="nonzero" d="M2.182 8.737H24v5.526H2.182z"></path>
+ </g>
+ </svg></span><span class="icon cvv-code-helper"><svg xmlns="http://www.w3.org/2000/svg" width="24"
+ height="24">
+ <g fill="none" fill-rule="evenodd">
+ <circle fill="#191919" cx="12" cy="12" r="12"></circle><text font-family="Arial-BoldMT, Arial"
+ font-size="15" font-weight="bold" fill="#FFF">
+ <tspan x="7" y="17.235">?</tspan>
+ </text>
+ </g>
+ </svg></span></div>
+ </div>
+ </div>
+ <div class="grid__list adjust-date"><label class="label">Expiry date</label>
+ <div>
+ <div class="flex-grid--inline">
+ <div class="flex-grid--inline">
+ <div class="form-group expiry-month">
+ <div>
+ <div class="input-group"><input class="form-control" name="month" type="tel" placeholder="MM"
+ data-id="input-payment-add-card-expiry-month" value=""></div>
+ </div>
+ </div><span class="date-separator">/</span>
+ <div class="form-group expiry-year">
+ <div class="input-group">
+ <div>
+ <div class="input-group"><input class="form-control" name="year" type="tel" placeholder="YY"
+ data-id="input-payment-add-card-expiry-year" value=""></div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div></div>
+ </div>
+ </div>
+ </div>
+ <div class="save-card-text">
+ <div>
+ <div class="checkbox-section size-normal"><input class="checkbox-field" name="save" id="save"
+ data-id="checkbox-payment-save-card" type="checkbox"><label for="save"
+ class="custom-checkbox"></label><label for="save" class="checkbox-label">Save card</label></div>
+ </div>
+ </div>
+ </div>
+ </form>
+</body>
+
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/third_party/Ebay/Checkout_Payment_FR.html b/browser/extensions/formautofill/test/fixtures/third_party/Ebay/Checkout_Payment_FR.html
new file mode 100644
index 0000000000..83717a6c17
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/third_party/Ebay/Checkout_Payment_FR.html
@@ -0,0 +1,135 @@
+<!DOCTYPE html>
+<html lang="fr">
+
+<head>
+ <meta charset="UTF-8">
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Checkout Payment - Ebay - FR</title>
+</head>
+
+<body class="xo desktop">
+ <div id="root">
+ <div class="bodyContent" tabindex="-1">
+ <div userid="hadox-45" isebayintermediated="true" aria-hidden="true"></div>
+ <div id="gh-gb" tabindex="-1"></div>
+ <h1 class="page-title">Finalisation de l'achat</h1>
+ <div class="credit-card-container">
+ <div class="form-element card-number">
+ <div class="credit-card-number" aria-live="polite">
+ <div class="float-label expanded"><label for="cardNumber">Numéro de carte</label>
+ <div><input aria-required="true" data-validations="REQUIRED_FIELD" class="" autocomplete="cc-number"
+ data-val_required_field_params="{}" id="cardNumber" type="tel" name="cardNumber"
+ value="4111 1111 1111 1111" error=""></div>
+ <div class="card-types" aria-live="polite"><span aria-hidden="false" class="payment-logo VISA small"
+ aria-label="VISA" role="img"></span></div>
+ </div>
+ </div>
+ </div>
+ <div class="">
+ <div class="form-element">
+ <div class="float-label expanded"><label for="cardExpiryDate">Date d'expiration</label>
+ <div><input autocomplete="cc-exp" class="" aria-describedby="cardExpiryDate-accessorylabel"
+ aria-required="true" data-val_month_and_year_format_params="{}" data-val_required_field_params="{}"
+ id="cardExpiryDate" type="tel" name="cardExpiryDate" placeholder="MM / AA"
+ data-validations="MONTH_AND_YEAR_FORMAT,REQUIRED_FIELD" value="02/23" error=""></div>
+ <div id="cardExpiryDate-accessorylabel" class="secondary-text"></div>
+ </div>
+ </div>
+ <div class="form-element card-cvv">
+ <div class="float-label"><label for="securityCode">Code de sécurité</label>
+ <div><input autocomplete="cc-csc" maxlength="3" optionaltext="Facultatif pour une carte de débit."
+ pattern="[0-9]*" id="securityCode" type="tel" name="securityCode" class=""
+ placeholder="3 ou 4 chiffres" data-validations="CVV_NUMBER" value="" cardnumber="4111 1111 1111 1111"
+ data-val_cvv_number_params="{}"></div>
+ <div class="bubble"><span class="bubble-child"><span class="bubblehelp"><span class="infotip "><button
+ tabindex="0" aria-expanded="false" aria-label="En savoir plus sur le code de sécurité."
+ class="icon-btn infotip__host" type="button"><svg height="24px" width="24px"
+ class="icon icon--information " xmlns="http://www.w3.org/2000/svg" focusable="false"
+ aria-hidden="true">
+ <use xlink:href="#icon-information"></use>
+ </svg></button><span class="infotip__overlay" role="tooltip"
+ style="inset: 218.3px auto auto 709.7px; position: fixed;"><span
+ class="infotip__pointer infotip__pointer--top-left"></span><span class="infotip__mask"><span
+ class="infotip__cell"><span class="infotip__content">
+ <div class="ICON"><span class="loadable-icon-and-text"><span
+ class="visa-cvv loadable-icon-and-text-icon" aria-hidden="true"></span></span></div>
+ <div class="ICON"><span class="loadable-icon-and-text"><span
+ class="amex-cvv loadable-icon-and-text-icon" aria-hidden="true"></span></span></div>
+ <div class="TITLE"><span class="loadable-icon-and-text"><span class="text-display"><span
+ class="">Visa, Mastercard ou Discover</span></span></span></div>
+ <ul>
+ <li class="DETAILS_LIST"><span class="loadable-icon-and-text"><span
+ class="text-display"><span class="">Il s'agit du numéro à 3&nbsp;chiffres situé au
+ verso de votre carte, à côté de l'emplacement réservé à la
+ signature.</span></span></span></li>
+ </ul>
+ <div class="TITLE"><span class="loadable-icon-and-text"><span class="text-display"><span
+ class="">American Express</span></span></span></div>
+ <ul>
+ <li class="DETAILS_LIST"><span class="loadable-icon-and-text"><span
+ class="text-display"><span class="">Il s'agit du numéro à 4&nbsp;chiffres situé au
+ recto de votre carte au-dessus de son numéro.</span></span></span></li>
+ </ul>
+ </span><button aria-label="Close CVV Information Overlay" class="infotip__close"
+ type="button"><svg height="24px" width="24px" class="icon icon--close "
+ xmlns="http://www.w3.org/2000/svg" focusable="false" aria-hidden="true">
+ <use xlink:href="#icon-close"></use>
+ </svg></button></span></span></span></span></span></span></div>
+ </div>
+ </div>
+ </div>
+ <div class="form-element">
+ <div class="float-label expanded"><label for="cardHolderFirstName">Prénom</label>
+ <div><input autocomplete="cc-given-name" aria-required="true"
+ aria-describedby="cardHolderFirstName-accessorylabel" data-val_required_field_params="{}"
+ id="cardHolderFirstName" type="text" name="cardHolderFirstName" class=""
+ placeholder="Saisissez le prénom sur la carte" data-validations="REQUIRED_FIELD" value="John"></div>
+ <div id="cardHolderFirstName-accessorylabel" class="secondary-text"></div>
+ </div>
+ </div>
+ <div class="form-element">
+ <div class="float-label expanded"><label for="cardHolderLastName">Nom</label>
+ <div><input autocomplete="cc-family-name" aria-required="true"
+ aria-describedby="cardHolderLastName-accessorylabel" data-val_required_field_params="{}"
+ id="cardHolderLastName" type="text" name="cardHolderLastName" class=""
+ placeholder="Saisissez le nom sur la carte" data-validations="REQUIRED_FIELD" value="Smith"></div>
+ <div id="cardHolderLastName-accessorylabel" class="secondary-text"></div>
+ </div>
+ </div>
+ <div class="form-element remember-card"><span class="checkbox-wrapper field"><span
+ class="checkbox field__control"><svg style="display: none;">
+ <symbol id="icon-checkbox-checked" viewBox="0 0 22 22">
+ <path fill-rule="evenodd"
+ d="M1 0h20a1 1 0 011 1v20a1 1 0 01-1 1H1a1 1 0 01-1-1V1a1 1 0 011-1zm7.3 15.71a1 1 0 001.41 0l8-8h-.01a1 1 0 00-1.41-1.41L9 13.59 5.71 10.3a1 1 0 00-1.41 1.41l4 4z">
+ </path>
+ </symbol>
+ <symbol id="icon-checkbox-checked-small" viewBox="0 0 14 14">
+ <path
+ d="M13 0H1a1 1 0 00-1 1v12a1 1 0 001 1h12a1 1 0 001-1V1a1 1 0 00-1-1zm-2.29 5.71l-4 4a1 1 0 01-1.41 0l-2-2a1 1 0 011.41-1.42L6 7.59 9.29 4.3a1 1 0 011.41 1.41h.01z">
+ </path>
+ </symbol>
+ <symbol id="icon-checkbox-unchecked" viewBox="0 0 21 22">
+ <path fill-rule="evenodd"
+ d="M.955 0h19.09c.528 0 .955.448.955 1v20c0 .552-.427 1-.955 1H.955C.427 22 0 21.552 0 21V1c0-.552.427-1 .955-1zm.954 20h17.182V2H1.909v18z">
+ </path>
+ </symbol>
+ <symbol id="icon-checkbox-unchecked-small" viewBox="0 0 14 14">
+ <path d="M13 14H1a1 1 0 01-1-1V1a1 1 0 011-1h12a1 1 0 011 1v12a1 1 0 01-1 1zM2 12h10V2H2v10z"></path>
+ </symbol>
+ </svg><input for="rememberCard" id="rememberCard" class="checkbox__control " type="checkbox"
+ checked=""><span class="checkbox__icon" hidden=""><svg height="24px" width="24px"
+ class="checkbox__checked" xmlns="http://www.w3.org/2000/svg" focusable="false" aria-hidden="true">
+ <use xlink:href="#icon-checkbox-checked"></use>
+ </svg><svg height="24px" width="24px" class="checkbox__unchecked" xmlns="http://www.w3.org/2000/svg"
+ focusable="false" aria-hidden="true">
+ <use xlink:href="#icon-checkbox-unchecked"></use>
+ </svg></span></span><label class="field__label field__label--end" for="rememberCard"><span
+ class="text-display"><span class="">Enregistrer cette carte pour de futurs
+ achats</span></span></label></span></div>
+ </div>
+ </div>
+ </div>
+</body>
+
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/third_party/GlobalDirectAsda/Payment.html b/browser/extensions/formautofill/test/fixtures/third_party/GlobalDirectAsda/Payment.html
new file mode 100644
index 0000000000..7eee1b1215
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/third_party/GlobalDirectAsda/Payment.html
@@ -0,0 +1,154 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+ <meta charset="UTF-8">
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Global.direct.asda.com</title>
+</head>
+
+<body>
+ <form action="/1/Payments/HandleCreditCardRequestV2?mode=13534" method="post" id="paymentFrm" novalidate="novalidate">
+ <div class="clearfix" id="secureContainer" data-culture="en-GB" data-direction="ltr">
+ <div class="form-horizontal">
+ <div class="form-group has-error has-feedback">
+
+ <label for="cardNum" class="col-sm-4 col-xs-12 control-label fcap paylabel">Card number<div data-show="true"
+ class="glyphicon glyphicon-star astrsk"></div></label>
+ <div class="col-sm-8 col-xs-12 fval" id="CreditCardCell" data-cc-valid="false">
+ <input aria-label="Card number" autocomplete="off" class="form-control input-validation-error"
+ data-type="unknown" data-type-id="1" data-val="true" data-val-luhn="Card number not valid"
+ data-val-luhn-allowempty="False" data-val-luhn-allowspaces="False" data-val-required="Card number"
+ id="cardNum" name="PaymentData.cardNum" pattern="[0-9]{13,16}" placeholder="Card number" type="tel"
+ value="" aria-required="true"><span class="glyphicon glyphicon-remove form-control-feedback"
+ aria-hidden="true"></span>
+ <div id="cardTypeInfo">
+ <div class="isvisa pm_visa pm_general"></div>
+ <div class="ismastercard pm_mastercard pm_general"></div>
+ <div class="isamex pm_amex pm_general"></div>
+ <div class="ismaestro pm_maestro pm_general"></div>
+ <div class="isjcb pm_jcb pm_general"></div>
+ <div class="isdiners pm_diners pm_general"></div>
+ <div class="isdiscover pm_discover pm_general"></div>
+ <div class="ismir pm_mir pm_general"></div>
+ <div class="isdefault pm_default pm_general"></div>
+ </div>
+ </div>
+ </div>
+ <div class="form-group">
+ <label for="cardExpiryMonth" class="col-sm-4 col-xs-12 control-label fcap paylabel">Expiry date<div
+ data-show="true" class="glyphicon glyphicon-star astrsk"></div></label>
+ <div class="col-sm-8 col-xs-12 fval">
+ <div class="row" id="expDateRow">
+
+ <div class="col-xs-6">
+ <div class="FSelect">
+ <div class="arrow"></div>
+ <div class="FCurValue">Month</div><select aria-label="Month" class="form-control"
+ data-tooltip-special-pos="top left;bottom left" data-val="true"
+ data-val-datemustbeequalorgreaterthancurrentdate="The credit card expiration date you provided has already expired."
+ data-val-required="Expiry date" data-widget="lightcombobox" id="cardExpiryMonth"
+ name="PaymentData.cardExpiryMonth" data-rendered="true" aria-required="true">
+ <option selected="selected" value="">Month</option>
+ <option value="1">01</option>
+ <option value="2">02</option>
+ <option value="3">03</option>
+ <option value="4">04</option>
+ <option value="5">05</option>
+ <option value="6">06</option>
+ <option value="7">07</option>
+ <option value="8">08</option>
+ <option value="9">09</option>
+ <option value="10">10</option>
+ <option value="11">11</option>
+ <option value="12">12</option>
+ </select>
+ </div>
+ </div>
+
+ <div class="col-xs-6">
+ <div class="FSelect">
+ <div class="arrow"></div>
+ <div class="FCurValue">Year</div><select aria-label="Year" class="form-control"
+ data-tooltip-special-pos="top right;bottom right" data-val="true"
+ data-val-datemustbeequalorgreaterthancurrentdate="The credit card expiration date you provided has already expired."
+ data-val-required="Expiry date" data-widget="lightcombobox" id="cardExpiryYear"
+ name="PaymentData.cardExpiryYear" data-rendered="true" aria-required="true">
+ <option selected="selected" value="">Year</option>
+ <option value="2022">2022</option>
+ <option value="2023">2023</option>
+ <option value="2024">2024</option>
+ <option value="2025">2025</option>
+ <option value="2026">2026</option>
+ <option value="2027">2027</option>
+ <option value="2028">2028</option>
+ <option value="2029">2029</option>
+ <option value="2030">2030</option>
+ <option value="2031">2031</option>
+ <option value="2032">2032</option>
+ <option value="2033">2033</option>
+ <option value="2034">2034</option>
+ <option value="2035">2035</option>
+ <option value="2036">2036</option>
+ <option value="2037">2037</option>
+ <option value="2038">2038</option>
+ <option value="2039">2039</option>
+ <option value="2040">2040</option>
+ <option value="2041">2041</option>
+ <option value="2042">2042</option>
+ </select>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div id="cvvContainer" class="form-group">
+ <label for="cvdNumber" class="col-sm-4 col-xs-12 control-label fcap paylabel">Security code<div
+ data-show="true" class="glyphicon glyphicon-star astrsk"></div></label>
+ <div class="col-sm-8 col-xs-12 fval">
+ <input aria-label="Security code" autocomplete="off" class="form-control" data-tt-pos="top" data-val="true"
+ data-val-cvvval="Please enter a valid CVV" data-val-cvvval-otherpropertyname=""
+ data-val-required="Security code" id="cvdNumber" name="PaymentData.cvdNumber" pattern="[0-9]{3,4}"
+ placeholder="CVV" type="tel" value="" aria-required="true">
+ <span id="cvvDescriptionContainer" data-toggle="tooltip" data-placement="top" title=""
+ data-original-title="The&nbsp;Security Code&nbsp;on your credit card or debit card is a 3 digit number on the back of your VISA®, MasterCard® and Discover® branded credit and debit cards. On your American Express® branded credit or debit card it is a 4 digit numeric code on the front of the card.">
+ <label tabindex="0" class="control-label" id="cvvInfo"> What is this?</label>
+ <label class="control-label secureinfolabel"><span class="secureInfo lazy"></span></label>
+ </span>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!--#region Hidden Payment Parameters-->
+ <input type="hidden" name="PaymentData.checkoutV2" value="true">
+ <input type="hidden" name="PaymentData.cartToken" id="cartToken" value="045078e1-dafa-4d42-8e1a-a8a8dc3c2234">
+ <input type="hidden" name="PaymentData.gatewayId" id="gatewayId" value="2">
+ <input type="hidden" name="PaymentData.paymentMethodId" id="paymentMethodId" value="1">
+ <input type="hidden" name="PaymentData.machineId" id="machineId">
+ <!--Needs to be updated by script-->
+ <input type="hidden" name="PaymentData.createTransaction" id="createTransaction" value="true">
+ <input type="hidden" name="PaymentData.checkoutCDNEnabled" id="checkoutURL" value="value">
+ <input type="hidden" name="PaymentData.recapchaToken" id="recapchaToken">
+ <!--Needs to be updated by script-->
+ <input type="hidden" name="PaymentData.recapchaTime" id="recapchaTime">
+ <!--Needs to be updated by script-->
+ <input type="hidden" name="PaymentData.customerScreenColorDepth" id="customerScreenColorDepth">
+ <!--Needs to be updated by script-->
+ <input type="hidden" name="PaymentData.customerScreenWidth" id="customerScreenWidth">
+ <!--Needs to be updated by script-->
+ <input type="hidden" name="PaymentData.customerScreenHeight" id="customerScreenHeight">
+ <!--Needs to be updated by script-->
+ <input type="hidden" name="PaymentData.customerTimeZoneOffset" id="customerTimeZoneOffset">
+ <!--Needs to be updated by script-->
+ <input type="hidden" name="PaymentData.customerLanguage" id="customerLanguage">
+ <!--Needs to be updated by script-->
+ <input type="hidden" name="PaymentData.UrlStructureTokenEncoded" id="UrlStructureTokenEncoded"
+ value="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJFbmNvZGVkTWVyY2hhbnRJZCI6IjhybzgiLCJDYXJ0VG9rZW4iOiIwNDUwNzhlMS1kYWZhLTRkNDItOGUxYS1hOGE4ZGMzYzIyMzQiLCJJc1JlcXVpcmVkVG9QYXlXaXRoRGVjb2RlZE1lcmNoYW50SWRBbmRUb2tlbkluVXJsIjpmYWxzZX0.NaxXTwl3w9OurCnPmosjy0P0kSvvs9JfY1OnRVI_w_4">
+ <!--#endregion-->
+ </form>
+</body>
+
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/third_party/HomeDepot/Checkout_ShippingPayment.html b/browser/extensions/formautofill/test/fixtures/third_party/HomeDepot/Checkout_ShippingPayment.html
new file mode 100644
index 0000000000..1825350651
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/third_party/HomeDepot/Checkout_ShippingPayment.html
@@ -0,0 +1,381 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta http-equiv="Content-Type" content="text/html; charset=windows-1252">
+ <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>The Home Depot - Checkout</title>
+ </head>
+ <body>
+ <checkout-form name="pickupDetailsSection">
+ <shipping>
+ <div>
+ <hd-address type="shipping">
+ <hd-address-field label="Shipping Address" type="type">
+ <div name="addressFieldForm">
+ <div>
+ <div>
+ <div>
+ <hd-name-field name="firstName" label="First Name" analytics-tag="pickup options">
+ <span name="firstName">
+ <label for="inputField">
+<span>First Name</span>
+</label>
+<input type="text" id="firstName" name="inputField" maxlength="30" placeholder="" required="required">
+ </span>
+ </hd-name-field>
+ </div>
+ <div>
+ <hd-name-field name="lastName" label="Last Name" analytics-tag="pickup options">
+ <span name="lastName">
+ <label for="inputField">
+<span>Last Name</span>
+</label>
+<input type="text" id="lastName" name="inputField" maxlength="30" placeholder="" required="required">
+ </span>
+ </hd-name-field>
+ </div>
+ </div>
+ <div>
+ <div>
+ <hd-email-field name="emailInput" label="Email" placeholder="you@domain.com" analytics-tag="pickup options">
+ <span name="emailInput">
+ <label for="inputField">
+<span>Email</span>
+</label>
+<input id="emailInput" type="email" name="inputField" placeholder="you@domain.com" required="required">
+ </span>
+ </hd-email-field>
+ </div>
+ </div>
+ <div>
+ <create-account>
+ <div>
+ <div>
+ <div>
+<span role="button" tabindex="0">Create an account</span> to track your order history and check out faster - all we need is a password.</div>
+ <div>
+ <p>Check out faster, access past orders, and organize products into lists.</p>
+ </div>
+ </div>
+ <div>
+ <div>
+ <hd-password-field label="Password" name="password">
+<span name="hdPasswordField">
+<label for="textPasswordInput">
+<span>Password</span>
+</label>
+<input type="password" name="inputField">
+<span>
+</span>
+</span>
+</hd-password-field>
+ </div>
+ <div>
+ <hd-password-field name="confirmPassword" label="Confirm Password">
+<span name="hdPasswordField">
+<label for="textPasswordInput">
+<span>Confirm Password</span>
+</label>
+<input type="password" name="inputField">
+<span>
+</span>
+</span>
+</hd-password-field>
+ </div>
+ <div>
+ <div>
+ <hd-check-box field-value="" label="SHOW PASSWORD" tab-index-hd="-1">
+ <div>
+ <div>
+<input tabindex="-1" type="checkbox" name="hdCheckBox_3" id="hdCheckBox_3">
+<label for="hdCheckBox_3">SHOW PASSWORD</label>
+</div>
+ </div>
+ </hd-check-box>
+ </div>
+ <div>
+ <span>Passwords are case sensitive and must be at least 8 characters.</span>
+ <div>
+ <span>Create a strong password by:</span>
+ <ul>
+ <li>Including numbers or symbols</li>
+ <li>Mixing upper/lowercase</li>
+ </ul>
+ </div>
+ </div>
+ <div>
+<a target="_blank">Terms &amp; Conditions</a> &nbsp;|&nbsp; <a target="_blank">Privacy &amp; Security</a>
+</div>
+ </div>
+ <create-account-button>
+</create-account-button>
+ </div>
+ </div>
+ </create-account>
+ </div>
+ <div>
+ <div>
+ <hd-phone-field name="phone" label="Phone" analytics-tag="pickup options">
+ <span name="phone">
+ <label for="inputField">
+<span>Phone</span>
+</label>
+<input id="phone" name="inputField" type="tel" inputmode="numeric" placeholder="(___) ___-____" required="required">
+ </span>
+ </hd-phone-field>
+ </div>
+ </div>
+ </div>
+ <div>
+ <div>
+ <div>
+ <label for="billingAddress">
+<span>Shipping Address</span>
+</label>
+ </div>
+ <span name="streetInput">
+ <hd-type-ahead id="billingAddress" name="billingAddress" label="Billing Street Address" placeholder="Address Line 1" pause="700" input-class="form-input__field">
+ <div>
+ <input id="billingAddress_value" name="billingAddress" type="text" maxlength="30" placeholder="Address Line 1" required="">
+ </div>
+ </hd-type-ahead>
+ </span>
+ </div>
+ </div>
+ <div>
+<span role="button" tabindex="0">Add an apartment, suite, building, etc.</span>
+</div>
+ <div>
+ <div>
+ <span name="zipInput">
+ <label for="zip">
+<span>ZIP Code</span>
+</label>
+<input type="tel" name="zip" maxlength="5" required="">
+ </span>
+ </div>
+ <div>
+ <span>
+ <label>
+<span>City, State</span>
+</label>
+ <span>
+<b>MOUNTAIN VIEW, CA</b>
+</span>
+ <div>
+ <span name="stateInput">
+ <select id="cityStateListSelector" autocomplete="billing street-address" name="pickupLocation">
+ <option value="? object:null ?" selected="selected">
+</option>
+ </select>
+ </span>
+ </div>
+ </div>
+ </div>
+ </div>
+ </hd-address-field>
+ </hd-address>
+ <div>
+ <hd-check-box field-value="checkoutFlags.hideBillingAddress" label="Use as Billing Address" checked="checked">
+ <div>
+ <div>
+<input tabindex="" type="checkbox" name="hdCheckBox_1" id="hdCheckBox_1" checked="checked">
+<label for="hdCheckBox_1">Use as Billing Address</label>
+</div>
+ </div>
+ </hd-check-box>
+ </div>
+ </div>
+ </shipping>
+ </checkout-form>
+ <card-field form-is-valid="formIsValid">
+ <div name="cardForm">
+ <div>
+<input name="expiryMonth" type="tel" autocomplete="cc-exp-month" tabindex="-1">
+<input name="expiryYear" type="tel" autocomplete="cc-exp-year" tabindex="-1">
+</div>
+ <div>
+ <div>
+ <span>Payment</span>
+ </div>
+ </div>
+ <div>
+ <div>
+ <input type="radio" value="PayPal" id="payPal" name="paymentOptions">
+ <label for="payPal" tabindex="10">
+ <span>
+</span>
+ <div>
+</div>
+ </label>
+ </div>
+ <div>
+<input type="radio" select-payment="" value="creditCard" id="creditCard" name="paymentOptions" checked="checked">
+<label for="creditCard" tabindex="10">
+<span>
+</span>Credit Card</label>
+</div>
+ <div>
+ <span>
+ <label>
+ </label>
+ <input id="cardNumber" name="cardNumber" type="tel" mask-field="" mask="0000 0000 0000 0000 0000 0" maxlength="26" placeholder="Enter credit card number" required="required">
+<span id="ccIcon" tabindex="-1" role="button">
+</span>
+ </span>
+ </div>
+ <div>
+ <div>
+ <div>
+ <div>
+ <span>Expiration</span>
+ <span>
+ <select id="ccMonth" name="ccMonth" required="">
+ <option value="" selected="selected">Month</option>
+ <option label="01 - January" value="object:17">01 - January</option>
+ <option label="02 - February" value="object:18">02 - February</option>
+ <option label="03 - March" value="object:19">03 - March</option>
+ <option label="04 - April" value="object:20">04 - April</option>
+ <option label="05 - May" value="object:21">05 - May</option>
+ <option label="06 - June" value="object:22">06 - June</option>
+ <option label="07 - July" value="object:23">07 - July</option>
+ <option label="08 - August" value="object:24">08 - August</option>
+ <option label="09 - September" value="object:25">09 - September</option>
+ <option label="10 - October" value="object:26">10 - October</option>
+ <option label="11 - November" value="object:27">11 - November</option>
+ <option label="12 - December" value="object:28">12 - December</option>
+ </select>
+ </span>
+ </div>
+ </div>
+ <div>
+ <div>
+ <span>
+ <select id="ccYear" name="ccYear" required="">
+ <option value="" selected="selected">Year</option>
+ <option label="2017" value="object:29">2017</option>
+ <option label="2018" value="object:30">2018</option>
+ <option label="2019" value="object:31">2019</option>
+ <option label="2020" value="object:32">2020</option>
+ <option label="2021" value="object:33">2021</option>
+ <option label="2022" value="object:34">2022</option>
+ <option label="2023" value="object:35">2023</option>
+ <option label="2024" value="object:36">2024</option>
+ <option label="2025" value="object:37">2025</option>
+ <option label="2026" value="object:38">2026</option>
+ <option label="2027" value="object:39">2027</option>
+ <option label="2028" value="object:40">2028</option>
+ <option label="2029" value="object:41">2029</option>
+ <option label="2030" value="object:42">2030</option>
+ <option label="2031" value="object:43">2031</option>
+ <option label="2032" value="object:44">2032</option>
+ <option label="2033" value="object:45">2033</option>
+ <option label="2034" value="object:46">2034</option>
+ <option label="2035" value="object:47">2035</option>
+ </select>
+ </span>
+ </div>
+ </div>
+ </div>
+ <div>
+ <span>
+ <label>
+ <span>
+ CVV (on back)
+ </span>
+ </label>
+ <input id="cvv" name="cvv" type="tel" placeholder="ñññ" minlength="3" required="">
+<span id="ccIcon">
+</span>
+ </span>
+ </div>
+ </div>
+ <div>
+ <span role="button" tabindex="0">Apply a Gift Card</span>
+ <span>&nbsp;|&nbsp;</span>
+ <span role="button" tabindex="0">Have a PO/Job Code for this order?</span>
+ </div>
+ </div>
+ <div>
+ </div>
+ <div>
+ </div>
+ </div>
+ </card-field>
+ <base>
+ <div>
+ <ui-view>
+ <div id="checkout" analytics="">
+ <div name="checkout">
+ <div>
+ <div>
+ <div>
+ <myapron-display>
+ <div>
+ <div>
+<span name="myApronID">
+<label for="myapron">
+<span>myApron ID (Optional)</span>
+</label>
+<input type="tel" name="myapron" maxlength="10">
+</span>
+</div>
+ </div>
+ </myapron-display>
+ <email-subscribe>
+ <div>
+ <div>
+ <hd-check-box field-value="user.emailSubscribed" name="emailSubscribed" label="Yes, I would like to receive emails about unadvertised &amp; online only specials, new products and store promotions." checked="true">
+ <div>
+ <div>
+<input tabindex="" type="checkbox" name="emailSubscribed" id="hdCheckBox_2" checked="checked">
+<label for="hdCheckBox_2">Yes, I would like to receive emails about unadvertised &amp; online only specials, new products and store promotions.</label>
+</div>
+ </div>
+ </hd-check-box>
+ </div>
+ </div>
+ </email-subscribe>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div>
+ <right-rail pick-up-options="pickUpOptions" messages="checkoutModel.messagesSummary">
+ <div>
+ <div>
+ <promotions-summary promotions="order.promotionsModel" messages="messages">
+ <div name="promoForm">
+ <div>
+<span role="button" tabindex="0">Have a promo code?</span>
+</div>
+ <div>
+ <div>
+<span>
+<span>
+</span>
+</span>
+</div>
+ </div>
+ <div>
+ <span>
+<input id="promoId" name="promoId" type="text">
+</span>
+<span>
+<a>
+<span>Apply</span>
+</a>
+</span>
+ </div>
+ </div>
+ </promotions-summary>
+ </div>
+ </div>
+ </right-rail>
+ </div>
+ </div>
+ </ui-view>
+ </div>
+ </body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/third_party/HomeDepot/SignIn.html b/browser/extensions/formautofill/test/fixtures/third_party/HomeDepot/SignIn.html
new file mode 100644
index 0000000000..b741ce5f0c
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/third_party/HomeDepot/SignIn.html
@@ -0,0 +1,83 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
+ <title>The Home Depot - SignIn</title>
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <meta content="yes" name="apple-mobile-web-app-capable">
+ <meta content="black" name="apple-mobile-web-app-status-bar-style">
+ <meta content="&nbsp;at The Home Depot - Tablet" name="description">
+ <meta content="" name="keywords">
+ </head>
+ <body>
+ <form name="shoppingCartForm" id="shopCartForm" method="post">
+ <input type="hidden" id="lineItemId" name="lineItemId" value="480de313-1be8-41ed-b8f9-1b1e371a7eb9_480de313-1be8-41ed-b8f9-1b1e371a7eb9">
+ <input type="hidden" id="quantity" name="quantity" value="">
+ <input type="hidden" id="fulfillmentMethod" name="fulfillmentMethod" value="ShipToHome">
+ <input type="hidden" id="fulfillmentLocation" name="fulfillmentLocation" value="">
+ <input type="hidden" id="LOCAL_STORE_ID" name="LOCAL_STORE_ID" value="">
+ <input type="hidden" id="orderId" name="orderId" value="711220840">
+ <input type="hidden" name="proceedAsGuest" value="yes">
+ <input type="hidden" id="currentPage" name="currentPage" value="LogonPage">
+ <input type="hidden" id="cartVisited" name="cartVisited" value="false">
+ <input type="hidden" id="isEligibleForOPC" name="isEligibleForOPC" value="true">
+ <input type="hidden" id="paymentType" name="paymentType" value="regularCheckout">
+ <div>
+<span>
+<label>
+<span>Email Address:</span>
+</label>
+ <input type="email" placeholder="you@domain.com" id="guestEmail" name="guestLoginValue" value="" required="" errorkey="email">
+</span>
+ </div>
+ <div>
+ <p>You will have the opportunity to create an account and track your order once you complete your checkout.
+ </p>
+ <p>
+</p>
+ </div>
+ <div>
+<button>
+<span>Continue</span>
+</button>
+</div>
+ </form>
+ <form name="checkOutLogonForm" id="checkOutLogonForm" method="post" action="https://secure2.homedepot.com/MCCCheckout/Checkout/checkoutLogon.do">
+ <input type="hidden" name="dologin" value="yes">
+ <input type="hidden" id="orderId" name="orderId" value="711220840">
+ <h3>I'm a Returning Customer
+ </h3>
+ <div>
+ <span>Your sign in is incorrect. Please enter your email address or password. Note: One more invalid attempt will lock your account.</span>
+ </div>
+ <div>
+ <span>We periodically require password updates. Please <a>reset your password</a> or continue as a guest.</span>
+ </div>
+ <div>
+<span>
+<label>
+<span>Email Address:</span>
+</label>
+ <input type="email" placeholder="you@domain.com" name="logonId" id="email">
+</span>
+ </div>
+ <div>
+<span>
+<label>
+<span>Password:</span>
+</label>
+<label id="toogleBtn">Show</label>
+<input type="password" name="logonPassword" id="password">
+</span>
+</div>
+ <div>
+ <div id="SignInPart">
+ <input id="signInNow" type="submit" value="Sign In">
+ </div>
+ <div>
+ <a>Reset Password</a>
+ </div>
+ </div>
+ </form>
+ </body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/third_party/Lufthansa/Checkout_Payment.html b/browser/extensions/formautofill/test/fixtures/third_party/Lufthansa/Checkout_Payment.html
new file mode 100644
index 0000000000..38facb8009
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/third_party/Lufthansa/Checkout_Payment.html
@@ -0,0 +1,23 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Document</title>
+</head>
+<body>
+ <form>
+ <div role="form" class="tab-target wdk-tab-target wdk-SYF-yes payment-card" id="ITEM_PAYMENT_FLIGHTS_CC" tabindex="-1" style="display: block;"> <section> <fieldset data-di-form-id="payment_line_option_credit_card" data-di-form-track=""> <legend class="invisible-element">Payment line option Credit card</legend> <div> <p id="MCP_CC_CARD_NOT_ELIGIBLE" style="display: none;"><b>Please Note:</b> Not all card types can be used for payment in combination with currency conversion.
+ </p> <div class="cluster cluster-select" id="CLUSTER_ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_TYPES_0"><span class=" label " data-di-id="di-id-f9256ae3-51938348"><label for="ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_TYPES_0" class=" ">Select your card type</label></span><span class="clearer afterLabel" data-di-id="di-id-71a9adab-a6783585"><span>&nbsp;</span></span><span id="DATA_ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_TYPES_0" class="data select" data-di-field-id="DATA_ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_TYPES_0" data-di-id="#DATA_ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_TYPES_0"><div class="sb selectbox data-0 open focused" aria-disabled="false"><div class="display active"><div class="text">- Please select -</div><div class="arrow_btn"><span class="Icon expand-headline normal"><span class="invisible-element">Click to expand</span></span></div></div></div><select id="ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_TYPES_0" name="ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_TYPES_0" aria-required="true" role="listbox" class="has_sb" aria-owns="ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_TYPES_0-list" aria-disabled="false" tabindex="0"><option value="NONE" role="option"> Please select </option><option value="CA" role="option">Mastercard</option><option value="VI" role="option">Visa</option><option value="AX" role="option">American Express</option><option value="DC" role="option">Diners Club / Discover</option><option value="JC" role="option">JCB</option><option value="TP" role="option">AirPlus / UATP</option></select></span><span class="clearer" data-di-id="di-id-9c77c574-986cb509"><span>&nbsp;</span></span></div> <hr aria-hidden="true" class="separator separator-small"> <div class="cluster cluster-text partial-border" id="CLUSTER_ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_NUMBER_0"><span class=" label " data-di-id="di-id-91d0068e-476c9da"><span class=" labelLeft ">Enter your card number<span id="INFO_ICON_ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_NUMBER_0" class="icon icon-info " data-infotextid="INFO_TEXT_ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_NUMBER_0" data-proactive="false" data-autohide="true" tabindex="0" data-hasqtip="0"><span class="invisible-element">Information</span></span><div style="display: none;" id="INFO_TEXT_ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_NUMBER_0"><div id="ALLP.content.Textbox.FlightCcNumber.InfoText" class="dwm-content"><div class="tip-complex tip1 tipbox-4" height="192" width="272">
+ <p><strong>Please make sure you select the correct card type:</strong></p>
+ <br>
+ <strong>American Express</strong> begins with a 3.<br> <strong>Visa</strong> begins with a 4.<br><strong> Mastercard</strong> begins with a 5.<br><strong>Diners Club</strong> begins with a 3 and the second digit must be a 0, 6, or 8. <br><strong>Discover</strong> begins with a 6.<br> <strong>JCB</strong> cards begin with the following four digits: 3088, 3096, 3112, 3158, 3337
+ <br><br>
+ </div></div></div></span></span><span class="clearer afterLabel" data-di-id="di-id-9c77c574-c10145ea"><span>&nbsp;</span></span><span id="DATA_ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_NUMBER_0" class="data input card-4" data-di-id="#DATA_ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_NUMBER_0"><label for="ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_NUMBER_00" class="invisible-element">Enter your card number first four digits block</label><input aria-required="true" maxlength="4" style="width: 20%;" id="ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_NUMBER_00" pattern="\d*" type="text" value="" autocomplete="off" aria-disabled="false" tabindex="0" data-di-field-id="ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_NUMBER_00"><span class="separator">|</span><label for="ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_NUMBER_01" class="invisible-element">Enter your card number second four digits block</label><input aria-required="true" maxlength="4" style="width: 20%;" id="ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_NUMBER_01" pattern="\d*" type="text" value="" autocomplete="off" aria-disabled="false" tabindex="0" data-di-field-id="ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_NUMBER_01"><span class="separator">|</span><label for="ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_NUMBER_02" class="invisible-element">Enter your card number third four digits block</label><input aria-required="true" maxlength="4" style="width: 20%;" id="ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_NUMBER_02" pattern="\d*" type="text" value="" autocomplete="off" aria-disabled="false" tabindex="0" data-di-field-id="ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_NUMBER_02"><span class="separator">|</span><label for="ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_NUMBER_03" class="invisible-element">Enter your card number fourth four digits block</label><input aria-required="true" maxlength="4" style="width: 20%;" id="ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_NUMBER_03" pattern="\d*" type="text" value="" autocomplete="off" aria-disabled="false" tabindex="0" data-di-field-id="ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_NUMBER_03"></span><span id="SPINNER_ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_NUMBER_0" class="field-spinner notdisplayed" data-di-id="#SPINNER_ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_NUMBER_0"></span><span class="clearer" data-di-id="di-id-9c77c574-5acbb42e"><span>&nbsp;</span></span></div><div class="cluster cluster-mini cluster-select cluster-dual" id="CLUSTER_ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_EXPIRY_DATE_0"><span id="LABEL_ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_EXPIRY_DATE_0" class="label " data-di-id="#LABEL_ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_EXPIRY_DATE_0"><span class=" labelLeft "><span id="ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_EXPIRY_DATE_0">Expiry date</span> <small class="info " id="ADDITIONAL_LABEL_ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_EXPIRY_DATE_0">(month/year)</small></span></span><span class="clearer" data-di-id="di-id-26bb1425-5b43b2fc"><span>&nbsp;</span></span><span id="DATA_ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_EXPIRY_DATE_0" class="data d-flex select" data-di-field-id="DATA_ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_EXPIRY_DATE_0" data-di-id="#DATA_ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_EXPIRY_DATE_0"><label for="ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_EXPIRY_DATE_EXPIRY_MONTH_0" class="invisible-element">Expiry date (month/year) Month Selection</label><div class="sb selectbox data-0" aria-disabled="false"><div class="display"><div class="text">01</div><div class="arrow_btn"><span class="Icon expand-headline normal"><span class="invisible-element">Click to expand</span></span></div></div></div><select id="ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_EXPIRY_DATE_EXPIRY_MONTH_0" data-valuegroup="ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_EXPIRY_DATE_0_EXPIRY_DATE" data-valueindex="0" name="ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_EXPIRY_DATE_EXPIRY_MONTH_0" aria-required="false" role="listbox" class="has_sb" aria-owns="ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_EXPIRY_DATE_EXPIRY_MONTH_0-list" aria-disabled="false" tabindex="0"><option value="01">01</option><option value="02">02</option><option value="03">03</option><option value="04">04</option><option value="05">05</option><option value="06">06</option><option value="07">07</option><option value="08">08</option><option value="09">09</option><option value="10">10</option><option value="11">11</option><option value="12">12</option></select><label for="ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_EXPIRY_DATE_EXPIRY_YEAR_0" class="invisible-element">Expiry date (month/year) Year Selection</label><div class="sb selectbox data-1" aria-disabled="false"><div class="display"><div class="text">2021</div><div class="arrow_btn"><span class="Icon expand-headline normal"><span class="invisible-element">Click to expand</span></span></div></div></div><select id="ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_EXPIRY_DATE_EXPIRY_YEAR_0" data-valuegroup="ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_EXPIRY_DATE_0_EXPIRY_DATE" data-valueindex="1" name="ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_EXPIRY_DATE_EXPIRY_YEAR_0" aria-required="false" role="listbox" class="has_sb" aria-owns="ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_EXPIRY_DATE_EXPIRY_YEAR_0-list" aria-disabled="false" tabindex="0"><option value="21">2021</option><option value="22">2022</option><option value="23">2023</option><option value="24">2024</option><option value="25">2025</option><option value="26">2026</option><option value="27">2027</option><option value="28">2028</option><option value="29">2029</option><option value="30">2030</option><option value="31">2031</option><option value="32">2032</option><option value="33">2033</option><option value="34">2034</option><option value="35">2035</option><option value="36">2036</option><option value="37">2037</option><option value="38">2038</option><option value="39">2039</option></select><input type="hidden" id="ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_EXPIRY_DATE_0_EXPIRY_DATE" value="01/21" data-di-field-id="ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_EXPIRY_DATE_0_EXPIRY_DATE"></span><span class="clearer" data-di-id="di-id-f5261533-b4ef2ed8"><span>&nbsp;</span></span></div><div class="cluster cluster-text cluster-mini" id="CLUSTER_ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_SECURITY_CODE_0"><span class=" label " data-di-id="di-id-8912f414-6ecc1dca"><span class=" labelLeft ">Security Code <small class="info " id="ADDITIONAL_LABEL_ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_SECURITY_CODE_0">(CVC/CVS) The security code of your Mastercard/ Visa consists of 3 digits and is printed on the backside of your card close to/ within the signature field. For American Express it is 4 digits printed on the front side.</small></span></span><span class="clearer afterLabel" data-di-id="di-id-f5261533-5e23f38f"><span>&nbsp;</span></span><span id="DATA_ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_SECURITY_CODE_0" class="data input card-1" data-di-id="#DATA_ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_SECURITY_CODE_0"><label for="ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_SECURITY_CODE_00" class="invisible-element">Security Code (CVC/CVS) The security code of your Mastercard/ Visa consists of 3 digits and is printed on the backside of your card close to/ within the signature field. For American Express it is 4 digits printed on the front side. 4 digits block</label><input aria-required="true" maxlength="4" style="width: 95%;" id="ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_SECURITY_CODE_00" type="text" value="" autocomplete="off" aria-disabled="false" tabindex="0" data-di-field-id="ITEM_PAYMENT_FLIGHTS_FLIGHT_CC_SECURITY_CODE_00"></span><span class="clearer" data-di-id="di-id-9f45a8c0-fb4556d"><span>&nbsp;</span></span></div> <div class="wdk-payment-edit"><div class="inline-buttons"> <span class="clearer" data-di-id="di-id-8c6a68d1-4c06ba33"><span>&nbsp;</span></span> </div></div> <div class="cluster cluster-checkbox" id="CLUSTER_ITEM_PAYMENT_FLIGHTS_FLIGHT_REMEMBER_CC_PAYMENT_OPTION_0"><span id="DATA_ITEM_PAYMENT_FLIGHTS_FLIGHT_REMEMBER_CC_PAYMENT_OPTION_0" class="data checkbox d-flex flex-column" data-di-id="#DATA_ITEM_PAYMENT_FLIGHTS_FLIGHT_REMEMBER_CC_PAYMENT_OPTION_0"><input aria-required="false" data-onvalue="1" data-offvalue="0" type="checkbox" id="ITEM_PAYMENT_FLIGHTS_FLIGHT_REMEMBER_CC_PAYMENT_OPTION_0" name="ITEM_PAYMENT_FLIGHTS_FLIGHT_REMEMBER_CC_PAYMENT_OPTION_0" aria-disabled="false" tabindex="0" data-di-field-id="ITEM_PAYMENT_FLIGHTS_FLIGHT_REMEMBER_CC_PAYMENT_OPTION_0"></span><label for="ITEM_PAYMENT_FLIGHTS_FLIGHT_REMEMBER_CC_PAYMENT_OPTION_0" class=" label ">Remember this as my preferred payment option for future bookings.</label><span class="clearer" data-di-id="di-id-d9691fec-44549b25"><span>&nbsp;</span></span></div> <leg-miles-and-more-payment ng-version="9.1.13"><miles-and-more-payment-pres _nghost-gpj-c217=""><!----></miles-and-more-payment-pres></leg-miles-and-more-payment> </div> </fieldset> <div class="info"> <hr aria-hidden="true"> <div class="partners 3ds-icons-div"> <p> <span class="ico ico-lock ico-lock-footer-3ds" data-di-id="di-id-6716e5dc-3c143102">&nbsp;</span> <small>Your payment details will be processed applying highest security standards.<br><br>In the next step an input window of your bank might open in which you will need to enter your TAN/mTAN belonging to the card in order to confirm the payment. For a smooth payment authentication, please make sure that your pop-up blocker is deactivated and your card is enabled for this process.</small> </p> <span class="clearer" data-di-id="di-id-ab404bff-9912b7ce"> <span>&nbsp;</span> </span> </div> </div> <span class="clearer" data-di-id="di-id-a6101a37-d8efe855"><span>&nbsp;</span></span> </section>
+ <button type="submit" action="/">Submit form</button>
+ </div>
+</form>
+
+</body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/third_party/Lush/index.html b/browser/extensions/formautofill/test/fixtures/third_party/Lush/index.html
new file mode 100644
index 0000000000..a77fd13132
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/third_party/Lush/index.html
@@ -0,0 +1,421 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" data-page-type="select-pmm-page" data-page-model="one-page" data-shopperlocale="en_GB"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src 'self' data:; media-src 'self' data:; style-src 'self' data: 'unsafe-inline'; font-src 'self' data:; frame-src 'self' data:"><meta http-equiv="Memento-Datetime" content="Thu, 04 Jun 2020 09:28:03 GMT"><link rel="original" href="https://live.adyen.com/hpp/pay.shtml">
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
+
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+
+ <title>Step 1: Choose your Payment Method</title>
+
+ <link rel="shortcut icon" href="resources/EN_B357a/1.ico" data-original-href="https://live.adyen.com/sf/q2TgsJh7/img/favicon.ico">
+
+ <link rel="stylesheet" type="text/css" href="resources/EN_B357a/2.css" data-original-href="https://live.adyen.com/hpp/css/reset.css;jsessionid=B450B7268B623F8AA545472EE35D974D.live110e?v=8160">
+ <link rel="stylesheet" media="screen" type="text/css" href="resources/EN_B357a/3.css" data-original-href="https://live.adyen.com/sf/q2TgsJh7/css/screen.css">
+ <link rel="stylesheet" media="print" type="text/css" href="resources/EN_B357a/4.css" data-original-href="https://live.adyen.com/sf/q2TgsJh7/css/print.css">
+
+
+ <!--[if lt IE 7]>
+ <link rel="stylesheet" type="text/css" href="/sf/q2TgsJh7/css/screen_ie6.css" />
+ <![endif]-->
+</head>
+<body>
+
+
+
+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">
+<meta name="apple-mobile-web-app-capable" content="yes">
+<div class="swatch-black nav desktop-nav-checkout">
+ <div class="desktop-container">
+ <div class="container-padding">
+ <ul class="logo-banner">
+ <li class="logo"></li>
+ </ul>
+ <div class="desktop-checkout-help mobileHide">
+ <p class="colour-black50 size-7 break-2-size-9">Need help?</p>
+ <p class="colour-white size-8 break-2-size-9">Call 01202 668 545</p>
+ </div>
+ </div>
+ </div>
+</div>
+<div class="desktop-container layout-main-content-region mobileHide">
+ <div class="container-padding">
+ <div class="block--commerce-checkout-progress-indication">
+ <ol class="commerce-checkout-progress">
+ <li class="addresses">
+ <span class="checkout-progress-img"></span>
+ <span class="checkout-progress-line"></span>
+ <span class="size-6">Addresses</span>
+ </li>
+ <li class="next summary" title="Review your order before continuing.">
+ <span class="checkout-progress-img"></span>
+ <span class="checkout-progress-line"></span>
+ <span class="size-6">Order summary</span>
+ </li>
+ <li class="active payment" title="Use the button below to proceed to the payment server.">
+ <span class="checkout-progress-img"></span>
+ <span class="checkout-progress-line"></span>
+ <span class="size-6">Payment</span>
+ </li>
+ <li class="complete" style="display:none;">Checkout complete</li>
+ </ol>
+ </div>
+ </div>
+</div>
+<div class="centerpage">
+
+ <form id="pageform" action="https://live.adyen.com/hpp/completeCard.shtml" method="post" autocomplete="off">
+ <div id="content">
+ <!-- <div id="logoheader">
+ <div class="SmartLogo"></div>
+ <div class="IAlogo"></div>
+</div> -->
+
+<div id="logoheader2">
+</div>
+
+<div id="pmcontent" data-fathom="form">
+
+<div class="InfoBox">
+ <div class="Payment"> </div>
+
+ </div>
+ <div class="paddiv1"></div>
+
+
+
+ <input type="hidden" id="displayGroup" name="displayGroup" value="card">
+<h2 id="stageheader">Step 1: Please select your payment method</h2>
+
+<div id="displayAmount">
+ Total payment amount GBP 31.40 <span id="extraCostAmount"></span>
+</div>
+
+
+<ul id="paymentMethods">
+
+
+
+
+ <li style="list-style-type: none;" data-variant="givex">
+ <input type="submit" name="brandName" value="LUSH Gift Card" class="imgB pmB pmBgivex" data-fathom="ccPaymentType">
+ <span id="pmmextracosts-givex" class="pmmextracosts">
+ </span>
+
+ <span id="pmgivexdescription" class="pmmdescription"></span>
+ <div id="pmmdetails-givex" class="pmmdetails" style="overflow: hidden; visibility: visible; height: 0px;">
+
+
+
+
+<br><br>
+<table class="basetable">
+<tbody><tr>
+ <td><div>Name</div></td>
+ <td><div class="fieldDiv"><input type="text" class="inputField" id="givex.cardHolderName" name="givex.cardHolderName" value="" size="19" maxlength="30"></div></td>
+</tr>
+<tr>
+ <td><div>Card Number</div></td>
+ <td>
+ <div class="fieldDiv"><input type="text" class="inputField" id="givex.cardNumber" name="givex.cardNumber" value="" size="22" maxlength="22"></div>
+ </td>
+</tr>
+ <tr>
+ <td colspan="2"><div class="l">
+ <input type="checkbox" value="true" name="givex.partialPayments" id="givex.partialPayments" checked="checked"> If there is not enough balance on my card, pay the rest of the payment amount with an other payment method.
+ </div></td>
+ </tr>
+ <input type="hidden" value="false" name="disablePartialPaymentsInHPP" id="disablePartialPaymentsInHPP">
+ <tr>
+ <td colspan="2"><div class="r">
+ <input class="paySubmit paySubmitgivex" type="submit" name="pay" value="Pay">
+ </div></td>
+ </tr>
+ </tbody></table>
+
+<br><br>
+
+
+
+
+ </div></li><li style="list-style-type: none;" data-variant="amex">
+ <input type="submit" name="brandName" value="Card Payment" class="imgB pmB pmBcard" data-fathom="ccPaymentType">
+ <span id="pmmextracosts-card" class="pmmextracosts">
+ </span>
+
+ <span id="pmcarddescription" class="pmmdescription"></span>
+ <div id="pmmdetails-card" class="pmmdetails" style="overflow: hidden; visibility: visible; height: 475px;" data-collapse="collapsed">
+
+
+
+
+<!-- useNewCardId = true, groupName = card -->
+
+
+
+
+<table class="basetable">
+
+
+
+<tbody><tr id="card.cclogoTr">
+ <td class="mid">
+ <div id="card.cclogoheader" style="display: none">Card Type</div>
+ </td>
+ <td class="mid">
+ <div style="height: 25px" id="card.cclogo">
+ <img alt="" id="card.cclogo0" style="display: inline;" class="mid" src="resources/EN_B357a/5.png" data-original-src="https://live.adyen.com/hpp//img/pm/amex_small.png">
+ <img alt="" id="card.cclogo1" style="display: inline;" class="mid" src="resources/EN_B357a/6.png" data-original-src="https://live.adyen.com/hpp//img/pm/mc_small.png">
+ <img alt="" id="card.cclogo2" style="display: inline;" class="mid" src="resources/EN_B357a/7.png" data-original-src="https://live.adyen.com/hpp//img/pm/visa_small.png">
+ <img alt="" id="card.cclogo3" style="display: none" class="mid" src="resources/EN_B357a/8.png" data-original-src="https://live.adyen.com/hpp/img/pm/unknown_small.png;jsessionid=B450B7268B623F8AA545472EE35D974D.live110e">
+ <img alt="" id="card.cclogo4" style="display: none" class="mid" src="resources/EN_B357a/8.png" data-original-src="https://live.adyen.com/hpp/img/pm/unknown_small.png;jsessionid=B450B7268B623F8AA545472EE35D974D.live110e">
+ <img alt="" id="card.cclogo5" style="display: none" class="mid" src="resources/EN_B357a/8.png" data-original-src="https://live.adyen.com/hpp/img/pm/unknown_small.png;jsessionid=B450B7268B623F8AA545472EE35D974D.live110e">
+ <img alt="" id="card.cclogo6" style="display: none" class="mid" src="resources/EN_B357a/8.png" data-original-src="https://live.adyen.com/hpp/img/pm/unknown_small.png;jsessionid=B450B7268B623F8AA545472EE35D974D.live110e">
+ <img alt="" id="card.cclogo7" style="display: none" class="mid" src="resources/EN_B357a/8.png" data-original-src="https://live.adyen.com/hpp/img/pm/unknown_small.png;jsessionid=B450B7268B623F8AA545472EE35D974D.live110e">
+ <img alt="" id="card.cclogo8" style="display: none" class="mid" src="resources/EN_B357a/8.png" data-original-src="https://live.adyen.com/hpp/img/pm/unknown_small.png;jsessionid=B450B7268B623F8AA545472EE35D974D.live110e">
+
+ </div>
+ </td>
+</tr>
+
+<tr id="card.cardNumberTr">
+ <td class="cardNumberTitle"><div>Card Number</div></td>
+ <td><div class="fieldDiv"><input type="text" class="inputField" id="card.cardNumber" autocomplete="cc-number" name="card.cardNumber" value="" size="24" maxlength="23" data-fathom="number"></div></td>
+</tr>
+
+<tr>
+ <td><div>Card Holder Name</div></td>
+ <td><div class="fieldDiv">
+ <input type="text" class="inputField" id="card.cardHolderName" name="card.cardHolderName" value="" size="19" maxlength="30" data-fathom="name">
+ </div></td>
+</tr>
+
+
+<tr>
+ <td><div>Card Expiry Date</div></td><td>
+ <div class="fieldDiv" id="card.expiryContainer">
+ <select class="inputField hpp-expiry-month" name="card.expiryMonth" id="card.expiryMonth" size="1" data-fathom="expirationMonth">
+ <option value="">&nbsp;</option>
+ <option value="01">01</option>
+ <option value="02">02</option>
+ <option value="03">03</option>
+ <option value="04">04</option>
+ <option value="05">05</option>
+ <option value="06">06</option>
+ <option value="07">07</option>
+ <option value="08">08</option>
+ <option value="09">09</option>
+ <option value="10">10</option>
+ <option value="11">11</option>
+ <option value="12">12</option>
+ </select>
+ &nbsp;/&nbsp;
+ <select class="inputField hpp-expiry-year" name="card.expiryYear" id="card.expiryYear" size="1" data-fathom="expirationYear">
+ <option value="">&nbsp;</option>
+ <option value="2020">2020</option>
+ <option value="2021">2021</option>
+ <option value="2022">2022</option>
+ <option value="2023">2023</option>
+ <option value="2024">2024</option>
+ <option value="2025">2025</option>
+ <option value="2026">2026</option>
+ <option value="2027">2027</option>
+ <option value="2028">2028</option>
+ <option value="2029">2029</option>
+ <option value="2030">2030</option>
+ <option value="2031">2031</option>
+ <option value="2032">2032</option>
+ <option value="2033">2033</option>
+ <option value="2034">2034</option>
+ <option value="2035">2035</option>
+ <option value="2036">2036</option>
+ <option value="2037">2037</option>
+ <option value="2038">2038</option>
+ <option value="2039">2039</option>
+ <option value="2040">2040</option>
+ <option value="2041">2041</option>
+ <option value="2042">2042</option>
+ <option value="2043">2043</option>
+ <option value="2044">2044</option>
+ </select>
+ </div>
+ </td>
+</tr>
+
+ <tr>
+ <!-- brandCodeUndef -->
+ <td><div id="card.cvcName">CVC/CVV/CID </div></td>
+ <td><div class="fieldDiv"><input class="inputField" type="text" name="card.cvcCode" value="" id="card.cvcCode" size="7" maxlength="3" data-fathom="security"> &nbsp;
+ <a href="https://live.adyen.com/hpp/pay.shtml#">
+ <span id="card.cvcWhatIs">What is CVC/CVV/CID?</span></a></div></td>
+ </tr>
+
+
+
+
+
+
+ <tr>
+ <td colspan="2"><div class="r">
+ <input class="paySubmit paySubmitcard" type="submit" name="pay" value="Pay">
+ </div></td>
+ </tr>
+</tbody></table>
+
+<div class="popupMsg popupMsgOPP " style="display: none;" id="card.cvcFrame">
+ <h3>What is CVC/CVV/CID?</h3>
+ <span style="width: 15px; height: 15px; border: 1px solid black; border-radius: 15px; position: absolute; right: 10px; top: 10px; text-align: center;">x</span>
+ <p>The Card Security Code (CVC/CVV/CID) is an <i>additional</i>
+ three or four digit security code that is printed (not embossed) on the front or the back
+ of your card.</p>
+ <p>The CVC/CVV/CID is an extra security measure to ensure that you are in possession of the card.</p>
+ </div>
+
+
+
+
+
+
+
+
+
+ </div></li><li style="list-style-type: none;" data-variant="paypal">
+ <input type="submit" name="brandName" value="PayPal" class="imgB pmB pmBpaypal" data-fathom="ccPaymentType">
+ <span id="pmmextracosts-paypal" class="pmmextracosts">
+ </span>
+
+ <span id="pmpaypaldescription" class="pmmdescription"></span>
+ <div id="pmmdetails-paypal" class="pmmdetails" style="overflow: hidden; visibility: visible; height: 0px;">
+
+
+
+ <table class="basetable">
+
+ <tbody><tr>
+ <td>
+ Pay using your PayPal account. You will be redirected to the PayPal system to complete the payment.
+ </td>
+ </tr>
+ <tr>
+ <td colspan="2"><div class="r">
+ <input class="paySubmit paySubmitpaypal" type="submit" name="pay" value="Pay">
+ </div></td>
+ </tr>
+</tbody></table>
+ </div>
+ </li>
+
+
+</ul>
+
+
+ <div id="errorFrame" style="display: none;" class="popupMsg errorFrame">
+ <div id="errorFrameValidationErrors">
+ </div>
+ </div>
+ <div id="okFrame" style="display: none;" class="popupMsg okFrame">
+ <div id="okFrameMessages">
+ </div>
+</div>
+
+
+
+<input type="text" style="display: none">
+ <input type="hidden" name="sig" value="DYjKpO5ZYg5WdOnHGxBGZg8cxbY=">
+ <input type="hidden" name="merchantReference" value="UK-LW-18894662">
+ <input type="hidden" name="brandCode" value="brandCodeUndef">
+ <input type="hidden" name="paymentAmount" value="3140">
+ <input type="hidden" name="currencyCode" value="GBP">
+
+ <input type="hidden" name="skinCode" value="q2TgsJh7">
+ <input type="hidden" name="merchantAccount" value="LushCOMUK">
+ <input type="hidden" name="shopperLocale" value="en_GB">
+ <input type="hidden" name="stage" value="pay">
+ <input type="hidden" name="sessionId" value="Kn5uBswMGbS8dZfKIOc5JBomZXXIscszA2tii46aF9A=">
+
+
+ <input type="hidden" name="sessionValidity" value="2020-06-04T12:25:13+01:00">
+
+
+
+ <input type="hidden" name="countryCode" value="US">
+
+ <input type="hidden" name="shopperEmail" value="alabalaa12@yahoo.com">
+ <input type="hidden" name="shopperReference" value="alabalaa12@yahoo.com">
+
+ <input type="hidden" name="recurringContract" value="RECURRING">
+ <input type="hidden" name="resURL" value="https://uk.lush.com/checkout/18894662/payment/return/ryQWZ_B6eE5h1k03sTpHUomc1Rc2NOsjxDuy0zioQNU">
+
+
+
+ <input type="hidden" name="merchantReturnData" value="ryQWZ_B6eE5h1k03sTpHUomc1Rc2NOsjxDuy0zioQNU">
+
+
+
+ <input type="hidden" name="originalSession" value="H4sIAAAAAAAAAJ1WbXPiNhD+Kx5/aucuYDtACDOdKW9NuBCc8Ja5Ozo3wl6wipBcSebia/vfuwJMCNiXaz8wDLuPdp99dlfiL5tyqkeaLMFu2DFJ7fe2hAVICXIy7KMt0jpWjVl5Vk5WJZaoqBSI9awcRBCsRKJnZbdev67Uat6sjMfXwDWGUJGIY5B9ERBmAgP/ctPahlb/N+qsLEEnkuN3+vj06UurBt1q5K6cSzWObydiHbjDwBv46o/nTpI636h4HEwwZSASrmXaFqEhMhmhaR+xuTYuu3HpVhzEJVgzDzLgTethWweNW7AQEjpEo5knjL231yCDiHA9NELhmW3gu4v+00XG2hxdUb6P9ac3XqoP0ZX9crQZBLvkdh+Lb/v3kzv0ChmCxEwky6RAKSr4lDAaUp0i3HM858KpXTiVses1vGrDvXznuA3HeVG9uyaUIZQwMscPcb1fUxIJYSR+QR2TL0BmZH1D6wi/4ybBSEb5si1QYBKYYobd9mQ47A1u8DRhTHyF8B50JEKVnZozEazOrHtOOIcatiN0prRp/U4Z+7/1XywWCg4BlcZeHrhQxpB/MwxxLtU4jQ+eEBjdgExzXHuqxyZUKyY8PTa9Dl1SWoIhYXte1bFG1r3vD7ofrVZ/2rHPwJFIFAyS9RykLwdkbTp0jlJGK18+SLGhuy62m+eoWCCO7eewfu3Wrs8xwW6y+s2RNe3eNEd/D6Y5oN0aZSt0ItAbBZ6iCyo8D5pb4ikst8ZTUH6RZ6jXVe57XVpQqfSeZ0+ROTBGjtyUL+jzroTMxMjhwFBE5NuRawkc1+k1PDSFLlpU6qhDUn9xjysVFUK+7/0I5CS6BgZxJPhecnRWq9dXFcep1t0jmBIBJWxk1hrFOmDNDsXAKd8IGgAmIiW+a92iTzko0/QcDA4PeCWK+5zds7ZXqzjfRU6JfsCNxwtg9x4Vo3Nv6zyoeyDbwwSGrFsY9EeRbkb2UFkRUbcUggokjTXe4oi7xSakVs96IiqC0BpHYN3RUFmfYynCJNC/vyXPmxm9k4w9rkFyYn4RZv3UFthckD9bn83TFuOCF6V0f7gj+H8h4WHnVdrh1mZpYWX7YmV7UJzsUFzVKS5vQ3Qbh30ptms6QEELY+ZCsyfFCLOUW2FKentz27cPxVOUO3BZrBFdou3x2V/PR9Npc1D99JvP6/zd1Vc36DE+6cvprFyjtU0wrt/2SC+c/GL/8y/bjcnWewkAAA==">
+
+
+
+ <input type="hidden" name="billingAddress.street" value="2250 S MOONEY BLVD"> <input type="hidden" name="billingAddress.houseNumberOrName" value=""> <input type="hidden" name="billingAddress.city" value="LAS VEGAS|NV"> <input type="hidden" name="billingAddress.postalCode" value="89169"> <input type="hidden" name="billingAddress.stateOrProvince" value="CA"> <input type="hidden" name="billingAddress.country" value="US">
+ <input type="hidden" name="deliveryAddress.street" value="2250 S MOONEY BLVD"> <input type="hidden" name="deliveryAddress.houseNumberOrName" value=""> <input type="hidden" name="deliveryAddress.city" value="LAS VEGAS|NV"> <input type="hidden" name="deliveryAddress.postalCode" value="89169"> <input type="hidden" name="deliveryAddress.stateOrProvince" value="CA"> <input type="hidden" name="deliveryAddress.country" value="US">
+ <input type="hidden" name="shopper.firstName" value="Isabella"> <input type="hidden" name="shopper.lastName" value="Rohaz"> <input type="hidden" name="shopper.telephoneNumber" value="5597400581">
+
+
+
+ <input type="hidden" name="openinvoicedata.numberOfLines" value="2">
+ <input type="hidden" name="openinvoicedata.line2.itemAmount" value="2640">
+ <input type="hidden" name="openinvoicedata.line2.itemVatPercentage" value="0">
+ <input type="hidden" name="openinvoicedata.line2.currencyCode" value="GBP">
+ <input type="hidden" name="openinvoicedata.line1.numberOfItems" value="1">
+ <input type="hidden" name="openinvoicedata.line2.numberOfItems" value="1">
+ <input type="hidden" name="openinvoicedata.line1.itemVatAmount" value="0">
+ <input type="hidden" name="openinvoicedata.line1.description" value="Honey I Washed The Kids [product]">
+ <input type="hidden" name="openinvoicedata.line2.itemVatAmount" value="0">
+ <input type="hidden" name="openinvoicedata.line2.description" value="International (Courier) [shipping]">
+ <input type="hidden" name="openinvoicedata.line1.itemVatPercentage" value="0">
+ <input type="hidden" name="openinvoicedata.refundDescription" value="Refund to Isabella Rohaz">
+ <input type="hidden" name="openinvoicedata.line1.itemAmount" value="500">
+ <input type="hidden" name="openinvoicedata.line2.vatCategory" value="None">
+ <input type="hidden" name="openinvoicedata.line1.vatCategory" value="None">
+ <input type="hidden" name="merchantIntegration.type" value="HPP">
+ <input type="hidden" name="openinvoicedata.line1.currencyCode" value="GBP">
+
+
+ <input type="hidden" name="referrerURL" value="https://uk.lush.com/checkout/18894662/payment">
+
+
+
+
+
+
+ <div id="df_swf_c" style="display:none;"></div>
+ <input type="hidden" name="dfValue" id="dfValue" value="1B2M2Y8Asg0010000000000000CEENawQ48p0050271576cVB94iKzBGs1JtJP2ftZ1B2M2Y8Asg000joETVQLPRH00000qZkTE1BXw4QyCgloH7n6Gldz2JXe0J:40">
+
+ <input type="hidden" name="usingFrame" id="usingFrame" value="false">
+ <input type="hidden" name="usingPopUp" id="usingPopUp" value="false">
+
+ <div class="paddiv2"></div>
+ </div>
+ </div>
+ <div id="foot">
+ <div id="footc">
+ <div id="nextstep">
+ <div id="nextstepc">Next Step: Enter your Payment Details</div>
+ </div>
+ <div id="footerb2div">
+ </div>
+ <div id="footerb1div">
+ <input name="back" id="mainBack" value="Previous" type="submit" class="hideforprint footerB backB">
+ </div>
+ </div>
+ </div>
+
+ <input type="hidden" name="shopperBehaviorLog" id="hpp-sbLog" value="{&quot;numberBind&quot;:&quot;1&quot;,&quot;holderNameBind&quot;:&quot;1&quot;,&quot;cvcBind&quot;:&quot;1&quot;,&quot;deactivate&quot;:&quot;11&quot;,&quot;activate&quot;:&quot;10&quot;,&quot;numberFieldFocusCount&quot;:&quot;2&quot;,&quot;numberFieldLog&quot;:&quot;fo@533,bl@544,fo@662,bl@662&quot;,&quot;numberFieldBlurCount&quot;:&quot;2&quot;,&quot;holderNameFieldFocusCount&quot;:&quot;2&quot;,&quot;holderNameFieldLog&quot;:&quot;fo@662,bl@675,fo@793,bl@793&quot;,&quot;holderNameFieldBlurCount&quot;:&quot;2&quot;,&quot;cvcFieldFocusCount&quot;:&quot;2&quot;,&quot;cvcFieldLog&quot;:&quot;fo@1136,bl@1149,fo@1288,bl@1288&quot;,&quot;cvcFieldBlurCount&quot;:&quot;2&quot;}">
+
+ </form>
+ </div>
+
+
+
+
+
+
+</body></html>
diff --git a/browser/extensions/formautofill/test/fixtures/third_party/Macys/Checkout_Payment.html b/browser/extensions/formautofill/test/fixtures/third_party/Macys/Checkout_Payment.html
new file mode 100644
index 0000000000..85717a4eaa
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/third_party/Macys/Checkout_Payment.html
@@ -0,0 +1,474 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
+ <title>Macy's Checkout</title>
+ <meta http-equiv="generator" content="JACPKMALPHTCSJDTCR">
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
+ <meta name="format-detection" content="telephone=no">
+ </head>
+ <body>
+ <form id="rc-payment-info-form" novalidate="">
+ <div>
+ <div>
+ </div>
+ </div>
+ <div id="rc-payment-selection-row">
+ <div>
+ <input type="radio" id="rc-creditcard" for="rc-creditcard-label" name="payment.type" checked="checked" value="CREDITCARD"
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: Credit card
+ parseable name: payment.type
+ field signature: 2449554739
+ form signature: 4053649612452005841"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ <label id="rc-creditcard-label" for="rc-creditcard">Credit card</label>
+ </div>
+ <div>
+ <input type="radio" id="rc-paypal" name="payment.type" value="PAYPAL"
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: Choose payment method
+ parseable name: payment.type
+ field signature: 2449554739
+ form signature: 4053649612452005841"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ <span for="rc-paypal">
+</span>
+ </div>
+ </div>
+ <div id="rc-paypal-disclaimer-cc-row">
+ <div id="rc-paypal-disclaimer">
+ <b>Note:&nbsp;</b>PayPal can't be used with Gift Cards, Reward Cards and Credit Cards.
+ Plenti points can be earned but not used with PayPal.
+ </div>
+ </div>
+ <fieldset id="rc-credit-card-container">
+ <div>
+ <div>
+ <div>
+</div>
+ </div>
+ <div>
+ <p>Secure payment
+ <a target="_blank">more info</a>
+ </p>
+ </div>
+ </div>
+ <div>
+ <div>
+ <label for="rc-payment-card-type">Card type</label>
+ <select name="creditCard.cardType.code" id="rc-payment-card-type" autocomplete="off"
+title="overall type: CREDIT_CARD_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: CREDIT_CARD_TYPE
+ label: Card type
+ parseable name: creditCard.cardType.code
+ field signature: 1958753038
+ form signature: 4053649612452005841"
+autofill-prediction="CREDIT_CARD_TYPE"
+>
+ <option value="-1">Select</option>
+ <option value="Y">Macy's</option>
+ <option value="B">Macy's American Express</option>
+ <option value="A">American Express</option>
+ <option value="V">Visa</option>
+ <option value="M">MasterCard</option>
+ <option value="O">Discover</option>
+ <option value="F">Employee Card</option>
+ </select>
+ <div id="payment-aria-info" tabindex="-1">Your Shipping, Plenti, and Gift Card information can be found and verified at the top of this page"</div>
+ </div>
+ </div>
+ <div id="rc-payment-card-number-row">
+ <div>
+ <label for="rc-payment-card-number">Card number</label>
+ <input type="text" maxlength="20" pattern="\d*" name="creditCard.cardNumber" id="rc-payment-card-number" value="" autocomplete="off" autocorrect="off"
+title="overall type: CREDIT_CARD_NUMBER
+ server type: CREDIT_CARD_NUMBER
+ heuristic type: CREDIT_CARD_NUMBER
+ label: Card number
+ parseable name: creditCard.cardNumber
+ field signature: 2117159926
+ form signature: 4053649612452005841"
+autofill-prediction="CREDIT_CARD_NUMBER"
+>
+ <input type="hidden" name="creditCard.maskedCreditCardNumber" value="">
+ </div>
+ </div>
+ <div>
+ <div>
+ <label id="rc-payment-expiration-label">Expiration date</label>
+ </div>
+ </div>
+ <div>
+ <div>
+ <select name="creditCard.expMonth" id="rc-payment-card-month" autocomplete="off"
+title="overall type: CREDIT_CARD_EXP_MONTH
+ server type: CREDIT_CARD_EXP_MONTH
+ heuristic type: CREDIT_CARD_EXP_MONTH
+ label: Expiration date
+ parseable name: creditCard.expMonth
+ field signature: 989675451
+ form signature: 4053649612452005841"
+autofill-prediction="CREDIT_CARD_EXP_MONTH"
+>
+ <option>01</option>
+ <option>02</option>
+ <option>03</option>
+ <option>04</option>
+ <option>05</option>
+ <option>06</option>
+ <option>07</option>
+ <option>08</option>
+ <option>09</option>
+ <option>10</option>
+ <option>11</option>
+ <option>12</option>
+ </select>
+ </div>
+ <div>
+ <select name="creditCard.expYear" id="rc-payment-card-year" autocomplete="off"
+title="overall type: CREDIT_CARD_EXP_4_DIGIT_YEAR
+ server type: CREDIT_CARD_EXP_4_DIGIT_YEAR
+ heuristic type: CREDIT_CARD_EXP_4_DIGIT_YEAR
+ label: Expiration date
+ parseable name: creditCard.expYear
+ field signature: 891328465
+ form signature: 4053649612452005841"
+autofill-prediction="CREDIT_CARD_EXP_4_DIGIT_YEAR"
+>
+ <option>2017</option>
+ <option>2018</option>
+ <option>2019</option>
+ <option>2020</option>
+ <option>2021</option>
+ <option>2022</option>
+ <option>2023</option>
+ <option>2024</option>
+ <option>2025</option>
+ <option>2026</option>
+ </select>
+ </div>
+ </div>
+ <div>
+ <div>
+ <label for="rc-payment-scode" id="rc-payment-scode-label">Security&nbsp;code</label>
+ </div>
+ </div>
+ <div id="rc-payment-scode-row">
+ <div>
+ <input type="text" name="fake-password" id="rc-fake-password" autocomplete="off"
+title="overall type: CREDIT_CARD_VERIFICATION_CODE
+ server type: NO_SERVER_DATA
+ heuristic type: CREDIT_CARD_VERIFICATION_CODE
+ label: Please enter the 4 digit security code on the front of your credit card Please enter the 3 digit sec
+ parseable name: fake-password
+ field signature: 3761992124
+ form signature: 4053649612452005841"
+autofill-prediction="CREDIT_CARD_VERIFICATION_CODE"
+>
+ <input type="text" maxlength="4" pattern="\d*" name="creditCard.securityCode" id="rc-payment-scode" autocomplete="off"
+title="overall type: CREDIT_CARD_VERIFICATION_CODE
+ server type: NO_SERVER_DATA
+ heuristic type: CREDIT_CARD_VERIFICATION_CODE
+ label: Security code
+ parseable name: creditCard.securityCode
+ field signature: 789650537
+ form signature: 4053649612452005841"
+autofill-prediction="CREDIT_CARD_VERIFICATION_CODE"
+>
+ </div>
+ <div id="amex-cvv-info" tabindex="-1">Please enter the 4 digit security code on the front of your credit card</div>
+ <div id="not-amex-cvv-info" tabindex="-1">Please enter the 3 digit security code on the back of your credit card</div>
+ <div id="rc-cvv-icon-row">
+ <div id="rc-cvv-icon">
+ <span id="rc-cvv-img">
+</span>
+ <span id="rc-cvv-hint">
+</span>
+ </div>
+ </div>
+ </div>
+ </fieldset>
+ <section id="rc-paypal-container">
+ <div>
+ <div id="rc-paypal-disclaimer">
+ <b>Note:&nbsp;</b>PayPal can't be used with Gift Cards, Reward Cards and Credit Cards.
+ Plenti points can be earned but not used with PayPal.
+ </div>
+ </div>
+ <div id="rc-paypal-login-disclaimer">
+ <div>
+ You will login on PayPal's site on the next page and review your order, then you will finish the transaction at macy's.com
+ </div>
+ </div>
+ </section>
+ <fieldset id="rc-billing-address-container">
+ <div>
+ <div>
+ </div>
+ </div>
+ <div id="rc-use-shipping-container">
+ <div>
+ <input type="checkbox" id="rc-use-shipping" name="useMyShippingAddress" value="" checked=""
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: Use my shipping address
+ parseable name: useMyShippingAddress
+ field signature: 1490259836
+ form signature: 4053649612452005841"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ </div>
+ <div>
+ <label id="rc-use-shipping-label" for="rc-use-shipping">Use my shipping address</label>
+ </div>
+ </div>
+ <div id="co-useshippingAddress-summary">
+ <div>
+ </div>
+ </div>
+ <div id="rc-billing-address">
+ <div>
+ <div>
+ <label for="rc-payment-firstName">First name</label>
+ <input type="text" maxlength="20" name="billingContact.firstName" id="rc-payment-firstName" value="" autocorrect="off" autocomplete="given-name"
+title="overall type: HTML_TYPE_GIVEN_NAME
+ server type: NAME_FIRST
+ heuristic type: NAME_FIRST
+ label: First name
+ parseable name: billingContact.firstName
+ field signature: 1110479879
+ form signature: 4053649612452005841"
+autofill-prediction="HTML_TYPE_GIVEN_NAME"
+>
+ </div>
+ </div>
+ <div>
+ <div>
+ <label for="rc-payment-lastName">Last name</label>
+ <input type="text" maxlength="30" name="billingContact.lastName" id="rc-payment-lastName" value="" autocorrect="off" autocomplete="family-name"
+title="overall type: HTML_TYPE_FAMILY_NAME
+ server type: NAME_LAST
+ heuristic type: NAME_LAST
+ label: Last name
+ parseable name: billingContact.lastName
+ field signature: 724121833
+ form signature: 4053649612452005841"
+autofill-prediction="HTML_TYPE_FAMILY_NAME"
+>
+ </div>
+ </div>
+ <div>
+ <div>
+ <label for="rc-payment-line1">Address line 1</label>
+ <input type="text" maxlength="35" name="billingAddress.addressLine1" id="rc-payment-line1" value="" autocorrect="off" autocomplete="address-line1"
+title="overall type: HTML_TYPE_ADDRESS_LINE1
+ server type: ADDRESS_HOME_LINE1
+ heuristic type: ADDRESS_HOME_LINE1
+ label: Address line 1
+ parseable name: billingAddress.addressLine1
+ field signature: 3436138513
+ form signature: 4053649612452005841"
+autofill-prediction="HTML_TYPE_ADDRESS_LINE1"
+>
+ </div>
+ </div>
+ <div>
+ <div>
+ <label for="rc-payment-line2">Address line 2 (optional)</label>
+ <input type="text" maxlength="35" name="billingAddress.addressLine2" id="rc-payment-line2" autocorrect="off" autocomplete="address-line2" value=""
+title="overall type: HTML_TYPE_ADDRESS_LINE2
+ server type: ADDRESS_HOME_LINE2
+ heuristic type: ADDRESS_HOME_LINE2
+ label: Address line 2 (optional)
+ parseable name: billingAddress.addressLine2
+ field signature: 3817149136
+ form signature: 4053649612452005841"
+autofill-prediction="HTML_TYPE_ADDRESS_LINE2"
+>
+ </div>
+ </div>
+ <div>
+ <div>
+ <label for="rc-payment-city">City</label>
+ <input type="text" maxlength="25" name="billingAddress.city" id="rc-payment-city" value="" autocorrect="off" autocomplete="address-level2"
+title="overall type: HTML_TYPE_ADDRESS_LEVEL2
+ server type: ADDRESS_HOME_CITY
+ heuristic type: ADDRESS_HOME_CITY
+ label: City
+ parseable name: billingAddress.city
+ field signature: 2433902552
+ form signature: 4053649612452005841"
+autofill-prediction="HTML_TYPE_ADDRESS_LEVEL2"
+>
+ </div>
+ </div>
+ <div id="rc-paystateZipRow">
+ <div>
+ <label for="rc-payment-state">State</label>
+ <select name="billingAddress.state" id="rc-payment-state" autocomplete="address-level1"
+title="overall type: HTML_TYPE_ADDRESS_LEVEL1
+ server type: ADDRESS_HOME_STATE
+ heuristic type: ADDRESS_HOME_STATE
+ label: State
+ parseable name: billingAddress.state
+ field signature: 333754698
+ form signature: 4053649612452005841"
+autofill-prediction="HTML_TYPE_ADDRESS_LEVEL1"
+>
+ <option value="-1">Select</option>
+ <option value="AL">AL</option>
+ <option value="AK">AK</option>
+ <option value="AS">AS</option>
+ <option value="AZ">AZ</option>
+ <option value="AR">AR</option>
+ <option value="AA">AA</option>
+ <option value="AE">AE</option>
+ <option value="AP">AP</option>
+ <option value="CA">CA</option>
+ <option value="CO">CO</option>
+ <option value="CT">CT</option>
+ <option value="DE">DE</option>
+ <option value="DC">DC</option>
+ <option value="FM">FM</option>
+ <option value="FL">FL</option>
+ <option value="GA">GA</option>
+ <option value="GU">GU</option>
+ <option value="HI">HI</option>
+ <option value="ID">ID</option>
+ <option value="IL">IL</option>
+ <option value="IN">IN</option>
+ <option value="IA">IA</option>
+ <option value="KS">KS</option>
+ <option value="KY">KY</option>
+ <option value="LA">LA</option>
+ <option value="ME">ME</option>
+ <option value="MH">MH</option>
+ <option value="MD">MD</option>
+ <option value="MA">MA</option>
+ <option value="MI">MI</option>
+ <option value="MN">MN</option>
+ <option value="MS">MS</option>
+ <option value="MO">MO</option>
+ <option value="MT">MT</option>
+ <option value="NE">NE</option>
+ <option value="NV">NV</option>
+ <option value="NH">NH</option>
+ <option value="NJ">NJ</option>
+ <option value="NM">NM</option>
+ <option value="NY">NY</option>
+ <option value="NC">NC</option>
+ <option value="ND">ND</option>
+ <option value="MP">MP</option>
+ <option value="OH">OH</option>
+ <option value="OK">OK</option>
+ <option value="OR">OR</option>
+ <option value="PW">PW</option>
+ <option value="PA">PA</option>
+ <option value="PR">PR</option>
+ <option value="RI">RI</option>
+ <option value="SC">SC</option>
+ <option value="SD">SD</option>
+ <option value="TN">TN</option>
+ <option value="TX">TX</option>
+ <option value="VI">VI</option>
+ <option value="UT">UT</option>
+ <option value="VT">VT</option>
+ <option value="VA">VA</option>
+ <option value="WA">WA</option>
+ <option value="WV">WV</option>
+ <option value="WI">WI</option>
+ <option value="WY">WY</option>
+ </select>
+ </div>
+ <div>
+ <label for="rc-payment-zipCode">ZIP code</label>
+ <input type="text" maxlength="5" pattern="\d*" name="billingAddress.zipCode" id="rc-payment-zipCode" value="" autocorrect="off" autocomplete="postal-code"
+title="overall type: HTML_TYPE_POSTAL_CODE
+ server type: ADDRESS_HOME_ZIP
+ heuristic type: ADDRESS_HOME_ZIP
+ label: ZIP code
+ parseable name: billingAddress.zipCode
+ field signature: 837276327
+ form signature: 4053649612452005841"
+autofill-prediction="HTML_TYPE_POSTAL_CODE"
+>
+ </div>
+ </div>
+ </div>
+ </fieldset>
+ <div>
+ <div>
+ </div>
+ </div>
+ <div>
+ <div>
+ We'll only contact you if we have questions about this order.
+ </div>
+ </div>
+ <div>
+ <div>
+ <label for="rc-payment-phone">Phone number</label>
+ <input type="text" maxlength="14" pattern="\d*" name="billingAddress.phone" id="rc-payment-phone" value="" autocorrect="off" autocomplete="tel"
+title="overall type: HTML_TYPE_TEL
+ server type: PHONE_HOME_CITY_AND_NUMBER
+ heuristic type: PHONE_HOME_WHOLE_NUMBER
+ label: Phone number
+ parseable name: billingAddress.phone
+ field signature: 2467847771
+ form signature: 4053649612452005841"
+autofill-prediction="HTML_TYPE_TEL"
+>
+ </div>
+ </div>
+ <div>
+ <div>
+ <label for="rc-payment-email">Email address</label>
+ <input type="email" maxlength="75" name="billingContact.email" id="rc-payment-email" value="" autocapitalize="off" autocorrect="off" autocomplete="email"
+title="overall type: HTML_TYPE_EMAIL
+ server type: EMAIL_ADDRESS
+ heuristic type: EMAIL_ADDRESS
+ label: Email address
+ parseable name: billingContact.email
+ field signature: 4157735572
+ form signature: 4053649612452005841"
+autofill-prediction="HTML_TYPE_EMAIL"
+>
+ </div>
+ </div>
+ <fieldset>
+ <div id="rc-paypalcontinue-row">
+ <div>
+ <button type="button" id="rc-paypal-continue">Continue to Paypal</button>
+ </div>
+ </div>
+ <div id="rc-normalpaycontinue-row">
+ <div>
+ <button type="submit" id="rc-payment-continue">Continue</button>
+ </div>
+ </div>
+ </fieldset>
+ </form>
+ <input type="hidden" id="gmeUrl" value="https://www.googleapis.com/mapsengine/v1/tables/06739517320133004748-11853667273131550346/features?version=published&amp;key=AIzaSyCzwiHW1tSp_4FXaFuORRffbxBzQUN1qs4">
+ <input type="hidden" id="gmClientId" value="gme-macysinc">
+ <input type="hidden" id="gmeAPIKey" value="AIzaSyCzwiHW1tSp_4FXaFuORRffbxBzQUN1qs4">
+ <input type="hidden" id="gmeTableId" value="06739517320133004748-11853667273131550346">
+ <input type="hidden" id="gmeToSdpEnabled" value="true">
+ <input type="hidden" id="macysCookieDomain" value=".macys.com">
+ <input type="hidden" id="MACYS_secureHostName" value="https://www.macys.com">
+ <input type="hidden" id="MACYS_baseHostName" value="https://www.macys.com">
+ <input type="hidden" id="MACYS_assetsHostName" value="https://www.macys.com">
+ <input type="hidden" id="MACYS_imageHostName" value="/img/ts/is/image/MCY">
+ <input type="hidden" id="AKAMAI_LOGIC" value="hybrid">
+ <input type="hidden" id="searchBoxPlaceholderGuest" value="Search or enter web ID">
+ <input type="hidden" id="searchBoxPlaceholderUser" value="[userName], Search or enter web ID">
+ </body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/third_party/Macys/Checkout_ShippingAddress.html b/browser/extensions/formautofill/test/fixtures/third_party/Macys/Checkout_ShippingAddress.html
new file mode 100644
index 0000000000..7ed68344fa
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/third_party/Macys/Checkout_ShippingAddress.html
@@ -0,0 +1,439 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
+ <title>Macy's Checkout</title>
+ <meta http-equiv="generator" content="JACPKMALPHTCSJDTCR">
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
+ <meta name="format-detection" content="telephone=no">
+ <meta name="viewport" content="width=device-width">
+ </head>
+ <body>
+ <form id="rc-shipping-info-form">
+ <fieldset>
+ <fieldset id="rc-shipping-address-wrapper">
+ <div>
+ <div>
+ <label for="rc-shipping-firstName">First name</label>
+ <input type="text" maxlength="20" name="contact.firstName" id="rc-shipping-firstName" value="" autocorrect="off" autocomplete="given-name"
+title="overall type: HTML_TYPE_GIVEN_NAME
+ server type: NO_SERVER_DATA
+ heuristic type: NAME_FIRST
+ label: First name
+ parseable name: contact.firstName
+ field signature: 2682246885
+ form signature: 6571413743856647727"
+autofill-prediction="HTML_TYPE_GIVEN_NAME"
+>
+<small>Please enter a first name.</small>
+ <div>
+ <div>
+</div>
+ </div>
+ </div>
+ </div>
+ <div>
+ <div>
+ <label for="rc-shipping-lastName">Last name</label>
+ <input type="text" maxlength="30" name="contact.lastName" id="rc-shipping-lastName" value="" autocorrect="off" autocomplete="family-name"
+title="overall type: HTML_TYPE_FAMILY_NAME
+ server type: NO_SERVER_DATA
+ heuristic type: NAME_LAST
+ label: Last name
+ parseable name: contact.lastName
+ field signature: 4271897545
+ form signature: 6571413743856647727"
+autofill-prediction="HTML_TYPE_FAMILY_NAME"
+>
+ </div>
+ </div>
+ <div>
+ <div>
+ <label for="rc-shipping-line1">Address line 1</label>
+ <input type="text" maxlength="35" name="address.addressLine1" id="rc-shipping-line1" value="" autocorrect="off" autocomplete="address-line1"
+title="overall type: HTML_TYPE_ADDRESS_LINE1
+ server type: NO_SERVER_DATA
+ heuristic type: ADDRESS_HOME_LINE1
+ label: Address line 1
+ parseable name: address.addressLine1
+ field signature: 2758559035
+ form signature: 6571413743856647727"
+autofill-prediction="HTML_TYPE_ADDRESS_LINE1"
+>
+ </div>
+ </div>
+ <div>
+ <div>
+ <label for="rc-shipping-line2">Address line 2 (optional)</label>
+ <input type="text" maxlength="35" name="address.addressLine2" id="rc-shipping-line2" value="" autocorrect="off" autocomplete="address-line2" placeholder="Apt, Suite, Bldg, Floor, etc"
+title="overall type: HTML_TYPE_ADDRESS_LINE2
+ server type: NO_SERVER_DATA
+ heuristic type: ADDRESS_HOME_LINE2
+ label: Address line 2 (optional)
+ parseable name: address.addressLine2
+ field signature: 1355777065
+ form signature: 6571413743856647727"
+autofill-prediction="HTML_TYPE_ADDRESS_LINE2"
+>
+ </div>
+ </div>
+ <div>
+ <div>
+ <label for="rc-shipping-city">City</label>
+ <input type="text" maxlength="25" name="address.city" id="rc-shipping-city" value="" autocorrect="off" autocomplete="address-level2"
+title="overall type: HTML_TYPE_ADDRESS_LEVEL2
+ server type: NO_SERVER_DATA
+ heuristic type: ADDRESS_HOME_CITY
+ label: City
+ parseable name: address.city
+ field signature: 242591127
+ form signature: 6571413743856647727"
+autofill-prediction="HTML_TYPE_ADDRESS_LEVEL2"
+>
+ </div>
+ </div>
+ <div id="rc-stateZipRow">
+ <div>
+ <label for="rc-shipping-state">State</label>
+ <select name="address.state" id="rc-shipping-state" autocomplete="address-level1"
+title="overall type: HTML_TYPE_ADDRESS_LEVEL1
+ server type: NO_SERVER_DATA
+ heuristic type: ADDRESS_HOME_STATE
+ label: State
+ parseable name: address.state
+ field signature: 1305630158
+ form signature: 6571413743856647727"
+autofill-prediction="HTML_TYPE_ADDRESS_LEVEL1"
+>
+ <option value="-1">Select</option>
+ <option value="AL">AL</option>
+ <option value="AK">AK</option>
+ <option value="AS">AS</option>
+ <option value="AZ">AZ</option>
+ <option value="AR">AR</option>
+ <option value="AA">AA</option>
+ <option value="AE">AE</option>
+ <option value="AP">AP</option>
+ <option value="CA">CA</option>
+ <option value="CO">CO</option>
+ <option value="CT">CT</option>
+ <option value="DE">DE</option>
+ <option value="DC">DC</option>
+ <option value="FM">FM</option>
+ <option value="FL">FL</option>
+ <option value="GA">GA</option>
+ <option value="GU">GU</option>
+ <option value="HI">HI</option>
+ <option value="ID">ID</option>
+ <option value="IL">IL</option>
+ <option value="IN">IN</option>
+ <option value="IA">IA</option>
+ <option value="KS">KS</option>
+ <option value="KY">KY</option>
+ <option value="LA">LA</option>
+ <option value="ME">ME</option>
+ <option value="MH">MH</option>
+ <option value="MD">MD</option>
+ <option value="MA">MA</option>
+ <option value="MI">MI</option>
+ <option value="MN">MN</option>
+ <option value="MS">MS</option>
+ <option value="MO">MO</option>
+ <option value="MT">MT</option>
+ <option value="NE">NE</option>
+ <option value="NV">NV</option>
+ <option value="NH">NH</option>
+ <option value="NJ">NJ</option>
+ <option value="NM">NM</option>
+ <option value="NY">NY</option>
+ <option value="NC">NC</option>
+ <option value="ND">ND</option>
+ <option value="MP">MP</option>
+ <option value="OH">OH</option>
+ <option value="OK">OK</option>
+ <option value="OR">OR</option>
+ <option value="PW">PW</option>
+ <option value="PA">PA</option>
+ <option value="PR">PR</option>
+ <option value="RI">RI</option>
+ <option value="SC">SC</option>
+ <option value="SD">SD</option>
+ <option value="TN">TN</option>
+ <option value="TX">TX</option>
+ <option value="VI">VI</option>
+ <option value="UT">UT</option>
+ <option value="VT">VT</option>
+ <option value="VA">VA</option>
+ <option value="WA">WA</option>
+ <option value="WV">WV</option>
+ <option value="WI">WI</option>
+ <option value="WY">WY</option>
+ </select>
+ </div>
+ <div>
+ <label for="rc-shipping-postal-code">ZIP code</label>
+ <input type="text" maxlength="5" name="address.zipCode" id="rc-shipping-postal-code" value="" autocorrect="off" autocomplete="postal-code" pattern="\d*"
+title="overall type: HTML_TYPE_POSTAL_CODE
+ server type: NO_SERVER_DATA
+ heuristic type: ADDRESS_HOME_ZIP
+ label: ZIP code
+ parseable name: address.zipCode
+ field signature: 1541909938
+ form signature: 6571413743856647727"
+autofill-prediction="HTML_TYPE_POSTAL_CODE"
+>
+ </div>
+ </div>
+ <div>
+ <div>
+ <label for="rc-shipping-phone">Phone number</label>
+ <input type="text" maxlength="14" pattern="\d*" name="address.phone" id="rc-shipping-phone" autocomplete="tel" value="" autocorrect="off"
+title="overall type: HTML_TYPE_TEL
+ server type: NO_SERVER_DATA
+ heuristic type: PHONE_HOME_WHOLE_NUMBER
+ label: Phone number
+ parseable name: address.phone
+ field signature: 3405332267
+ form signature: 6571413743856647727"
+autofill-prediction="HTML_TYPE_TEL"
+>
+ </div>
+ </div>
+ </fieldset>
+ </fieldset>
+ <fieldset id="rc-shipping-method-set">
+ <div>
+ <div>
+ <legend tabindex="-1">Shipping method</legend>
+ </div>
+ </div>
+ <div>
+ <div>
+ <div>
+ <input type="radio" name="shippingMethod.methodCode" value="G" id="rc-shipping-shipping-methodG" checked="checked"
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: Standard Transit time: 3-6 business days
+ parseable name: shippingMethod.methodCode
+ field signature: 1185913307
+ form signature: 6571413743856647727"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ </div>
+ <div>
+ <label for="rc-shipping-shipping-methodG">Standard
+ <br>
+ Transit time: 3-6 business days
+ </label>
+ </div>
+ <div>
+ <div>$10.95
+ </div>
+ </div>
+ </div>
+ <div>
+ <div>
+ <input type="radio" name="shippingMethod.methodCode" value="2" id="rc-shipping-shipping-method2" disabled=""
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: Premium Transit time: 2-3 business days
+ parseable name: shippingMethod.methodCode
+ field signature: 1185913307
+ form signature: 6571413743856647727"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ </div>
+ <div>
+ <label for="rc-shipping-shipping-method2">Premium
+ <br>
+ Transit time: 2-3 business days
+ </label>
+ </div>
+ <div>
+ <div>$19.95
+ </div>
+ </div>
+ </div>
+ <div>
+ <div>
+ <input type="radio" name="shippingMethod.methodCode" value="O" id="rc-shipping-shipping-methodO" disabled=""
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: Express Transit time: 1-2 business days
+ parseable name: shippingMethod.methodCode
+ field signature: 1185913307
+ form signature: 6571413743856647727"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ </div>
+ <div>
+ <label for="rc-shipping-shipping-methodO">Express
+ <br>
+ Transit time: 1-2 business days
+ </label>
+ </div>
+ <div>
+ <div>$29.95
+ </div>
+ </div>
+ </div>
+ <div>
+ <p>
+<b>Note: We'll send you an email to schedule your delivery.</b>
+</p>
+ <p>
+<b>Note: Some items in your order may ship separately. Transit time is the time between leaving our fulfillment center &amp; delivery to you.</b>
+</p>
+ </div>
+ </div>
+ </fieldset>
+ <fieldset>
+ <div>
+ <div>
+ <legend tabindex="-1">
+ Gift Options
+ </legend>
+ </div>
+ </div>
+ <div>
+ <div>
+ <input type="checkbox" name="giftOrder" id="rc-giftoption-isgift" value="true"
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: This order contains a gift
+ parseable name: giftOrder
+ field signature: 2509042763
+ form signature: 6571413743856647727"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ <div id="aria-giftcardinfo" tabindex="-1">
+ "Selecting this checkbox will expand additional gift options"
+ </div>
+ <label for="rc-giftoption-isgift">This order contains a gift</label>
+ <input type="hidden" value="true" name="_giftOrder">
+ </div>
+ </div>
+ <div id="rc-giftoptions-additional">
+ <div>
+ <div>
+ <input type="checkbox" name="giftOptions.haveGiftMessage" value="true" id="rc-giftoption-giftMsg"
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: Gift message (optional)
+ parseable name: giftOptions.haveGiftMessage
+ field signature: 1542269083
+ form signature: 6571413743856647727"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ <label for="rc-giftoption-giftMsg">Gift message (optional)</label>
+ <input type="hidden" value="true" name="_giftOptions.haveGiftMessage">
+<br>
+ </div>
+ </div>
+ <div id="rc-giftmsg-text">
+ <div>
+ <div>
+ <span id="rc-giftmsg-additional-info">Write a personal message. We'll print it on a card &amp; send it along with the order.</span>
+ </div>
+ </div>
+ <div>
+ <div>
+ <label for="rc-gift-msg1">Message line 1 (max 45 characters)</label>
+ <input type="text" maxlength="45" name="giftOptions.senderName" id="rc-gift-msg1" value="" autocomplete="off" placeholder="Message line 1 (max 45 characters)"
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: Message line 1 (max 45 characters)
+ parseable name: giftOptions.senderName
+ field signature: 851756973
+ form signature: 6571413743856647727"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ </div>
+ </div>
+ <div>
+ <div>
+ <label for="rc-gift-msg2">Message line 2 (max 45 characters)</label>
+ <input type="text" maxlength="45" name="giftOptions.giftMessage1" id="rc-gift-msg2" value="" autocomplete="off" placeholder="Message line 2 (max 45 characters)"
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: Message line 2 (max 45 characters)
+ parseable name: giftOptions.giftMessage1
+ field signature: 893448548
+ form signature: 6571413743856647727"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ </div>
+ </div>
+ <div>
+ <div>
+ <label for="rc-gift-msg3">Message line 3 (max 45 characters)</label>
+ <input type="text" maxlength="45" name="giftOptions.giftMessage2" id="rc-gift-msg3" value="" autocomplete="off" placeholder="Message line 3 (max 45 characters)"
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: Message line 3 (max 45 characters)
+ parseable name: giftOptions.giftMessage2
+ field signature: 3147898435
+ form signature: 6571413743856647727"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ </div>
+ </div>
+ </div>
+ <div>
+ <div>
+ <input type="checkbox" name="giftOptions.packageInGiftBox" value="true" id="rc-giftoption-giftBox"
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: Send in a gift box ($6.00 per order)
+ parseable name: giftOptions.packageInGiftBox
+ field signature: 1556860808
+ form signature: 6571413743856647727"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ <label for="rc-giftoption-giftBox">Send in a gift box ($6.00 per order)</label>
+ <input type="hidden" value="true" name="_giftOptions.packageInGiftBox">
+ </div>
+ </div>
+ <div>
+ <div>
+ <input type="checkbox" name="giftOptions.sendGiftReceipt" value="true" id="rc-giftoption-packingSlip" checked="checked"
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: Hide prices on the packing slip
+ parseable name: giftOptions.sendGiftReceipt
+ field signature: 367416642
+ form signature: 6571413743856647727"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ <label for="rc-giftoption-packingSlip">Hide prices on the packing slip</label>
+ <input type="hidden" value="true" name="_giftOptions.sendGiftReceipt">
+ </div>
+ </div>
+ </div>
+ </fieldset>
+ </form>
+ <input type="hidden" id="gmeUrl" value="https://www.googleapis.com/mapsengine/v1/tables/06739517320133004748-11853667273131550346/features?version=published&amp;key=AIzaSyCzwiHW1tSp_4FXaFuORRffbxBzQUN1qs4">
+ <input type="hidden" id="gmClientId" value="gme-macysinc">
+ <input type="hidden" id="gmeAPIKey" value="AIzaSyCzwiHW1tSp_4FXaFuORRffbxBzQUN1qs4">
+ <input type="hidden" id="gmeTableId" value="06739517320133004748-11853667273131550346">
+ <input type="hidden" id="gmeToSdpEnabled" value="true">
+ <input type="hidden" id="macysCookieDomain" value=".macys.com">
+ <input type="hidden" id="MACYS_secureHostName" value="https://www.macys.com">
+ <input type="hidden" id="MACYS_baseHostName" value="https://www.macys.com">
+ <input type="hidden" id="MACYS_assetsHostName" value="https://www.macys.com">
+ <input type="hidden" id="MACYS_imageHostName" value="/img/ts/is/image/MCY">
+ <input type="hidden" id="AKAMAI_LOGIC" value="hybrid">
+ <input type="hidden" id="searchBoxPlaceholderGuest" value="Search or enter web ID">
+ <input type="hidden" id="searchBoxPlaceholderUser" value="[userName], Search or enter web ID">
+ </body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/third_party/Macys/SignIn.html b/browser/extensions/formautofill/test/fixtures/third_party/Macys/SignIn.html
new file mode 100644
index 0000000000..51dff05d04
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/third_party/Macys/SignIn.html
@@ -0,0 +1,208 @@
+<!DOCTYPE html>
+<html lang="en" id="yui_3_8_1_1_1489978541398_168">
+ <head>
+ <meta http-equiv="Content-Type" content="text/html; charset=windows-1252">
+ <title>Sign In - Macy's Checkout</title>
+ <meta http-equiv="generator" content="JACPKMALPHTCSJDTCR">
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
+ <meta name="format-detection" content="telephone=no">
+ <meta name="viewport" content="width=device-width">
+ </head>
+ <body id="yui_3_8_1_1_1489978541398_167">
+ <form id="signInForm" action="https://www.macys.com/account/signin?fromCheckout=fromCheckout" method="post">
+ <div id="yui_3_8_1_1_1489978541398_165">
+ <ul id="yui_3_8_1_1_1489978541398_164">
+ <li id="yui_3_8_1_1_1489978541398_163">
+ <div>
+ <label for="emailAddr">Email address:</label>
+ </div>
+ <div id="yui_3_8_1_1_1489978541398_162">
+ <input id="emailAddr" name="email" type="text" value="" maxlength="75">
+ </div>
+ <div>&nbsp;</div>
+ </li>
+ <li id="yui_3_8_1_1_1489978541398_175">
+ <div>
+ <label for="password">
+ Password:
+ </label>
+ </div>
+ <div id="yui_3_8_1_1_1489978541398_174">
+ <input id="password" name="password" type="password" value="" maxlength="16">
+ </div>
+ <div>&nbsp;</div>
+ </li>
+ </ul>
+ <div>
+ <div>
+<span>Password is case sensitive</span>
+</div>
+ <div>
+ <a id="frgtPwd">Forgot Your Password?</a>
+ </div>
+ <div>
+ <button type="submit" id="isnormalcheckout" name="accountSignIn">
+<span>checkout</span>
+</button>
+ </div>
+ </div>
+ </div>
+ </form>
+ <form id="emailForm" action="https://www.macys.com/account/signin?fromCheckout=fromCheckout" method="post">
+ <fieldset>
+ <ul>
+ <li id="overlaySubmitDiv">
+ <label name="" for="forgotEmail" csserrorclass="error">
+ Get started by entering the email address you use to sign in:
+ </label>
+ <input id="forgotEmail" name="passwordRecovery.email" type="text" value="">
+ </li>
+ <li id="overlaySubmitbtnDiv">
+ <button id="verifySubmitBtn" type="submit">
+<span>continue </span>
+</button>
+ </li>
+ </ul>
+ </fieldset>
+ </form>
+ <form id="capthaForm" action="https://www.macys.com/account/signin?fromCheckout=fromCheckout" method="post">
+ <fieldset>
+ <ul>
+ <li>
+ <label name="email" for="email" csserrorclass="error">
+ Your email address:
+ </label>
+ <span>
+<span id="emailID">
+</span>
+</span>
+ </li>
+ <li id="Captchapart">
+ <img id="captchaImg" alt="enter the letters in the below field">
+ <div>
+<a>
+<img id="refreshbtn" src="./Sign In - Macy&#39;s Checkout_files/new_image.gif" alt="new_image">
+</a>
+</div>
+ </li>
+ <li>
+ <label path="securityCode" for="securityCode" csserrorclass="error">
+ Please enter the characters shown above and we'll email you a link to reset your password:
+ </label>
+ <input id="securityCode" name="passwordRecovery.securityCode" type="text" value="" maxlength="50">
+ <input id="refreshCaptcha" name="passwordRecovery.refreshCaptcha" type="hidden" value="">
+ </li>
+ <li>
+ <input id="verifyCaptchaBtn" type="submit" value="submit">
+ </li>
+ </ul>
+ </fieldset>
+ </form>
+ <form id="securityQAForm" action="https://www.macys.com/account/signin?fromCheckout=fromCheckout" method="post">
+ <fieldset>
+ <ul>
+ <li>
+ <label name="email" for="email" csserrorclass="error">
+ Your email address:
+ <span>
+ <div id="secureEmailID">
+</div>
+ </span>
+ </label>
+ <input id="hiddenEmail" name="passwordRecovery.email" type="hidden" value="">
+ <input id="hiddenSecQue" name="passwordRecovery.question" type="hidden" value="">
+ </li>
+ <li>
+ <div id="question">
+</div>
+ </li>
+ <li>
+ <input id="secureAnswer" name="passwordRecovery.secureAnswer" type="text" value="">
+ </li>
+ <li>
+ <button type="submit" id="verifysecurityBtn">
+<span>continue </span>
+</button>
+ </li>
+ </ul>
+ </fieldset>
+ </form>
+ <form id="resetPasswordForm" action="https://www.macys.com/account/signin?fromCheckout=fromCheckout" method="post">
+ <fieldset>
+ <ul>
+ <li>
+ <div>
+ <div>
+ <label name="password" for="passwordfield" csserrorclass="error inline" id="resetPasswordLabel">
+ Enter new password: </label>
+ </div>
+ <div>
+ <div>
+ <input id="passwordfield" name="passwordRecovery.password"
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: Enter new password:
+ parseable name: passwordRecovery.password
+ field signature: 457433414
+ form signature: 10250648745005274367" type="password" value="" maxlength="16"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ </div>
+ <div>
+ <div>
+</div>
+ </div>
+ </div>
+ </div>
+ </li>
+ <li>
+ <div>
+ <div>
+ <label path="verifyPassword" for="verifyPassword" csserrorclass="error inline" id="confirmPasswordLabel">
+ Confirm password:
+ </label>
+ </div>
+ <div>
+ <div>
+ <input id="verifyPasswordfield" name="passwordRecovery.verifyPassword"
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: Confirm password:
+ parseable name: passwordRecovery.verifyPassword
+ field signature: 1462365960
+ form signature: 10250648745005274367" type="password" value="" maxlength="16"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ </div>
+ <div>
+ <div>
+</div>
+ </div>
+ </div>
+ </div>
+ </li>
+ <li>
+ <button id="verifyResetPasswordBtn" type="submit">
+<span>Save &amp; SignIn</span>
+</button>
+ </li>
+ </ul>
+ </fieldset>
+ </form>
+ <input type="hidden" id="gmeUrl" value="https://www.googleapis.com/mapsengine/v1/tables/06739517320133004748-11853667273131550346/features?version=published&amp;key=AIzaSyCzwiHW1tSp_4FXaFuORRffbxBzQUN1qs4">
+ <input type="hidden" id="gmClientId" value="gme-macysinc">
+ <input type="hidden" id="gmeAPIKey" value="AIzaSyCzwiHW1tSp_4FXaFuORRffbxBzQUN1qs4">
+ <input type="hidden" id="gmeTableId" value="06739517320133004748-11853667273131550346">
+ <input type="hidden" id="gmeToSdpEnabled" value="true">
+ <input type="hidden" id="macysCookieDomain" value=".macys.com">
+ <input type="hidden" id="MACYS_secureHostName" value="https://www.macys.com">
+ <input type="hidden" id="MACYS_baseHostName" value="https://www.macys.com">
+ <input type="hidden" id="MACYS_assetsHostName" value="https://www.macys.com">
+ <input type="hidden" id="MACYS_imageHostName" value="/img/ts/is/image/MCY">
+ <input type="hidden" id="AKAMAI_LOGIC" value="hybrid">
+ <input type="hidden" id="searchBoxPlaceholderGuest" value="Search or enter web ID">
+ <input type="hidden" id="searchBoxPlaceholderUser" value="[userName], Search or enter web ID">
+ </body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/third_party/NewEgg/BillingInfo.html b/browser/extensions/formautofill/test/fixtures/third_party/NewEgg/BillingInfo.html
new file mode 100644
index 0000000000..d44bc01b70
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/third_party/NewEgg/BillingInfo.html
@@ -0,0 +1,1074 @@
+<!DOCTYPE html>
+<html lang="en-us">
+ <head>
+ <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
+ <title>Newegg.com - Billing Info</title>
+ <meta name="keywords" content="Newegg.com - Once You Know, You Newegg">
+ <meta name="description" content="Newegg.com offers the best prices on computer parts, laptop computers, digital cameras, electronics and more with fast shipping and top-rated customer service. Once you know, you Newegg!">
+ <meta name="google-translate-customization" content="d08b8c829bab9a46-ac914e23c0a3607a-g7fba27d07436db8a-e">
+ <meta name="language" content="english">
+ <meta name="copyright" content="© 2000-2017 Newegg Inc.">
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
+ <meta name="robots" content="index,follow">
+ </head>
+ <body>
+ <form id="checkout" name="checkout" action="https://secure.newegg.com/GlobalShopping/CheckoutStep2.aspx?CartID=415%2b903ZRCECSZWBJE0&amp;IsCombineGuest=1" method="post" novalidate="novalidate">
+ <div>
+ Redeem Newegg gift cards<span>
+</span>
+ </div>
+ <div>
+ <div>
+ <div>
+ <div>
+ <label>Card Number</label>
+ <input type="text" name="GiftCode1"
+title="overall type: EMAIL_ADDRESS
+ server type: EMAIL_ADDRESS
+ heuristic type: UNKNOWN_TYPE
+ label: Card Number
+ parseable name: GiftCode1
+ field signature: 2868641577
+ form signature: 16660402300910943442"
+autofill-prediction="EMAIL_ADDRESS"
+>
+ <input type="text" name="GiftCode" id="GiftCode" size="20" maxlength="16" autocomplete="off"
+title="overall type: EMAIL_ADDRESS
+ server type: EMAIL_ADDRESS
+ heuristic type: UNKNOWN_TYPE
+ label: Card Number
+ parseable name: GiftCode
+ field signature: 1229209062
+ form signature: 16660402300910943442"
+autofill-prediction="EMAIL_ADDRESS"
+>
+ </div>
+ <div>
+ <label>Security Code</label>
+ <input type="password" name="ScurityCode1"
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: Security Code
+ parseable name: ScurityCode1
+ field signature: 828427756
+ form signature: 16660402300910943442"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ <input type="password" maxlength="4" name="SecurityCode" id="SecurityCode" autocomplete="off"
+title="overall type: CREDIT_CARD_VERIFICATION_CODE
+ server type: NO_SERVER_DATA
+ heuristic type: CREDIT_CARD_VERIFICATION_CODE
+ label: Security Code
+ parseable name: SecurityCode
+ field signature: 3977890449
+ form signature: 16660402300910943442"
+autofill-prediction="CREDIT_CARD_VERIFICATION_CODE"
+>
+ </div>
+ </div>
+ <div>
+ <a>
+ <i>
+</i>
+ Enter A Gift Card
+ <i>
+</i>
+ </a>
+ </div>
+ <input type="hidden" name="AllGiftCodes" id="AllGiftCodes" value="">
+ <input type="hidden" name="AllGiftPwds" id="AllGiftPwds" value="">
+ <input type="hidden" name="GiftMethodAction" id="GiftMethodAction" value="0">
+ </div>
+ <div>
+ <img
+title="EggPoints" alt="EggPoints" src="./Newegg.com - Billing Info_files/none.gif">
+ </div>
+ </div>
+ <ul>
+ <li>
+ <div id="GiftCardEmptyDiv">
+ <div id="GiftCardEmptyMsg">
+ <div>
+ <div>
+ <div>
+</div>
+ <div>
+<span>Missing Information </span>Card Number and Security Code fields cannot be empty. Please enter valid information and try again.</div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </li>
+ </ul>
+ <div>
+ Payment Methods
+ <a rel="modal"
+title="What&#39;s this?" id="PaymentMethodDisabled">
+</a>
+ <div id="PaymentMethodDisabled_Content">
+ <p>Some payment methods may not be eligible for your order. Please review the full list by clicking <a>here</a> for payment restrictions.</p>
+ </div>
+ <span>
+</span>
+ </div>
+ <div>
+ <ul>
+ <li>
+ <input type="radio" name="paymentmethod" id="NSCC"
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: Newegg Store Credit Card
+ parseable name: paymentmethod
+ field signature: 1614873398
+ form signature: 16660402300910943442"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ <label for="NSCC">
+ <strong>Newegg Store Credit Card</strong>
+ </label>
+ <div>
+ <div>
+ <div>
+ <img src="./Newegg.com - Billing Info_files/newegg-cc.png" alt="Newegg Store Credit Card">
+ <p>
+ </p>
+ </div>
+ <div>
+ <strong>Newegg Store Credit Card</strong>
+ </div>
+ </div>
+ <div>
+ <div>
+ <div>
+ <label for="StoreCard_HolderName">
+ Cardholder Name
+ <span>(exactly as shown on card)</span>
+ </label>
+ <input
+title="overall type: CREDIT_CARD_NAME_FULL
+ server type: CREDIT_CARD_NAME_FULL
+ heuristic type: CREDIT_CARD_NAME_FULL
+ label: Cardholder Name (exactly as shown on card)
+ parseable name: StoreCard_HolderName
+ field signature: 853107187
+ form signature: 16660402300910943442" type="text" maxlength="80" name="StoreCard_HolderName" id="StoreCard_HolderName" value=""
+autofill-prediction="CREDIT_CARD_NAME_FULL"
+>
+ </div>
+ <div>
+ <label for="StoreCard_Number">
+ Card Number
+ </label>
+ <input
+title="overall type: CREDIT_CARD_NUMBER
+ server type: CREDIT_CARD_NUMBER
+ heuristic type: CREDIT_CARD_NUMBER
+ label: Card Number
+ parseable name: StoreCard_Number
+ field signature: 3054139089
+ form signature: 16660402300910943442" datavalid="60345916" autocomplete="off" id="StoreCard_Number" type="text" name="StoreCard_Number" value="" focus="false"
+autofill-prediction="CREDIT_CARD_NUMBER"
+>
+ </div>
+ <div>
+ <div>
+ <label>
+ <input type="checkbox" checked="checked" name="saveNsccCard" id="saveCard"
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: Save for future orders
+ parseable name: saveNsccCard
+ field signature: 2599746278
+ form signature: 16660402300910943442"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ Save for future orders
+ </label>
+ </div>
+ </div>
+ </div>
+ <div>
+</div>
+ </div>
+ <div>Do not have a Newegg Store Credit Card? <a>Learn More</a>
+</div>
+ </div>
+ </li>
+ <li>
+ <input type="radio" name="paymentmethod" value="Amex Express Checkout" id="amex"
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: Amex Express Checkout
+ parseable name: paymentmethod
+ field signature: 1614873398
+ form signature: 16660402300910943442"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ <label for="amex">
+ <strong>Amex Express Checkout</strong>
+ </label>
+ <div>
+ <div>
+ <div>
+ <img src="./Newegg.com - Billing Info_files/AEC_PaymentMark_Blue_48x30.png" alt="Amex Express Checkout Button">
+ <p>
+ </p>
+ </div>
+ <div>
+ <strong>Checkout faster with American Express</strong>
+ </div>
+ </div>
+ </div>
+ </li>
+ <li>
+ <input type="radio" name="paymentmethod" id="bitcoin" disabled="disabled"
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: Bitcoin
+ parseable name: paymentmethod
+ field signature: 1614873398
+ form signature: 16660402300910943442"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ <label for="bitcoin">
+ <strong>Bitcoin</strong>
+ </label>
+ <div>
+ <div>
+ <div>
+ <img alt="Bitcoin accepted here"
+title="Bitcoin accepted here" src="./Newegg.com - Billing Info_files/bitcoin_logo.png">
+ <p>
+ </p>
+ </div>
+ <div>
+ <strong>Bitcoin is the safest and most secure way to pay online.</strong>
+ <ul>
+ <li>No identity theft risk; no payment information is ever stored.</li>
+ <li>Your payment completes immediately.</li>
+ <li>
+<a target="_blank">Learn more
+ </a>
+ </li>
+ </ul>
+ </div>
+ <div>
+ <div>
+ <div>
+ <div>
+</div>
+ <div>
+<span>NOTE: </span>Please note that when using Bitcoin as your payment method, once you have clicked the "bitcoin checkout now" button, you will have only 15 minutes to complete your payment. If you are unable to complete your payment, you will have two options: You can try again later to place a new order or you can change your payment method later from Order History in My Account.<br>
+<br>
+ All orders fully paid by Bitcoin are final and cannot be returned for Bitcoin or hard currency. All returns will be made in the form of a Newegg Gift Card. All returns follow our return policy.
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </li>
+ <input type="hidden" name="IsMasterPassLightBoxEnable" id="IsMasterPassLightBoxEnable" value="True">
+ <li>
+ <input type="radio" name="paymentmethod" id="masterpass"
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: MasterPass
+ parseable name: paymentmethod
+ field signature: 1614873398
+ form signature: 16660402300910943442"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ <label for="masterpass">
+ <strong>MasterPass</strong>
+ </label>
+ <div>
+ <div>
+ <div>
+ <img src="./Newegg.com - Billing Info_files/mp_mc_acc_038px_gif.gif" alt="MasterPass">
+ <p>
+ </p>
+ </div>
+ <div>
+ <strong>MasterPass is a free service that is a fast, simple and safe way to check out online. It cuts down on the time and effort it takes to buy the things you want and need. And because it's from MasterCard, you can trust that it's secure.
+ </strong>
+ <ul>
+ <li>
+ When you are ready to check out online, click on the "Buy with MasterPass" button.
+ </li>
+ <li>
+ Next, unlock your MasterPass account to choose your payment method and shipping address.
+ </li>
+ <li>
+ Then simply confirm your purchase, and you are done!
+ </li>
+ <li>
+ Use it with all major credit, debit, and prepaid cards.
+ </li>
+ <li>
+ Use it to easily store all your cards and addresses in one place.
+ </li>
+ <li>
+ Use it on all your connected devices.
+ </li>
+ </ul>
+ </div>
+ </div>
+ </div>
+ </li>
+ <li>
+ <input type="radio" name="paymentmethod" id="visacheckout"
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: Visa Checkout
+ parseable name: paymentmethod
+ field signature: 1614873398
+ form signature: 16660402300910943442"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ <label for="visacheckout">
+ <strong>Visa Checkout</strong>
+ </label>
+ <div>
+ <div>
+ <div>
+ <span id="#visacheckoutlearnmore">
+<img src="./Newegg.com - Billing Info_files/Cart_LearnMore_Pop-Up_left.png" alt="Visa Checkout">
+</span>
+ <img src="./Newegg.com - Billing Info_files/POS_horizontal_99x34.png" alt="Visa Checkout">
+ <ul>
+ <li>
+<a>
+ Learn more
+ </a>
+ </li>
+ </ul>
+ <p>
+ </p>
+ </div>
+ </div>
+ </div>
+ </li>
+ <li>
+ <input type="radio" name="paymentmethod" id="paypal"
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: PayPal
+ parseable name: paymentmethod
+ field signature: 1614873398
+ form signature: 16660402300910943442"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ <label for="paypal">
+ <strong>PayPal</strong>
+ </label>
+ <div>
+ <div>
+ <div>
+ <img src="./Newegg.com - Billing Info_files/pp-acceptance-medium.png" alt="newegg">
+ <ul>
+ <li>
+ Speed through checkout.
+ </li>
+ <li>
+ PayPal is the safer, easier way to pay.
+ </li>
+ <li>
+<a>
+ What is PayPal?
+ </a>
+ </li>
+ </ul>
+ <p>
+ </p>
+ </div>
+ <div>
+ <strong>
+ PayPal is the safer, easier way to pay
+ </strong>
+ <ul>
+ <li>
+ Never expose your credit card number.
+ </li>
+ <li>
+ Speed through checkout all over the web. One account, one password - no need to retype your shipping or financial information.
+ </li>
+ </ul>
+ </div>
+ </div>
+ </div>
+ </li>
+ <li>
+ <input type="radio" name="paymentmethod" id="creditcard"
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: Credit Card
+ parseable name: paymentmethod
+ field signature: 1614873398
+ form signature: 16660402300910943442"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ <label for="creditcard">
+ <strong>Credit Card</strong>
+ </label>
+ <div id="CreditCardArea">
+ <dl id="CardList">
+ <div id="div_cardcvv2">
+ <div>
+ <label>
+ <span>Security Code (CVV2)</span>
+ <input
+title="overall type: CREDIT_CARD_VERIFICATION_CODE
+ server type: NO_SERVER_DATA
+ heuristic type: CREDIT_CARD_VERIFICATION_CODE
+ label: Security Code (CVV2)
+ parseable name: creditCardCVV2
+ field signature: 2862210423
+ form signature: 16660402300910943442" data-msg-validatecvv2="Security Code is invalid." type="text" value="" maxlength="4" id="creditCardCVV2" autocomplete="off"
+autofill-prediction="CREDIT_CARD_VERIFICATION_CODE"
+>
+ <a
+title="What is this?" rel="modal" id="CVV2CodeHelper">
+ <img src="./Newegg.com - Billing Info_files/cvv2_sm.gif" alt="cvv2">
+</a>
+ </label>
+ </div>
+ </div>
+ <dt>
+ <input id="AddNewCareditCard" name="cardList_R" type="radio" value="new"
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: Add New Credit Card
+ parseable name: cardList_R
+ field signature: 513212207
+ form signature: 16660402300910943442"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ <div>
+ <label for="AddNewCareditCard">Add New Credit Card</label>
+ </div>
+ </dt>
+ <dd>
+ <div>
+ <div>
+ <p>
+ <strong>All major credit cards accepted:</strong>
+ <span>
+ <img id="ImgDiscover" graysrc="https://ssl-images.newegg.com/WebResource/Themes/2005/Nest/img_ccDnetwork_v1_grey.gif" src="./Newegg.com - Billing Info_files/img_ccDnetwork_v1.gif" alt="Major Cards Accepted">
+ <img id="ImgMastercard" graysrc="https://ssl-images.newegg.com/WebResource/Themes/2005/Nest/img_ccMastercard_grey.gif" src="./Newegg.com - Billing Info_files/img_ccMastercard.gif" alt="Major Cards Accepted">
+ <img id="ImgAmex" graysrc="https://ssl-images.newegg.com/WebResource/Themes/2005/Nest/img_ccAmex_grey.gif" src="./Newegg.com - Billing Info_files/img_ccAmex.gif" alt="Major Cards Accepted">
+ <img id="ImgVisa" graysrc="https://ssl-images.newegg.com/WebResource/Themes/2005/Nest/img_ccVisa_grey.gif" src="./Newegg.com - Billing Info_files/img_ccVisa.gif" alt="Major Cards Accepted">
+ </span>
+ </p>
+ </div>
+ <div>
+ <label for="Card_HolderNameNew">
+ Cardholder Name
+ <span>(exactly as shown on card)</span>
+ </label>
+ <input
+title="overall type: CREDIT_CARD_NAME_FULL
+ server type: CREDIT_CARD_NAME_FULL
+ heuristic type: CREDIT_CARD_NAME_FULL
+ label: Cardholder Name (exactly as shown on card)
+ parseable name: Card_HolderNameNew
+ field signature: 3439434497
+ form signature: 16660402300910943442" type="text" maxlength="80" name="Card_HolderNameNew" id="Card_HolderNameNew" value="" focus="false"
+autofill-prediction="CREDIT_CARD_NAME_FULL"
+>
+ </div>
+ <div>
+ <label for="Card_CCNUMBERNEW">
+ Card Number
+ </label>
+ <input
+title="overall type: CREDIT_CARD_NUMBER
+ server type: CREDIT_CARD_NUMBER
+ heuristic type: CREDIT_CARD_NUMBER
+ label: Card Number
+ parseable name: Card_CCNUMBERNEW
+ field signature: 1223289945
+ form signature: 16660402300910943442" autocomplete="off" id="Card_CCNUMBERNEW" type="text" name="Card_CCNUMBERNEW" value="" onkeyup="if (&#39;True&#39; == &#39;True&#39;) {
+ Biz.Payment.AmexPoints.OnChangeOfCardNumberForNewInGlobalCheckoutStep2();}" focus="false"
+autofill-prediction="CREDIT_CARD_NUMBER"
+>
+ <img alt="Major Cards Accepted">
+ </div>
+ <div>
+ <label for="Card_exp_monthNew">
+ Expiration Date
+ </label>
+ <select name="Card_exp_monthNew" id="Card_exp_monthNew" size="1" focus="false"
+title="overall type: CREDIT_CARD_EXP_MONTH
+ server type: CREDIT_CARD_EXP_MONTH
+ heuristic type: CREDIT_CARD_EXP_MONTH
+ label: Expiration Date
+ parseable name: Card_exp_monthNew
+ field signature: 52703496
+ form signature: 16660402300910943442"
+autofill-prediction="CREDIT_CARD_EXP_MONTH"
+>
+ <option value="Month">Month</option>
+ <option value="01">01</option>
+ <option value="02">02</option>
+ <option value="03">03</option>
+ <option value="04">04</option>
+ <option value="05">05</option>
+ <option value="06">06</option>
+ <option value="07">07</option>
+ <option value="08">08</option>
+ <option value="09">09</option>
+ <option value="10">10</option>
+ <option value="11">11</option>
+ <option value="12">12</option>
+ </select>
+ <select name="Card_exp_yearNew" id="Card_exp_yearNew" size="1" focus="false"
+title="overall type: CREDIT_CARD_EXP_4_DIGIT_YEAR
+ server type: CREDIT_CARD_EXP_4_DIGIT_YEAR
+ heuristic type: CREDIT_CARD_EXP_4_DIGIT_YEAR
+ label: Expiration Date
+ parseable name: Card_exp_yearNew
+ field signature: 3677977052
+ form signature: 16660402300910943442"
+autofill-prediction="CREDIT_CARD_EXP_4_DIGIT_YEAR"
+>
+ <option value="Year">Year</option>
+ <option value="2017">2017</option>
+ <option value="2018">2018</option>
+ <option value="2019">2019</option>
+ <option value="2020">2020</option>
+ <option value="2021">2021</option>
+ <option value="2022">2022</option>
+ <option value="2023">2023</option>
+ <option value="2024">2024</option>
+ <option value="2025">2025</option>
+ <option value="2026">2026</option>
+ <option value="2027">2027</option>
+ <option value="2028">2028</option>
+ <option value="2029">2029</option>
+ <option value="2030">2030</option>
+ </select>
+ </div>
+ <div>
+ <label for="cvv2code">
+ <span>Security Code (CVV2)</span>
+ </label>
+ <input
+title="overall type: CREDIT_CARD_VERIFICATION_CODE
+ server type: NO_SERVER_DATA
+ heuristic type: CREDIT_CARD_VERIFICATION_CODE
+ label: Security Code (CVV2)
+ parseable name: cvv2code
+ field signature: 1678191663
+ form signature: 16660402300910943442" data-msg-validatecvv2="Security Code is invalid." type="text" value="" maxlength="4" name="cvv2code" id="cvv2code" autocomplete="off"
+autofill-prediction="CREDIT_CARD_VERIFICATION_CODE"
+>
+ <a
+title="What is this?" rel="modal" id="CVV2CodeHelper">
+ <img src="./Newegg.com - Billing Info_files/cvv2_sm.gif" alt="cvv2">
+</a>
+ </div>
+ <div>
+ <div>
+ <label>
+ <input type="checkbox" checked="checked" name="saveCard" id="saveCard"
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: Save for future orders
+ parseable name: saveCard
+ field signature: 4084541912
+ form signature: 16660402300910943442"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ Save for future orders
+ </label>
+ </div>
+ <div>
+ <label>
+ Save Credit Card As:
+ <input type="text" value="untitled" maxlength="80" id="Card_PaytermLabel" name="Card_PaytermLabel"
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: Save Credit Card As:
+ parseable name: Card_PaytermLabel
+ field signature: 3910487729
+ form signature: 16660402300910943442"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ </label>
+ </div>
+ <div>
+ <label>
+ <input type="checkbox" checked="checked" name="IsNotDefault" id="makeDefaultOptionNew"
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: Set as default credit card
+ parseable name: IsNotDefault
+ field signature: 3972010007
+ form signature: 16660402300910943442"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ Set as default credit card
+ </label>
+ </div>
+ </div>
+ </div>
+ <div>
+</div>
+ <input type="hidden" name="Card_BankPhone" id="Card_BankPhone" value="888-888-8888">
+ <input type="hidden" name="Card_TransactionNumber" id="Card_TransactionNumber" value="">
+ <input type="hidden" name="Card_HolderName" id="Card_HolderName" value="">
+ <input type="hidden" name="Card_Number" id="Card_Number" value="">
+ <input type="hidden" name="ReEnter_Card_Number" id="ReEnter_Card_Number" value="">
+ <input type="hidden" name="IS_Mark_Default" id="IS_Mark_Default" value="">
+ <input type="hidden" name="Card_exp_month" id="Card_exp_month" value="">
+ <input type="hidden" name="Card_exp_year" id="Card_exp_year" value="">
+ <input type="hidden" name="Card_CVV2" id="Card_CVV2" value="">
+ <input type="hidden" name="Card_IsDefault" id="Card_IsDefault" value="">
+ <input type="hidden" name="Card_CCTYPE" id="Card_CCTYPE" value="">
+ <input type="hidden" name="IsUsedAmexSaveCard" id="IsUsedAmexSaveCard" value="false">
+ <input type="hidden" id="defalutSelectCreditCardRow" value="AddNewCareditCard">
+ <input type="hidden" name="supportCTypes" id="supportCTypes" value="4,5,6,3">
+ <input type="hidden" name="IsAMEXDistributedCard" value="False">
+ <input type="hidden" name="AMEXDistributedCard" value="">
+ <input type="hidden" name="AMEXDistributedCardVI" value="">
+ <input type="hidden" name="AMEXDistributedCardRequestID" value="">
+ <input type="hidden" id="cardNumber-1" value="">
+ </dd>
+ </dl>
+ </div>
+ </li>
+ </ul>
+ <input type="hidden" id="defaultSelectPaytermMethod" value="creditcard" readonly="readonly">
+ </div>
+ <div>
+ Billing Address
+ <span>
+</span>
+ </div>
+ <ul>
+ <li id="address-chooser">
+ <input type="checkbox" id="billingsameaddress" name="IsBilling" checked="" value="yes"
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: Same as the shipping address
+ parseable name: IsBilling
+ field signature: 1298880426
+ form signature: 16660402300910943442"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ <label for="billingsameaddress">
+ Same as the shipping address
+ </label>
+ </li>
+ <li id="same-address">
+ </li>
+ <li id="new-address">
+ <div>
+ <input type="hidden" name="STransNumber" value="0">
+ <input type="hidden" name="action" value="">
+ <input type="hidden" name="OldContactWith" value="">
+ <ul>
+ <li>
+ <label>
+ Country
+ <span>(<strong>NOTE: </strong>
+<em>Billing country cannot be different from shipping country you selected in previous step.</em>)</span>
+ </label>
+ <select name="SCountry_Option" selectcountry="USA" id="countryOption" tabindex="0" size="1"
+title="overall type: ADDRESS_HOME_COUNTRY
+ server type: ADDRESS_HOME_COUNTRY
+ heuristic type: ADDRESS_HOME_COUNTRY
+ label: Country (NOTE: Billing country cannot be different from shipping country you selected in previous st
+ parseable name: SCountry_Option
+ field signature: 3459145984
+ form signature: 16660402300910943442"
+autofill-prediction="ADDRESS_HOME_COUNTRY"
+>
+ <option value="USA">United States</option>
+ </select>
+ </li>
+ <li>
+ <label>Address</label>
+ <input
+title="overall type: ADDRESS_HOME_LINE1
+ server type: ADDRESS_HOME_LINE1
+ heuristic type: ADDRESS_HOME_LINE1
+ label: Address
+ parseable name: SAddress1
+ field signature: 328501296
+ form signature: 16660402300910943442" type="text" name="SAddress1" id="SAddress1" maxlength="100" value="" placeholder=""
+autofill-prediction="ADDRESS_HOME_LINE1"
+>
+ </li>
+ <li>
+ <label>
+ Address 2
+ <span>
+<strong>(OPTIONAL)</strong>
+<em>Apartment, suite, floor, etc.</em>
+</span>
+</label>
+ <input
+title="overall type: ADDRESS_HOME_LINE2
+ server type: ADDRESS_HOME_LINE2
+ heuristic type: ADDRESS_HOME_LINE2
+ label: Address 2 (OPTIONAL) Apartment, suite, floor, etc.
+ parseable name: SAddress2
+ field signature: 26232370
+ form signature: 16660402300910943442" type="text" name="SAddress2" id="SAddress2" maxlength="100" value="" placeholder=""
+autofill-prediction="ADDRESS_HOME_LINE2"
+>
+ </li>
+ <li>
+ <ul>
+ <li>
+ <div>
+ <label id="lblCity">City</label>
+ <input
+title="overall type: ADDRESS_HOME_CITY
+ server type: ADDRESS_HOME_CITY
+ heuristic type: ADDRESS_HOME_CITY
+ label: City
+ parseable name: SCity
+ field signature: 1173963820
+ form signature: 16660402300910943442" type="text" name="SCity" id="SCity" maxlength="45" value=""
+autofill-prediction="ADDRESS_HOME_CITY"
+>
+ </div>
+ </li>
+ <li>
+ <div>
+ <label for="stateDropDownList" id="lblState">State/Province/Region</label>
+ <div>
+ <select selectstate="CA" name="SState_Option" id="SState_Option_USA" tabindex="0" size="1"
+title="overall type: ADDRESS_HOME_STATE
+ server type: ADDRESS_HOME_STATE
+ heuristic type: ADDRESS_HOME_STATE
+ label: State/Province/Region
+ parseable name: SState_Option
+ field signature: 3077158397
+ form signature: 16660402300910943442"
+autofill-prediction="ADDRESS_HOME_STATE"
+>
+ <option value="AA">AA</option>
+ <option value="AP">AP</option>
+ <option value="AE">AE</option>
+ <option value="AL">ALABAMA</option>
+ <option value="AK">ALASKA</option>
+ <option value="AS">AMERICAN SAMOA</option>
+ <option value="AZ">ARIZONA</option>
+ <option value="AR">ARKANSAS</option>
+ <option value="CA">CALIFORNIA</option>
+ <option value="CO">COLORADO</option>
+ <option value="CT">CONNECTICUT</option>
+ <option value="DE">DELAWARE</option>
+ <option value="DC">DISTRICT OF COLUMBIA</option>
+ <option value="FL">FLORIDA</option>
+ <option value="GA">GEORGIA</option>
+ <option value="HI">HAWAII</option>
+ <option value="ID">IDAHO</option>
+ <option value="IL">ILLINOIS</option>
+ <option value="IN">INDIANA</option>
+ <option value="IA">IOWA</option>
+ <option value="KS">KANSAS</option>
+ <option value="KY">KENTUCKY</option>
+ <option value="LA">LOUISIANA</option>
+ <option value="ME">MAINE</option>
+ <option value="MD">MARYLAND</option>
+ <option value="MA">MASSACHUSETTS</option>
+ <option value="MI">MICHIGAN</option>
+ <option value="MN">MINNESOTA</option>
+ <option value="MS">MISSISSIPPI</option>
+ <option value="MO">MISSOURI</option>
+ <option value="MT">MONTANA</option>
+ <option value="NE">NEBRASKA</option>
+ <option value="NV">NEVADA</option>
+ <option value="NH">NEW HAMPSHIRE</option>
+ <option value="NJ">NEW JERSEY</option>
+ <option value="NM">NEW MEXICO</option>
+ <option value="NY">NEW YORK</option>
+ <option value="NC">NORTH CAROLINA</option>
+ <option value="ND">NORTH DAKOTA</option>
+ <option value="OH">OHIO</option>
+ <option value="OK">OKLAHOMA</option>
+ <option value="OR">OREGON</option>
+ <option value="PA">PENNSYLVANIA</option>
+ <option value="PR">PUERTO RICO</option>
+ <option value="RI">RHODE ISLAND</option>
+ <option value="SC">SOUTH CAROLINA</option>
+ <option value="SD">SOUTH DAKOTA</option>
+ <option value="TN">TENNESSEE</option>
+ <option value="TX">TEXAS</option>
+ <option value="VI">U.S. Virgin Islands</option>
+ <option value="UT">UTAH</option>
+ <option value="VT">VERMONT</option>
+ <option value="VA">VIRGINIA</option>
+ <option value="WA">WASHINGTON</option>
+ <option value="WV">WEST VIRGINIA</option>
+ <option value="WI">WISCONSIN</option>
+ <option value="WY">WYOMING</option>
+ </select>
+ </div>
+ </div>
+ <div>
+ <label id="lblZip">Zip/Postal Code</label>
+ <input
+title="overall type: ADDRESS_HOME_ZIP
+ server type: ADDRESS_HOME_ZIP
+ heuristic type: ADDRESS_HOME_ZIP
+ label: Zip/Postal Code
+ parseable name: SZip
+ field signature: 3887080504
+ form signature: 16660402300910943442" maxlength="20" size="20" type="text" name="SZip" id="SZip" value=""
+autofill-prediction="ADDRESS_HOME_ZIP"
+>
+ </div>
+ </li>
+ <li>
+ <label>
+ Phone
+ </label>
+ <input
+title="overall type: PHONE_HOME_CITY_AND_NUMBER
+ server type: PHONE_HOME_CITY_AND_NUMBER
+ heuristic type: PHONE_HOME_WHOLE_NUMBER
+ label: Phone
+ parseable name: ShippingPhone
+ field signature: 3574122515
+ form signature: 16660402300910943442" type="text" name="ShippingPhone" maxlength="30" id="ShippingPhone" value=""
+autofill-prediction="PHONE_HOME_CITY_AND_NUMBER"
+>
+ </li>
+ <li>
+ <label>
+ &nbsp;
+ </label>
+ <label>
+ </label>
+ </li>
+ <input type="hidden" id="IsDefault" name="IsDefault">
+ <input type="hidden" name="SContactWith" value="Tester Mo">
+ </ul>
+ </li>
+ </ul>
+ </div>
+ </li>
+ <input type="hidden" name="BCountry" value="USA">
+ <input type="hidden" name="BAddress1" value="331 E. Evelyn Avenue">
+ <input type="hidden" name="BAddress2" value="">
+ <input type="hidden" name="BCity" value="Mountain View">
+ <input type="hidden" name="BState" value="CA">
+ <input type="hidden" name="BZip" value="94041">
+ <input type="hidden" name="CPCPostalCodeForDisplay" value="">
+ <input type="hidden" name="IsCanadaPost" value="False">
+ <input type="hidden" name="BPhone" value="650-903-0800">
+ <input type="hidden" name="BContactWith" value="Tester Mo">
+ <input type="hidden" name="hiddenSCountry" value="USA">
+ <input type="hidden" name="BIsInPost" value="False">
+ </ul>
+ <input type="hidden" id="cfAppendix" name="cfAppendix">
+ <input type="hidden" id="isIgnoreBillingFocus" value="False">
+ <input type="hidden" name="IsReEnterCreditCardValid" id="IsReEnterCreditCardValid" value="True">
+ <input type="hidden" name="VMESuccessfulReturnInfo" id="VMESuccessfulReturnInfo">
+ <input type="hidden" name="SubmitTypeValue" id="SubmitTypeValue" value="">
+ <input type="hidden" name="lastPurchaseDate" id="lastPurchaseDate" value="1/1/0001 12:00:00 AM">
+ <input type="hidden" id="GoogleWalletMaskedRequest" name="GoogleWalletMaskedRequest" value="">
+ <input type="hidden" id="GoogleWalletMaskedResponse" name="GoogleWalletMaskedResponse">
+ <input type="hidden" name="IsEnoughGCAmount" value="0">
+ <input type="hidden" name="CustomerCardCVV2IsRequired" id="CustomerCardCVV2IsRequired" value="1">
+ <input type="hidden" name="CustomerAppendix" id="CustomerAppendix" value="+KrrFNdT/kbgvDIajN498xXVkpXHtGV4IGEz2zWZhtCaE5W/pB02+iKMnppgs68Q">
+ </form>
+ <form action="https://secure.newegg.com/Common/Ajax/SavePaymentByAjax.aspx" method="POST" id="creditCardCommandEdit_Form" name="creditCardCommandEdit_Form" novalidate="novalidate">
+ <div>
+ <div>
+</div>
+ <ul>
+ <li>
+ <label for="Card_PaytermLabel">
+ Save As
+ <span>
+ <em>(a nickname for easy recognition)</em>
+ </span>
+ </label>
+ <input type="text" value="" id="Card_PaytermLabel" name="Card_PaytermLabel" maxlength="80"
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: Save As (a nickname for easy recognition)
+ parseable name: Card_PaytermLabel
+ field signature: 3910487729
+ form signature: 15313939527407611188"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ </li>
+ <li>
+ <label for="Card_HolderName">
+ Cardholder Name
+ <span>
+ <em>(exactly as shown on card)</em>
+ </span>
+ </label>
+ <input
+title="overall type: CREDIT_CARD_NAME_FULL
+ server type: CREDIT_CARD_NAME_FULL
+ heuristic type: CREDIT_CARD_NAME_FULL
+ label: Cardholder Name (exactly as shown on card)
+ parseable name: Card_HolderName
+ field signature: 2535895036
+ form signature: 15313939527407611188" type="text" maxlength="80" name="Card_HolderName" id="Card_HolderName" focus="false"
+autofill-prediction="CREDIT_CARD_NAME_FULL"
+>
+ </li>
+ <li>
+ <label for="Card_Number">
+ Card Number
+ </label>
+ <input
+title="overall type: CREDIT_CARD_NUMBER
+ server type: CREDIT_CARD_NUMBER
+ heuristic type: CREDIT_CARD_NUMBER
+ label: Card Number
+ parseable name: Card_Number
+ field signature: 3930555619
+ form signature: 15313939527407611188" autocomplete="off" id="Card_Number" type="text" name="Card_Number" focus="false"
+autofill-prediction="CREDIT_CARD_NUMBER"
+>
+ <img id="CardImg" name="CardImg" alt="Major Cards Accepted">
+ </li>
+ <li>
+ <label for="Card_exp_month">
+ Expiration Date
+ </label>
+ <select name="Card_exp_month" id="Card_exp_month" size="1" focus="false"
+title="overall type: CREDIT_CARD_EXP_MONTH
+ server type: CREDIT_CARD_EXP_MONTH
+ heuristic type: CREDIT_CARD_EXP_MONTH
+ label: Expiration Date
+ parseable name: Card_exp_month
+ field signature: 3840696256
+ form signature: 15313939527407611188"
+autofill-prediction="CREDIT_CARD_EXP_MONTH"
+>
+ <option value="Month">Month</option>
+ <option value="01">01</option>
+ <option value="02">02</option>
+ <option value="03">03</option>
+ <option value="04">04</option>
+ <option value="05">05</option>
+ <option value="06">06</option>
+ <option value="07">07</option>
+ <option value="08">08</option>
+ <option value="09">09</option>
+ <option value="10">10</option>
+ <option value="11">11</option>
+ <option value="12">12</option>
+ </select>
+ <select name="Card_exp_year" id="Card_exp_year" size="1" focus="false"
+title="overall type: CREDIT_CARD_EXP_4_DIGIT_YEAR
+ server type: CREDIT_CARD_EXP_4_DIGIT_YEAR
+ heuristic type: CREDIT_CARD_EXP_4_DIGIT_YEAR
+ label: Expiration Date
+ parseable name: Card_exp_year
+ field signature: 185580832
+ form signature: 15313939527407611188"
+autofill-prediction="CREDIT_CARD_EXP_4_DIGIT_YEAR"
+>
+ <option value="Year">Year</option>
+ <option value="2017">2017</option>
+ <option value="2018">2018</option>
+ <option value="2019">2019</option>
+ <option value="2020">2020</option>
+ <option value="2021">2021</option>
+ <option value="2022">2022</option>
+ <option value="2023">2023</option>
+ <option value="2024">2024</option>
+ <option value="2025">2025</option>
+ <option value="2026">2026</option>
+ <option value="2027">2027</option>
+ <option value="2028">2028</option>
+ <option value="2029">2029</option>
+ <option value="2030">2030</option>
+ </select>
+ </li>
+ <li>
+ <label id="makeCCDefaultOptionNew">
+ <input type="checkbox" id="makeDefaultOptionNew" name="Card_IsDefault"
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: Set as default credit card
+ parseable name: Card_IsDefault
+ field signature: 1494055862
+ form signature: 15313939527407611188"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ Set as default credit card
+ </label>
+ </li>
+ <li>
+</li>
+ </ul>
+ </div>
+ <input type="hidden" name="IsInBillingPage" id="IsInBillingPage" value="True">
+ <input type="hidden" name="action" value="edit">
+ <input type="hidden" name="selectIndex" value="">
+ <input type="hidden" name="Card_CCTYPE" id="Card_CCTYPE" value="">
+ <input type="hidden" name="Card_BankPhone" id="Card_BankPhone" value="888-888-8888">
+ <input type="hidden" name="Card_TransactionNumber" id="Card_TransactionNumber" value="">
+ <input type="hidden" name="supportCTypes" id="supportCTypes" value="4,5,6,3">
+ </form>
+ <form action="https://secure.newegg.com/Common/Ajax/SavePaymentByAjax.aspx" method="POST" id="creditCardCommandRemove_Form" name="creditCardCommandRemove_Form" novalidate="novalidate">
+ <div>
+ <div>
+</div>
+ </div>
+ <input type="hidden" name="action" value="remove">
+ <input type="hidden" name="selectIndex" value="">
+ <input type="hidden" name="Card_TransactionNumber" value="">
+ </form>
+ <form action="https://secure.newegg.com/Common/Ajax/SavePaymentByAjax.aspx" method="POST" id="NsccCardCommandEdit_Form" name="NsccCardCommandEdit_Form" novalidate="novalidate">
+ <div>
+ <div>
+</div>
+ <ul>
+ <li>
+ <label for="Card_HolderName">
+ Cardholder Name
+ <span>
+ <em>(exactly as shown on card)</em>
+ </span>
+ </label>
+ <input
+title="Cardholder Name" type="text" maxlength="80" name="StoreCard_HolderName" id="StoreCard_HolderName" focus="false">
+ </li>
+ <li>
+ <label for="StoreCard_Number">
+ Card Number
+ </label>
+ <input
+title="Card Number" datavalid="60345916" autocomplete="off" id="StoreCard_Number" type="text" name="StoreCard_Number" focus="false">
+ </li>
+ <li>
+ </li>
+ <input type="hidden" name="action" value="nsccedit">
+ <input type="hidden" name="StoreCard_TransactionNumber" value="">
+ </ul>
+ </div>
+ </form>
+ </body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/third_party/NewEgg/Login.html b/browser/extensions/formautofill/test/fixtures/third_party/NewEgg/Login.html
new file mode 100644
index 0000000000..a5f149b683
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/third_party/NewEgg/Login.html
@@ -0,0 +1,156 @@
+<!DOCTYPE html>
+<html lang="en-us">
+ <head>
+ <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
+ <title>Newegg.com - Login</title>
+ <meta name="keywords" content="Newegg.com - Once You Know, You Newegg">
+ <meta name="description" content="Newegg.com offers the best prices on computer parts, laptop computers, digital cameras, electronics and more with fast shipping and top-rated customer service. Once you know, you Newegg!">
+ <meta name="google-translate-customization" content="d08b8c829bab9a46-ac914e23c0a3607a-g7fba27d07436db8a-e">
+ <meta name="language" content="english">
+ <meta name="copyright" content="© 2000-2017 Newegg Inc.">
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
+ <meta name="robots" content="index,follow">
+ </head>
+ <body>
+ <form method="post" action="https://secure.newegg.com/Shopping/ShoppingLogin.aspx" name="loginForm" id="loginForm" novalidate="novalidate">
+ <span id="CaptchaS">
+</span>
+ <input type="hidden" name="nextpage" value="https://secure.newegg.com/GlobalShopping/CheckoutStep1.aspx?CartID=797%2b903ZRCECSZWBJE0&amp;PaymentType=SIMPLECHECKOUT&amp;IsFromCart=1">
+ <input type="hidden" name="StuTargetPage" value="">
+ <input type="hidden" name="action" value="login">
+ <input type="hidden" name="ShoppingLoginFlag" value="0">
+ <input type="hidden" name="SPSign" value="">
+ <input type="hidden" name="LoginAppendix" value="x0kFyaA7FPQ7xpTt6JRs7tjF3jD2dY/TYhVkLYYFvo6cyOJwvqVUNlxxUjSZYUB0">
+ <ul>
+ <li>
+ <label for="UserName">
+ Newegg ID
+ <span>
+ (Typically your email address)
+ </span>
+ </label>
+ <input type="text" tabindex="5" maxlength="128" size="15"
+title="Email Address" id="UserName" name="UserName" value="">
+ </li>
+ <li>
+ <label for="UserPwd">
+ Password
+ </label>
+ <input type="password" tabindex="6" maxlength="30" size="17"
+title="Password" id="UserPwd" name="UserPwd">
+ </li>
+ <li>
+ <input type="checkbox" value="1" id="IsRemember" name="IsRemember" tabindex="8">
+<label for="IsRemember">Remember Me</label>
+ </li>
+ <li id="guestloginforgot">
+<a
+title="Forgot your Newegg ID or Password?">
+ Forgot your Newegg ID or Password?
+ </a>
+ </li>
+ <li>
+ <div>
+ <div>
+ </div>
+ <a tabindex="7" id="submit">
+ SIGN IN <span>►</span>
+ </a>
+ </div>
+ </li>
+ <li>
+ <input type="checkbox" value="1" id="IsSubscribe" name="Newsletter" tabindex="9">
+<label for="IsSubscribe">Subscribe for exclusive e-mail offers and discounts</label>
+ </li>
+ </ul>
+ <input type="hidden" id="cfAppendix" name="cfAppendix">
+ <input type="hidden" name="LoginAdditional" value="V6QdNc8GTJxKoxj5ef1cRLAlFLzcWhGcVj7od0iDpBUAd2FabNw7ApfN0g6LtbiB7EHyhgAaqpOdFbyt3NLuwPc9y1fkPs72+5qzSWHzJu19uWH+4mAefmVtLbXxTeIyGjEzvpanJrgib/dw61t1qZBot9DAigxz">
+ <input type="hidden" name="IsPrevCaptcha" value="False">
+ <input type="hidden" id="GoogleReCAPTCHA" name="GoogleReCAPTCHA" value="False">
+ <input type="hidden" id="hiddenServerDateTimeNow" value="Mon, 20 Mar 2017 03:43:47 GMT">
+ <input type="hidden" id="hiddenAllowClientTimeDiffInMinute" value="10">
+ </form>
+ <form method="post" action="https://secure.newegg.com/Shopping/ShoppingLogin.aspx" name="guestCheckOutForm" id="guestCheckOutForm" novalidate="novalidate">
+ <input type="hidden" name="action" value="guestckeckout">
+ <a rel="">CONTINUE AS A GUEST <span>►</span>
+</a>
+ </form>
+ <form name="registerForm" id="registerForm" method="post" action="https://secure.newegg.com/Shopping/ShoppingLogin.aspx" novalidate="novalidate">
+ <input type="hidden" value="https://secure.newegg.com/GlobalShopping/CheckoutStep1.aspx?CartID=797%2b903ZRCECSZWBJE0&amp;PaymentType=SIMPLECHECKOUT&amp;IsFromCart=1" name="NextPage">
+ <input type="hidden" name="ShoppingLoginFlag" value="0">
+ <input type="hidden" name="action" value="register">
+ <input type="hidden" name="SPSign" value="">
+ <input type="hidden" name="LoginAppendix" id="LoginAppendix" value="x0kFyaA7FPQ7xpTt6JRs7tjF3jD2dY/TYhVkLYYFvo6cyOJwvqVUNlxxUjSZYUB0">
+ <input type="hidden" name="IsPrevCaptcha" value="False">
+ <ul>
+ <li>
+<label for="LoginName">Email Address</label>
+<input name="LoginName" id="LoginName" tabindex="10" maxlength="128"
+title="overall type: EMAIL_ADDRESS
+ server type: EMAIL_ADDRESS
+ heuristic type: EMAIL_ADDRESS
+ label: Email Address
+ parseable name: LoginName
+ field signature: 1679789274
+ form signature: 17613573414325428514" value="" type="text"
+autofill-prediction="EMAIL_ADDRESS"
+>
+</li>
+ <li>
+<label for="Password">Password</label>
+<input name="Password" id="Password" tabindex="12" type="password"
+title="overall type: ACCOUNT_CREATION_PASSWORD
+ server type: ACCOUNT_CREATION_PASSWORD
+ heuristic type: UNKNOWN_TYPE
+ label: Password
+ parseable name: Password
+ field signature: 3935904101
+ form signature: 17613573414325428514"
+autofill-prediction="ACCOUNT_CREATION_PASSWORD"
+>
+</li>
+ <li>
+<label for="LoginName1">Confirm Email Address</label>
+<input name="LoginName1" tabindex="11" maxlength="128" id="LoginName1"
+title="overall type: EMAIL_ADDRESS
+ server type: EMAIL_ADDRESS
+ heuristic type: EMAIL_ADDRESS
+ label: Confirm Email Address
+ parseable name: LoginName1
+ field signature: 2268781658
+ form signature: 17613573414325428514" value="" type="text"
+autofill-prediction="EMAIL_ADDRESS"
+>
+</li>
+ <li>
+<label for="Password1">Confirm Password</label>
+<input name="Password1" id="Password1" tabindex="13"
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: Confirm Password
+ parseable name: Password1
+ field signature: 1108250005
+ form signature: 17613573414325428514" type="password"
+autofill-prediction="UNKNOWN_TYPE"
+>
+</li>
+ <li>
+</li>
+ <li>
+ <input id="IsSubscribe2" type="checkbox" value="1" name="Newsletter" tabindex="15" checked=""
+title="overall type: UNKNOWN_TYPE
+ server type: NO_SERVER_DATA
+ heuristic type: UNKNOWN_TYPE
+ label: Subscribe for exclusive e-mail offers and discounts
+ parseable name: Newsletter
+ field signature: 2681942707
+ form signature: 17613573414325428514"
+autofill-prediction="UNKNOWN_TYPE">
+<label for="IsSubscribe2"
+>Subscribe for exclusive e-mail offers and discounts</label>
+ </li>
+ </ul>
+ </form>
+ </body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/third_party/NewEgg/ShippingInfo.html b/browser/extensions/formautofill/test/fixtures/third_party/NewEgg/ShippingInfo.html
new file mode 100644
index 0000000000..14bd9502af
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/third_party/NewEgg/ShippingInfo.html
@@ -0,0 +1,270 @@
+<!DOCTYPE html>
+<html lang="en-us">
+ <head>
+ <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
+ <title>Newegg.com - Shipping Info</title>
+ <meta name="keywords" content="Newegg.com - Once You Know, You Newegg">
+ <meta name="description" content="Newegg.com offers the best prices on computer parts, laptop computers, digital cameras, electronics and more with fast shipping and top-rated customer service. Once you know, you Newegg!">
+ <meta name="google-translate-customization" content="d08b8c829bab9a46-ac914e23c0a3607a-g7fba27d07436db8a-e">
+ <meta name="language" content="english">
+ <meta name="copyright" content="© 2000-2017 Newegg Inc.">
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
+ <meta name="robots" content="index,follow">
+ </head>
+ <body>
+ <form id="checkout-shipping" action="https://secure.newegg.com/GlobalShopping/CheckoutStep1.aspx?CartID=415%2b903ZRCECSZWBJE0&amp;IsCombineGuest=1" method="POST" novalidate="novalidate">
+ <input type="hidden" name="SubmitFlag" value="true">
+ <input type="hidden" name="STransNumber" value="0">
+ <input type="hidden" name="action" value="">
+ <input type="hidden" name="OldContactWith" value="">
+ <ul>
+ <li>
+ <label>
+ First Name
+ </label>
+ <input validgroup="g1"
+title="overall type: NAME_FIRST
+ server type: NAME_FIRST
+ heuristic type: NAME_FIRST
+ label: First Name
+ parseable name: SFirstName
+ field signature: 3883597444
+ form signature: 7987654844929240962" type="text" name="SFirstName" id="SFirstName" maxlength="80" value=""
+autofill-prediction="NAME_FIRST" enablevalid="true"
+>
+ </li>
+ <li>
+ <label>
+ Last Name
+ </label>
+ <input validgroup="g1"
+title="overall type: NAME_LAST
+ server type: NAME_LAST
+ heuristic type: NAME_LAST
+ label: Last Name
+ parseable name: SLastName
+ field signature: 1256168210
+ form signature: 7987654844929240962" type="text" name="SLastName" id="SLastName" maxlength="80" value=""
+autofill-prediction="NAME_LAST" enablevalid="true"
+>
+ </li>
+ <li>
+ <label>
+ Country
+ </label>
+ <select name="SCountry_Option" selectcountry="USA" id="countryOption" tabindex="0" size="1"
+title="overall type: ADDRESS_HOME_COUNTRY
+ server type: ADDRESS_HOME_COUNTRY
+ heuristic type: ADDRESS_HOME_COUNTRY
+ label: Country
+ parseable name: SCountry_Option
+ field signature: 3459145984
+ form signature: 7987654844929240962"
+autofill-prediction="ADDRESS_HOME_COUNTRY"
+>
+ <option value="USA">United States</option>
+ </select>
+ </li>
+ <li>
+ <label>Address</label>
+ <input
+title="overall type: ADDRESS_HOME_LINE1
+ server type: ADDRESS_HOME_LINE1
+ heuristic type: ADDRESS_HOME_LINE1
+ label: Address
+ parseable name: SAddress1
+ field signature: 328501296
+ form signature: 7987654844929240962" type="text" name="SAddress1" id="SAddress1" maxlength="100" value="" placeholder=""
+autofill-prediction="ADDRESS_HOME_LINE1"
+>
+ </li>
+ <li>
+ <label>
+ Address 2
+ <span>
+<strong>(OPTIONAL)</strong>
+<em>Apartment, suite, floor, etc.</em>
+</span>
+</label>
+ <input
+title="overall type: ADDRESS_HOME_LINE2
+ server type: ADDRESS_HOME_LINE2
+ heuristic type: ADDRESS_HOME_LINE2
+ label: Address 2 (OPTIONAL) Apartment, suite, floor, etc.
+ parseable name: SAddress2
+ field signature: 26232370
+ form signature: 7987654844929240962" type="text" name="SAddress2" id="SAddress2" maxlength="100" value="" placeholder=""
+autofill-prediction="ADDRESS_HOME_LINE2"
+>
+ </li>
+ <li>
+ <ul>
+ <li>
+ <div>
+ <label id="lblCity">City</label>
+ <input
+title="overall type: ADDRESS_HOME_CITY
+ server type: ADDRESS_HOME_CITY
+ heuristic type: ADDRESS_HOME_CITY
+ label: City
+ parseable name: SCity
+ field signature: 1173963820
+ form signature: 7987654844929240962" type="text" name="SCity" id="SCity" maxlength="45" value=""
+autofill-prediction="ADDRESS_HOME_CITY"
+>
+ </div>
+ </li>
+ <li>
+ <div>
+ <label for="stateDropDownList" id="lblState">State/Province/Region</label>
+ <div>
+ <select selectstate="" name="SState_Option" id="SState_Option_USA" tabindex="0" size="1"
+title="overall type: ADDRESS_HOME_STATE
+ server type: ADDRESS_HOME_STATE
+ heuristic type: ADDRESS_HOME_STATE
+ label: State/Province/Region
+ parseable name: SState_Option
+ field signature: 3077158397
+ form signature: 7987654844929240962"
+autofill-prediction="ADDRESS_HOME_STATE"
+>
+ <option value="AA">AA</option>
+ <option value="AP">AP</option>
+ <option value="AE">AE</option>
+ <option value="AL">ALABAMA</option>
+ <option value="AK">ALASKA</option>
+ <option value="AS">AMERICAN SAMOA</option>
+ <option value="AZ">ARIZONA</option>
+ <option value="AR">ARKANSAS</option>
+ <option value="CA">CALIFORNIA</option>
+ <option value="CO">COLORADO</option>
+ <option value="CT">CONNECTICUT</option>
+ <option value="DE">DELAWARE</option>
+ <option value="DC">DISTRICT OF COLUMBIA</option>
+ <option value="FL">FLORIDA</option>
+ <option value="GA">GEORGIA</option>
+ <option value="HI">HAWAII</option>
+ <option value="ID">IDAHO</option>
+ <option value="IL">ILLINOIS</option>
+ <option value="IN">INDIANA</option>
+ <option value="IA">IOWA</option>
+ <option value="KS">KANSAS</option>
+ <option value="KY">KENTUCKY</option>
+ <option value="LA">LOUISIANA</option>
+ <option value="ME">MAINE</option>
+ <option value="MD">MARYLAND</option>
+ <option value="MA">MASSACHUSETTS</option>
+ <option value="MI">MICHIGAN</option>
+ <option value="MN">MINNESOTA</option>
+ <option value="MS">MISSISSIPPI</option>
+ <option value="MO">MISSOURI</option>
+ <option value="MT">MONTANA</option>
+ <option value="NE">NEBRASKA</option>
+ <option value="NV">NEVADA</option>
+ <option value="NH">NEW HAMPSHIRE</option>
+ <option value="NJ">NEW JERSEY</option>
+ <option value="NM">NEW MEXICO</option>
+ <option value="NY">NEW YORK</option>
+ <option value="NC">NORTH CAROLINA</option>
+ <option value="ND">NORTH DAKOTA</option>
+ <option value="OH">OHIO</option>
+ <option value="OK">OKLAHOMA</option>
+ <option value="OR">OREGON</option>
+ <option value="PA">PENNSYLVANIA</option>
+ <option value="PR">PUERTO RICO</option>
+ <option value="RI">RHODE ISLAND</option>
+ <option value="SC">SOUTH CAROLINA</option>
+ <option value="SD">SOUTH DAKOTA</option>
+ <option value="TN">TENNESSEE</option>
+ <option value="TX">TEXAS</option>
+ <option value="VI">U.S. Virgin Islands</option>
+ <option value="UT">UTAH</option>
+ <option value="VT">VERMONT</option>
+ <option value="VA">VIRGINIA</option>
+ <option value="WA">WASHINGTON</option>
+ <option value="WV">WEST VIRGINIA</option>
+ <option value="WI">WISCONSIN</option>
+ <option value="WY">WYOMING</option>
+ </select>
+ </div>
+ </div>
+ <div>
+ <label id="lblZip">Zip/Postal Code</label>
+ <input
+title="overall type: ADDRESS_HOME_ZIP
+ server type: ADDRESS_HOME_ZIP
+ heuristic type: ADDRESS_HOME_ZIP
+ label: Zip/Postal Code
+ parseable name: SZip
+ field signature: 3887080504
+ form signature: 7987654844929240962" maxlength="20" size="20" type="text" name="SZip" id="SZip" value=""
+autofill-prediction="ADDRESS_HOME_ZIP"
+>
+ </div>
+ </li>
+ <li>
+ <label>
+ Phone
+ </label>
+ <input
+title="overall type: PHONE_HOME_CITY_AND_NUMBER
+ server type: PHONE_HOME_CITY_AND_NUMBER
+ heuristic type: PHONE_HOME_WHOLE_NUMBER
+ label: Phone
+ parseable name: ShippingPhone
+ field signature: 3574122515
+ form signature: 7987654844929240962" type="text" name="ShippingPhone" maxlength="30" id="ShippingPhone" value=""
+autofill-prediction="PHONE_HOME_CITY_AND_NUMBER"
+>
+ </li>
+ <li>
+ <label>
+ Email
+ </label>
+ <input
+title="overall type: EMAIL_ADDRESS
+ server type: EMAIL_ADDRESS
+ heuristic type: EMAIL_ADDRESS
+ label: Email
+ parseable name: GuestEmail
+ field signature: 658796670
+ form signature: 7987654844929240962" type="text" name="GuestEmail" id="email" value="" maxlength="128"
+autofill-prediction="EMAIL_ADDRESS"
+>
+ </li>
+ <li>
+ <label>
+ <span>
+ <strong>NOTE:</strong> Your phone number is needed to contact you for shipping-related questions and your email address will be used only to send you information about your order.
+ </span>
+ <p>
+ <span>
+ Please visit our <a>Privacy Policy</a> for more information.</span>
+ </p>
+ </label>
+ </li>
+ <input type="hidden" id="IsDefault" name="IsDefault">
+ </ul>
+ </li>
+ </ul>
+ <input type="hidden" value="True" id="CheckGuestEmail" name="CheckGuestEmail">
+ </form>
+ <input type="hidden" id="FedExShippingIsDefault" value="False">
+ <input type="hidden" id="CanadaPostIsDefault" value="False">
+ <input type="hidden" id="IsBrowserBack" value="false">
+ <ul>
+ <li>
+ <input type="radio" name="shippingAddressPanel" value="creditcard" id="shippingAddressAdd">
+ <label for="shippingAddressAdd">
+ <strong>Enter a new shipping address</strong>
+ </label>
+ <div id="checkoutShippingAddPanel">
+ </div>
+ </li>
+ </ul>
+ <div>
+ <input type="checkbox" id="makeDefaultOption" name="makeDefaultOption">
+ <label for="makeDefaultOption">Set as default credit card</label>
+ </div>
+ </body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/third_party/OfficeDepot/Payment.html b/browser/extensions/formautofill/test/fixtures/third_party/OfficeDepot/Payment.html
new file mode 100644
index 0000000000..06b9f8e763
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/third_party/OfficeDepot/Payment.html
@@ -0,0 +1,672 @@
+<!DOCTYPE html>
+<html lang="en-US">
+ <head>
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
+ <meta http-equiv="X-UA-Compatible" content="IE=Edge,chrome=1">
+ <meta http-equiv="expires" content="0">
+ <meta http-equiv="pragma" content="no-cache">
+ <meta http-equiv="cache-control" content="no-cache">
+ <meta http-equiv="pragma-directive" content="no-cache">
+ <meta http-equiv="cache-directive" content="no-cache">
+ <meta name="robots" content="NOODP, NOYDIR">
+ <title>
+</title>
+ </head>
+ <body>
+ <meta name="apple-itunes-app" content="app-id=471037434">
+ <title>Office Supplies, Furniture, Technology at Offic Depot</title>
+ <meta name="description" content="Shop office supplies, furniture &amp; technology at Office Depot. For paper, ink, toner &amp; more, find trusted brands at everyday low prices.">
+ <meta name="keywords" content="office supplies, office furniture, technology, electronics">
+ <meta property="og:image" content="https://secure.www.odcdn.com/images/us/od/brand.png">
+ <form name="anonymousConfirmForm" method="post" action="https://www.officedepot.com/checkout/anonymousConfirmRouter.do" id="confirmFormId" novalidate="novalidate">
+ <input type="hidden" name="partialReg" value="false">
+ <input type="hidden" name="cartIsNotAllPickup" value="true">
+ <input type="hidden" name="step" value="bill">
+ <input type="hidden" name="orderNumber" value="914646582" id="orderNumber">
+ <input type="hidden" name="orderSubNumber" value="001" id="orderSubNumber">
+ <input type="hidden" name="linked" value="false">
+ <input type="hidden" name="billToID" value="">
+ <input type="hidden" name="requestFromPage" value="true">
+ <input type="hidden" name="nececessaryToRevalidate" id="nececessaryToRevalidate" value="false">
+ <input type="hidden" name="revalidateTrigger" id="revalidateTrigger" value="">
+ <input type="submit" name="cmd_confirm" tabindex="1">
+ <input type="hidden" name="proceedFromBill" value="true">
+ <input type="hidden" name="flowMode" id="flowMode" value="ANONYMOUS">
+ <div id="checkoutBillingV3">
+ <div>
+ <div>
+ <div>
+ <input type="hidden" name="guestEmailOptIn" value="true">
+ <input type="hidden" id="isAnonCheckout" value="true">
+ <div>
+ <input type="hidden" name="payWithSavedCard" id="payWithSavedCard" value="false">
+ <input type="hidden" id="cardTypeForMaskedValue" value="">
+ <input type="hidden" id="showCardList" value="false">
+ <div id="payWithCreditCard">
+ <div id="iFrameFields">
+ <div id="creditCardIframe">
+ <div id="cardValidationError">
+</div>
+ <div id="preventFlicker">
+ <div id="creditCardFrame">
+ <div id="iFrameExpDateFields">
+ <label>Expiration Date</label>
+ <select name="paymentFormInfo.payPageFormInfo.creditCardExpMonth"
+title="overall type: CREDIT_CARD_EXP_MONTH server type: CREDIT_CARD_EXP_MONTH heuristic type: UNKNOWN_TYPE label: Expiration Date parseable name: paymentFormInfo.payPageFormInfo.creditCardExpMonth field signature: 1909413716 form signature: 4882000530220881601" tabindex="2"
+autofill-prediction="CREDIT_CARD_EXP_MONTH"
+>
+ <option value=" ">
+ --
+ </option>
+ <option value="01">
+ 01
+ </option>
+ <option value="02">
+ 02
+ </option>
+ <option value="03">
+ 03
+ </option>
+ <option value="04">
+ 04
+ </option>
+ <option value="05">
+ 05
+ </option>
+ <option value="06">
+ 06
+ </option>
+ <option value="07">
+ 07
+ </option>
+ <option value="08">
+ 08
+ </option>
+ <option value="09">
+ 09
+ </option>
+ <option value="10">
+ 10
+ </option>
+ <option value="11">
+ 11
+ </option>
+ <option value="12">
+ 12
+ </option>
+ </select>
+ <select name="paymentFormInfo.payPageFormInfo.creditCardExpYear"
+title="overall type: CREDIT_CARD_EXP_4_DIGIT_YEAR server type: CREDIT_CARD_EXP_4_DIGIT_YEAR heuristic type: CREDIT_CARD_EXP_DATE_4_DIGIT_YEAR label: Expiration Date parseable name: paymentFormInfo.payPageFormInfo.creditCardExpYear field signature: 884603578 form signature: 4882000530220881601" tabindex="3"
+autofill-prediction="CREDIT_CARD_EXP_4_DIGIT_YEAR"
+>
+ <option value="">
+ --
+ </option>
+ <option value="17">
+ 2017
+ </option>
+ <option value="18">
+ 2018
+ </option>
+ <option value="19">
+ 2019
+ </option>
+ <option value="20">
+ 2020
+ </option>
+ <option value="21">
+ 2021
+ </option>
+ <option value="22">
+ 2022
+ </option>
+ <option value="23">
+ 2023
+ </option>
+ <option value="24">
+ 2024
+ </option>
+ <option value="25">
+ 2025
+ </option>
+ <option value="26">
+ 2026
+ </option>
+ <option value="27">
+ 2027
+ </option>
+ <option value="28">
+ 2028
+ </option>
+ <option value="29">
+ 2029
+ </option>
+ <option value="30">
+ 2030
+ </option>
+ <option value="31">
+ 2031
+ </option>
+ <option value="32">
+ 2032
+ </option>
+ <option value="33">
+ 2033
+ </option>
+ <option value="34">
+ 2034
+ </option>
+ <option value="35">
+ 2035
+ </option>
+ </select>
+ </div>
+ <div id="frameFields">
+ <input type="hidden" id="getMerchantIdPrefix" value="CKWWW">
+ <input type="hidden" id="paypageRegistrationId" name="paymentFormInfo.payPageFormInfo.payPageRegistrationId" >
+ <input type="hidden" id="bin" name="paymentFormInfo.payPageFormInfo.binRange">
+ <input type="hidden" id="merchantTxnId" name="paymentFormInfo.payPageFormInfo.merchantTxnId" value="CKWWW-201732012117">
+ <input type="hidden" id="orderId" name="paymentFormInfo.payPageFormInfo.orderId" value="914646582-001">
+ <input type="hidden" id="code" name="paymentFormInfo.payPageFormInfo.code">
+ <input type="hidden" id="responseTime" name="paymentFormInfo.payPageFormInfo.responseTime" >
+ <input type="hidden" id="message" name="paymentFormInfo.payPageFormInfo.message" size="100">
+ <input type="hidden" id="litleTxnId" name="paymentFormInfo.payPageFormInfo.litleTxnId">
+ <input type="hidden" id="type" name="paymentFormInfo.payPageFormInfo.cardType">
+ <input type="hidden" id="firstSix" name="paymentFormInfo.payPageFormInfo.firstSix">
+ <input type="hidden" id="lastFour" name="paymentFormInfo.payPageFormInfo.lastFour">
+ <input type="hidden" id="timeoutMessage" name="paymentFormInfo.payPageFormInfo.timeoutMessage" >
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div>
+ <div>
+ <label for="paymentFormInfo.tenderType" >
+ <input type="radio" name="paymentFormInfo.tenderType" value="CR" checked
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Credit Card parseable name: paymentFormInfo.tenderType field signature: 920121368 form signature: 4882000530220881601"
+autofill-prediction="UNKNOWN_TYPE"
+> Credit Card</label>
+ </div>
+ </div>
+ </div>
+ <div id="payWithPayPal">
+ <input type="hidden" name="paypalInfoInSession" id="paypalInfoInSession" value="false">
+ <div>
+ <label for="paymentFormInfo.tenderType" >
+ <input type="radio" name="paymentFormInfo.tenderType" value="PL"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Pay with PayPal You'll be redirected to the PayPal site to sign in and confirm your payment. You wil parseable name: paymentFormInfo.tenderType field signature: 920121368 form signature: 4882000530220881601"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ </label>
+ </div>
+ </div>
+ <div id="payWithMasterPass">
+ <div>
+ <label for="paymentFormInfo.tenderType" >
+ <input type="radio" name="paymentFormInfo.tenderType" value="MS"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Pay with MasterPass You'll be redirected to the MasterPass site to sign in and confirm your payment. parseable name: paymentFormInfo.tenderType field signature: 920121368 form signature: 4882000530220881601"
+autofill-prediction="UNKNOWN_TYPE"
+>
+</label>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div id="checkoutBillingAddressSection">
+ <div id="shippingInfo">
+ <input type="hidden" name="addrsForm[2].firstName" value="Tester" id="firstName-2">
+ <input type="hidden" name="addrsForm[2].lastName" value="Mo" id="lastName-2">
+ <input type="hidden" name="addrsForm[2].shiptoName" value="Mozilla" id="shiptoName-2">
+ <input type="hidden" name="addrsForm[2].address1" value="331 E. Evelyn Avenue" id="address1-2">
+ <input type="hidden" name="addrsForm[2].address2" value="" id="address2-2">
+ <input type="hidden" name="addrsForm[2].city" value="MOUNTAIN VIEW" id="city-2">
+ <input type="hidden" name="addrsForm[2].state" value="CA" id="state-2">
+ <input type="hidden" name="addrsForm[2].postalCode1" value="94041" id="postalCode1-2">
+ <input type="hidden" name="addrsForm[2].country" value="USA" id="country-2">
+ <input type="hidden" name="addrsForm[2].phoneNumber1" value="650" id="phoneNumber1-2">
+ <input type="hidden" name="addrsForm[2].phoneNumber2" value="903" id="phoneNumber2-2">
+ <input type="hidden" name="addrsForm[2].phoneNumber3" value="0800" id="phoneNumber3-2">
+ <input type="hidden" name="addrsForm[2].phoneNumber4" value="0800" id="phoneNumber4-2">
+ <input type="hidden" name="addrsForm[2].email" value="formautofilltester@gmail.com" id="email-2">
+ <input type="hidden" name="shippingEmailPreferences.optInSelected" value="true">
+ </div>
+ <div id="billingInfo">
+ <div>
+ <label>
+ <input type="checkbox" name="sameAsBilling" id="billingAddressSameAsShipping" checked tabindex="4"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Same as Shipping parseable name: sameAsBilling field signature: 2684212655 form signature: 4882000530220881601"
+autofill-prediction="UNKNOWN_TYPE"
+> Same as Shipping</label>
+ </div>
+ <div id="reg_billingInfo">
+ <div id="anonymousBillingInfoForm">
+ <input type="hidden" id="country-0" name="addrsForm[0].country" value="USA">
+ <div>
+ <div>
+ <div>
+ <div>
+ <label for="firstName-0">
+<em>*</em>First Name:</label>
+ <input type="text" id="firstName-0" name="addrsForm[0].firstName" value="" maxlength="30"
+title="overall type: CREDIT_CARD_NAME_FIRST server type: NAME_FIRST heuristic type: CREDIT_CARD_NAME_FIRST label: *First Name: parseable name: addrsForm[0].firstName field signature: 923482701 form signature: 4882000530220881601"
+autofill-prediction="CREDIT_CARD_NAME_FIRST"
+>
+ </div>
+ </div>
+ <div>
+ <div>
+ <label for="lastName-0">
+<em>*</em>Last Name:</label>
+ <input type="text" id="lastName-0" name="addrsForm[0].lastName" value="" maxlength="30"
+title="overall type: CREDIT_CARD_NAME_LAST server type: NAME_LAST heuristic type: CREDIT_CARD_NAME_LAST label: *Last Name: parseable name: addrsForm[0].lastName field signature: 3226352217 form signature: 4882000530220881601"
+autofill-prediction="CREDIT_CARD_NAME_LAST"
+>
+ </div>
+ </div>
+ </div>
+ <div>
+ <label for="shipToName-0">Company Name:</label>
+ <input type="text" id="shipToName-0" name="addrsForm[0].shiptoName" value="" maxlength="30"
+title="overall type: COMPANY_NAME server type: COMPANY_NAME heuristic type: COMPANY_NAME label: Company Name: parseable name: addrsForm[0].shiptoName field signature: 2508018426 form signature: 4882000530220881601"
+autofill-prediction="COMPANY_NAME"
+>
+ </div>
+ <div>
+ <label for="address1-0">
+<em>*</em>Address:</label>
+ <input type="text" id="address1-0" name="addrsForm[0].address1" value="" maxlength="25"
+title="overall type: ADDRESS_HOME_LINE1 server type: ADDRESS_HOME_LINE1 heuristic type: ADDRESS_HOME_LINE1 label: *Address: parseable name: addrsForm[0].address1 field signature: 3908367322 form signature: 4882000530220881601"
+autofill-prediction="ADDRESS_HOME_LINE1"
+>
+ </div>
+ <div>
+ <label for="address2-0">Address Line 2: <span>(optional)</span>
+</label>
+ <input type="text" id="address2-0" name="addrsForm[0].address2" value="" maxlength="25"
+title="overall type: ADDRESS_HOME_LINE2 server type: ADDRESS_HOME_LINE2 heuristic type: ADDRESS_HOME_LINE2 label: Address Line 2: (optional) parseable name: addrsForm[0].address2 field signature: 3866764654 form signature: 4882000530220881601"
+autofill-prediction="ADDRESS_HOME_LINE2"
+>
+ </div>
+ <div>
+ <div>
+ <div>
+ <label for="postalCode1-0">
+<em>*</em>Postal Code:</label>
+ <input type="text" id="postalCode1-0" name="addrsForm[0].postalCode1" value="" maxlength="9"
+title="overall type: ADDRESS_HOME_ZIP server type: ADDRESS_HOME_ZIP heuristic type: ADDRESS_HOME_ZIP label: *Postal Code: parseable name: addrsForm[0].postalCode1 field signature: 1708240695 form signature: 4882000530220881601"
+autofill-prediction="ADDRESS_HOME_ZIP"
+>
+ </div>
+ </div>
+ </div>
+ <div>
+ <div>
+ <div >
+ <label for="checkoutCityAndState">
+<em>*</em>City & State</label>
+ <select id="checkoutCityAndState"
+title="overall type: ADDRESS_HOME_CITY server type: NO_SERVER_DATA heuristic type: ADDRESS_HOME_CITY label: *City &amp; State parseable name: checkoutCityAndState field signature: 3899416585 form signature: 4882000530220881601"
+autofill-prediction="ADDRESS_HOME_CITY"
+>
+ </select>
+ </div>
+ </div>
+ </div>
+ <div>
+ <div>
+ <div>
+ <label for="city-0">
+<em>*</em>City:</label>
+ <input type="text" id="city-0" name="addrsForm[0].city" value=""
+title="overall type: ADDRESS_HOME_CITY server type: ADDRESS_HOME_CITY heuristic type: ADDRESS_HOME_CITY label: *City: parseable name: addrsForm[0].city field signature: 1634404945 form signature: 4882000530220881601"
+autofill-prediction="ADDRESS_HOME_CITY"
+>
+ </div>
+ </div>
+ <div>
+ <div >
+ <label for="state-0">
+<em>*</em>State:</label>
+ <select name="addrsForm[0].state" id="state-0" size="1"
+title="overall type: ADDRESS_HOME_STATE server type: ADDRESS_HOME_STATE heuristic type: ADDRESS_HOME_STATE label: *State: parseable name: addrsForm[0].state field signature: 3377657622 form signature: 4882000530220881601"
+autofill-prediction="ADDRESS_HOME_STATE"
+>
+ <option value="{blank}"> Select a state
+ </option>
+ <option value="AK"> AK - Alaska
+ </option>
+ <option value="AL"> AL - Alabama
+ </option>
+ <option value="AR"> AR - Arkansas
+ </option>
+ <option value="AZ"> AZ - Arizona
+ </option>
+ <option value="CA"> CA - California
+ </option>
+ <option value="CO"> CO - Colorado
+ </option>
+ <option value="CT"> CT - Connecticut
+ </option>
+ <option value="DC"> DC - District of Columbia
+ </option>
+ <option value="DE"> DE - Delaware
+ </option>
+ <option value="FL"> FL - Florida
+ </option>
+ <option value="GA"> GA - Georgia
+ </option>
+ <option value="HI"> HI - Hawaii
+ </option>
+ <option value="IA"> IA - Iowa
+ </option>
+ <option value="ID"> ID - Idaho
+ </option>
+ <option value="IL"> IL - Illinois
+ </option>
+ <option value="IN"> IN - Indiana
+ </option>
+ <option value="KS"> KS - Kansas
+ </option>
+ <option value="KY"> KY - Kentucky
+ </option>
+ <option value="LA"> LA - Louisiana
+ </option>
+ <option value="MA"> MA - Massachusetts
+ </option>
+ <option value="MD"> MD - Maryland
+ </option>
+ <option value="ME"> ME - Maine
+ </option>
+ <option value="MI"> MI - Michigan
+ </option>
+ <option value="MN"> MN - Minnesota
+ </option>
+ <option value="MO"> MO - Missouri
+ </option>
+ <option value="MS"> MS - Mississippi
+ </option>
+ <option value="MT"> MT - Montana
+ </option>
+ <option value="NC"> NC - North Carolina
+ </option>
+ <option value="ND"> ND - North Dakota
+ </option>
+ <option value="NE"> NE - Nebraska
+ </option>
+ <option value="NH"> NH - New Hampshire
+ </option>
+ <option value="NJ"> NJ - New Jersey
+ </option>
+ <option value="NM"> NM - New Mexico
+ </option>
+ <option value="NV"> NV - Nevada
+ </option>
+ <option value="NY"> NY - New York
+ </option>
+ <option value="OH"> OH - Ohio
+ </option>
+ <option value="OK"> OK - Oklahoma
+ </option>
+ <option value="OR"> OR - Oregon
+ </option>
+ <option value="PA"> PA - Pennsylvania
+ </option>
+ <option value="PR"> PR - Puerto Rico
+ </option>
+ <option value="RI"> RI - Rhode Island
+ </option>
+ <option value="SC"> SC - South Carolina
+ </option>
+ <option value="SD"> SD - South Dakota
+ </option>
+ <option value="TN"> TN - Tennessee
+ </option>
+ <option value="TX"> TX - Texas
+ </option>
+ <option value="UT"> UT - Utah
+ </option>
+ <option value="VA"> VA - Virginia
+ </option>
+ <option value="VI"> VI - US Virgin Islands
+ </option>
+ <option value="VT"> VT - Vermont
+ </option>
+ <option value="WA"> WA - Washington
+ </option>
+ <option value="WI"> WI - Wisconsin
+ </option>
+ <option value="WV"> WV - West Virginia
+ </option>
+ <option value="WY"> WY - Wyoming
+ </option>
+ </select>
+ </div>
+ </div>
+ </div>
+ <div>
+ <div>
+ <div>
+ <label>
+<em>*</em>Phone:</label>
+ <input type="tel" id="phoneNumber1-0" name="addrsForm[0].phoneNumber1" value="" maxlength="3"
+title="overall type: PHONE_HOME_CITY_CODE server type: PHONE_HOME_CITY_CODE heuristic type: PHONE_HOME_CITY_CODE label: *Phone: parseable name: addrsForm[0].phoneNumber1 field signature: 1254557631 form signature: 4882000530220881601"
+autofill-prediction="PHONE_HOME_CITY_CODE"
+>
+ <input type="tel" id="phoneNumber2-0" name="addrsForm[0].phoneNumber2" value="" maxlength="3"
+title="overall type: PHONE_HOME_NUMBER server type: PHONE_HOME_NUMBER heuristic type: PHONE_HOME_NUMBER label: *Phone: parseable name: addrsForm[0].phoneNumber2 field signature: 1999321122 form signature: 4882000530220881601"
+autofill-prediction="PHONE_HOME_NUMBER"
+>
+ <input type="tel" id="phoneNumber3-0" name="addrsForm[0].phoneNumber3" value="" maxlength="4"
+title="overall type: PHONE_HOME_NUMBER server type: PHONE_HOME_NUMBER heuristic type: PHONE_HOME_NUMBER label: *Phone: parseable name: addrsForm[0].phoneNumber3 field signature: 348537713 form signature: 4882000530220881601"
+autofill-prediction="PHONE_HOME_NUMBER"
+>
+ </div>
+ </div>
+ <div>
+ <div>
+ <label for="phoneNumber4-0">Ext</label>
+ <input type="tel" id="phoneNumber4-0" name="addrsForm[0].phoneNumber4" value="" maxlength="4"
+title="overall type: PHONE_HOME_NUMBER server type: PHONE_HOME_NUMBER heuristic type: PHONE_HOME_EXTENSION label: Ext parseable name: addrsForm[0].phoneNumber4 field signature: 3060772033 form signature: 4882000530220881601"
+autofill-prediction="PHONE_HOME_NUMBER"
+>
+ </div>
+ </div>
+ </div>
+ <div>
+ <div>
+ <div id="addressChangeEmailConfirm">
+ <label for="email-0">
+<em>*</em>Email Address:</label>
+ <input type="email" id="email-0" name="addrsForm[0].email" value="" maxlength="40"
+title="overall type: EMAIL_ADDRESS server type: EMAIL_ADDRESS heuristic type: EMAIL_ADDRESS label: *Email Address: parseable name: addrsForm[0].email field signature: 1389763646 form signature: 4882000530220881601"
+autofill-prediction="EMAIL_ADDRESS"
+>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div>
+ <input type="hidden" name="billingEmailPreferences.emailHtml" value="true">
+ <div>
+ <input type="hidden" name="billingEmailPreferences.optInSelected" value="true">
+<a target="_blank">Privacy Policy</a>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div>
+ <div id="checkoutBillLoyalty">
+ <div id="rewardsSections">
+ <div id="noWorkLifeRewards">
+ <div>
+ <div>
+ <div>
+ <input type="text" name="loyaltyID"
+title="overall type: PHONE_HOME_CITY_AND_NUMBER server type: PHONE_HOME_CITY_AND_NUMBER heuristic type: UNKNOWN_TYPE label: Apply parseable name: loyaltyID field signature: 1222391720 form signature: 4882000530220881601" maxlength="10" value=""
+autofill-prediction="PHONE_HOME_CITY_AND_NUMBER"
+>
+ <p>
+<a
+ >Member number lookup</a>
+ </p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div id="checkoutBillingGiftCards">
+ <div>
+ <div>
+ <div>
+ <div>
+ <div>
+ <label for="gcInput">Gift card or certificate number</label>
+ <input type="tel" name="paymentFormInfo.storedValueCardNumber" maxlength="19" value="" id="gcInput"
+title="overall type: CREDIT_CARD_NUMBER server type: CREDIT_CARD_NUMBER heuristic type: UNKNOWN_TYPE label: Gift card or certificate number parseable name: paymentFormInfo.storedValueCardNumber field signature: 2610516022 form signature: 4882000530220881601"
+autofill-prediction="CREDIT_CARD_NUMBER"
+>
+ </div>
+ </div>
+ <div>
+ <div>
+ <label for="gcPinInput">PIN</label>
+ <input type="tel" name="paymentFormInfo.storedValueCardPin" maxlength="4" value="" id="gcPinInput"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: PIN parseable name: paymentFormInfo.storedValueCardPin field signature: 3801610036 form signature: 4882000530220881601"
+autofill-prediction="UNKNOWN_TYPE">
+<img src="./Office%20Supplies,%20Furniture,%20Technology%20at%20Office%20Depot%20-%20Payment_files/info-blue.png" alt="Need Help?" title="Need Help?" id="giftCardPinTooltipTarget"
+>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div>
+ <div>
+ <input type="hidden" id="couponCount" value="0">
+ <input type="hidden" id="invalidCouponNumber" value="">
+ <input type="hidden" id="couponInWhichStep" value="checkoutV2">
+ <input type="hidden" id="couponRemove" value="Remove Coupon">
+ <input type="hidden" id="close" value="Close">
+ <input type="hidden" id="invalidCouponCode" value="&lt;p&gt;Invalid Coupon Code&lt;/p&gt;">
+ <input type="hidden" id="validCouponCode" value="&lt;p&gt;Coupon Code Applied&lt;/p&gt;">
+ <input type="hidden" id="validCouponPrompt" value="This coupon has been applied to your order.">
+ <input type="hidden" id="couponRemoveFailHeader" value="Remove Coupon Fail">
+ <input type="hidden" name="offer.x" value="y">
+ <div>
+ <div id="couponDialog"
+title="Coupon Offer">
+ <div>
+ <div>
+ <ul>
+ <li>
+ <input type="button" value="Skip and Continue">
+ </li>
+ <li>
+ <input type="button" value="See Offer Details">
+ </li>
+ </ul>
+ </div>
+ </div>
+ </div>
+ <div>
+ <div>
+ <div>
+ <label for="referralCode">Coupon Code:</label>
+ <input type="tel" name="referralCode" id="referralCode" size="14" maxlength="14"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Coupon Code: parseable name: referralCode field signature: 2314309716 form signature: 4882000530220881601"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div>
+ <input type="hidden" name="customLabelsPermissionsForm.requestor" value="confirm">
+ <input type="hidden" name="modifyFormDataWithBackendData" value="true">
+ <div id="checkoutBillingAdditionalInfo">
+ <div>
+ <div>
+ <div>
+ <div>
+ <label for="sourceCode">Catalog/source code, Federal government code <img src="./Office%20Supplies,%20Furniture,%20Technology%20at%20Office%20Depot%20-%20Payment_files/info-blue.png" alt="What is a Catalog or Source Code, Federal Government Code?"
+title="What is a Catalog or Source Code, Federal Government Code?">
+ </label>
+ <input type="text" name="sourceCode" maxlength="5" size="22" autocomplete="" value=""
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Catalog/source code, Federal government code parseable name: sourceCode field signature: 1650657570 form signature: 4882000530220881601"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ </div>
+ </div>
+ </div>
+ <div>
+ <div>
+ <div>
+ <label>Customer PO#</label>
+ <input type="text" name="poName" maxlength="22" size="22" autocomplete="" value=""
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Customer PO# parseable name: poName field signature: 330999673 form signature: 4882000530220881601"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ </div>
+ </div>
+ </div>
+ <div>
+ <div>
+ <label>Comments</label>
+ <p>
+<label>Informational purposes only. Not utilized by our delivery carriers.</label>
+ </p>
+ <textarea name="commentText" cols="30" rows="3" maxlength="87"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Comments Informational purposes only. Not utilized by our delivery carriers. parseable name: commentText field signature: 2293147818 form signature: 4882000530220881601"
+autofill-prediction="UNKNOWN_TYPE"
+>
+</textarea>
+ <span id="">87</span>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div id="paymentsButtonsMainBilling">
+ <input type="hidden" id="showPayPalCheckoutButton_toggleCheck" name="showPayPalCheckoutButton_toggleCheck" value="true">
+ <input type="hidden" id="showMasterPassCheckoutButton_toggleCheck" name="showMasterPassCheckoutButton_toggleCheck" value="true">
+ </div>
+ </div>
+ <div>
+ <div>
+ <div>
+ <div>
+ <table>
+ <tbody>
+ <tr>
+ <td colspan="4">
+ <input type="hidden" name="cartRow[0].cartEntryId" value="0">
+ <input type="hidden" name="cartRow[0].minQty" value="0">
+ <input type="hidden" name="cartRow[0].originalQty" value="0">
+ <input type="hidden" name="cartRow[0].skuNoEffort" value="510493">
+ <input type="hidden" name="cartRow[0].qtyMinimumLimitation" value="1">
+ <input type="hidden" name="cartRow[0].qtyIncrementLimitation" value="1">
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </div>
+ <div id="paymentsOrderSummary">
+ <input type="hidden" id="showPayPalCheckoutButton_toggleCheck" name="showPayPalCheckoutButton_toggleCheck" value="true">
+ <input type="hidden" id="showMasterPassCheckoutButton_toggleCheck" name="showMasterPassCheckoutButton_toggleCheck" value="true">
+ </div>
+ </div>
+ </div>
+ </div>
+ </form>
+ </body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/third_party/OfficeDepot/ShippingAddress.html b/browser/extensions/formautofill/test/fixtures/third_party/OfficeDepot/ShippingAddress.html
new file mode 100644
index 0000000000..849e3be495
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/third_party/OfficeDepot/ShippingAddress.html
@@ -0,0 +1,347 @@
+<!DOCTYPE html>
+<html lang="en-US">
+ <head>
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
+ <meta http-equiv="X-UA-Compatible" content="IE=Edge,chrome=1">
+ <meta http-equiv="expires" content="0">
+ <meta http-equiv="pragma" content="no-cache">
+ <meta http-equiv="cache-control" content="no-cache">
+ <meta http-equiv="pragma-directive" content="no-cache">
+ <meta http-equiv="cache-directive" content="no-cache">
+ <meta name="robots" content="NOODP, NOYDIR">
+ <title>
+</title>
+ </head>
+ <body>
+ <meta name="apple-itunes-app" content="app-id=471037434">
+ <title>Office Supplies, Furniture, Technology at Office Depot</title>
+ <meta name="description" content="Shop office supplies, furniture &amp; technology at Office Depot. For paper, ink, toner &amp; more, find trusted brands at everyday low prices.">
+ <meta name="keywords" content="office supplies, office furniture, technology, electronics">
+ <meta property="og:image" content="https://secure.www.odcdn.com/images/us/od/brand.png">
+ <form name="anonymousConfirmForm" method="post" action="https://www.officedepot.com/checkout/anonymousConfirmRouter.do" id="confirmFormId" novalidate="novalidate">
+ <input type="hidden" name="partialReg" value="false">
+<input type="hidden" name="cartIsNotAllPickup" value="true">
+<input type="hidden" name="returnurl" value="/checkout/checkout/anonymousConfirmRouter.do">
+ <div id="shipPageV2">
+ <div>
+ <div>
+ <div>
+ <div>
+ <div>
+ <div>
+ <div id="reg_shippingInfo">
+ <input id="skipGroup1ShippingAddress" type="hidden" name="skipGroup1ShippingAddress" value="false">
+ <div>
+ <input type="hidden" id="country-2" name="addrsForm[2].country" value="USA">
+ <div>
+ <div>
+ <div>
+ <div >
+ <label for="firstName-2" >
+ <em>*</em>First Name:</label>
+ <input type="text" id="firstName-2" name="addrsForm[2].firstName" value="" maxlength="30" tabindex="1"
+title="overall type: NAME_FIRST server type: NAME_FIRST heuristic type: NAME_FIRST label: *First Name: parseable name: addrsForm[2].firstName field signature: 3337773590 form signature: 5001876119589580889"
+autofill-prediction="NAME_FIRST"
+>
+ </div>
+ </div>
+ <div>
+ <div >
+ <label for="lastName-2">
+ <em>*</em>Last Name:</label>
+ <input type="text" id="lastName-2" name="addrsForm[2].lastName" value="" maxlength="30" tabindex="2"
+title="overall type: NAME_LAST server type: NAME_LAST heuristic type: NAME_LAST label: *Last Name: parseable name: addrsForm[2].lastName field signature: 3075576638 form signature: 5001876119589580889"
+autofill-prediction="NAME_LAST"
+>
+ </div>
+ </div>
+ </div>
+ <div>
+ <label for="shipToName-2"> Company Name:</label>
+ <input type="text" id="shipToName-2" name="addrsForm[2].shiptoName" value="" maxlength="30" tabindex="3"
+title="overall type: COMPANY_NAME server type: COMPANY_NAME heuristic type: COMPANY_NAME label: Company Name: parseable name: addrsForm[2].shiptoName field signature: 2052742641 form signature: 5001876119589580889"
+autofill-prediction="COMPANY_NAME"
+>
+ </div>
+ <div >
+ <label for="address1-2">
+ <em>*</em>Address:</label>
+ <input type="text" id="address1-2" name="addrsForm[2].address1" value="" maxlength="25" tabindex="4"
+title="overall type: ADDRESS_HOME_LINE1 server type: ADDRESS_HOME_LINE1 heuristic type: ADDRESS_HOME_LINE1 label: *Address: parseable name: addrsForm[2].address1 field signature: 2660215956 form signature: 5001876119589580889"
+autofill-prediction="ADDRESS_HOME_LINE1"
+>
+ </div>
+ <div>
+ <label for="address2-2"> Address Line 2:
+ <span>(optional)</span>
+</label>
+ <input type="text" id="address2-2" name="addrsForm[2].address2" value="" maxlength="25" tabindex="5"
+title="overall type: ADDRESS_HOME_LINE2 server type: ADDRESS_HOME_LINE2 heuristic type: ADDRESS_HOME_LINE2 label: Address Line 2: (optional) parseable name: addrsForm[2].address2 field signature: 2293911247 form signature: 5001876119589580889"
+autofill-prediction="ADDRESS_HOME_LINE2"
+>
+ </div>
+ <div>
+ <div>
+ <div >
+ <label for="postalCode1-2">
+ <em>*</em>Postal Code:</label>
+ <input type="text" id="postalCode1-2" name="addrsForm[2].postalCode1" value="" maxlength="9" tabindex="6"
+title="overall type: ADDRESS_HOME_ZIP server type: ADDRESS_HOME_ZIP heuristic type: ADDRESS_HOME_ZIP label: *Postal Code: parseable name: addrsForm[2].postalCode1 field signature: 1044898225 form signature: 5001876119589580889"
+autofill-prediction="ADDRESS_HOME_ZIP"
+>
+ </div>
+ </div>
+ </div>
+ <div>
+ <div>
+ <div >
+ <label for="checkoutCityAndState">
+<em>*</em>
+</em>City & State</label>
+ <select id="checkoutCityAndState"
+title="overall type: ADDRESS_HOME_CITY server type: NO_SERVER_DATA heuristic type: ADDRESS_HOME_CITY label: *City &amp; State parseable name: checkoutCityAndState field signature: 3899416585 form signature: 5001876119589580889"
+autofill-prediction="ADDRESS_HOME_CITY"
+>
+ <option value="0"> MOUNTAIN VIEW, CA
+ </option>
+ <option value="other"> Other City and State
+ </option>
+ </select>
+ </div>
+ </div>
+ </div>
+ <div>
+ <div>
+ <div >
+ <label for="city-2">
+ <em>*</em>City:</label>
+ <input type="text" id="city-2" name="addrsForm[2].city" value=""
+title="overall type: ADDRESS_HOME_CITY server type: ADDRESS_HOME_CITY heuristic type: ADDRESS_HOME_CITY label: *City: parseable name: addrsForm[2].city field signature: 2341281094 form signature: 5001876119589580889"
+autofill-prediction="ADDRESS_HOME_CITY"
+>
+ </div>
+ </div>
+ <div>
+ <div >
+ <label for="state-2">
+ <em>*</em>State:</label>
+ <select name="addrsForm[2].state" id="state-2" size="1"
+title="overall type: ADDRESS_HOME_STATE server type: ADDRESS_HOME_STATE heuristic type: ADDRESS_HOME_STATE label: *State: parseable name: addrsForm[2].state field signature: 3265256938 form signature: 5001876119589580889"
+autofill-prediction="ADDRESS_HOME_STATE"
+>
+ <option value="{blank}"> Select a state
+ </option>
+ <option value="AK"> AK - Alaska
+ </option>
+ <option value="AL"> AL - Alabama
+ </option>
+ <option value="AR"> AR - Arkansas
+ </option>
+ <option value="AZ"> AZ - Arizona
+ </option>
+ <option value="CA"> CA - California
+ </option>
+ <option value="CO"> CO - Colorado
+ </option>
+ <option value="CT"> CT - Connecticut
+ </option>
+ <option value="DC"> DC - District of Columbia
+ </option>
+ <option value="DE"> DE - Delaware
+ </option>
+ <option value="FL"> FL - Florida
+ </option>
+ <option value="GA"> GA - Georgia
+ </option>
+ <option value="HI"> HI - Hawaii
+ </option>
+ <option value="IA"> IA - Iowa
+ </option>
+ <option value="ID"> ID - Idaho
+ </option>
+ <option value="IL"> IL - Illinois
+ </option>
+ <option value="IN"> IN - Indiana
+ </option>
+ <option value="KS"> KS - Kansas
+ </option>
+ <option value="KY"> KY - Kentucky
+ </option>
+ <option value="LA"> LA - Louisiana
+ </option>
+ <option value="MA"> MA - Massachusetts
+ </option>
+ <option value="MD"> MD - Maryland
+ </option>
+ <option value="ME"> ME - Maine
+ </option>
+ <option value="MI"> MI - Michigan
+ </option>
+ <option value="MN"> MN - Minnesota
+ </option>
+ <option value="MO"> MO - Missouri
+ </option>
+ <option value="MS"> MS - Mississippi
+ </option>
+ <option value="MT"> MT - Montana
+ </option>
+ <option value="NC"> NC - North Carolina
+ </option>
+ <option value="ND"> ND - North Dakota
+ </option>
+ <option value="NE"> NE - Nebraska
+ </option>
+ <option value="NH"> NH - New Hampshire
+ </option>
+ <option value="NJ"> NJ - New Jersey
+ </option>
+ <option value="NM"> NM - New Mexico
+ </option>
+ <option value="NV"> NV - Nevada
+ </option>
+ <option value="NY"> NY - New York
+ </option>
+ <option value="OH"> OH - Ohio
+ </option>
+ <option value="OK"> OK - Oklahoma
+ </option>
+ <option value="OR"> OR - Oregon
+ </option>
+ <option value="PA"> PA - Pennsylvania
+ </option>
+ <option value="PR"> PR - Puerto Rico
+ </option>
+ <option value="RI"> RI - Rhode Island
+ </option>
+ <option value="SC"> SC - South Carolina
+ </option>
+ <option value="SD"> SD - South Dakota
+ </option>
+ <option value="TN"> TN - Tennessee
+ </option>
+ <option value="TX"> TX - Texas
+ </option>
+ <option value="UT"> UT - Utah
+ </option>
+ <option value="VA"> VA - Virginia
+ </option>
+ <option value="VI"> VI - US Virgin Islands
+ </option>
+ <option value="VT"> VT - Vermont
+ </option>
+ <option value="WA"> WA - Washington
+ </option>
+ <option value="WI"> WI - Wisconsin
+ </option>
+ <option value="WV"> WV - West Virginia
+ </option>
+ <option value="WY"> WY - Wyoming
+ </option>
+ </select>
+ </div>
+ </div>
+ </div>
+ <div>
+ <div>
+ <div>
+ <label>
+ <em>*</em>Phone:</label>
+ <input type="tel" id="phoneNumber1-2" name="addrsForm[2].phoneNumber1" value="" maxlength="3"
+title="overall type: PHONE_HOME_CITY_CODE server type: PHONE_HOME_CITY_CODE heuristic type: PHONE_HOME_CITY_CODE label: *Phone: parseable name: addrsForm[2].phoneNumber1 field signature: 3051888398 form signature: 5001876119589580889" tabindex="7"
+autofill-prediction="PHONE_HOME_CITY_CODE"
+>
+ <input type="tel" id="phoneNumber2-2" name="addrsForm[2].phoneNumber2" value="" maxlength="3"
+title="overall type: PHONE_HOME_NUMBER server type: PHONE_HOME_NUMBER heuristic type: PHONE_HOME_NUMBER label: *Phone: parseable name: addrsForm[2].phoneNumber2 field signature: 4001233923 form signature: 5001876119589580889" tabindex="8"
+autofill-prediction="PHONE_HOME_NUMBER"
+>
+ <input type="tel" id="phoneNumber3-2" name="addrsForm[2].phoneNumber3" value="" maxlength="4"
+title="overall type: PHONE_HOME_NUMBER server type: PHONE_HOME_NUMBER heuristic type: PHONE_HOME_NUMBER label: *Phone: parseable name: addrsForm[2].phoneNumber3 field signature: 3507119292 form signature: 5001876119589580889" tabindex="9"
+autofill-prediction="PHONE_HOME_NUMBER"
+>
+ </div>
+ </div>
+ <div>
+ <div>
+ <label for="phoneNumber4-2"> Ext</label>
+ <input type="tel" id="phoneNumber4-2" name="addrsForm[2].phoneNumber4" value="" maxlength="4" tabindex="10"
+title="overall type: PHONE_HOME_NUMBER server type: PHONE_HOME_NUMBER heuristic type: PHONE_HOME_EXTENSION label: Ext parseable name: addrsForm[2].phoneNumber4 field signature: 2592995828 form signature: 5001876119589580889"
+autofill-prediction="PHONE_HOME_NUMBER"
+>
+ </div>
+ </div>
+ </div>
+ <div>
+ <div>
+ <div id="addressChangeEmailConfirm" >
+ <label for="email-2">
+ <em>*</em>Email Address:</label>
+ <input type="email" id="email-2" name="addrsForm[2].email" value="" maxlength="40" tabindex="11"
+title="overall type: EMAIL_ADDRESS server type: EMAIL_ADDRESS heuristic type: EMAIL_ADDRESS label: *Email Address: parseable name: addrsForm[2].email field signature: 3353412459 form signature: 5001876119589580889"
+autofill-prediction="EMAIL_ADDRESS"
+>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div>
+ <input type="checkbox" id="guestEmailOptIn" name="guestEmailOptIn" checked="checked" tabindex="12"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Send me exclusive coupons and special offers to my inbox. parseable name: guestEmailOptIn field signature: 2624588538 form signature: 5001876119589580889"
+autofill-prediction="UNKNOWN_TYPE">
+<input type="hidden" name="guestEmailOptIn" value="false">
+<label for="guestEmailOptIn"
+>Send me exclusive coupons and special offers to my inbox.</label>
+ </div>
+ <input type="hidden" name="shippingEmailPreferences.emailHtml" value="true">
+<input type="hidden" name="shippingEmailPreferences.optInSelected" value="true">
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div>
+ <div>
+ <div>
+ <div>
+ <table>
+ <tbody>
+ <tr>
+ <td colspan="4">
+<input type="hidden" name="cartRow[0].cartEntryId" value="0">
+<input type="hidden" name="cartRow[0].minQty" value="0">
+ <input type="hidden" name="cartRow[0].originalQty" value="0">
+<input type="hidden" name="cartRow[0].skuNoEffort" value="510493">
+<input type="hidden" name="cartRow[0].qtyMinimumLimitation" value="1">
+<input type="hidden" name="cartRow[0].qtyIncrementLimitation" value="1">
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <input type="hidden" name="step" value="ship">
+<input type="hidden" name="orderNumber" value="914646582">
+<input type="hidden" name="orderSubNumber" value="001">
+<input type="hidden" name="sameAsBilling" value="true">
+ <input type="hidden" name="linked" value="false">
+<input type="hidden" name="billToID" value="">
+<input type="hidden" name="nececessaryToRevalidate" id="nececessaryToRevalidate" value="false">
+ <input type="hidden" name="revalidateTrigger" id="revalidateTrigger" value="">
+<input type="hidden" name="group1Error" id="group1Error" value="false">
+<input type="hidden" name="flowMode" id="flowMode" value="ANONYMOUS">
+ <div id="skipGroupOne">
+ <div>
+ <input type="submit" value="Edit" name="cmd_edit" id="editShipping"
+title="Edit">
+<button type="submit" name="cmd_confirm" id="continue">Continue</button>
+ </div>
+ </div>
+ </div>
+ </form>
+ <input type="hidden" name="enableGoogleAddr" id="enableGoogleAddr" value="true">
+ </body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/third_party/OfficeDepot/SignIn.html b/browser/extensions/formautofill/test/fixtures/third_party/OfficeDepot/SignIn.html
new file mode 100644
index 0000000000..70b55feddd
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/third_party/OfficeDepot/SignIn.html
@@ -0,0 +1,44 @@
+<!DOCTYPE html>
+<html lang="en-US">
+ <head>
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
+ <meta http-equiv="X-UA-Compatible" content="IE=Edge,chrome=1">
+ <meta http-equiv="expires" content="0">
+ <meta http-equiv="pragma" content="no-cache">
+ <meta http-equiv="cache-control" content="no-cache">
+ <meta http-equiv="pragma-directive" content="no-cache">
+ <meta http-equiv="cache-directive" content="no-cache">
+ <meta name="robots" content="NOODP, NOYDIR">
+ <title>
+</title>
+ </head>
+ <body>
+ <meta name="apple-itunes-app" content="app-id=471037434">
+ <title>Office Supplies, Furniture, Technology at Office Depot</title>
+ <meta name="description" content="Shop office supplies, furniture &amp; technology at Office Depot. For paper, ink, toner &amp; more, find trusted brands at everyday low prices.">
+ <meta name="keywords" content="office supplies, office furniture, technology, electronics">
+ <meta property="og:image" content="https://secure.www.odcdn.com/images/us/od/brand.png">
+ <form name="loginForm" method="post" action="https://www.officedepot.com/account/loginAccountSet.do" autocomplete="off" id="loginForm">
+ <input type="hidden" name="confirmationRequired" value="false">
+ <input type="hidden" name="requestor" value="accountSummary">
+ <input type="hidden" name="loginDestination" value="">
+ <input type="hidden" id="isLoginFromRewardsModal" name="isLoginFromRewardsModal">
+ <input type="hidden" name="reqLevel" value="ACCOUNT">
+ <div>
+ <label for="loginName-0">Login name or email address</label>
+ <label>Logging in as a different user may cause pricing changes</label>
+ <input type="text" name="loginName" maxlength="100" size="10" autocomplete="" value="" id="loginName-0" tabindex="1">
+ </div>
+ <div>
+ <label for="loginPassword">Password</label>
+ <input type="password" name="password" maxlength="50" size="10" value="" id="loginPassword" tabindex="2">
+ <span id="forgotPasswordLink">
+<a >Forgot login name/password?</a>
+</span>
+ </div>
+ <div>
+ <input type="checkbox" name="autoLogin" tabindex="3" value="on"> Keep me logged in
+ </div>
+ </form>
+ </body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/third_party/QVC/PaymentMethod.html b/browser/extensions/formautofill/test/fixtures/third_party/QVC/PaymentMethod.html
new file mode 100644
index 0000000000..d8878692c3
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/third_party/QVC/PaymentMethod.html
@@ -0,0 +1,527 @@
+<html xmlns="http://www.w3.org/1999/xhtml">
+ <head>
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
+ <meta http-equiv="X-UA-Compatible" content="IE=edge" />
+ <meta property="fb:app_id" content="105047196722" />
+ <meta property="fb:page_id" content="23797290954" />
+ <meta name="format-detection" content="telephone=no" />
+ <meta name="viewport" content="width=919" />
+ <title>Payment Method</title>
+ </head>
+ <body>
+ <form id="frmEditPaymentMethod" name="frmEditPaymentMethod" method="post" action="https://www.qvc.com/webapp/wcs/stores/servlet/NPOOrderAddPaymentMethods">
+ <input type="hidden" name="csrfToken" value="M1E1cTBwME9saUVZaU9iNUdJVnZqSm9JQThHM3gwSUt0elROSE9oSDJaST0=" />
+ <input type="hidden" name="ccId" value="" />
+ <input type="hidden" name="payMethodExpireMonth" value="" />
+ <input type="hidden" name="payMethodExpireYear" value="" />
+ <input type="hidden" name="ccDtime" value="" />
+ <input type="hidden" name="payMethodNewCard" value="N" />
+ <input type="hidden" name="payMethodExpiryEdited" value="N" />
+ <input type="hidden" name="payMethodCVV" value="" />
+ <input type="hidden" name="orderId" value="476661567" />
+ <input type="hidden" name="langId" value="-1" />
+ <input type="hidden" name="catalogId" value="10151" />
+ <input type="hidden" name="storeId" value="10251" />
+ <input type="hidden" name="dummydata" value="" />
+ <input type="hidden" id="currentPaymentMethod" name="currentPaymentMethod" value="VI" />
+ <input type="hidden" name="checkoutStep" value="3" />
+ <input type="hidden" name="fromPaymentPage" value="Y" />
+ <input type="hidden" name="URL" value="" />
+ <input type="hidden" id="addAnotherGC" name="addAnotherGC" value="N" />
+ <input type="hidden" name="BMLFilePath" id="BMLFilePath" value="https://www.qvc.com/wcsstore/US/content/html/popups/BillMeLaterTermsandConditions.html" />
+ <div id="divTotalPurchaseSummary">
+ <fieldset>
+ <label id="lblTotalPurchaseAmount" for="spanTotalPurchaseAmount">Total Purchase
+ <span>(including tax and S&amp;H)</span>:</label>
+ </fieldset>
+ </div>
+ <div id="divEasyPayOptions">
+ <fieldset>
+ <ul>
+ <li>
+ <input type="radio" value="Z" id="rb1PaymentsItem_1" name="rbItemEasyPayOption_1"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: 4 Easy Pays of $39.74 parseable name: rbItemEasyPayOption_1 field signature: 915844214 form signature: 12190733459375771907"
+autofill-prediction="UNKNOWN_TYPE"
+/>
+ <label for="rb1PaymentsItem_1">4 Easy Pays of $39.74</label>
+ </li>
+ <li>
+ <input type="radio" value="N" id="rb2PaymentsItem_1" name="rbItemEasyPayOption_1"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: 1 payment of $158.96 parseable name: rbItemEasyPayOption_1 field signature: 915844214 form signature: 12190733459375771907"
+autofill-prediction="UNKNOWN_TYPE"
+/>
+ <label for="rb2PaymentsItem_1">1 payment of $158.96</label>
+ </li>
+ </ul>
+ <input type="hidden" name="hItemEasyPayOptionPayMthd_1" id="hItemEasyPayOptionPayMthd_1" value="QVAXCBDCSIMCVIBL " />
+ <input type="hidden" name="orderItemId_1" id="orderItemId_1" value="660521668" />
+ </fieldset>
+ </div>
+ <div id="divNewPaymentMethod">
+ <input id="ccId_1" type="hidden" name="ccId_1" value="" />
+ <table id="tblNewPaymentMethod" border="0" cellspacing="0" cellpadding="0" width="100%" summary="Payment methods available for Checkout">
+ <tbody>
+ <tr id="trBillMeLaterRow-1">
+ <td>
+ <input type="radio" name="rbNewPaymentMethod" id="rbBillLater" value="rbBillLater"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: PayPal Credit parseable name: rbNewPaymentMethod field signature: 95492298 form signature: 12190733459375771907"
+autofill-prediction="UNKNOWN_TYPE"
+/>
+ <label for="rbBillLater">PayPal Credit</label>
+ <span>(formerly Bill Me Later®)</span>
+ </td>
+ </tr>
+ <tr id="trBillMeLaterRow-2">
+ <td colspan="5">
+ <div id="divBillMeLater">
+ <fieldset>
+ <label id="lblPrimaryPhone" for="txtPrimaryPhone">Home Phone:</label>
+ <input id="txtPrimaryPhone" type="tel" name="txtPrimaryPhone" autocomplete="off" autocorrect="off" value="" maxlength="14" size="14"
+title="overall type: PHONE_HOME_CITY_AND_NUMBER server type: PHONE_HOME_CITY_AND_NUMBER heuristic type: PHONE_HOME_WHOLE_NUMBER label: Home Phone: parseable name: txtPrimaryPhone field signature: 918983855 form signature: 12190733459375771907"
+autofill-prediction="PHONE_HOME_CITY_AND_NUMBER"
+/>
+ </fieldset>
+ <fieldset>
+ <label id="lblEmailAddress" for="txtEmailAddress">Email Address:</label>
+ <input id="txtEmailAddress" type="email" name="txtEmailAddress" autocomplete="on" autocorrect="off" value=""
+title="overall type: EMAIL_ADDRESS server type: EMAIL_ADDRESS heuristic type: EMAIL_ADDRESS label: Email Address: parseable name: txtEmailAddress field signature: 653947670 form signature: 12190733459375771907"
+autofill-prediction="EMAIL_ADDRESS"
+/>
+ </fieldset>
+ <fieldset>
+ <label id="lblSsn" for="txtSsn">Social Security Number:</label>XXX-XX-<input id="txtSsn" type="text" pattern="[0-9]*" autocomplete="off" autocorrect="off" maxlength="4" size="4" name="txtLast4SSN" value=""
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Social Security Number: parseable name: txtLast4SSN field signature: 598258955 form signature: 12190733459375771907"
+autofill-prediction="UNKNOWN_TYPE"
+/>
+ </fieldset>
+ <fieldset>
+ <label id="lblDateOfBirthMonth" for="selDobMonth">Date of Birth:</label>
+ <select id="selDobMonth" name="dobMonth" size="1"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Date of Birth: parseable name: dobMonth field signature: 3916402925 form signature: 12190733459375771907"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ <option value="month" selected="selected">Month</option>
+ <option value="01">January</option>
+ <option value="02">February</option>
+ <option value="03">March</option>
+ <option value="04">April</option>
+ <option value="05">May</option>
+ <option value="06">June</option>
+ <option value="07">July</option>
+ <option value="08">August</option>
+ <option value="09">September</option>
+ <option value="10">October</option>
+ <option value="11">November</option>
+ <option value="12">December</option>
+ </select>
+ <label id="lblDateOfBirthDay" for="selDobDay">Day of Birth:</label>
+ <select id="selDobDay" name="dobDay" size="1"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Day of Birth: parseable name: dobDay field signature: 4127787517 form signature: 12190733459375771907"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ <option value="day" selected="selected">Day</option>
+ <option value="1">1</option>
+ <option value="2">2</option>
+ <option value="3">3</option>
+ <option value="4">4</option>
+ <option value="5">5</option>
+ <option value="6">6</option>
+ <option value="7">7</option>
+ <option value="8">8</option>
+ <option value="9">9</option>
+ <option value="10">10</option>
+ <option value="11">11</option>
+ <option value="12">12</option>
+ <option value="13">13</option>
+ <option value="14">14</option>
+ <option value="15">15</option>
+ <option value="16">16</option>
+ <option value="17">17</option>
+ <option value="18">18</option>
+ <option value="19">19</option>
+ <option value="20">20</option>
+ <option value="21">21</option>
+ <option value="22">22</option>
+ <option value="23">23</option>
+ <option value="24">24</option>
+ <option value="25">25</option>
+ <option value="26">26</option>
+ <option value="27">27</option>
+ <option value="28">28</option>
+ <option value="29">29</option>
+ <option value="30">30</option>
+ <option value="31">31</option>
+ </select>
+ <label id="lblDateOfBirthYear" for="selDobYear">Year of Birth:</label>
+ <select id="selDobYear" name="dobYear" size="1"
+title="overall type: COMPANY_NAME server type: COMPANY_NAME heuristic type: UNKNOWN_TYPE label: Year of Birth: parseable name: dobYear field signature: 3750696607 form signature: 12190733459375771907"
+autofill-prediction="COMPANY_NAME"
+>
+ <option value="year" selected="selected">Year</option>
+ <option value="1927">1927</option>
+ <option value="1928">1928</option>
+ <option value="1929">1929</option>
+ <option value="1930">1930</option>
+ <option value="1931">1931</option>
+ <option value="1932">1932</option>
+ <option value="1933">1933</option>
+ <option value="1934">1934</option>
+ <option value="1935">1935</option>
+ <option value="1936">1936</option>
+ <option value="1937">1937</option>
+ <option value="1938">1938</option>
+ <option value="1939">1939</option>
+ <option value="1940">1940</option>
+ <option value="1941">1941</option>
+ <option value="1942">1942</option>
+ <option value="1943">1943</option>
+ <option value="1944">1944</option>
+ <option value="1945">1945</option>
+ <option value="1946">1946</option>
+ <option value="1947">1947</option>
+ <option value="1948">1948</option>
+ <option value="1949">1949</option>
+ <option value="1950">1950</option>
+ <option value="1951">1951</option>
+ <option value="1952">1952</option>
+ <option value="1953">1953</option>
+ <option value="1954">1954</option>
+ <option value="1955">1955</option>
+ <option value="1956">1956</option>
+ <option value="1957">1957</option>
+ <option value="1958">1958</option>
+ <option value="1959">1959</option>
+ <option value="1960">1960</option>
+ <option value="1961">1961</option>
+ <option value="1962">1962</option>
+ <option value="1963">1963</option>
+ <option value="1964">1964</option>
+ <option value="1965">1965</option>
+ <option value="1966">1966</option>
+ <option value="1967">1967</option>
+ <option value="1968">1968</option>
+ <option value="1969">1969</option>
+ <option value="1970">1970</option>
+ <option value="1971">1971</option>
+ <option value="1972">1972</option>
+ <option value="1973">1973</option>
+ <option value="1974">1974</option>
+ <option value="1975">1975</option>
+ <option value="1976">1976</option>
+ <option value="1977">1977</option>
+ <option value="1978">1978</option>
+ <option value="1979">1979</option>
+ <option value="1980">1980</option>
+ <option value="1981">1981</option>
+ <option value="1982">1982</option>
+ <option value="1983">1983</option>
+ <option value="1984">1984</option>
+ <option value="1985">1985</option>
+ <option value="1986">1986</option>
+ <option value="1987">1987</option>
+ <option value="1988">1988</option>
+ <option value="1989">1989</option>
+ <option value="1990">1990</option>
+ <option value="1991">1991</option>
+ <option value="1992">1992</option>
+ <option value="1993">1993</option>
+ <option value="1994">1994</option>
+ <option value="1995">1995</option>
+ <option value="1996">1996</option>
+ <option value="1997">1997</option>
+ <option value="1998">1998</option>
+ <option value="1999">1999</option>
+ <option value="2000">2000</option>
+ <option value="2001">2001</option>
+ <option value="2002">2002</option>
+ <option value="2003">2003</option>
+ <option value="2004">2004</option>
+ <option value="2005">2005</option>
+ <option value="2006">2006</option>
+ <option value="2007">2007</option>
+ <option value="2008">2008</option>
+ <option value="2009">2009</option>
+ <option value="2010">2010</option>
+ <option value="2011">2011</option>
+ <option value="2012">2012</option>
+ <option value="2013">2013</option>
+ <option value="2014">2014</option>
+ <option value="2015">2015</option>
+ <option value="2016">2016</option>
+ <option value="2017">2017</option>
+ </select>
+ </fieldset>
+ <div id="monetate_selectorHTML_b234e9d_0">
+ <div>
+ <label for="cbBillMeLaterElectronicConsent" id="lblBillMeLaterElectronicConsent">&nbsp;&nbsp;<input id="cbBillMeLaterElectronicConsent" name="bmlAcceptTerms" type="checkbox" />
+ </label>
+ </div>
+ </div>
+ </div>
+ <div id="BMLScroll">
+ <input name="bmlAcceptTermsTemp" id="cbBillMeLaterElectronicConsentTemp" type="checkbox"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Note: After scrolling, please remain at the bottom of the Terms and Conditions section to continue. parseable name: bmlAcceptTermsTemp field signature: 1379157860 form signature: 12190733459375771907"
+autofill-prediction="UNKNOWN_TYPE"
+/>
+ <label for="cbBillMeLaterElectronicConsent" id="lblBillMeLaterElectronicConsent">&nbsp;&nbsp;<input name="bmlAcceptTerms" id="cbBillMeLaterElectronicConsent" type="checkbox"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Note: After scrolling, please remain at the bottom of the Terms and Conditions section to continue. parseable name: bmlAcceptTerms field signature: 4275106371 form signature: 12190733459375771907"
+autofill-prediction="UNKNOWN_TYPE"
+/>
+ </label>
+ </div>
+ </td>
+ </tr>
+ <tr id="trEnterNewCard-1">
+ <td colspan="5">
+ <input type="radio" name="rbNewPaymentMethod" id="rbNewCard" value="rbNewCard" checked="checked"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Enter New Card parseable name: rbNewPaymentMethod field signature: 95492298 form signature: 12190733459375771907"
+autofill-prediction="UNKNOWN_TYPE"
+/>
+ <label for="rbNewCard">&nbsp;Enter New Card</label>&nbsp;&nbsp;&nbsp;&nbsp;
+ </td>
+ </tr>
+ <tr id="trEnterNewCard-2">
+ <td colspan="5">
+ <div id="divEnterNewCard">
+ <fieldset>
+ <label id="lblNewCardType" for="selNewCardType">Type</label>
+ <select name="NewCardType" id="selNewCardType" size="1"
+title="overall type: CREDIT_CARD_TYPE server type: CREDIT_CARD_TYPE heuristic type: CREDIT_CARD_TYPE label: Type parseable name: NewCardType field signature: 3035337803 form signature: 12190733459375771907"
+autofill-prediction="CREDIT_CARD_TYPE"
+>
+ <option value="AX">American Express</option>
+ <option value="CB">Carte Blanc</option>
+ <option value="DC">Diners Club</option>
+ <option value="SI">Discover</option>
+ <option value="MC">MasterCard</option>
+ <option value="QV">QCard</option>
+ <option value="VI" selected="selected">Visa</option>
+ </select>
+ <label id="lblNewCardNumber" for="txtNewCardNumber">Number:</label>
+ <input id="txtNewCardNumber" name="NewCardNumber" type="text" maxlength="20" size="21" autocomplete="off" autocorrect="off" value="" pattern="[0-9]*"
+title="overall type: CREDIT_CARD_NUMBER server type: CREDIT_CARD_NUMBER heuristic type: CREDIT_CARD_NUMBER label: Number: parseable name: NewCardNumber field signature: 2370218454 form signature: 12190733459375771907"
+autofill-prediction="CREDIT_CARD_NUMBER"
+/>
+ <input id="hidNewLastCC" name="hidNewLastCC" type="hidden" value="" />
+ <fieldset id="fldExpireDateNewCard">
+ <label id="lblNewCard" for="selNewCard">Expiration Date:</label>
+ <select name="selNewCard" id="selNewCard" size="1"
+title="overall type: CREDIT_CARD_EXP_DATE_4_DIGIT_YEAR server type: NO_SERVER_DATA heuristic type: CREDIT_CARD_EXP_DATE_4_DIGIT_YEAR label: Expiration Date: parseable name: selNewCard field signature: 2308816317 form signature: 12190733459375771907"
+autofill-prediction="CREDIT_CARD_EXP_DATE_4_DIGIT_YEAR"
+>
+ <option value="03/2017" selected="selected">03/2017</option>
+ <option value="04/2017">04/2017</option>
+ <option value="05/2017">05/2017</option>
+ <option value="06/2017">06/2017</option>
+ <option value="07/2017">07/2017</option>
+ <option value="08/2017">08/2017</option>
+ <option value="09/2017">09/2017</option>
+ <option value="10/2017">10/2017</option>
+ <option value="11/2017">11/2017</option>
+ <option value="12/2017">12/2017</option>
+ <option value="01/2018">01/2018</option>
+ <option value="02/2018">02/2018</option>
+ <option value="03/2018">03/2018</option>
+ <option value="04/2018">04/2018</option>
+ <option value="05/2018">05/2018</option>
+ <option value="06/2018">06/2018</option>
+ <option value="07/2018">07/2018</option>
+ <option value="08/2018">08/2018</option>
+ <option value="09/2018">09/2018</option>
+ <option value="10/2018">10/2018</option>
+ <option value="11/2018">11/2018</option>
+ <option value="12/2018">12/2018</option>
+ <option value="01/2019">01/2019</option>
+ <option value="02/2019">02/2019</option>
+ <option value="03/2019">03/2019</option>
+ <option value="04/2019">04/2019</option>
+ <option value="05/2019">05/2019</option>
+ <option value="06/2019">06/2019</option>
+ <option value="07/2019">07/2019</option>
+ <option value="08/2019">08/2019</option>
+ <option value="09/2019">09/2019</option>
+ <option value="10/2019">10/2019</option>
+ <option value="11/2019">11/2019</option>
+ <option value="12/2019">12/2019</option>
+ <option value="01/2020">01/2020</option>
+ <option value="02/2020">02/2020</option>
+ <option value="03/2020">03/2020</option>
+ <option value="04/2020">04/2020</option>
+ <option value="05/2020">05/2020</option>
+ <option value="06/2020">06/2020</option>
+ <option value="07/2020">07/2020</option>
+ <option value="08/2020">08/2020</option>
+ <option value="09/2020">09/2020</option>
+ <option value="10/2020">10/2020</option>
+ <option value="11/2020">11/2020</option>
+ <option value="12/2020">12/2020</option>
+ <option value="01/2021">01/2021</option>
+ <option value="02/2021">02/2021</option>
+ <option value="03/2021">03/2021</option>
+ <option value="04/2021">04/2021</option>
+ <option value="05/2021">05/2021</option>
+ <option value="06/2021">06/2021</option>
+ <option value="07/2021">07/2021</option>
+ <option value="08/2021">08/2021</option>
+ <option value="09/2021">09/2021</option>
+ <option value="10/2021">10/2021</option>
+ <option value="11/2021">11/2021</option>
+ <option value="12/2021">12/2021</option>
+ <option value="01/2022">01/2022</option>
+ <option value="02/2022">02/2022</option>
+ <option value="03/2022">03/2022</option>
+ <option value="04/2022">04/2022</option>
+ <option value="05/2022">05/2022</option>
+ <option value="06/2022">06/2022</option>
+ <option value="07/2022">07/2022</option>
+ <option value="08/2022">08/2022</option>
+ <option value="09/2022">09/2022</option>
+ <option value="10/2022">10/2022</option>
+ <option value="11/2022">11/2022</option>
+ <option value="12/2022">12/2022</option>
+ <option value="01/2023">01/2023</option>
+ <option value="02/2023">02/2023</option>
+ <option value="03/2023">03/2023</option>
+ <option value="04/2023">04/2023</option>
+ <option value="05/2023">05/2023</option>
+ <option value="06/2023">06/2023</option>
+ <option value="07/2023">07/2023</option>
+ <option value="08/2023">08/2023</option>
+ <option value="09/2023">09/2023</option>
+ <option value="10/2023">10/2023</option>
+ <option value="11/2023">11/2023</option>
+ <option value="12/2023">12/2023</option>
+ <option value="01/2024">01/2024</option>
+ <option value="02/2024">02/2024</option>
+ <option value="03/2024">03/2024</option>
+ <option value="04/2024">04/2024</option>
+ <option value="05/2024">05/2024</option>
+ <option value="06/2024">06/2024</option>
+ <option value="07/2024">07/2024</option>
+ <option value="08/2024">08/2024</option>
+ <option value="09/2024">09/2024</option>
+ <option value="10/2024">10/2024</option>
+ <option value="11/2024">11/2024</option>
+ <option value="12/2024">12/2024</option>
+ <option value="01/2025">01/2025</option>
+ <option value="02/2025">02/2025</option>
+ <option value="03/2025">03/2025</option>
+ <option value="04/2025">04/2025</option>
+ <option value="05/2025">05/2025</option>
+ <option value="06/2025">06/2025</option>
+ <option value="07/2025">07/2025</option>
+ <option value="08/2025">08/2025</option>
+ <option value="09/2025">09/2025</option>
+ <option value="10/2025">10/2025</option>
+ <option value="11/2025">11/2025</option>
+ <option value="12/2025">12/2025</option>
+ <option value="01/2026">01/2026</option>
+ <option value="02/2026">02/2026</option>
+ <option value="03/2026">03/2026</option>
+ <option value="04/2026">04/2026</option>
+ <option value="05/2026">05/2026</option>
+ <option value="06/2026">06/2026</option>
+ <option value="07/2026">07/2026</option>
+ <option value="08/2026">08/2026</option>
+ <option value="09/2026">09/2026</option>
+ <option value="10/2026">10/2026</option>
+ <option value="11/2026">11/2026</option>
+ <option value="12/2026">12/2026</option>
+ <option value="01/2027">01/2027</option>
+ <option value="02/2027">02/2027</option>
+ <option value="03/2027">03/2027</option>
+ <option value="04/2027">04/2027</option>
+ <option value="05/2027">05/2027</option>
+ <option value="06/2027">06/2027</option>
+ <option value="07/2027">07/2027</option>
+ <option value="08/2027">08/2027</option>
+ <option value="09/2027">09/2027</option>
+ <option value="10/2027">10/2027</option>
+ <option value="11/2027">11/2027</option>
+ <option value="12/2027">12/2027</option>
+ </select>
+ </fieldset>
+ </fieldset>
+ <fieldset id="fldSecurityCodeNewCard">
+ <div id="fldSecurityCode">
+ <label for="txtSecurityCode">Security Code:</label>&nbsp;<input id="txtSecurityCode" name="SecurityCode" type="text" pattern="[0-9]*" maxlength="5" size="5" value=""
+title="overall type: CREDIT_CARD_VERIFICATION_CODE server type: NO_SERVER_DATA heuristic type: CREDIT_CARD_VERIFICATION_CODE label: Security Code: parseable name: SecurityCode field signature: 4107652875 form signature: 12190733459375771907"
+autofill-prediction="CREDIT_CARD_VERIFICATION_CODE"
+/>
+ </div>
+ </fieldset>
+ <div id="divQButton">
+ <span>
+ <input type="button" id="btnQCard" value="Add My QCard" />
+ </span>
+ <input type="hidden" id="addMyQCard" name="addMyQCard" value="false" />
+ <input type="hidden" id="isNPO" name="isNPO" value="true" />
+ </div>
+ </div>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ <div id="divQvcGiftCardsMethod">
+ <div>
+ <div id="divGiftCardPaymentOption">
+ <input type="checkbox" name="cbGiftCard" id="cbGiftCard"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Use a Gift Card parseable name: cbGiftCard field signature: 2461714937 form signature: 12190733459375771907"
+autofill-prediction="UNKNOWN_TYPE"
+/>
+ <label for="cbGiftCard">Use a Gift Card</label>
+ <div id="divQvcGiftCardEntry">
+ <fieldset>
+ <label for="txtQvcGiftCardNumber">Card Number:</label>
+ <input id="txtQvcGiftCardNumber" name="txtQvcGiftCardNumber" type="tel" autocomplete="off" autocorrect="off" maxlength="19" size="19" value=""
+title="overall type: CREDIT_CARD_NUMBER server type: CREDIT_CARD_NUMBER heuristic type: CREDIT_CARD_NUMBER label: Card Number: parseable name: txtQvcGiftCardNumber field signature: 375442765 form signature: 12190733459375771907"
+autofill-prediction="CREDIT_CARD_NUMBER"
+/>
+ <label for="txtQvcGiftCardSecurityIdNumber">Security ID Number:</label>
+ <input id="txtQvcGiftCardSecurityIdNumber" name="txtQvcGiftCardSecurityIdNumber" type="text" autocomplete="off" autocorrect="off" maxlength="12" size="19" value=""
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Security ID Number: parseable name: txtQvcGiftCardSecurityIdNumber field signature: 383370886 form signature: 12190733459375771907"
+autofill-prediction="UNKNOWN_TYPE"
+/>
+ <span>
+ <input type="button" id="btnQvcGiftCardEnterAnotherCard" name="btnQvcGiftCardEnterAnotherCard" value="Enter Another Card" />
+ <span>Enter Another Card</span>
+ </span>
+ </fieldset>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div id="monetate_selectorHTML_c937b2dd_0">
+ <div id="mainVoucherCodeDiv">
+ <input id="txtQvcApplyCodeNumber" name="txtQvcApplyCodeNumber" type="text" autocomplete="off" autocorrect="off" maxlength="25" size="19" value="" />
+ <input type="button" id="btnApplyCode" value="Apply Code" />
+ </div>
+ </div>
+ <div id="divButtons">
+ <span>
+ <input type="button" id="btnSubmitChanges" value="Continue Checkout" />
+ <span>Continue Checkout</span>
+ </span>
+ <span>
+ <span>Continue Checkout</span>
+ <input type="button" id="btnQCard2" value="Continue Checkout" />
+ </span>
+ <span>
+ <input type="button" id="btnReturnToOrder" value="EDIT SHOPPING CART" />
+ <span>Edit Shopping cart</span>
+ </span>
+ </div>
+ </form>
+ <form id="captureFormFooter" method="post" name="captureFormFooter">
+ <div id="divEmailFormFooter">
+ <label for="emailAddress1Footer">Get sneak previews of special offers and upcoming events delivered to your inbox.</label>
+ <span id="emailAddressErrorFooter">*</span>
+ <input id="emailAddress1Footer" type="text" value="Enter email" />
+ <input id="emailAddress2Footer" type="text" value="Confirm email" />
+ <input id="signUpFooter" type="submit" value="Sign Up" />
+ <span id="disclaimerTextFooter">*You're signing up to receive QVC promotional email.</span>
+ </div>
+ </form>
+ </body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/third_party/QVC/SignIn.html b/browser/extensions/formautofill/test/fixtures/third_party/QVC/SignIn.html
new file mode 100644
index 0000000000..a056ccfc5c
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/third_party/QVC/SignIn.html
@@ -0,0 +1,80 @@
+<html xmlns="http://www.w3.org/1999/xhtml">
+ <head>
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
+ <meta property="fb:app_id" content="105047196722" />
+ <meta property="fb:page_id" content="23797290954" />
+ <meta name="format-detection" content="telephone=no" />
+ <meta name="viewport" content="width=919" />
+ <title>QVC.com Sign In</title>
+ </head>
+ <body>
+ <form id="frmMastheadSearch" method="get" action="http://www.qvc.com/CatalogSearch">
+ <fieldset>
+ <input type="hidden" name="langId" value="-1" />
+ <input type="hidden" name="storeId" value="10251" />
+ <input type="hidden" name="catalogId" value="10151" />
+ <label for="txtMastheadSearch">Search QVC:</label>
+ <input id="txtMastheadSearch" name="keyword" type="text" value="" autocomplete="off" autocorrect="off" placeholder="Search QVC" />
+ <input id="btnMastheadSearch" type="submit" alt="Go" value="Go" />
+ </fieldset>
+ </form>
+ <form id="frmSignIn" name="frmSignIn" method="post">
+ <input type="hidden" name="csrfToken" value="M1E1cTBwME9saUVZaU9iNUdJVnZqSm9JQThHM3gwSUt0elROSE9oSDJaST0=" />
+ <fieldset>
+ <input type="hidden" name="storeId" value="10251" />
+ <input type="hidden" name="catalogId" value="10151" />
+ <input type="hidden" name="langId" value="-1" />
+ <input type="hidden" name="URL" id="URL" value="http://www.qvc.com/Checkout?orderId=476661567&amp;langId=-1&amp;storeId=10251&amp;catalogId=10151" />
+ <input type="hidden" name="reLogonURL" value="LogonForm" />
+ <input type="hidden" name="rememberMe" id="rememberMe" value="true" />
+ <input type="hidden" name="fromPage" id="fromPage" value="checkout" />
+ <input type="hidden" name="orderId" value="476661567" />
+ </fieldset>
+ <div id="signInFields">
+ <label id="lblEmailAddress" for="txtEmailAddress">Email Address:</label>
+ <input id="txtEmailAddress" type="email" value="" maxlength="128" size="30" name="logonId" placeholder="Email Address" />
+ <div>
+ <label id="lblPassword" for="txtPassword">QVC Password:</label>
+ <input id="txtPassword" type="password" maxlength="24" size="30" name="logonPassword" placeholder="QVC Password" />
+ </div>
+ </div>
+ <div>
+ <div id="divUseDefaults">
+ <input id="cbUseDefaults" type="checkbox" name="cbReviewOrderTotal" value="on" />
+ <label id="lblUseDefaults" for="cbUseDefaults">Using your default shipping and payment information? Check the box to go directly to Order Summary.</label>
+ <input type="hidden" name="speedBuyTypeInd" id="speedBuyTypeInd" value="C" />
+ </div>
+ </div>
+ <div id="divFormButtons">
+ <div>
+ <span id="createPasswordSpan">
+ <span>Create Password</span>
+ <input id="btnSignIn" type="button" value="Create Password" />
+ </span>
+ <span id="continueButtonSpan">
+ <span>Continue</span>
+ <input id="btnSignIn" type="button" value="Continue" />
+ </span>
+ </div>
+ <div>
+ <span>
+ <span>Sign In</span>
+ <input id="btnSignIn" type="submit" value="Sign In" />
+ </span>
+ </div>
+ </div>
+ <fieldset>
+ <input id="userPrefs" type="hidden" value="" name="userPrefs" />
+ </fieldset>
+ </form>
+ <form id="frmCreateAccount">
+ <input type="hidden" name="csrfToken" value="M1E1cTBwME9saUVZaU9iNUdJVnZqSm9JQThHM3gwSUt0elROSE9oSDJaST0=" />
+ <div id="divButtons">
+ <span>
+ <span>Continue</span>
+ <input id="btnSignUp" type="button" value="Continue" />
+ </span>
+ </div>
+ </form>
+ </body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/third_party/QVC/YourInformation.html b/browser/extensions/formautofill/test/fixtures/third_party/QVC/YourInformation.html
new file mode 100644
index 0000000000..df5fdc2200
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/third_party/QVC/YourInformation.html
@@ -0,0 +1,522 @@
+<html xmlns="http://www.w3.org/1999/xhtml">
+ <head>
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
+ <meta http-equiv="X-UA-Compatible" content="IE=edge" />
+ <meta property="fb:app_id" content="105047196722" />
+ <meta property="fb:page_id" content="23797290954" />
+ <meta name="format-detection" content="telephone=no" />
+ <meta name="viewport" content="width=919" />
+ <title>Payment Method</title>
+ </head>
+ <body>
+ <form id="frmEditPaymentMethod" name="frmEditPaymentMethod" method="post" action="https://www.qvc.com/webapp/wcs/stores/servlet/NPOOrderAddPaymentMethods">
+ <input type="hidden" name="csrfToken" value="M1E1cTBwME9saUVZaU9iNUdJVnZqSm9JQThHM3gwSUt0elROSE9oSDJaST0=" />
+ <input type="hidden" name="ccId" value="" />
+ <input type="hidden" name="payMethodExpireMonth" value="" />
+ <input type="hidden" name="payMethodExpireYear" value="" />
+ <input type="hidden" name="ccDtime" value="" />
+ <input type="hidden" name="payMethodNewCard" value="N" />
+ <input type="hidden" name="payMethodExpiryEdited" value="N" />
+ <input type="hidden" name="payMethodCVV" value="" />
+ <input type="hidden" name="orderId" value="476661567" />
+ <input type="hidden" name="langId" value="-1" />
+ <input type="hidden" name="catalogId" value="10151" />
+ <input type="hidden" name="storeId" value="10251" />
+ <input type="hidden" name="dummydata" value="" />
+ <input type="hidden" id="currentPaymentMethod" name="currentPaymentMethod" value="VI" />
+ <input type="hidden" name="checkoutStep" value="3" />
+ <input type="hidden" name="fromPaymentPage" value="Y" />
+ <input type="hidden" name="URL" value="" />
+ <input type="hidden" id="addAnotherGC" name="addAnotherGC" value="N" />
+ <input type="hidden" name="BMLFilePath" id="BMLFilePath" value="https://www.qvc.com/wcsstore/US/content/html/popups/BillMeLaterTermsandConditions.html" />
+ <div id="divEasyPayOptions">
+ <fieldset>
+ <ul>
+ <li>
+ <input type="radio" value="Z" id="rb1PaymentsItem_1" name="rbItemEasyPayOption_1"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: 4 Easy Pays of $39.74 parseable name: rbItemEasyPayOption_1 field signature: 915844214 form signature: 12190733459375771907"
+autofill-prediction="UNKNOWN_TYPE"
+/>
+ <label for="rb1PaymentsItem_1">4 Easy Pays of $39.74</label>
+ </li>
+ <li>
+ <input type="radio" value="N" id="rb2PaymentsItem_1" name="rbItemEasyPayOption_1"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: 1 payment of $158.96 parseable name: rbItemEasyPayOption_1 field signature: 915844214 form signature: 12190733459375771907"
+autofill-prediction="UNKNOWN_TYPE"
+/>
+ <label for="rb2PaymentsItem_1">1 payment of $158.96</label>
+ </li>
+ </ul>
+ <input type="hidden" name="hItemEasyPayOptionPayMthd_1" id="hItemEasyPayOptionPayMthd_1" value="QVAXCBDCSIMCVIBL " />
+ <input type="hidden" name="orderItemId_1" id="orderItemId_1" value="660521668" />
+ </fieldset>
+ </div>
+ <div id="divNewPaymentMethod">
+ <input id="ccId_1" type="hidden" name="ccId_1" value="" />
+ <table id="tblNewPaymentMethod" border="0" cellspacing="0" cellpadding="0" width="100%" summary="Payment methods available for Checkout">
+ <tbody>
+ <tr id="trBillMeLaterRow-1">
+ <td>
+ <input type="radio" name="rbNewPaymentMethod" id="rbBillLater" value="rbBillLater"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: PayPal Credit parseable name: rbNewPaymentMethod field signature: 95492298 form signature: 12190733459375771907"
+autofill-prediction="UNKNOWN_TYPE"
+/>
+ <label for="rbBillLater">PayPal Credit</label>
+ </td>
+ </tr>
+ <tr id="trBillMeLaterRow-2">
+ <td colspan="5">
+ <div id="divBillMeLater">
+ <fieldset>
+ <label id="lblPrimaryPhone" for="txtPrimaryPhone">Home Phone:</label>
+ <input id="txtPrimaryPhone" type="tel" name="txtPrimaryPhone" autocomplete="off" autocorrect="off" value="" maxlength="14" size="14"
+title="overall type: PHONE_HOME_CITY_AND_NUMBER server type: PHONE_HOME_CITY_AND_NUMBER heuristic type: PHONE_HOME_WHOLE_NUMBER label: Home Phone: parseable name: txtPrimaryPhone field signature: 918983855 form signature: 12190733459375771907"
+autofill-prediction="PHONE_HOME_CITY_AND_NUMBER"
+/>
+ </fieldset>
+ <fieldset>
+ <label id="lblEmailAddress" for="txtEmailAddress">Email Address:</label>
+ <input id="txtEmailAddress" type="email" name="txtEmailAddress" autocomplete="on" autocorrect="off" value=""
+title="overall type: EMAIL_ADDRESS server type: EMAIL_ADDRESS heuristic type: EMAIL_ADDRESS label: Email Address: parseable name: txtEmailAddress field signature: 653947670 form signature: 12190733459375771907"
+autofill-prediction="EMAIL_ADDRESS"
+/>
+ </fieldset>
+ <fieldset>
+ <label id="lblSsn" for="txtSsn">Social Security Number:</label>XXX-XX-<input id="txtSsn" type="text" pattern="[0-9]*" autocomplete="off" autocorrect="off" maxlength="4" size="4" name="txtLast4SSN" value=""
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Social Security Number: parseable name: txtLast4SSN field signature: 598258955 form signature: 12190733459375771907"
+autofill-prediction="UNKNOWN_TYPE"
+/>
+ </fieldset>
+ <fieldset>
+ <label id="lblDateOfBirthMonth" for="selDobMonth">Date of Birth:</label>
+ <select id="selDobMonth" name="dobMonth" size="1"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Date of Birth: parseable name: dobMonth field signature: 3916402925 form signature: 12190733459375771907"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ <option value="month" selected="selected">Month</option>
+ <option value="01">January</option>
+ <option value="02">February</option>
+ <option value="03">March</option>
+ <option value="04">April</option>
+ <option value="05">May</option>
+ <option value="06">June</option>
+ <option value="07">July</option>
+ <option value="08">August</option>
+ <option value="09">September</option>
+ <option value="10">October</option>
+ <option value="11">November</option>
+ <option value="12">December</option>
+ </select>
+ <label id="lblDateOfBirthDay" for="selDobDay">Day of Birth:</label>
+ <select id="selDobDay" name="dobDay" size="1"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Day of Birth: parseable name: dobDay field signature: 4127787517 form signature: 12190733459375771907"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ <option value="day" selected="selected">Day</option>
+ <option value="1">1</option>
+ <option value="2">2</option>
+ <option value="3">3</option>
+ <option value="4">4</option>
+ <option value="5">5</option>
+ <option value="6">6</option>
+ <option value="7">7</option>
+ <option value="8">8</option>
+ <option value="9">9</option>
+ <option value="10">10</option>
+ <option value="11">11</option>
+ <option value="12">12</option>
+ <option value="13">13</option>
+ <option value="14">14</option>
+ <option value="15">15</option>
+ <option value="16">16</option>
+ <option value="17">17</option>
+ <option value="18">18</option>
+ <option value="19">19</option>
+ <option value="20">20</option>
+ <option value="21">21</option>
+ <option value="22">22</option>
+ <option value="23">23</option>
+ <option value="24">24</option>
+ <option value="25">25</option>
+ <option value="26">26</option>
+ <option value="27">27</option>
+ <option value="28">28</option>
+ <option value="29">29</option>
+ <option value="30">30</option>
+ <option value="31">31</option>
+ </select>
+ <label id="lblDateOfBirthYear" for="selDobYear">Year of Birth:</label>
+ <select id="selDobYear" name="dobYear" size="1"
+title="overall type: COMPANY_NAME server type: COMPANY_NAME heuristic type: UNKNOWN_TYPE label: Year of Birth: parseable name: dobYear field signature: 3750696607 form signature: 12190733459375771907"
+autofill-prediction="COMPANY_NAME"
+>
+ <option value="year" selected="selected">Year</option>
+ <option value="1927">1927</option>
+ <option value="1928">1928</option>
+ <option value="1929">1929</option>
+ <option value="1930">1930</option>
+ <option value="1931">1931</option>
+ <option value="1932">1932</option>
+ <option value="1933">1933</option>
+ <option value="1934">1934</option>
+ <option value="1935">1935</option>
+ <option value="1936">1936</option>
+ <option value="1937">1937</option>
+ <option value="1938">1938</option>
+ <option value="1939">1939</option>
+ <option value="1940">1940</option>
+ <option value="1941">1941</option>
+ <option value="1942">1942</option>
+ <option value="1943">1943</option>
+ <option value="1944">1944</option>
+ <option value="1945">1945</option>
+ <option value="1946">1946</option>
+ <option value="1947">1947</option>
+ <option value="1948">1948</option>
+ <option value="1949">1949</option>
+ <option value="1950">1950</option>
+ <option value="1951">1951</option>
+ <option value="1952">1952</option>
+ <option value="1953">1953</option>
+ <option value="1954">1954</option>
+ <option value="1955">1955</option>
+ <option value="1956">1956</option>
+ <option value="1957">1957</option>
+ <option value="1958">1958</option>
+ <option value="1959">1959</option>
+ <option value="1960">1960</option>
+ <option value="1961">1961</option>
+ <option value="1962">1962</option>
+ <option value="1963">1963</option>
+ <option value="1964">1964</option>
+ <option value="1965">1965</option>
+ <option value="1966">1966</option>
+ <option value="1967">1967</option>
+ <option value="1968">1968</option>
+ <option value="1969">1969</option>
+ <option value="1970">1970</option>
+ <option value="1971">1971</option>
+ <option value="1972">1972</option>
+ <option value="1973">1973</option>
+ <option value="1974">1974</option>
+ <option value="1975">1975</option>
+ <option value="1976">1976</option>
+ <option value="1977">1977</option>
+ <option value="1978">1978</option>
+ <option value="1979">1979</option>
+ <option value="1980">1980</option>
+ <option value="1981">1981</option>
+ <option value="1982">1982</option>
+ <option value="1983">1983</option>
+ <option value="1984">1984</option>
+ <option value="1985">1985</option>
+ <option value="1986">1986</option>
+ <option value="1987">1987</option>
+ <option value="1988">1988</option>
+ <option value="1989">1989</option>
+ <option value="1990">1990</option>
+ <option value="1991">1991</option>
+ <option value="1992">1992</option>
+ <option value="1993">1993</option>
+ <option value="1994">1994</option>
+ <option value="1995">1995</option>
+ <option value="1996">1996</option>
+ <option value="1997">1997</option>
+ <option value="1998">1998</option>
+ <option value="1999">1999</option>
+ <option value="2000">2000</option>
+ <option value="2001">2001</option>
+ <option value="2002">2002</option>
+ <option value="2003">2003</option>
+ <option value="2004">2004</option>
+ <option value="2005">2005</option>
+ <option value="2006">2006</option>
+ <option value="2007">2007</option>
+ <option value="2008">2008</option>
+ <option value="2009">2009</option>
+ <option value="2010">2010</option>
+ <option value="2011">2011</option>
+ <option value="2012">2012</option>
+ <option value="2013">2013</option>
+ <option value="2014">2014</option>
+ <option value="2015">2015</option>
+ <option value="2016">2016</option>
+ <option value="2017">2017</option>
+ </select>
+ </fieldset>
+ <div id="monetate_selectorHTML_b234e9d_0">
+ <div>
+ <label for="cbBillMeLaterElectronicConsent" id="lblBillMeLaterElectronicConsent">&nbsp;&nbsp;<input id="cbBillMeLaterElectronicConsent" name="bmlAcceptTerms" type="checkbox" />
+ </label>
+ </div>
+ </div>
+ </div>
+ <div id="BMLScroll">
+ <input name="bmlAcceptTermsTemp" id="cbBillMeLaterElectronicConsentTemp" type="checkbox"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Note: After scrolling, please remain at the bottom of the Terms and Conditions section to continue. parseable name: bmlAcceptTermsTemp field signature: 1379157860 form signature: 12190733459375771907"
+autofill-prediction="UNKNOWN_TYPE"
+/>
+ <label for="cbBillMeLaterElectronicConsent" id="lblBillMeLaterElectronicConsent">&nbsp;&nbsp;<input name="bmlAcceptTerms" id="cbBillMeLaterElectronicConsent" type="checkbox"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Note: After scrolling, please remain at the bottom of the Terms and Conditions section to continue. parseable name: bmlAcceptTerms field signature: 4275106371 form signature: 12190733459375771907"
+autofill-prediction="UNKNOWN_TYPE"
+/>
+ </label>
+ </div>
+ </td>
+ </tr>
+ <tr id="trEnterNewCard-1">
+ <td colspan="5">
+ <input type="radio" name="rbNewPaymentMethod" id="rbNewCard" value="rbNewCard" checked="checked"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Enter New Card parseable name: rbNewPaymentMethod field signature: 95492298 form signature: 12190733459375771907"
+autofill-prediction="UNKNOWN_TYPE"
+/>
+ <label for="rbNewCard">&nbsp;Enter New Card</label>&nbsp;&nbsp;&nbsp;&nbsp;
+ </td>
+ </tr>
+ <tr id="trEnterNewCard-2">
+ <td colspan="5">
+ <div id="divEnterNewCard">
+ <fieldset>
+ <label id="lblNewCardType" for="selNewCardType">Type</label>
+ <select name="NewCardType" id="selNewCardType" size="1"
+title="overall type: CREDIT_CARD_TYPE server type: CREDIT_CARD_TYPE heuristic type: CREDIT_CARD_TYPE label: Type parseable name: NewCardType field signature: 3035337803 form signature: 12190733459375771907"
+autofill-prediction="CREDIT_CARD_TYPE"
+>
+ <option value="AX">American Express</option>
+ <option value="CB">Carte Blanc</option>
+ <option value="DC">Diners Club</option>
+ <option value="SI">Discover</option>
+ <option value="MC">MasterCard</option>
+ <option value="QV">QCard</option>
+ <option value="VI" selected="selected">Visa</option>
+ </select>
+ <label id="lblNewCardNumber" for="txtNewCardNumber">Number:</label>
+ <input id="txtNewCardNumber" name="NewCardNumber" type="text" maxlength="20" size="21" autocomplete="off" autocorrect="off" value="" pattern="[0-9]*"
+title="overall type: CREDIT_CARD_NUMBER server type: CREDIT_CARD_NUMBER heuristic type: CREDIT_CARD_NUMBER label: Number: parseable name: NewCardNumber field signature: 2370218454 form signature: 12190733459375771907"
+autofill-prediction="CREDIT_CARD_NUMBER"
+/>
+ <input id="hidNewLastCC" name="hidNewLastCC" type="hidden" value="" />
+ <fieldset id="fldExpireDateNewCard">
+ <label id="lblNewCard" for="selNewCard">Expiration Date:</label>
+ <select name="selNewCard" id="selNewCard" size="1"
+title="overall type: CREDIT_CARD_EXP_DATE_4_DIGIT_YEAR server type: NO_SERVER_DATA heuristic type: CREDIT_CARD_EXP_DATE_4_DIGIT_YEAR label: Expiration Date: parseable name: selNewCard field signature: 2308816317 form signature: 12190733459375771907"
+autofill-prediction="CREDIT_CARD_EXP_DATE_4_DIGIT_YEAR"
+>
+ <option value="03/2017" selected="selected">03/2017</option>
+ <option value="04/2017">04/2017</option>
+ <option value="05/2017">05/2017</option>
+ <option value="06/2017">06/2017</option>
+ <option value="07/2017">07/2017</option>
+ <option value="08/2017">08/2017</option>
+ <option value="09/2017">09/2017</option>
+ <option value="10/2017">10/2017</option>
+ <option value="11/2017">11/2017</option>
+ <option value="12/2017">12/2017</option>
+ <option value="01/2018">01/2018</option>
+ <option value="02/2018">02/2018</option>
+ <option value="03/2018">03/2018</option>
+ <option value="04/2018">04/2018</option>
+ <option value="05/2018">05/2018</option>
+ <option value="06/2018">06/2018</option>
+ <option value="07/2018">07/2018</option>
+ <option value="08/2018">08/2018</option>
+ <option value="09/2018">09/2018</option>
+ <option value="10/2018">10/2018</option>
+ <option value="11/2018">11/2018</option>
+ <option value="12/2018">12/2018</option>
+ <option value="01/2019">01/2019</option>
+ <option value="02/2019">02/2019</option>
+ <option value="03/2019">03/2019</option>
+ <option value="04/2019">04/2019</option>
+ <option value="05/2019">05/2019</option>
+ <option value="06/2019">06/2019</option>
+ <option value="07/2019">07/2019</option>
+ <option value="08/2019">08/2019</option>
+ <option value="09/2019">09/2019</option>
+ <option value="10/2019">10/2019</option>
+ <option value="11/2019">11/2019</option>
+ <option value="12/2019">12/2019</option>
+ <option value="01/2020">01/2020</option>
+ <option value="02/2020">02/2020</option>
+ <option value="03/2020">03/2020</option>
+ <option value="04/2020">04/2020</option>
+ <option value="05/2020">05/2020</option>
+ <option value="06/2020">06/2020</option>
+ <option value="07/2020">07/2020</option>
+ <option value="08/2020">08/2020</option>
+ <option value="09/2020">09/2020</option>
+ <option value="10/2020">10/2020</option>
+ <option value="11/2020">11/2020</option>
+ <option value="12/2020">12/2020</option>
+ <option value="01/2021">01/2021</option>
+ <option value="02/2021">02/2021</option>
+ <option value="03/2021">03/2021</option>
+ <option value="04/2021">04/2021</option>
+ <option value="05/2021">05/2021</option>
+ <option value="06/2021">06/2021</option>
+ <option value="07/2021">07/2021</option>
+ <option value="08/2021">08/2021</option>
+ <option value="09/2021">09/2021</option>
+ <option value="10/2021">10/2021</option>
+ <option value="11/2021">11/2021</option>
+ <option value="12/2021">12/2021</option>
+ <option value="01/2022">01/2022</option>
+ <option value="02/2022">02/2022</option>
+ <option value="03/2022">03/2022</option>
+ <option value="04/2022">04/2022</option>
+ <option value="05/2022">05/2022</option>
+ <option value="06/2022">06/2022</option>
+ <option value="07/2022">07/2022</option>
+ <option value="08/2022">08/2022</option>
+ <option value="09/2022">09/2022</option>
+ <option value="10/2022">10/2022</option>
+ <option value="11/2022">11/2022</option>
+ <option value="12/2022">12/2022</option>
+ <option value="01/2023">01/2023</option>
+ <option value="02/2023">02/2023</option>
+ <option value="03/2023">03/2023</option>
+ <option value="04/2023">04/2023</option>
+ <option value="05/2023">05/2023</option>
+ <option value="06/2023">06/2023</option>
+ <option value="07/2023">07/2023</option>
+ <option value="08/2023">08/2023</option>
+ <option value="09/2023">09/2023</option>
+ <option value="10/2023">10/2023</option>
+ <option value="11/2023">11/2023</option>
+ <option value="12/2023">12/2023</option>
+ <option value="01/2024">01/2024</option>
+ <option value="02/2024">02/2024</option>
+ <option value="03/2024">03/2024</option>
+ <option value="04/2024">04/2024</option>
+ <option value="05/2024">05/2024</option>
+ <option value="06/2024">06/2024</option>
+ <option value="07/2024">07/2024</option>
+ <option value="08/2024">08/2024</option>
+ <option value="09/2024">09/2024</option>
+ <option value="10/2024">10/2024</option>
+ <option value="11/2024">11/2024</option>
+ <option value="12/2024">12/2024</option>
+ <option value="01/2025">01/2025</option>
+ <option value="02/2025">02/2025</option>
+ <option value="03/2025">03/2025</option>
+ <option value="04/2025">04/2025</option>
+ <option value="05/2025">05/2025</option>
+ <option value="06/2025">06/2025</option>
+ <option value="07/2025">07/2025</option>
+ <option value="08/2025">08/2025</option>
+ <option value="09/2025">09/2025</option>
+ <option value="10/2025">10/2025</option>
+ <option value="11/2025">11/2025</option>
+ <option value="12/2025">12/2025</option>
+ <option value="01/2026">01/2026</option>
+ <option value="02/2026">02/2026</option>
+ <option value="03/2026">03/2026</option>
+ <option value="04/2026">04/2026</option>
+ <option value="05/2026">05/2026</option>
+ <option value="06/2026">06/2026</option>
+ <option value="07/2026">07/2026</option>
+ <option value="08/2026">08/2026</option>
+ <option value="09/2026">09/2026</option>
+ <option value="10/2026">10/2026</option>
+ <option value="11/2026">11/2026</option>
+ <option value="12/2026">12/2026</option>
+ <option value="01/2027">01/2027</option>
+ <option value="02/2027">02/2027</option>
+ <option value="03/2027">03/2027</option>
+ <option value="04/2027">04/2027</option>
+ <option value="05/2027">05/2027</option>
+ <option value="06/2027">06/2027</option>
+ <option value="07/2027">07/2027</option>
+ <option value="08/2027">08/2027</option>
+ <option value="09/2027">09/2027</option>
+ <option value="10/2027">10/2027</option>
+ <option value="11/2027">11/2027</option>
+ <option value="12/2027">12/2027</option>
+ </select>
+ </fieldset>
+ </fieldset>
+ <fieldset id="fldSecurityCodeNewCard">
+ <div id="fldSecurityCode">
+ <label for="txtSecurityCode">Security Code:</label>&nbsp;<input id="txtSecurityCode" name="SecurityCode" type="text" pattern="[0-9]*" maxlength="5" size="5" value=""
+title="overall type: CREDIT_CARD_VERIFICATION_CODE server type: NO_SERVER_DATA heuristic type: CREDIT_CARD_VERIFICATION_CODE label: Security Code: parseable name: SecurityCode field signature: 4107652875 form signature: 12190733459375771907"
+autofill-prediction="CREDIT_CARD_VERIFICATION_CODE"
+/>
+ </div>
+ </fieldset>
+ <div id="divQButton">
+ <span>
+ <input type="button" id="btnQCard" value="Add My QCard" />
+ <span>Add My QCard</span>
+ </span>
+ <input type="hidden" id="addMyQCard" name="addMyQCard" value="false" />
+ <input type="hidden" id="isNPO" name="isNPO" value="true" />
+ </div>
+ </div>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ <div id="divQvcGiftCardsMethod">
+ <div>
+ <div id="divGiftCardPaymentOption">
+ <input type="checkbox" name="cbGiftCard" id="cbGiftCard"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Use a Gift Card parseable name: cbGiftCard field signature: 2461714937 form signature: 12190733459375771907"
+autofill-prediction="UNKNOWN_TYPE"
+/>
+ <label for="cbGiftCard">Use a Gift Card</label>
+ <div id="divQvcGiftCardEntry">
+ <fieldset>
+ <label for="txtQvcGiftCardNumber">Card Number:</label>
+ <input id="txtQvcGiftCardNumber" name="txtQvcGiftCardNumber" type="tel" autocomplete="off" autocorrect="off" maxlength="19" size="19" value=""
+title="overall type: CREDIT_CARD_NUMBER server type: CREDIT_CARD_NUMBER heuristic type: CREDIT_CARD_NUMBER label: Card Number: parseable name: txtQvcGiftCardNumber field signature: 375442765 form signature: 12190733459375771907"
+autofill-prediction="CREDIT_CARD_NUMBER"
+/>
+ <label for="txtQvcGiftCardSecurityIdNumber">Security ID Number:</label>
+ <input id="txtQvcGiftCardSecurityIdNumber" name="txtQvcGiftCardSecurityIdNumber" type="text" autocomplete="off" autocorrect="off" maxlength="12" size="19" value=""
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Security ID Number: parseable name: txtQvcGiftCardSecurityIdNumber field signature: 383370886 form signature: 12190733459375771907"
+autofill-prediction="UNKNOWN_TYPE"
+/>
+ <span>
+ <input type="button" id="btnQvcGiftCardEnterAnotherCard" name="btnQvcGiftCardEnterAnotherCard" value="Enter Another Card" />
+ <span>Enter Another Card</span>
+ </span>
+ <br />
+ </fieldset>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div id="monetate_selectorHTML_c937b2dd_0">
+ <div id="mainVoucherCodeDiv">
+ <input id="txtQvcApplyCodeNumber" name="txtQvcApplyCodeNumber" type="text" autocomplete="off" autocorrect="off" maxlength="25" size="19" value="" />
+ <input type="button" id="btnApplyCode" value="Apply Code" />
+ </div>
+ </div>
+ <div id="divButtons">
+ <span>
+ <input type="button" id="btnSubmitChanges" value="Continue Checkout" />
+ <span>Continue Checkout</span>
+ </span>
+ <span>
+ <span>Continue Checkout</span>
+ <input type="button" id="btnQCard2" value="Continue Checkout" />
+ </span>
+ <span>
+ <input type="button" id="btnReturnToOrder" value="EDIT SHOPPING CART" />
+ <span>Edit Shopping cart</span>
+ </span>
+ </div>
+ </form>
+ <form id="captureFormFooter" method="post" name="captureFormFooter">
+ <div id="divEmailFormFooter">
+ <label for="emailAddress1Footer">Get sneak previews of special offers and upcoming events delivered to your inbox.</label>
+ <span id="emailAddressErrorFooter">*</span>
+ <input id="emailAddress1Footer" type="text" value="Enter email" />
+ <input id="emailAddress2Footer" type="text" value="Confirm email" />
+ <input id="signUpFooter" type="submit" value="Sign Up" />
+ <span id="disclaimerTextFooter">*You're signing up to receive QVC promotional email.</span>
+ </div>
+ </form>
+ </body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/third_party/README b/browser/extensions/formautofill/test/fixtures/third_party/README
new file mode 100644
index 0000000000..ca4750ec08
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/third_party/README
@@ -0,0 +1,4 @@
+This directory contains pages downloaded from the web for the purpose of testing
+Form Autofill against pages from the real world. These files are not made
+available under an open source license.
+
diff --git a/browser/extensions/formautofill/test/fixtures/third_party/Sears/PaymentOptions.html b/browser/extensions/formautofill/test/fixtures/third_party/Sears/PaymentOptions.html
new file mode 100644
index 0000000000..160823920e
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/third_party/Sears/PaymentOptions.html
@@ -0,0 +1,566 @@
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml">
+ <head>
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
+ <title>
+</title>
+ </head>
+ <body>
+ <meta http-equiv="imagetoolbar" content="no" />
+ <meta http-equiv="X-UA-Compatible" content="IE=edge" />
+ <title>Payment Options | Sears PartsDirect</title>
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <meta name="decorator" content="pdwCheckout" />
+ <input type="hidden" id="isCQTOFPDPPagesEnabled" value="true" />
+ <input type="hidden" id="cqHost" value="//www.searspartsdirect.com" />
+ <form id="modelSearchHeader" method="get" action="https://www.searspartsdirect.com/partsdirect/getModel.pd" name="modelSearch">
+ <fieldset>
+ <label>Try searching again:</label>
+ <label for="searchedModelField">Model Number</label>
+ <input id="searchedModelField" type="text"
+title="Enter model number" value="Enter model number" name="modelNumberPopUp" maxlength="35" />
+ <input type="hidden" name="shdMod" />
+ <input type="hidden" name="pathTaken" />
+ <input type="hidden" name="legacySlrSearch" />
+ </fieldset>
+ </form>
+ <form id="creditCard" name="creditCard" action="https://www.searspartsdirect.com/partsdirect/checkOut.pd" method="post" pd-form-id="1489978179816">
+ <input type="hidden" name="_eventId" value="goAddPaymentOpt" id="creditCard__eventId" />
+ <input type="hidden" name="paymentOptionStr" value="" id="creditCard_paymentOptionStr" />
+ <input type="hidden" name="userPaymentTypeId" value="" id="creditCard_userPaymentTypeId" />
+ <input type="hidden" name="associateDiscountInput" value="" id="creditCard_associateDiscountInput" />
+ <input type="hidden" name="saveAssociateId" value="false" id="creditCard_saveAssociateId" />
+ <input type="hidden" name="userPaymentCommercial" value="false" id="creditCard_userPaymentCommercial" />
+ <input type="hidden" name="paymentType" value="" id="creditCard_paymentType" />
+ <div id="GuestCreditCardForm">
+ <div>
+ <div>
+ <label for="order.paymentType.cardNumber">Card Number</label>
+ <input type="text" name="maskedCardNumber" maxlength="16" value="" id="maskedCardNumber" placeholder="Card Number"
+title="overall type: CREDIT_CARD_NUMBER server type: CREDIT_CARD_NUMBER heuristic type: CREDIT_CARD_NUMBER label: Card Number parseable name: maskedCardNumber field signature: 2745159259 form signature: 7671147655436241539"
+autofill-prediction="CREDIT_CARD_NUMBER"
+/>
+ </div>
+ <div>
+ <label for="order.paymentType.nameOnCard">Name On Card</label>
+ <input type="text" name="creditCardPaymentName" value="" id="creditCard_creditCardPaymentName" placeholder="Name On Card"
+title="overall type: CREDIT_CARD_NAME_FULL server type: CREDIT_CARD_NAME_FULL heuristic type: CREDIT_CARD_NAME_FULL label: Name On Card parseable name: creditCardPaymentName field signature: 2311472685 form signature: 7671147655436241539"
+autofill-prediction="CREDIT_CARD_NAME_FULL"
+/>
+ </div>
+ </div>
+ <div>
+ <div>
+ <div>
+ <label for="order.paymentType.securityCode">Security Code</label>
+ <input type="text" name="securityCode" value="" id="securityCode" placeholder="CVV"
+title="overall type: CREDIT_CARD_VERIFICATION_CODE server type: NO_SERVER_DATA heuristic type: CREDIT_CARD_VERIFICATION_CODE label: Security Code parseable name: securityCode field signature: 1305695504 form signature: 7671147655436241539"
+autofill-prediction="CREDIT_CARD_VERIFICATION_CODE"
+/>
+ </div>
+ <div>
+ <label for="order.paymentType.expirationDate">Expiration Date</label>
+ <select name="expMonth" id="expMonth"
+title="overall type: CREDIT_CARD_EXP_MONTH server type: CREDIT_CARD_EXP_MONTH heuristic type: CREDIT_CARD_EXP_MONTH label: Expiration Date parseable name: expMonth field signature: 2046285420 form signature: 7671147655436241539"
+autofill-prediction="CREDIT_CARD_EXP_MONTH"
+>
+ <option value="">Month</option>
+ <option value="1">01-January</option>
+ <option value="2">02-February</option>
+ <option value="3">03-March</option>
+ <option value="4">04-April</option>
+ <option value="5">05-May</option>
+ <option value="6">06-June</option>
+ <option value="7">07-July</option>
+ <option value="8">08-August</option>
+ <option value="9">09-September</option>
+ <option value="10">10-October</option>
+ <option value="11">11-November</option>
+ <option value="12">12-December</option>
+ </select>
+ </div>
+ <div>
+ <select name="expYear" id="expYear"
+title="overall type: CREDIT_CARD_EXP_4_DIGIT_YEAR server type: CREDIT_CARD_EXP_4_DIGIT_YEAR heuristic type: ADDRESS_HOME_CITY label: The City/State/ZIP Code combination you entered is incorrect. Please try again. Billing Address parseable name: expYear field signature: 2532266972 form signature: 7671147655436241539"
+autofill-prediction="CREDIT_CARD_EXP_4_DIGIT_YEAR"
+>
+ <option value="">Year</option>
+ <option value="2017">2017</option>
+ <option value="2018">2018</option>
+ <option value="2019">2019</option>
+ <option value="2020">2020</option>
+ <option value="2021">2021</option>
+ <option value="2022">2022</option>
+ <option value="2023">2023</option>
+ <option value="2024">2024</option>
+ <option value="2025">2025</option>
+ <option value="2026">2026</option>
+ <option value="2027">2027</option>
+ <option value="2028">2028</option>
+ <option value="2029">2029</option>
+ <option value="2030">2030</option>
+ </select>
+ </div>
+ </div>
+ </div>
+ </div>
+ </form>
+ <form id="anotherBillingAddress" name="anotherBillingAddress" method="post" pd-form-id="1489978179817">
+ <div id="billingAddressForm">
+ <div>
+ <label for="order.billingInfo.firstName">First Name<span>*</span>
+ </label>
+ <input type="text" name="order.billingInfo.firstName" maxlength="11" value="" id="order.billingInfo.firstName" placeholder="First Name *"
+title="overall type: NAME_FIRST server type: NAME_FIRST heuristic type: NAME_FIRST label: First Name* parseable name: firstName field signature: 3077178767 form signature: 17982067175666068474"
+autofill-prediction="NAME_FIRST"
+/>
+ </div>
+ <div>
+ <label for="order.billingInfo.lastName">Last Name<span>*</span>
+ </label>
+ <input type="text" name="order.billingInfo.lastName" value="" id="order.billingInfo.lastName" placeholder="Last Name *"
+title="overall type: NAME_LAST server type: NAME_LAST heuristic type: NAME_LAST label: Last Name* parseable name: lastName field signature: 2325932944 form signature: 17982067175666068474"
+autofill-prediction="NAME_LAST"
+/>
+ </div>
+ <div id="divCityStateZipId_3">
+ <div id="divCityStateZipId_2">
+ <div>
+ <div>
+ <label for="order.billingInfo.address.address1">Street Address<span>*</span> - 24 character limit</label>
+ <input type="text" name="order.billingInfo.address.address1" value="Island Drvie" id="order.billingInfo.address.address1" placeholder="Street Address *"
+title="overall type: ADDRESS_HOME_LINE1 server type: ADDRESS_HOME_LINE1 heuristic type: ADDRESS_HOME_LINE1 label: Street Address* - 24 character limit parseable name: address.address1 field signature: 796482076 form signature: 17982067175666068474"
+autofill-prediction="ADDRESS_HOME_LINE1"
+/>
+ </div>
+ <div>
+ <label for="order.billingInfo.address.address2">Apt. #</label>
+ <input type="text" name="order.billingInfo.address.address2" value="" id="order.billingInfo.address.address2" placeholder="Apt. #"
+title="overall type: ADDRESS_HOME_LINE2 server type: ADDRESS_HOME_LINE2 heuristic type: ADDRESS_HOME_LINE2 label: Apt. # parseable name: address.address2 field signature: 1242999964 form signature: 17982067175666068474"
+autofill-prediction="ADDRESS_HOME_LINE2"
+/>
+ </div>
+ </div>
+ <div id="divCityStateZipId">
+ <div>
+ <div>
+ <label for="order.billingInfo.address.city">City<span>*</span>
+ </label>
+ <input type="text" name="order.billingInfo.address.city" value="" id="order.billingInfo.address.city" placeholder="City *"
+title="overall type: ADDRESS_HOME_CITY server type: ADDRESS_HOME_CITY heuristic type: ADDRESS_HOME_CITY label: City* parseable name: address.city field signature: 1372321658 form signature: 17982067175666068474"
+autofill-prediction="ADDRESS_HOME_CITY"
+/>
+ </div>
+ <div>
+ <label for="order.billingInfo.address.state">State<span>*</span>
+ </label>
+ <select name="order.billingInfo.address.state" id="order.billingInfo.address.state"
+title="overall type: ADDRESS_HOME_STATE server type: ADDRESS_HOME_STATE heuristic type: ADDRESS_HOME_STATE label: State* parseable name: address.state field signature: 2106658457 form signature: 17982067175666068474"
+autofill-prediction="ADDRESS_HOME_STATE"
+>
+ <option value="">ST *</option>
+ <option value="AA">AA</option>
+ <option value="AE">AE</option>
+ <option value="AL">AL</option>
+ <option value="AK">AK</option>
+ <option value="AP">AP</option>
+ <option value="AZ">AZ</option>
+ <option value="AR">AR</option>
+ <option value="CA">CA</option>
+ <option value="CO">CO</option>
+ <option value="CT">CT</option>
+ <option value="DE">DE</option>
+ <option value="DC">DC</option>
+ <option value="FL">FL</option>
+ <option value="GA">GA</option>
+ <option value="GU">GU</option>
+ <option value="HI">HI</option>
+ <option value="ID">ID</option>
+ <option value="IL">IL</option>
+ <option value="IN">IN</option>
+ <option value="IA">IA</option>
+ <option value="KS">KS</option>
+ <option value="KY">KY</option>
+ <option value="LA">LA</option>
+ <option value="ME">ME</option>
+ <option value="MD">MD</option>
+ <option value="MA">MA</option>
+ <option value="MI">MI</option>
+ <option value="MN">MN</option>
+ <option value="MS">MS</option>
+ <option value="MO">MO</option>
+ <option value="MT">MT</option>
+ <option value="NE">NE</option>
+ <option value="NV">NV</option>
+ <option value="NH">NH</option>
+ <option value="NJ">NJ</option>
+ <option value="NM">NM</option>
+ <option value="NY">NY</option>
+ <option value="NC">NC</option>
+ <option value="ND">ND</option>
+ <option value="OH">OH</option>
+ <option value="OK">OK</option>
+ <option value="OR">OR</option>
+ <option value="PA">PA</option>
+ <option value="PR">PR</option>
+ <option value="RI">RI</option>
+ <option value="SC">SC</option>
+ <option value="SD">SD</option>
+ <option value="TN">TN</option>
+ <option value="TX">TX</option>
+ <option value="UT">UT</option>
+ <option value="VA">VA</option>
+ <option value="VI">VI</option>
+ <option value="VT">VT</option>
+ <option value="WA">WA</option>
+ <option value="WV">WV</option>
+ <option value="WI">WI</option>
+ <option value="WY">WY</option>
+ </select>
+ </div>
+ </div>
+ <div>
+ <label for="order.billingInfo.address.zipCode">ZIP/Postal Code<span>*</span>
+ </label>
+ <input type="text" name="order.billingInfo.address.zipCode" value="" id="order.billingInfo.address.zipCode" placeholder="ZIP Code *"
+title="overall type: ADDRESS_HOME_ZIP server type: ADDRESS_HOME_ZIP heuristic type: ADDRESS_HOME_ZIP label: ZIP/Postal Code* parseable name: address.zipCode field signature: 1420459778 form signature: 17982067175666068474"
+autofill-prediction="ADDRESS_HOME_ZIP"
+/>
+ </div>
+ <input type="hidden" id="order.billingInfo.address.poBoxOrMillitaryAdd" name="order.billingInfo.address.poBoxOrMillitaryAdd" value="" />
+ </div>
+ </div>
+ </div>
+ <div>
+ <div>
+ <label for="order.billingInfo.dayTimePhone">Phone Number<span>*</span>
+ </label>
+ <input type="text" name="order.billingInfo.dayTimePhone" value="" id="order.billingInfo.dayTimePhone" placeholder="Phone Number *"
+title="overall type: PHONE_HOME_CITY_AND_NUMBER server type: PHONE_HOME_CITY_AND_NUMBER heuristic type: PHONE_HOME_WHOLE_NUMBER label: Phone Number* parseable name: dayTimePhone field signature: 2509269658 form signature: 17982067175666068474"
+autofill-prediction="PHONE_HOME_CITY_AND_NUMBER"
+/>
+ </div>
+ <div>
+ <label for="order.billingInfo.dayTimePhoneExt">Ext.</label>
+ <input type="text" name="order.billingInfo.dayTimePhoneExt" value="" id="order.billingInfo.dayTimePhoneExt" placeholder="Ext."
+title="overall type: PHONE_HOME_CITY_CODE server type: PHONE_HOME_CITY_CODE heuristic type: PHONE_HOME_EXTENSION label: Ext. parseable name: dayTimePhoneExt field signature: 1836849076 form signature: 17982067175666068474"
+autofill-prediction="PHONE_HOME_CITY_CODE"
+/>
+ </div>
+ </div>
+ </div>
+ </form>
+ <form id="eCheck" name="eCheck" action="https://www.searspartsdirect.com/partsdirect/checkOut.pd" method="post" pd-form-id="1489978179818">
+ <input type="hidden" name="_eventId" value="goAddPaymentOpt" id="eCheck__eventId" />
+ <input type="hidden" name="paymentOptionStr" value="" id="eCheck_paymentOptionStr" />
+ <input type="hidden" name="userPaymentTypeId" value="" id="eCheck_userPaymentTypeId" />
+ <input type="hidden" name="associateDiscountInput" value="" id="eCheck_associateDiscountInput" />
+ <input type="hidden" name="saveAssociateId" value="false" id="eCheck_saveAssociateId" />
+ <input type="hidden" name="userPaymentCommercial" value="false" id="eCheck_userPaymentCommercial" />
+ <input type="hidden" name="paymentType" value="" id="eCheck_paymentType" />
+ <div>
+ <input type="radio" value="true" name="businessAccountFlag" id="fldBusinessAccountFlag"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: This is a Business Account parseable name: businessAccountFlag field signature: 1078565374 form signature: 11778620883203943321"
+autofill-prediction="UNKNOWN_TYPE"
+/>
+ <label for="fldBusinessAccountFlag">This is a Business Account</label>
+ </div>
+ <div>
+ <input type="radio" value="false" checked="checked" name="businessAccountFlag" id="fldPersonalAccountFlag"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: This is a Personal Account parseable name: businessAccountFlag field signature: 1078565374 form signature: 11778620883203943321"
+autofill-prediction="UNKNOWN_TYPE"
+/>
+ <label for="fldPersonalAccountFlag">This is a Personal Account</label>
+ </div>
+ <label for="echeckFirstName">First name<span>*</span>
+ </label>
+ <div>
+ <input type="text" name="echeckFirstName" value="" id="echeckFirstName" placeholder="First name *"
+title="overall type: NAME_FIRST server type: NAME_FIRST heuristic type: NAME_FIRST label: First name* parseable name: echeckFirstName field signature: 721631680 form signature: 11778620883203943321"
+autofill-prediction="NAME_FIRST"
+/>
+ </div>
+ <div>
+ <label for="echeckLastName">Last name<span>*</span>
+ </label>
+ <input type="text" name="echeckLastName" value="" id="echeckLastName" placeholder="Last name *"
+title="overall type: NAME_LAST server type: NAME_LAST heuristic type: NAME_LAST label: Last name* parseable name: echeckLastName field signature: 2195362343 form signature: 11778620883203943321"
+autofill-prediction="NAME_LAST"
+/>
+ </div>
+ <div>
+ <label for="maskedBankRoutingNumber">Bank routing number<span>*</span>
+ </label>
+ <input type="text" name="maskedBankRoutingNumber" value="" id="maskedBankRoutingNumber" placeholder="Bank routing number *"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Bank routing number* parseable name: maskedBankRoutingNumber field signature: 3997200887 form signature: 11778620883203943321"
+autofill-prediction="UNKNOWN_TYPE"
+/>
+ </div>
+ <div>
+ <label for="maskedCheckingAcctNumber">Checking account number<span>*</span>
+ </label>
+ <input type="text" name="maskedCheckingAcctNumber" value="" id="maskedCheckingAcctNumber" placeholder="Checking account number *"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Checking account number* parseable name: maskedCheckingAcctNumber field signature: 963371530 form signature: 11778620883203943321"
+autofill-prediction="UNKNOWN_TYPE"
+/>
+ </div>
+ <div>
+ <label for="checkNumber">Check number<span>*</span>
+ </label>
+ <input type="text" name="checkNumber" value="" id="checkNumber" placeholder="Check number *"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Check number* parseable name: checkNumber field signature: 1195469146 form signature: 11778620883203943321"
+autofill-prediction="UNKNOWN_TYPE"
+/>
+ </div>
+ <div id="personalAccountId">
+ <div>
+ <label for="maskedDriversLicence">Driver's license or state identification #<span>*</span>
+ </label>
+ <input type="text" name="maskedDriversLicence" value="" id="maskedDriversLicence" placeholder="Driver's license or state identification # *"
+title="overall type: ADDRESS_HOME_STATE server type: ADDRESS_HOME_STATE heuristic type: ADDRESS_HOME_STATE label: Driver's license or state identification #* parseable name: maskedDriversLicence field signature: 1753257915 form signature: 11778620883203943321"
+autofill-prediction="ADDRESS_HOME_STATE"
+/>
+ </div>
+ <div>
+ <label for="state">State issued<span>*</span>
+ </label>
+ <select name="state" id="state"
+title="overall type: ADDRESS_HOME_STATE server type: ADDRESS_HOME_STATE heuristic type: ADDRESS_HOME_STATE label: State issued* parseable name: state field signature: 1878375253 form signature: 11778620883203943321"
+autofill-prediction="ADDRESS_HOME_STATE"
+>
+ <option value="">ST *</option>
+ <option value="AA">AA</option>
+ <option value="AE">AE</option>
+ <option value="AL">AL</option>
+ <option value="AK">AK</option>
+ <option value="AP">AP</option>
+ <option value="AZ">AZ</option>
+ <option value="AR">AR</option>
+ <option value="CA">CA</option>
+ <option value="CO">CO</option>
+ <option value="CT">CT</option>
+ <option value="DE">DE</option>
+ <option value="DC">DC</option>
+ <option value="FL">FL</option>
+ <option value="GA">GA</option>
+ <option value="GU">GU</option>
+ <option value="HI">HI</option>
+ <option value="ID">ID</option>
+ <option value="IL">IL</option>
+ <option value="IN">IN</option>
+ <option value="IA">IA</option>
+ <option value="KS">KS</option>
+ <option value="KY">KY</option>
+ <option value="LA">LA</option>
+ <option value="ME">ME</option>
+ <option value="MD">MD</option>
+ <option value="MA">MA</option>
+ <option value="MI">MI</option>
+ <option value="MN">MN</option>
+ <option value="MS">MS</option>
+ <option value="MO">MO</option>
+ <option value="MT">MT</option>
+ <option value="NE">NE</option>
+ <option value="NV">NV</option>
+ <option value="NH">NH</option>
+ <option value="NJ">NJ</option>
+ <option value="NM">NM</option>
+ <option value="NY">NY</option>
+ <option value="NC">NC</option>
+ <option value="ND">ND</option>
+ <option value="OH">OH</option>
+ <option value="OK">OK</option>
+ <option value="OR">OR</option>
+ <option value="PA">PA</option>
+ <option value="PR">PR</option>
+ <option value="RI">RI</option>
+ <option value="SC">SC</option>
+ <option value="SD">SD</option>
+ <option value="TN">TN</option>
+ <option value="TX">TX</option>
+ <option value="UT">UT</option>
+ <option value="VA">VA</option>
+ <option value="VI">VI</option>
+ <option value="VT">VT</option>
+ <option value="WA">WA</option>
+ <option value="WV">WV</option>
+ <option value="WI">WI</option>
+ <option value="WY">WY</option>
+ </select>
+ </div>
+ <div>
+ <label>Date of birth<span>*</span>
+ </label>
+ <select name="bdayMonth" id="bdayMonth"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Date of birth* parseable name: bdayMonth field signature: 1907288957 form signature: 11778620883203943321"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ <option value="">Month</option>
+ <option value="1">January</option>
+ <option value="2">February</option>
+ <option value="3">March</option>
+ <option value="4">April</option>
+ <option value="5">May</option>
+ <option value="6">June</option>
+ <option value="7">July</option>
+ <option value="8">August</option>
+ <option value="9">September</option>
+ <option value="10">October</option>
+ <option value="11">November</option>
+ <option value="12">December</option>
+ </select>
+ <select name="bdayDate" id="bdayDate"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Date of birth* parseable name: bdayDate field signature: 2056433281 form signature: 11778620883203943321"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ <option value="">Date</option>
+ <option value="1">1</option>
+ <option value="2">2</option>
+ <option value="3">3</option>
+ <option value="4">4</option>
+ <option value="5">5</option>
+ <option value="6">6</option>
+ <option value="7">7</option>
+ <option value="8">8</option>
+ <option value="9">9</option>
+ <option value="10">10</option>
+ <option value="11">11</option>
+ <option value="12">12</option>
+ <option value="13">13</option>
+ <option value="14">14</option>
+ <option value="15">15</option>
+ <option value="16">16</option>
+ <option value="17">17</option>
+ <option value="18">18</option>
+ <option value="19">19</option>
+ <option value="20">20</option>
+ <option value="21">21</option>
+ <option value="22">22</option>
+ <option value="23">23</option>
+ <option value="24">24</option>
+ <option value="25">25</option>
+ <option value="26">26</option>
+ <option value="27">27</option>
+ <option value="28">28</option>
+ <option value="29">29</option>
+ <option value="30">30</option>
+ <option value="31">31</option>
+ </select>
+ <select name="bdayYear" id="bdayYear"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Date of birth* parseable name: bdayYear field signature: 938244373 form signature: 11778620883203943321"
+autofill-prediction="UNKNOWN_TYPE"
+>
+ <option value="">Year</option>
+ <option value="1999">1999</option>
+ <option value="1998">1998</option>
+ <option value="1997">1997</option>
+ <option value="1996">1996</option>
+ <option value="1995">1995</option>
+ <option value="1994">1994</option>
+ <option value="1993">1993</option>
+ <option value="1992">1992</option>
+ <option value="1991">1991</option>
+ <option value="1990">1990</option>
+ <option value="1989">1989</option>
+ <option value="1988">1988</option>
+ <option value="1987">1987</option>
+ <option value="1986">1986</option>
+ <option value="1985">1985</option>
+ <option value="1984">1984</option>
+ <option value="1983">1983</option>
+ <option value="1982">1982</option>
+ <option value="1981">1981</option>
+ <option value="1980">1980</option>
+ <option value="1979">1979</option>
+ <option value="1978">1978</option>
+ <option value="1977">1977</option>
+ <option value="1976">1976</option>
+ <option value="1975">1975</option>
+ <option value="1974">1974</option>
+ <option value="1973">1973</option>
+ <option value="1972">1972</option>
+ <option value="1971">1971</option>
+ <option value="1970">1970</option>
+ <option value="1969">1969</option>
+ <option value="1968">1968</option>
+ <option value="1967">1967</option>
+ <option value="1966">1966</option>
+ <option value="1965">1965</option>
+ <option value="1964">1964</option>
+ <option value="1963">1963</option>
+ <option value="1962">1962</option>
+ <option value="1961">1961</option>
+ <option value="1960">1960</option>
+ <option value="1959">1959</option>
+ <option value="1958">1958</option>
+ <option value="1957">1957</option>
+ <option value="1956">1956</option>
+ <option value="1955">1955</option>
+ <option value="1954">1954</option>
+ <option value="1953">1953</option>
+ <option value="1952">1952</option>
+ <option value="1951">1951</option>
+ <option value="1950">1950</option>
+ <option value="1949">1949</option>
+ <option value="1948">1948</option>
+ <option value="1947">1947</option>
+ <option value="1946">1946</option>
+ <option value="1945">1945</option>
+ <option value="1944">1944</option>
+ <option value="1943">1943</option>
+ <option value="1942">1942</option>
+ <option value="1941">1941</option>
+ <option value="1940">1940</option>
+ <option value="1939">1939</option>
+ <option value="1938">1938</option>
+ <option value="1937">1937</option>
+ <option value="1936">1936</option>
+ <option value="1935">1935</option>
+ <option value="1934">1934</option>
+ <option value="1933">1933</option>
+ <option value="1932">1932</option>
+ <option value="1931">1931</option>
+ <option value="1930">1930</option>
+ <option value="1929">1929</option>
+ <option value="1928">1928</option>
+ <option value="1927">1927</option>
+ <option value="1926">1926</option>
+ <option value="1925">1925</option>
+ <option value="1924">1924</option>
+ <option value="1923">1923</option>
+ <option value="1922">1922</option>
+ <option value="1921">1921</option>
+ <option value="1920">1920</option>
+ <option value="1919">1919</option>
+ <option value="1918">1918</option>
+ <option value="1917">1917</option>
+ <option value="1916">1916</option>
+ <option value="1915">1915</option>
+ <option value="1914">1914</option>
+ <option value="1913">1913</option>
+ <option value="1912">1912</option>
+ <option value="1911">1911</option>
+ <option value="1910">1910</option>
+ <option value="1909">1909</option>
+ <option value="1908">1908</option>
+ <option value="1907">1907</option>
+ <option value="1906">1906</option>
+ <option value="1905">1905</option>
+ <option value="1904">1904</option>
+ <option value="1903">1903</option>
+ <option value="1902">1902</option>
+ <option value="1901">1901</option>
+ <option value="1900">1900</option>
+ <option value="1899">1899</option>
+ </select>
+ </div>
+ </div>
+ </form>
+ <form id="emailForUpdates" name="emailForUpdates" action="https://www.searspartsdirect.com/partsdirect/offerEmailsAction.pd" method="post">
+ <fieldset>
+ <input type="text" id="emailAdd" name="emailAddress" value="" tabindex="4" maxlength="50" />
+ <label for="emailAdd">enter email address</label>
+ </fieldset>
+ </form>
+ </body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/third_party/Sears/ShippingAddress.html b/browser/extensions/formautofill/test/fixtures/third_party/Sears/ShippingAddress.html
new file mode 100644
index 0000000000..a9d1f5cd0f
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/third_party/Sears/ShippingAddress.html
@@ -0,0 +1,447 @@
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml">
+ <head>
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
+ <title>
+</title>
+ </head>
+ <body>
+ <meta http-equiv="imagetoolbar" content="no" />
+ <meta http-equiv="X-UA-Compatible" content="IE=edge" />
+ <title>Shipping address | Sears PartsDirect</title>
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <meta name="decorator" content="pdwCheckout" />
+ <input type="hidden" id="isCQTOFPDPPagesEnabled" value="true" />
+ <input type="hidden" id="cqHost" value="//www.searspartsdirect.com" />
+ <form id="modelSearchHeader" method="get" action="https://www.searspartsdirect.com/partsdirect/getModel.pd" name="modelSearch" autocomplete="off">
+ <fieldset>
+ <label>Try searching again:</label>
+ <label for="searchedModelField">Model Number</label>
+ <input id="searchedModelField" type="text"
+title="Enter model number" value="Enter model number" name="modelNumberPopUp" maxlength="35" />
+ <input type="hidden" name="shdMod" />
+ <input type="hidden" name="pathTaken" />
+ <input type="hidden" name="legacySlrSearch" />
+ </fieldset>
+ </form>
+ <form id="forgotModalPswForm" name="forgotModalPswForm" action="https://www.searspartsdirect.com/partsdirect/initCheckoutAction.pd" method="post" autocomplete="off">
+ <input type="hidden" name="currentForgotPageURL" value="" id="currentForgotPageURL" />
+ <div>
+ <label for="email">Email</label>
+ </div>
+ <div>
+ <input type="text" name="email" id="forgotPwdFldEmail" />
+ </div>
+ <div>
+ <input type="hidden" value="true" name="isCaptchaEnabled" id="isCaptchaEnabled" />
+ <div>
+ <div>
+ <input type="hidden" value="VHM6MjAxNy0wMy0xOVQyMToxMjo1MFphYWExNjExNjM0OHp6ejIwMTctMDMtMTkgMjE6MTI6NTA=" name="captchaKey" id="forgotPwdFldCaptchaKey" />
+ <fieldset>
+ <input type="text" value="" tabindex="111" name="captchaText" id="forgotPwdFldCaptchaTxt" placeholder="Enter text" />
+ </fieldset>
+ </div>
+ </div>
+ <p>
+</p>
+ </div>
+ <input type="hidden" name="commercialUI" value="false" />
+ <input type="hidden" name="returnTo" value="#returnToVal" id="forgotModalPswForm_returnTo" />
+ <input type="hidden" name="commercialUI" value="#commercialUIVal" id="forgotModalPswForm_commercialUI" />
+ </form>
+ <form id="checkOut" name="checkOut" action="https://www.searspartsdirect.com/partsdirect/checkOut.pd" method="post" autocomplete="off" pd-form-id="1489975972490">
+ <input type="hidden" name="_eventId" value="goAddAddress" id="checkOut__eventId" />
+ <div id="shippingForm">
+ <input type="hidden" name="isShippingAddressChanged" value="false" id="isShippingAddressChanged" />
+ <input type="hidden" name="isShippingAddressEdited" value="false" id="isShippingAddressEdited" />
+ <div id="shippingFormContainer">
+ <div>
+ <label for="order.shippingInfo.firstName">First Name<span>*</span>
+ </label>
+ <input type="text" name="order.shippingInfo.firstName" maxlength="11" value="" id="checkOut_order_shippingInfo_firstName" placeholder="First Name *"
+title="overall type: NAME_FIRST server type: NAME_FIRST heuristic type: NAME_FIRST label: First Name* parseable name: order.shippingInfo.firstName field signature: 243029182 form signature: 17155013134718564270"
+autofill-prediction="NAME_FIRST"
+/>
+ </div>
+ <div>
+ <label>Last Name<span>*</span>
+ </label>
+ <input type="text" name="order.shippingInfo.lastName" value="" id="checkOut_order_shippingInfo_lastName" placeholder="Last Name *"
+title="overall type: NAME_LAST server type: NAME_LAST heuristic type: NAME_LAST label: Last Name* parseable name: order.shippingInfo.lastName field signature: 1858327987 form signature: 17155013134718564270"
+autofill-prediction="NAME_LAST"
+/>
+ </div>
+ <div id="divCityStateZipId_3">
+ <div id="divCityStateZipId_2">
+ <div>
+ <div>
+ <label for="order.shippingInfo.address.originalAddress">Street Address<span>*</span> - 24 character limit</label>
+ <input type="text" name="order.shippingInfo.address.originalAddress" value="" id="checkOut_order_shippingInfo_address_originalAddress" placeholder="Street Address *"
+title="overall type: ADDRESS_HOME_LINE1 server type: ADDRESS_HOME_LINE1 heuristic type: ADDRESS_HOME_LINE1 label: Street Address* - 24 character limit parseable name: order.shippingInfo.address.originalAddress field signature: 3461422700 form signature: 17155013134718564270"
+autofill-prediction="ADDRESS_HOME_LINE1"
+/>
+ <div>24 character limit</div>
+ </div>
+ <div>
+ <label for="order.shippingInfo.address.address2">Apt.
+ #</label>
+ <input type="text" name="order.shippingInfo.address.address2" value="" id="checkOut_order_shippingInfo_address_address2" placeholder="Apt. #"
+title="overall type: ADDRESS_HOME_LINE2 server type: ADDRESS_HOME_LINE2 heuristic type: ADDRESS_HOME_LINE2 label: Apt. # Apt. # parseable name: order.shippingInfo.address.address2 field signature: 1992141399 form signature: 17155013134718564270"
+autofill-prediction="ADDRESS_HOME_LINE2"
+/>
+ </div>
+ </div>
+ <div id="divCityStateZipId">
+ <div>
+ <div>
+ <label for="order.shippingInfo.address.city">City<span>*</span>
+ </label>
+ <input type="text" name="order.shippingInfo.address.city" value="" id="checkOut_order_shippingInfo_address_city" placeholder="City *"
+title="overall type: ADDRESS_HOME_CITY server type: ADDRESS_HOME_CITY heuristic type: ADDRESS_HOME_CITY label: City* parseable name: order.shippingInfo.address.city field signature: 2864354290 form signature: 17155013134718564270"
+autofill-prediction="ADDRESS_HOME_CITY"
+/>
+ </div>
+ <div>
+ <label for="order.shippingInfo.address.state">State<span>*</span>
+ </label>
+ <select name="order.shippingInfo.address.state" id="checkOut_order_shippingInfo_address_state"
+title="overall type: ADDRESS_HOME_STATE server type: ADDRESS_HOME_STATE heuristic type: ADDRESS_HOME_STATE label: State* parseable name: order.shippingInfo.address.state field signature: 296886288 form signature: 17155013134718564270"
+autofill-prediction="ADDRESS_HOME_STATE"
+>
+ <option value="">ST *</option>
+ <option value="AA">AA</option>
+ <option value="AE">AE</option>
+ <option value="AL">AL</option>
+ <option value="AK">AK</option>
+ <option value="AP">AP</option>
+ <option value="AZ">AZ</option>
+ <option value="AR">AR</option>
+ <option value="CA">CA</option>
+ <option value="CO">CO</option>
+ <option value="CT">CT</option>
+ <option value="DE">DE</option>
+ <option value="DC">DC</option>
+ <option value="FL">FL</option>
+ <option value="GA">GA</option>
+ <option value="GU">GU</option>
+ <option value="HI">HI</option>
+ <option value="ID">ID</option>
+ <option value="IL">IL</option>
+ <option value="IN">IN</option>
+ <option value="IA">IA</option>
+ <option value="KS">KS</option>
+ <option value="KY">KY</option>
+ <option value="LA">LA</option>
+ <option value="ME">ME</option>
+ <option value="MD">MD</option>
+ <option value="MA">MA</option>
+ <option value="MI">MI</option>
+ <option value="MN">MN</option>
+ <option value="MS">MS</option>
+ <option value="MO">MO</option>
+ <option value="MT">MT</option>
+ <option value="NE">NE</option>
+ <option value="NV">NV</option>
+ <option value="NH">NH</option>
+ <option value="NJ">NJ</option>
+ <option value="NM">NM</option>
+ <option value="NY">NY</option>
+ <option value="NC">NC</option>
+ <option value="ND">ND</option>
+ <option value="OH">OH</option>
+ <option value="OK">OK</option>
+ <option value="OR">OR</option>
+ <option value="PA">PA</option>
+ <option value="PR">PR</option>
+ <option value="RI">RI</option>
+ <option value="SC">SC</option>
+ <option value="SD">SD</option>
+ <option value="TN">TN</option>
+ <option value="TX">TX</option>
+ <option value="UT">UT</option>
+ <option value="VA">VA</option>
+ <option value="VI">VI</option>
+ <option value="VT">VT</option>
+ <option value="WA">WA</option>
+ <option value="WV">WV</option>
+ <option value="WI">WI</option>
+ <option value="WY">WY</option>
+ </select>
+ </div>
+ </div>
+ <div>
+ <label for="order.shippingInfo.address.zipCode">ZIP/Postal Code<span>*</span>
+ </label>
+ <input type="text" name="order.shippingInfo.address.zipCode" value="" id="checkOut_order_shippingInfo_address_zipCode" placeholder="ZIP Code *"
+title="overall type: ADDRESS_HOME_ZIP server type: ADDRESS_HOME_ZIP heuristic type: ADDRESS_HOME_ZIP label: ZIP/Postal Code* parseable name: order.shippingInfo.address.zipCode field signature: 2432211277 form signature: 17155013134718564270"
+autofill-prediction="ADDRESS_HOME_ZIP"
+/>
+ </div>
+ <input type="hidden" id="order.shippingInfo.address.poBoxOrMillitaryAdd" name="order.shippingInfo.address.poBoxOrMillitaryAdd" value="" />
+ </div>
+ </div>
+ </div>
+ <div>
+ <div>
+ <label for="order.shippingInfo.dayTimePhone">Phone Number<span>*</span>
+ </label>
+ <input type="text" name="order.shippingInfo.dayTimePhone" value="" id="checkOut_order_shippingInfo_dayTimePhone" placeholder="Phone Number *"
+title="overall type: PHONE_HOME_CITY_AND_NUMBER server type: PHONE_HOME_CITY_AND_NUMBER heuristic type: PHONE_HOME_WHOLE_NUMBER label: Phone Number* parseable name: order.shippingInfo.dayTimePhone field signature: 3680252951 form signature: 17155013134718564270"
+autofill-prediction="PHONE_HOME_CITY_AND_NUMBER"
+/>
+ </div>
+ <div>
+ <label for="order.shippingInfo.dayTimePhoneExt">Ext.</label>
+ <input type="text" name="order.shippingInfo.dayTimePhoneExt" value="" id="checkOut_order_shippingInfo_dayTimePhoneExt" placeholder="Ext."
+title="overall type: PHONE_HOME_EXTENSION server type: NO_SERVER_DATA heuristic type: PHONE_HOME_EXTENSION label: Ext. parseable name: order.shippingInfo.dayTimePhoneExt field signature: 3095839543 form signature: 17155013134718564270"
+autofill-prediction="PHONE_HOME_EXTENSION"
+/>
+ </div>
+ </div>
+ <div>
+ <label for="order.shippingInfo.email">Email Address<span>*</span>
+ </label>
+ <input type="text" name="order.shippingInfo.email" value="" id="confirmEmailDiv" placeholder="Email Address *"
+title="overall type: EMAIL_ADDRESS server type: EMAIL_ADDRESS heuristic type: EMAIL_ADDRESS label: Email Address* parseable name: order.shippingInfo.email field signature: 928233428 form signature: 17155013134718564270"
+autofill-prediction="EMAIL_ADDRESS"
+/>
+ </div>
+ <div>
+ <label for="order.shippingInfo.emailConfirm">Confirm Email Address<span>*</span>
+ </label>
+ <input type="text" placeholder="Email Address *" name="order.shippingInfo.emailConfirm" value=""
+title="overall type: EMAIL_ADDRESS server type: EMAIL_ADDRESS heuristic type: EMAIL_ADDRESS label: Confirm Email Address* parseable name: order.shippingInfo.emailConfirm field signature: 3242992973 form signature: 17155013134718564270"
+autofill-prediction="EMAIL_ADDRESS"
+/>
+ </div>
+ </div>
+ </div>
+ <div>
+ <input type="checkbox" name="order.shippingBillingSame" value="true" checked="checked" id="order.shippingBillingSame"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Same as shipping address parseable name: order.shippingBillingSame field signature: 1817157586 form signature: 17155013134718564270"
+autofill-prediction="UNKNOWN_TYPE"
+/>
+ <input type="hidden" id="__checkbox_order.shippingBillingSame" name="__checkbox_order.shippingBillingSame" value="true" />
+ <label for="order.shippingBillingSame">Same as shipping address</label>
+ </div>
+ <div id="billingForm">
+ <div id="billingFormContainer">
+ <div id="billingAddressFormId">
+ <div>
+ <label for="order.billingInfo.firstName">First Name<span>*</span>
+ </label>
+ <input type="text" name="order.billingInfo.firstName" maxlength="11" value="" id="checkOut_order_billingInfo_firstName" placeholder="First Name *"
+title="overall type: NAME_FIRST server type: NAME_FIRST heuristic type: NAME_FIRST label: First Name* parseable name: order.billingInfo.firstName field signature: 3077178767 form signature: 17155013134718564270"
+autofill-prediction="NAME_FIRST"
+/>
+ </div>
+ <div>
+ <label for="order.billingInfo.lastName">Last Name<span>*</span>
+ </label>
+ <input type="text" name="order.billingInfo.lastName" value="" id="checkOut_order_billingInfo_lastName" placeholder="Last Name *"
+title="overall type: NAME_LAST server type: NAME_LAST heuristic type: NAME_LAST label: Last Name* parseable name: order.billingInfo.lastName field signature: 2325932944 form signature: 17155013134718564270"
+autofill-prediction="NAME_LAST"
+/>
+ </div>
+ <div>
+ <div>
+ <label for="order.billingInfo.address.address1">Street Address<span>*</span> - 24 character limit</label>
+ <input type="text" name="order.billingInfo.address.address1" value="" id="checkOut_order_billingInfo_address_address1" placeholder="Street Address *"
+title="overall type: ADDRESS_HOME_LINE1 server type: ADDRESS_HOME_LINE1 heuristic type: ADDRESS_HOME_LINE1 label: Street Address* - 24 character limit parseable name: order.billingInfo.address.address1 field signature: 796482076 form signature: 17155013134718564270"
+autofill-prediction="ADDRESS_HOME_LINE1"
+/>
+ </div>
+ <div>
+ <label for="order.shippingInfo.address.address2">Apt.
+ #</label>
+ <input type="text" name="order.billingInfo.address.address2" value="" id="checkOut_order_billingInfo_address_address2" placeholder="Apt. #"
+title="overall type: ADDRESS_HOME_LINE2 server type: ADDRESS_HOME_LINE2 heuristic type: ADDRESS_HOME_LINE2 label: Apt. # parseable name: order.billingInfo.address.address2 field signature: 1242999964 form signature: 17155013134718564270"
+autofill-prediction="ADDRESS_HOME_LINE2"
+/>
+ </div>
+ </div>
+ <div>
+ <div>
+ <div>
+ <label for="order.billingInfo.address.city">City<span>*</span>
+ </label>
+ <input type="text" name="order.billingInfo.address.city" value="" id="checkOut_order_billingInfo_address_city" placeholder="City *"
+title="overall type: ADDRESS_HOME_CITY server type: ADDRESS_HOME_CITY heuristic type: ADDRESS_HOME_CITY label: City* parseable name: order.billingInfo.address.city field signature: 1372321658 form signature: 17155013134718564270"
+autofill-prediction="ADDRESS_HOME_CITY"
+/>
+ </div>
+ <div>
+ <label for="order.billingInfo.address.state">State<span>*</span>
+ </label>
+ <select name="order.billingInfo.address.state" id="checkOut_order_billingInfo_address_state"
+title="overall type: ADDRESS_HOME_STATE server type: ADDRESS_HOME_STATE heuristic type: ADDRESS_HOME_STATE label: State* parseable name: order.billingInfo.address.state field signature: 2106658457 form signature: 17155013134718564270"
+autofill-prediction="ADDRESS_HOME_STATE"
+>
+ <option value="">ST *</option>
+ <option value="AA">AA</option>
+ <option value="AE">AE</option>
+ <option value="AL">AL</option>
+ <option value="AK">AK</option>
+ <option value="AP">AP</option>
+ <option value="AZ">AZ</option>
+ <option value="AR">AR</option>
+ <option value="CA">CA</option>
+ <option value="CO">CO</option>
+ <option value="CT">CT</option>
+ <option value="DE">DE</option>
+ <option value="DC">DC</option>
+ <option value="FL">FL</option>
+ <option value="GA">GA</option>
+ <option value="GU">GU</option>
+ <option value="HI">HI</option>
+ <option value="ID">ID</option>
+ <option value="IL">IL</option>
+ <option value="IN">IN</option>
+ <option value="IA">IA</option>
+ <option value="KS">KS</option>
+ <option value="KY">KY</option>
+ <option value="LA">LA</option>
+ <option value="ME">ME</option>
+ <option value="MD">MD</option>
+ <option value="MA">MA</option>
+ <option value="MI">MI</option>
+ <option value="MN">MN</option>
+ <option value="MS">MS</option>
+ <option value="MO">MO</option>
+ <option value="MT">MT</option>
+ <option value="NE">NE</option>
+ <option value="NV">NV</option>
+ <option value="NH">NH</option>
+ <option value="NJ">NJ</option>
+ <option value="NM">NM</option>
+ <option value="NY">NY</option>
+ <option value="NC">NC</option>
+ <option value="ND">ND</option>
+ <option value="OH">OH</option>
+ <option value="OK">OK</option>
+ <option value="OR">OR</option>
+ <option value="PA">PA</option>
+ <option value="PR">PR</option>
+ <option value="RI">RI</option>
+ <option value="SC">SC</option>
+ <option value="SD">SD</option>
+ <option value="TN">TN</option>
+ <option value="TX">TX</option>
+ <option value="UT">UT</option>
+ <option value="VA">VA</option>
+ <option value="VI">VI</option>
+ <option value="VT">VT</option>
+ <option value="WA">WA</option>
+ <option value="WV">WV</option>
+ <option value="WI">WI</option>
+ <option value="WY">WY</option>
+ </select>
+ </div>
+ </div>
+ <div>
+ <label for="order.billingInfo.address.zipCode">ZIP/Postal Code<span>*</span>
+ </label>
+ <input type="text" name="order.billingInfo.address.zipCode" value="" id="checkOut_order_billingInfo_address_zipCode" placeholder="ZIP Code *"
+title="overall type: ADDRESS_HOME_ZIP server type: ADDRESS_HOME_ZIP heuristic type: ADDRESS_HOME_ZIP label: ZIP/Postal Code* parseable name: order.billingInfo.address.zipCode field signature: 1420459778 form signature: 17155013134718564270"
+autofill-prediction="ADDRESS_HOME_ZIP"
+/>
+ </div>
+ <input type="hidden" id="order.shippingInfo.address.poBoxOrMillitaryAdd" name="order.shippingInfo.address.poBoxOrMillitaryAdd" value="" />
+ </div>
+ <div>
+ <div>
+ <label for="order.billingInfo.dayTimePhone">Phone Number<span>*</span>
+ </label>
+ <input type="text" name="order.billingInfo.dayTimePhone" value="" id="checkOut_order_billingInfo_dayTimePhone" placeholder="Phone Number *"
+title="overall type: PHONE_HOME_CITY_AND_NUMBER server type: PHONE_HOME_CITY_AND_NUMBER heuristic type: PHONE_HOME_WHOLE_NUMBER label: Phone Number* parseable name: order.billingInfo.dayTimePhone field signature: 2509269658 form signature: 17155013134718564270"
+autofill-prediction="PHONE_HOME_CITY_AND_NUMBER"
+/>
+ </div>
+ <div>
+ <label for="order.billingInfo.dayTimePhoneExt">Ext.</label>
+ <input type="text" name="order.billingInfo.dayTimePhoneExt" value="" id="checkOut_order_billingInfo_dayTimePhoneExt" placeholder="Ext."
+title="overall type: PHONE_HOME_CITY_CODE server type: PHONE_HOME_CITY_CODE heuristic type: PHONE_HOME_EXTENSION label: Ext. parseable name: order.billingInfo.dayTimePhoneExt field signature: 1836849076 form signature: 17155013134718564270"
+autofill-prediction="PHONE_HOME_CITY_CODE"
+/>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div>
+ <input type="checkbox" name="orderSupport.saveShippingAddress" value="true" id="saveShipping"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Save my shipping address in My Profile. parseable name: orderSupport.saveShippingAddress field signature: 644246410 form signature: 17155013134718564270"
+autofill-prediction="UNKNOWN_TYPE"
+/>
+ <input type="hidden" id="__checkbox_saveShipping" name="__checkbox_orderSupport.saveShippingAddress" value="true" />
+ <label for="saveShipping">Save my shipping address in My Profile.</label>
+ <input type="checkbox" name="orderSupport.saveBillingAddress" value="true" id="saveBilling"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Save my billing address in My Profile. parseable name: orderSupport.saveBillingAddress field signature: 2778625714 form signature: 17155013134718564270"
+autofill-prediction="UNKNOWN_TYPE"
+/>
+ <input type="hidden" id="__checkbox_saveBilling" name="__checkbox_orderSupport.saveBillingAddress" value="true" />
+ <label for="saveBilling">Save my billing address in My Profile.</label>
+ </div>
+ </div>
+ <div id="createProfileContainer">
+ <div>
+ <label for="orderSupport.profilePassword">Password</label>
+ <input type="password" placeholder="Password" name="orderSupport.profilePassword"
+title="overall type: ACCOUNT_CREATION_PASSWORD server type: ACCOUNT_CREATION_PASSWORD heuristic type: UNKNOWN_TYPE label: Password Retype Password parseable name: orderSupport.profilePassword field signature: 2304557611 form signature: 17155013134718564270"
+autofill-prediction="ACCOUNT_CREATION_PASSWORD"
+/>
+ </div>
+ <div>
+ <label for="orderSupport.profilePassword">Retype Password</label>
+ <input type="password" placeholder="Retype Password" name="orderSupport.profilePasswordConfirm"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Retype Password parseable name: orderSupport.profilePasswordConfirm field signature: 196269180 form signature: 17155013134718564270"
+autofill-prediction="UNKNOWN_TYPE"
+/>
+ </div>
+ </div>
+ <div>
+ <input type="checkbox" name="order.shippingInfo.emailPromotion" value="true" checked="checked" id="order.shippingInfo.emailPromotion"
+title="overall type: UNKNOWN_TYPE server type: NO_SERVER_DATA heuristic type: UNKNOWN_TYPE label: Send me promotions, discounts and other special information from Sears.com. parseable name: order.shippingInfo.emailPromotion field signature: 2403849306 form signature: 17155013134718564270"
+autofill-prediction="UNKNOWN_TYPE"
+/>
+ <input type="hidden" id="__checkbox_order.shippingInfo.emailPromotion" name="__checkbox_order.shippingInfo.emailPromotion" value="true" />
+ <label for="order.shippingInfo.emailPromotion">Send me promotions, discounts and other special information from Sears.com.</label>
+ </div>
+ <div>
+ <ul>
+ <li>
+ <input type="submit"
+title="Continue checkout" value="Shipping Options" />
+ </li>
+ </ul>
+ </div>
+ </form>
+ <form id="verifyAddressForm" name="verifyAddressForm" action="https://www.searspartsdirect.com/partsdirect/checkOut.pd" method="post" autocomplete="off">
+ <input type="hidden" name="_eventId" value="goVerifyAddress" id="verifyAddressForm__eventId" />
+ <input type="hidden" id="CheckoutFlowAction_geoCode_submitted" name="geoCode" value="" />
+ </form>
+ <form id="emailForUpdates" name="emailForUpdates" action="https://www.searspartsdirect.com/partsdirect/offerEmailsAction.pd" method="post" autocomplete="off">
+ <fieldset>
+ <input type="text" id="emailAdd" name="emailAddress" value="" tabindex="4" maxlength="50" />
+ <label for="emailAdd">enter email address</label>
+ </fieldset>
+ </form>
+ <form id="shipSignForm" method="post" action="https://sso.shld.net/shccas/shcLogin" autocomplete="off" name="shipSignForm">
+ <input type="hidden" name="s" id="s" />
+ <input type="hidden" name="k" id="k" />
+ <input type="hidden" name="renew" value="true" id="renew" />
+ <input type="hidden" value="21" name="sourceSiteId" id="sourceSiteId" />
+ <input type="hidden" name="service" value="https://www.searspartsdirect.com/partsdirect/j_spring_cas_security_check" id="service" />
+ <div>
+ <label for="loginId">Email Address
+ <span>Required</span>
+ </label>
+ <input placeholder="Email Address" type="text" name="loginId" value="" id="email" />
+ </div>
+ <div>
+ <label for="password">Password<a tabindex="3">Forgot your password?</a>
+ </label>
+ <input placeholder="Password" type="password" value="" z-index="2000" name="logonPassword" id="password" />
+ </div>
+ </form>
+ </body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/third_party/Staples/Basic.html b/browser/extensions/formautofill/test/fixtures/third_party/Staples/Basic.html
new file mode 100644
index 0000000000..cf9e892cb2
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/third_party/Staples/Basic.html
@@ -0,0 +1,117 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta name="generator" content="HTML Tidy for HTML5 for Mac OS X version 5.4.0">
+ <title>It's easy to find the Office Supplies, Copy Paper,
+ Furniture, Ink, Toner, Cleaning Products, Electronics and the
+ Technology you need | Staples®</title>
+ <meta content="checkout" name="PageName">
+ <meta content="IE=edge,chrome=1" http-equiv="X-UA-Compatible">
+ <meta content="noindex,follow" name="robots">
+ <meta content="Shop Staples® for everyday low prices and get everything you need for a home office or business. Staples Rewards� members get free shipping every day and up to 5% back in rewards, some exclusions apply."
+ name="description">
+ <meta name="viewport" content="width=null, initial-scale=1">
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
+ <meta http-equiv="x-dns-prefetch-control" content="on">
+ <meta http-equiv="content-language" content="en-us">
+</head>
+<body>
+ <form>
+ <input autocomplete="false" name="hidden" type="text">
+ <div>
+ <div>
+ <div>
+ <label for="firstName">
+<span>First Name</span>
+ <span>*</span>
+</label>
+<br>
+ <input name="firstName" maxlength="40" autocomplete="off"
+ placeholder="" type="text">
+ </div>
+ </div>
+ <div>
+ <div>
+ <label for="lastName">
+<span>Last
+ Name</span>
+<span>*</span>
+</label>
+<br>
+ <input name="lastName" maxlength="40" autocomplete="off"
+ placeholder="" type="text">
+ </div>
+ </div>
+ <div>
+ <div>
+ <div>
+ <div>
+ <label for="address1">Shipping
+ Address<span>*</span>
+</label>
+<br>
+ <input name="address1" maxlength="35" autocomplete="off" placeholder="" id="oneAutoComplete" type="text">
+ </div>
+ </div>
+ </div>
+ </div>
+ <div>
+ <div>
+ <label for="emailId">
+<span>Email
+ Address</span>
+<span>*</span>
+</label>
+<br>
+ <input name="emailId" maxlength="80" autocomplete="off"
+ placeholder="" type="text">
+ </div>
+ </div>
+ <div>
+ <div>
+ <label for="phoneNo">
+<span>Phone</span>
+<span>*</span>
+</label>
+<br>
+ <input name="phoneNo" autocomplete="off" type="text">
+ </div>
+ </div>
+ <div>
+ <div>
+ <label for="phoneEx">
+<span>Extn.</span>
+</label>
+<br>
+ <input name="phoneEx" maxlength="6" autocomplete="off"
+ placeholder="" type="text">
+ </div>
+ </div>
+ <div>
+ <div>
+ <label for="companyName">
+<span>Company Name
+ (optional)</span>
+</label>
+<br>
+ <input name="companyName" maxlength="50" autocomplete="off" placeholder="" type="text">
+ </div>
+ </div>
+ <div>
+ <div>
+ <div tabindex="0">
+ <label>Email me exclusive offers & deals from
+ Staples</label>
+<input value="true" name="isEmailOfferChecked" type="hidden">
+ </div>
+ </div>
+ </div>
+ <div>
+ <div>
+ <button type="submit">continue</button>
+ </div>
+ </div>
+ </div>
+ </form>
+</body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/third_party/Staples/Basic_ac_on.html b/browser/extensions/formautofill/test/fixtures/third_party/Staples/Basic_ac_on.html
new file mode 100644
index 0000000000..d3ba1116aa
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/third_party/Staples/Basic_ac_on.html
@@ -0,0 +1,117 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta name="generator" content="HTML Tidy for HTML5 for Mac OS X version 5.4.0">
+ <title>It's easy to find the Office Supplies, Copy Paper,
+ Furniture, Ink, Toner, Cleaning Products, Electronics and the
+ Technology you need | Staples®</title>
+ <meta content="checkout" name="PageName">
+ <meta content="IE=edge,chrome=1" http-equiv="X-UA-Compatible">
+ <meta content="noindex,follow" name="robots">
+ <meta content="Shop Staples® for everyday low prices and get everything you need for a home office or business. Staples Rewards� members get free shipping every day and up to 5% back in rewards, some exclusions apply."
+ name="description">
+ <meta name="viewport" content="width=null, initial-scale=1">
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
+ <meta http-equiv="x-dns-prefetch-control" content="on">
+ <meta http-equiv="content-language" content="en-us">
+</head>
+<body>
+ <form>
+ <input name="hidden" type="text">
+ <div>
+ <div>
+ <div>
+ <label for="firstName">
+<span>First Name</span>
+ <span>*</span>
+</label>
+<br>
+ <input name="firstName" maxlength="40" placeholder=""
+ type="text">
+ </div>
+ </div>
+ <div>
+ <div>
+ <label for="lastName">
+<span>Last
+ Name</span>
+<span>*</span>
+</label>
+<br>
+ <input name="lastName" maxlength="40" placeholder=""
+ type="text">
+ </div>
+ </div>
+ <div>
+ <div>
+ <div>
+ <div>
+ <label for="address1">Shipping
+ Address<span>*</span>
+</label>
+<br>
+ <input name="address1" maxlength="35" placeholder=""
+ id="oneAutoComplete" type="text">
+ </div>
+ </div>
+ </div>
+ </div>
+ <div>
+ <div>
+ <label for="emailId">
+<span>Email
+ Address</span>
+<span>*</span>
+</label>
+<br>
+ <input name="emailId" maxlength="80" placeholder="" type="text">
+ </div>
+ </div>
+ <div>
+ <div>
+ <label for="phoneNo">
+<span>Phone</span>
+<span>*</span>
+</label>
+<br>
+ <input name="phoneNo" type="text">
+ </div>
+ </div>
+ <div>
+ <div>
+ <label for="phoneEx">
+<span>Extn.</span>
+</label>
+<br>
+ <input name="phoneEx" maxlength="6" placeholder="" type="text">
+ </div>
+ </div>
+ <div>
+ <div>
+ <label for="companyName">
+<span>Company Name
+ (optional)</span>
+</label>
+<br>
+ <input name="companyName" maxlength="50" placeholder=""
+ type="text">
+ </div>
+ </div>
+ <div>
+ <div>
+ <div tabindex="0">
+ <label>Email me exclusive offers & deals from
+ Staples</label>
+<input value="true" name="isEmailOfferChecked" type="hidden">
+ </div>
+ </div>
+ </div>
+ <div>
+ <div>
+ <button type="submit">continue</button>
+ </div>
+ </div>
+ </div>
+ </form>
+</body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/third_party/Staples/PaymentBilling.html b/browser/extensions/formautofill/test/fixtures/third_party/Staples/PaymentBilling.html
new file mode 100644
index 0000000000..37dadeb514
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/third_party/Staples/PaymentBilling.html
@@ -0,0 +1,99 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta name="generator" content="HTML Tidy for HTML5 for Mac OS X version 5.4.0">
+ <title>It's easy to find the Office Supplies, Copy Paper,
+ Furniture, Ink, Toner, Cleaning Products, Electronics and the
+ Technology you need | Staples®</title>
+ <meta content="checkout" name="PageName">
+ <meta content="IE=edge,chrome=1" http-equiv="X-UA-Compatible">
+ <meta content="noindex,follow" name="robots">
+ <meta content="Shop Staples® for everyday low prices and get everything you need for a home office or business. Staples Rewards� members get free shipping every day and up to 5% back in rewards, some exclusions apply."
+ name="description">
+ <meta name="viewport" content="width=null, initial-scale=1">
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
+ <meta http-equiv="x-dns-prefetch-control" content="on">
+ <meta http-equiv="content-language" content="en-us">
+</head>
+<body>
+ <form id="payment-cc" name="payment-cc">
+ <input autocomplete="false" name="hidden" type="text">
+ <div>
+ <div>
+ <div>
+ <div>
+ <label for="cardNumber">
+<span>Card
+ Number</span>
+<span>*</span>
+</label>
+<br>
+ <input name="cardNumber" autocomplete="off" type="text">
+ </div>
+ </div>
+ <div>
+ <div>
+ <label for="expDate">
+<span>Expiration
+ Date</span>
+<span>*</span>
+</label>
+<br>
+ <input name="expDate" placeholder="MM/YY" autocomplete="off" type="text">
+ </div>
+ </div>
+ <div>
+ <div>
+ <label for="secCode">
+<span>Security
+ Code</span>
+<span>*</span>
+</label>
+<br>
+ <input name="secCode" value="" autocomplete="off"
+ maxlength="4" type="password">
+ </div>
+ </div>
+ </div>
+ <div>
+ <input value="" name="cardBrand" type="hidden">
+ </div>
+ <div>
+ <div>
+ <div tabindex="0">
+ <label>Billing address is the same as shipping
+ address</label>
+<input value="true" name="isBillAddrSameShipAddr" type="hidden">
+ </div>
+ </div>
+ </div>
+ <div>
+ <input value="1F16D1368309E3DBE15C391E7571B371" name="addressId" type="hidden">
+ </div>
+ <div>
+ <p>Purchase Order # (optional) <span tabindex="0">Add</span>
+</p>
+ </div>
+ <section id="paymentGuestRadio">
+</section>
+ <div>
+ <div>
+ <button type="submit">place my order</button>
+ </div>
+ </div>
+ <div>
+ <div>
+ <p>
+<span>By placing your order, you agree to
+ Staples</span>
+<a target="_blank">
+<span>Terms &
+ Conditions.</span>
+</a>
+</p>
+ </div>
+ </div>
+ </div>
+ </form>
+</body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/third_party/Staples/PaymentBilling_ac_on.html b/browser/extensions/formautofill/test/fixtures/third_party/Staples/PaymentBilling_ac_on.html
new file mode 100644
index 0000000000..98c9fb8555
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/third_party/Staples/PaymentBilling_ac_on.html
@@ -0,0 +1,98 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta name="generator" content="HTML Tidy for HTML5 for Mac OS X version 5.4.0">
+ <title>It's easy to find the Office Supplies, Copy Paper,
+ Furniture, Ink, Toner, Cleaning Products, Electronics and the
+ Technology you need | Staples®</title>
+ <meta content="checkout" name="PageName">
+ <meta content="IE=edge,chrome=1" http-equiv="X-UA-Compatible">
+ <meta content="noindex,follow" name="robots">
+ <meta content="Shop Staples® for everyday low prices and get everything you need for a home office or business. Staples Rewards� members get free shipping every day and up to 5% back in rewards, some exclusions apply."
+ name="description">
+ <meta name="viewport" content="width=null, initial-scale=1">
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
+ <meta http-equiv="x-dns-prefetch-control" content="on">
+ <meta http-equiv="content-language" content="en-us">
+</head>
+<body>
+ <form id="payment-cc" name="payment-cc">
+ <input name="hidden" type="text">
+ <div>
+ <div>
+ <div>
+ <div>
+ <label for="cardNumber">
+<span>Card
+ Number</span>
+<span>*</span>
+</label>
+<br>
+ <input name="cardNumber" type="text">
+ </div>
+ </div>
+ <div>
+ <div>
+ <label for="expDate">
+<span>Expiration
+ Date</span>
+<span>*</span>
+</label>
+<br>
+ <input name="expDate" placeholder="MM/YY" type="text">
+ </div>
+ </div>
+ <div>
+ <div>
+ <label for="secCode">
+<span>Security
+ Code</span>
+<span>*</span>
+</label>
+<br>
+ <input name="secCode" value="" maxlength="4" type="password">
+ </div>
+ </div>
+ </div>
+ <div>
+ <input value="" name="cardBrand" type="hidden">
+ </div>
+ <div>
+ <div>
+ <div tabindex="0">
+ <label>Billing address is the same as shipping
+ address</label>
+<input value="true" name="isBillAddrSameShipAddr" type="hidden">
+ </div>
+ </div>
+ </div>
+ <div>
+ <input value="1F16D1368309E3DBE15C391E7571B371" name="addressId" type="hidden">
+ </div>
+ <div>
+ <p>Purchase Order # (optional) <span tabindex="0">Add</span>
+</p>
+ </div>
+ <section id="paymentGuestRadio">
+</section>
+ <div>
+ <div>
+ <button type="submit">place my order</button>
+ </div>
+ </div>
+ <div>
+ <div>
+ <p>
+<span>By placing your order, you agree to
+ Staples</span>
+<a target="_blank">
+<span>Terms &
+ Conditions.</span>
+</a>
+</p>
+ </div>
+ </div>
+ </div>
+ </form>
+</body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/third_party/Walmart/Checkout.html b/browser/extensions/formautofill/test/fixtures/third_party/Walmart/Checkout.html
new file mode 100644
index 0000000000..ee5ded483e
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/third_party/Walmart/Checkout.html
@@ -0,0 +1,243 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta name="generator" content="HTML Tidy for HTML5 for Mac OS X version 5.4.0">
+ <meta http-equiv="content-type" content="text/html; charset=utf-8">
+ <meta charset="utf-8">
+ <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
+ <meta name="viewport" content="width=device-width, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
+ <title>
+</title>
+ </head>
+ <body>
+ <form>
+ <p>Enter new zip code:</p>
+ <label>
+</label>
+ <div>
+ <p>
+<label>ZIP Code (required)</label>
+</p>
+ <label>
+<input name="zip-code" value="" placeholder="" type="text">
+</label>
+ </div>
+ <div>
+ <div>
+<button type="submit">Calculate</button>
+</div>
+ <div>
+<button type="button">Cancel</button>
+</div>
+ </div>
+ </form>
+ <form>
+ <label>
+<span>
+<span>Promo code
+ (optional)</span>
+</span>
+</label>
+ <div>
+<input name="promoCode">
+</div>
+ <button type="submit">
+<span>Apply</span>
+</button>
+ </form>
+ <form method="post" novalidate="">
+ <div>
+ <div>
+<label>
+<span>Email address (required)</span>
+</label>
+</div>
+ <div>
+ <div>
+<label>
+<input
+title="Email address" placeholder="" name="email" type="email">
+</label>
+</div>
+ </div>
+ </div>
+ <div>
+ <div>
+ <div>
+<label>
+<span>Password (required)</span>
+</label>
+</div>
+ <div>
+ <div>
+<label>
+<input
+title="Password" placeholder="" name="password"
+ type="password">
+</label>
+</div>
+ <div>
+<label>
+<button type="button">
+<label>Show</label>
+</button>
+</label>
+</div>
+ </div>
+ <div>
+<label>
+<span>Password (required)</span>
+</label>
+</div>
+ <div>
+ <div>
+<label>
+<input
+title="Password" placeholder="" name="password"
+ autocomplete="off" tabindex="-1" type="text">
+</label>
+</div>
+ <div>
+<label>
+<button type="button" tabindex="-1">
+<label>Hide</label>
+</button>
+</label>
+</div>
+ </div>
+ </div>
+ </div>
+ <div>
+ <div>
+ <div>
+<button type="button">Forgot password?</button>
+</div>
+ </div>
+ </div>
+ <div>
+<button type="submit">Sign In</button>
+</div>
+ </form>
+ <form novalidate="" method="post">
+ <div>*required field</div>
+ <div>
+<label>
+<span>First name*</span>
+</label>
+</div>
+ <div>
+ <div>
+<label>
+<input
+title="First name" placeholder="" name="firstName" type="text">
+</label>
+</div>
+ </div>
+ <div>
+<label>
+<span>Last name*</span>
+</label>
+</div>
+ <div>
+ <div>
+<label>
+<input
+title="Last name" placeholder="" name="lastName"
+ type="text">
+</label>
+</div>
+ </div>
+ <div>
+<label>
+<span>Email address*</span>
+</label>
+</div>
+ <div>
+ <div>
+<label>
+<input
+title="Email address" placeholder="" name="email" autocomplete="off" type="email">
+</label>
+</div>
+ </div>
+ <div>
+<input tabindex="-1"
+title="Email Address" type="text">
+<input tabindex="-1" title="Password" type="password">
+</div>
+ <div>
+ <div>
+ <div>
+<label>
+<span>Password*</span>
+</label>
+</div>
+ <div>
+ <div>
+<label>
+<input
+title="Password" placeholder="" name="password"
+ autocomplete="new-password" type="password">
+<span>Your password
+ must be between 6 and 12 characters.</span>
+</label>
+ </div>
+ <div>
+<label>
+<button type="button">
+<label>Show</label>
+</button>
+</label>
+</div>
+ </div>
+ <div>
+<label>
+<span>Password*</span>
+</label>
+</div>
+ <div>
+ <div>
+<label>
+<input
+title="Password" placeholder="" name="password"
+ autocomplete="off" tabindex="-1" type="text">
+<span>Your password
+ must be between 6 and 12 characters.</span>
+</label>
+ </div>
+ <div>
+<label>
+<button type="button" tabindex="-1">
+<label>Hide</label>
+</button>
+</label>
+</div>
+ </div>
+ </div>
+ </div>
+ <div>
+<span>
+<span>By clicking Create Account, you acknowledge you
+ have read and agreed to our</span>
+<a target="_blank">Terms of
+ Use</a>
+<span>and</span>
+<a target="_blank">Privacy
+ Policy</a>
+<span>.</span>
+</span>
+ </div>
+ <button type="submit">Create Account</button>
+ <div>
+ <div>
+<input name="newsletter" id="checkbox-0" value="true" checked="checked" type="checkbox">
+<label for="checkbox-0">
+<span>Email me
+ about Rollbacks, special pricing, hot new items, gift ideas and
+ more.</span>
+</label>
+ </div>
+ </div>
+ </form>
+ </body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/third_party/Walmart/Payment.html b/browser/extensions/formautofill/test/fixtures/third_party/Walmart/Payment.html
new file mode 100644
index 0000000000..f6a46387b2
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/third_party/Walmart/Payment.html
@@ -0,0 +1,235 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta name="generator" content="HTML Tidy for HTML5 for Mac OS X version 5.4.0">
+ <meta http-equiv="content-type" content="text/html; charset=utf-8">
+ <meta charset="utf-8">
+ <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
+ <meta name="viewport" content="width=device-width, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
+ <title>Checkout</title>
+ <meta property="og:type" content="Website">
+ <meta property="og:image" content="http://sphotos-b.xx.fbcdn.net/hphotos-ash4/229244_10150189115584236_162217_n.jpg">
+ <meta property="og:site_name" content="Walmart.com">
+ <meta property="fb:app_id" content="105223049547814">
+ <meta property="twitter:card" content="summary">
+ <meta property="twitter:image" content="https://pbs.twimg.com/profile_images/616833885/walmart_logo_youtube_bigger.jpg">
+ <meta property="twitter:site" content="@walmart">
+ <meta property="og:title" content="Checkout">
+ <meta property="twitter:title" content="Checkout">
+ </head>
+ <body>
+ <form>
+ <label>
+<span>
+<span>Promo code
+ (optional)</span>
+</span>
+</label>
+ <div>
+<input name="promoCode">
+</div>
+ <button type="submit">
+<span>Apply</span>
+</button>
+ </form>
+ <form>
+ <div>
+ <div>
+ <div>
+ <div>* required field</div>
+ <div>
+<label for="firstName">
+<span>First name on
+ card*</span>
+</label>
+ </div>
+ <div>
+ <div>
+<label for="firstName">
+<input id="firstName" value=""
+ name="firstName"
+title="First name" autocomplete="section-payment given-name" maxlength="25">
+</label>
+</div>
+ </div>
+ <div>
+<label for="lastName">
+<span>Last name on
+ card*</span>
+</label>
+ </div>
+ <div>
+ <div>
+<label for="lastName">
+<input id="lastName" value=""
+ name="lastName"
+title="Last name" autocomplete="section-payment family-name" maxlength="25">
+</label>
+</div>
+ </div>
+ <div>
+<label for="creditCard">
+<span>Card
+ number*</span>
+</label>
+ </div>
+ <div>
+ <div>
+<label for="creditCard">
+<input id="creditCard" pattern="[0-9]*" inputmode="numeric" name="creditCard" autocomplete="section-payment cc-number" maxlength="16">
+</label>
+</div>
+ </div>
+ <div>
+ <div>
+ <div>
+ <label>
+<span>Expiration date*</span>
+</label>
+ <div>
+ <span>
+ <label for="month-chooser">
+ <svg width="11" height="6">
+ <polygon fill="#027DC3" points="5.5,6 0,0 11,0">
+</polygon>
+ </svg>
+ <select id="month-chooser" name="month-chooser" autocomplete="section-payment cc-exp-month">
+ <option selected="selected" value="" disabled="disabled">
+ MM
+ </option>
+ <option value="01">01</option>
+ <option value="02">02</option>
+ <option value="03">03</option>
+ <option value="04">04</option>
+ <option value="05">05</option>
+ <option value="06">06</option>
+ <option value="07">07</option>
+ <option value="08">08</option>
+ <option value="09">09</option>
+ <option value="10">10</option>
+ <option value="11">11</option>
+ <option value="12">12</option>
+ </select>
+ </label>
+ </span>
+ </div>
+ <span>&nbsp;/&nbsp;</span>
+ <div>
+ <span>
+ <label for="year-chooser">
+ <svg width="11" height="6">
+ <polygon fill="#027DC3" points="5.5,6 0,0 11,0">
+</polygon>
+ </svg>
+ <select id="year-chooser" name="year-chooser" autocomplete="section-payment cc-exp-year">
+ <option selected="selected" value="" disabled="disabled">
+ YY
+ </option>
+ <option value="2017">17</option>
+ <option value="2018">18</option>
+ <option value="2019">19</option>
+ <option value="2020">20</option>
+ <option value="2021">21</option>
+ <option value="2022">22</option>
+ <option value="2023">23</option>
+ <option value="2024">24</option>
+ <option value="2025">25</option>
+ <option value="2026">26</option>
+ <option value="2027">27</option>
+ </select>
+ </label>
+ </span>
+ </div>
+ </div>
+ </div>
+ <div>
+ <input
+title=" " name="brwsrAutofillText" type="text">
+ <input
+title=" " name="brwsrAutofillPassword" type="password">
+ <div>
+ <div>
+ <label for="cvv">
+<span>
+<span>
+<span>Security code*</span>
+ </span>
+</span>
+</label>
+ <div>
+<label for="cvv">
+<button type="button">
+<label for="cvv">
+<span name="help" size="1" alt="Icon for help">
+</span>
+</label>
+</button>
+</label>
+</div>
+ </div>
+ <div>
+ <div>
+<input id="cvv" name="cvv"
+title="cvv" value="" autocomplete="section-payment cc-csc" maxlength="3" pattern="[0-9]*" inputmode="numeric" type="password">
+</div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div>
+<label for="phone">
+<span>Phone number*</span>
+<span>Ex: (415)
+ 444 - 5555</span>
+</label>
+ </div>
+ <div>
+ <div>
+<label for="phone">
+<input id="phone" value="" name="phone"
+title="Phone" autocomplete="section-payment tel" maxlength="14" type="tel">
+</label>
+</div>
+ </div>
+ </div>
+ </div>
+ <div>
+ <div>
+ <div>
+ <label>
+<input checked="checked" type="checkbox">
+</label>
+ <div>
+<label>
+<span>Same as shipping</span>
+</label>
+</div>
+ <div>
+ <p>
+<span>22F., No.55, Haiiu 1st Rd., Bafu Dist.,</span>
+<br>
+ <span>
+<span>San Bruno</span>
+<span>,</span>
+</span>
+ <span>
+<span>CA</span>
+</span>
+<span>94066</span>
+ </p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div>
+ <div>
+<button type="button">
+<span>Review Your
+ Order</span>
+</button>
+ </div>
+ </div>
+ </form>
+ </body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/third_party/Walmart/Shipping.html b/browser/extensions/formautofill/test/fixtures/third_party/Walmart/Shipping.html
new file mode 100644
index 0000000000..5f07a05bab
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/third_party/Walmart/Shipping.html
@@ -0,0 +1,234 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta name="generator" content="HTML Tidy for HTML5 for Mac OS X version 5.4.0">
+ <meta http-equiv="content-type" content="text/html; charset=utf-8">
+ <meta charset="utf-8">
+ <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
+ <meta name="viewport" content="width=device-width, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
+ <title>Checkout</title>
+ <meta property="og:type" content="Website">
+ <meta property="og:image" content="http://sphotos-b.xx.fbcdn.net/hphotos-ash4/229244_10150189115584236_162217_n.jpg">
+ <meta property="og:site_name" content="Walmart.com">
+ <meta property="fb:app_id" content="105223049547814">
+ <meta property="twitter:card" content="summary">
+ <meta property="twitter:image" content="https://pbs.twimg.com/profile_images/616833885/walmart_logo_youtube_bigger.jpg">
+ <meta property="twitter:site" content="@walmart">
+ <meta property="og:title" content="Checkout">
+ <meta property="twitter:title" content="Checkout">
+ </head>
+ <body>
+ <form>
+ <p>Enter new zip code:</p>
+ <label>
+</label>
+ <div>
+ <p>
+<label>ZIP Code (required)</label>
+</p>
+ <label>
+<input name="zip-code" value="" placeholder="" type="text">
+</label>
+ </div>
+ <div>
+ <div>
+<button type="submit">Calculate</button>
+</div>
+ <div>
+<button type="button">Cancel</button>
+</div>
+ </div>
+ </form>
+ <form>
+ <label>
+<span>
+<span>Promo code
+ (optional)</span>
+</span>
+</label>
+ <div>
+<input name="promoCode">
+</div>
+ <button type="submit">
+<span>Apply</span>
+</button>
+ </form>
+ <form>
+ <div>
+ <div>
+ <div>
+ <div>
+ <div>*required field</div>
+ <label>
+<span>
+<span>
+<span>First name*</span>
+</span>
+</span>
+</label>
+ <div>
+<input
+title="First name" name="firstName" type="text">
+</div>
+ <label>
+<span>
+<span>
+<span>Last name*</span>
+</span>
+</span>
+</label>
+ <div>
+<input
+title="Last name" name="lastName" type="text">
+</div>
+ <label>
+<span>
+<span>
+<span>Phone number*</span>
+</span>
+</span>
+</label>
+ <div>
+<input minlength="10" maxlength="14"
+title="Phone number" name="phone" type="text">
+</div>
+ </div>
+ <div>
+ <label>
+<span>
+<span>
+<span>Street address*</span>
+</span>
+</span>
+</label>
+ <div>
+<input
+title="Street address" name="addressLineOne" type="text">
+</div>
+ <label>
+<span>
+<span>
+<span>Apt, suite, etc (optional)</span>
+</span>
+</span>
+</label>
+ <div>
+<input
+title="Apt, suite, bldg, c/o (optional)" name="addressLineTwo" type="text">
+</div>
+ <label>
+<span>
+<span>
+<span>City*</span>
+</span>
+</span>
+</label>
+ <div>
+<input
+title="City" name="city" value="" type="text">
+</div>
+ <div>
+ <div>
+ <div>
+ <label for="5">
+<span>State*</span>
+</label>
+ <div>
+ <div>
+ <select id="5"
+title="State" name="state">
+ <option value="">
+</option>
+ <option value="AL">Alabama</option>
+ <option value="AK">Alaska</option>
+ <option value="AZ">Arizona</option>
+ <option value="AR">Arkansas</option>
+ <option value="CA">California</option>
+ <option value="CO">Colorado</option>
+ <option value="CT">Connecticut</option>
+ <option value="DC">District of Columbia</option>
+ <option value="DE">Delaware</option>
+ <option value="FL">Florida</option>
+ <option value="GA">Georgia</option>
+ <option value="HI">Hawaii</option>
+ <option value="ID">Idaho</option>
+ <option value="IL">Illinois</option>
+ <option value="IN">Indiana</option>
+ <option value="IA">Iowa</option>
+ <option value="KS">Kansas</option>
+ <option value="KY">Kentucky</option>
+ <option value="LA">Louisiana</option>
+ <option value="ME">Maine</option>
+ <option value="MD">Maryland</option>
+ <option value="MA">Massachusetts</option>
+ <option value="MI">Michigan</option>
+ <option value="MN">Minnesota</option>
+ <option value="MS">Mississippi</option>
+ <option value="MO">Missouri</option>
+ <option value="MT">Montana</option>
+ <option value="NE">Nebraska</option>
+ <option value="NV">Nevada</option>
+ <option value="NH">New Hampshire</option>
+ <option value="NJ">New Jersey</option>
+ <option value="NM">New Mexico</option>
+ <option value="NY">New York</option>
+ <option value="NC">North Carolina</option>
+ <option value="ND">North Dakota</option>
+ <option value="OH">Ohio</option>
+ <option value="OK">Oklahoma</option>
+ <option value="OR">Oregon</option>
+ <option value="PA">Pennsylvania</option>
+ <option value="RI">Rhode Island</option>
+ <option value="SC">South Carolina</option>
+ <option value="SD">South Dakota</option>
+ <option value="TN">Tennessee</option>
+ <option value="TX">Texas</option>
+ <option value="UT">Utah</option>
+ <option value="VT">Vermont</option>
+ <option value="VA">Virginia</option>
+ <option value="WA">Washington</option>
+ <option value="WV">West Virginia</option>
+ <option value="WI">Wisconsin</option>
+ <option value="WY">Wyoming</option>
+ <option value="AA">Armed Forces Americas</option>
+ <option value="AP">Armed Forces Pacific</option>
+ <option value="AE">Armed Forces other</option>
+ <option value="AS">American Samoa</option>
+ <option value="GU">Guam</option>
+ <option value="MP">N. Mariana Islands</option>
+ <option value="PW">Palau</option>
+ <option value="PR">Puerto Rico</option>
+ <option value="VI">Virgin Islands</option>
+ </select>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div>
+ <label>
+<span>
+<span>
+<span>ZIP
+ Code*</span>
+</span>
+</span>
+</label>
+ <div>
+<input
+title="Zip code" name="postalCode" value="" type="text">
+</div>
+ </div>
+ </div>
+ </div>
+ <div>
+<input id="4" name="isDefault" type="checkbox">
+<label for="4">
+<span>Set as my preferred address</span>
+</label>
+</div>
+ </div>
+ </div>
+ </div>
+ </form>
+ </body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/without_autocomplete_address_basic.html b/browser/extensions/formautofill/test/fixtures/without_autocomplete_address_basic.html
new file mode 100644
index 0000000000..a69b65f1d7
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/without_autocomplete_address_basic.html
@@ -0,0 +1,26 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Form Autofill Address Demo Page</title>
+</head>
+<body>
+ <h1>Form Autofill Address Demo Page (without autocomplete attribute)</h1>
+ <form id="form">
+ <p><label>givenname: <input type="text" id="given-name" name="given-name"/></label></p>
+ <p><label>familyname: <input type="text" id="family-name" name="family-name"/></label></p>
+ <p><label>organization: <input type="text" id="organization" name="organization"/></label></p>
+ <p><label>streetAddress: <input type="text" id="street-address" name="street-address"/></label></p>
+ <p><label>addressLevel2: <input type="text" id="address-level2" name="address-level2"/></label></p>
+ <p><label>addressLevel1: <input type="text" id="address-level1" name="address-level1"/></label></p>
+ <p><label>postalCode: <input type="text" id="postal-code" name="postal-code"/></label></p>
+ <p><label>country: <input type="text" id="country" name="country"/></label></p>
+ <p><label>tel: <input type="text" id="tel" name="tel"/></label></p>
+ <p><label>email: <input type="text" id="email" name="email"/></label></p>
+ <p>
+ <input type="submit"/>
+ <button type="reset">Reset</button>
+ </p>
+ </form>
+</body>
+</html>
diff --git a/browser/extensions/formautofill/test/fixtures/without_autocomplete_creditcard_basic.html b/browser/extensions/formautofill/test/fixtures/without_autocomplete_creditcard_basic.html
new file mode 100644
index 0000000000..b73645f718
--- /dev/null
+++ b/browser/extensions/formautofill/test/fixtures/without_autocomplete_creditcard_basic.html
@@ -0,0 +1,53 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Form Autofill Credit Card Demo Page</title>
+</head>
+<body>
+ <h1>Form Autofill Credit Card Demo Page (without Autocomplete attribute)</h1>
+ <!-- All credit card fields are in the same form -->
+ <form id="form">
+ <p><label>Name: <input id="cc-name" placeholder="Credit Card Name"></label></p>
+ <p><label>Card Number: <input id="cc-number" placeholder="Credit Card Number"></label></p>
+ <p><label>Expiration month: <input id="cc-exp-month" placeholder="Credit Card Expiration Month"></label></p>
+ <p><label>Expiration year: <input id="cc-exp-year" placeholder="Credit Card Expiration Year"></label></p>
+ <p><label>CSC: <input id="cc-csc" placeholder="Credit Card Security Code"></label></p>
+ <p><label>Card Type: <select id="cc-type" placeholder="Credit Card Type">
+ <option></option>
+ <option value="discover">Discover</option>
+ <option value="jcb">JCB</option>
+ <option value="visa">Visa</option>
+ <option value="mastercard">MasterCard</option>
+ <option value="gringotts">Unknown card network</option>
+ </select></label></p>
+ <p>
+ <input type="submit" value="Submit">
+ <button type="reset">Reset</button>
+ </p>
+ </form>
+
+ <!-- cc-number in one form, other credit card fields in another form -->
+ <form id="form2-cc-number">
+ <p><label>Card Number: <input id="cc-number" placeholder="Credit Card Number"></label></p>
+ </form>
+ <form id="form2-cc-other">
+ <p><label>Name: <input id="cc-name" placeholder="Credit Card Name"></label></p>
+ <p><label>Expiration month: <input id="cc-exp-month" placeholder="Credit Card Expiration Month"></label></p>
+ <p><label>Expiration year: <input id="cc-exp-year" placeholder="Credit Card Expiration Year"></label></p>
+ <p><label>CSC: <input id="cc-csc" placeholder="Credit Card Security Code"></label></p>
+ <p><label>Card Type: <select id="cc-type" placeholder="Credit Card Type">
+ <option></option>
+ <option value="discover">Discover</option>
+ <option value="jcb">JCB</option>
+ <option value="visa">Visa</option>
+ <option value="mastercard">MasterCard</option>
+ <option value="gringotts">Unknown card network</option>
+ </select></label></p>
+ <p>
+ <input type="submit" value="Submit">
+ <button type="reset">Reset</button>
+ </p>
+ </form>
+</body>
+</html>
diff --git a/browser/extensions/formautofill/test/mochitest/creditCard/mochitest.ini b/browser/extensions/formautofill/test/mochitest/creditCard/mochitest.ini
new file mode 100644
index 0000000000..d7a7ea2fd8
--- /dev/null
+++ b/browser/extensions/formautofill/test/mochitest/creditCard/mochitest.ini
@@ -0,0 +1,26 @@
+[DEFAULT]
+prefs =
+ extensions.formautofill.creditCards.supported=on
+ extensions.formautofill.creditCards.enabled=true
+ extensions.formautofill.reauth.enabled=true
+support-files =
+ !/toolkit/components/satchel/test/satchel_common.js
+ ../../../../../../toolkit/components/satchel/test/parent_utils.js
+ !/toolkit/components/satchel/test/parent_utils.js
+ !/browser/extensions/formautofill/test/mochitest/formautofill_common.js
+ !/browser/extensions/formautofill/test/mochitest/formautofill_parent_utils.js
+skip-if = xorigin
+ toolkit == 'android' # bug 1730213
+
+[test_basic_creditcard_autocomplete_form.html]
+scheme=https
+[test_clear_form.html]
+scheme=https
+[test_clear_form_expiry_select_elements.html]
+scheme=https
+[test_creditcard_autocomplete_off.html]
+scheme=https
+[test_preview_highlight_with_multiple_cc_number_fields.html]
+scheme=https
+[test_preview_highlight_with_site_prefill.html]
+scheme=https
diff --git a/browser/extensions/formautofill/test/mochitest/creditCard/test_basic_creditcard_autocomplete_form.html b/browser/extensions/formautofill/test/mochitest/creditCard/test_basic_creditcard_autocomplete_form.html
new file mode 100644
index 0000000000..0764bef0eb
--- /dev/null
+++ b/browser/extensions/formautofill/test/mochitest/creditCard/test_basic_creditcard_autocomplete_form.html
@@ -0,0 +1,251 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test basic autofill</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="../formautofill_common.js"></script>
+ <script type="text/javascript" src="../../../../../..//toolkit/components/satchel/test/satchel_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Form autofill test: simple form credit card autofill
+
+<script>
+"use strict";
+
+const MOCK_STORAGE = [{
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ "cc-exp-month": 4,
+ "cc-exp-year": 2017,
+}, {
+ "cc-name": "Timothy Berners-Lee",
+ "cc-number": "5103059495477870",
+ "cc-exp-month": 12,
+ "cc-exp-year": 2022,
+}];
+
+const reducedMockRecord = {
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+};
+
+async function setupCreditCardStorage() {
+ await addCreditCard(MOCK_STORAGE[0]);
+ await addCreditCard(MOCK_STORAGE[1]);
+}
+
+async function setupFormHistory() {
+ await updateFormHistory([
+ {op: "add", fieldname: "cc-name", value: "John Smith"},
+ {op: "add", fieldname: "cc-exp-year", value: 2023},
+ ]);
+}
+
+initPopupListener();
+
+// Form with history only.
+add_task(async function history_only_menu_checking() {
+ // TODO: eliminate the timeout when we're able to indicate the right
+ // timing to start.
+ //
+ // After test process was re-spawning to https scheme. Wait 2 secs
+ // to ensure the environment is ready to do storage setup.
+ await sleep(2000);
+ await setupFormHistory();
+
+ await setInput("#cc-exp-year", "");
+ synthesizeKey("KEY_ArrowDown");
+ await expectPopup();
+ checkMenuEntries(["2023"], false);
+});
+
+// Display credit card result even if the number of fillable fields is less than the threshold.
+add_task(async function all_saved_fields_less_than_threshold() {
+ await addCreditCard(reducedMockRecord);
+
+ await setInput("#cc-name", "");
+ await expectPopup();
+ synthesizeKey("KEY_ArrowDown");
+ checkMenuEntries([reducedMockRecord].map(patchRecordCCNumber).map(cc => JSON.stringify({
+ primary: cc["cc-name"],
+ secondary: cc.ccNumberFmt.affix + cc.ccNumberFmt.label,
+ ariaLabel: `Visa ${cc["cc-name"]} ${cc.ccNumberFmt.affix}${cc.ccNumberFmt.label}`,
+ })));
+
+ await cleanUpCreditCards();
+});
+
+// Form with both history and credit card storage.
+add_task(async function check_menu_when_both_existed() {
+ await setupCreditCardStorage();
+
+ await setInput("#cc-number", "");
+ await expectPopup();
+ synthesizeKey("KEY_ArrowDown");
+ checkMenuEntries(MOCK_STORAGE.map(patchRecordCCNumber).map(cc => JSON.stringify({
+ primaryAffix: cc.ccNumberFmt.affix,
+ primary: cc.ccNumberFmt.label,
+ secondary: cc["cc-name"],
+ ariaLabel: `${getCCTypeName(cc)} ${cc.ccNumberFmt.affix} ${cc.ccNumberFmt.label} ${cc["cc-name"]}`,
+ })));
+
+ await setInput("#cc-name", "");
+ await expectPopup();
+ synthesizeKey("KEY_ArrowDown");
+ checkMenuEntries(MOCK_STORAGE.map(patchRecordCCNumber).map(cc => JSON.stringify({
+ primary: cc["cc-name"],
+ secondary: cc.ccNumberFmt.affix + cc.ccNumberFmt.label,
+ ariaLabel: `${getCCTypeName(cc)} ${cc["cc-name"]} ${cc.ccNumberFmt.affix}${cc.ccNumberFmt.label}`,
+ })));
+
+ await setInput("#cc-exp-year", "");
+ await expectPopup();
+ synthesizeKey("KEY_ArrowDown");
+ checkMenuEntries(MOCK_STORAGE.map(patchRecordCCNumber).map(cc => JSON.stringify({
+ primary: cc["cc-exp-year"],
+ secondary: cc.ccNumberFmt.affix + cc.ccNumberFmt.label,
+ ariaLabel: `${getCCTypeName(cc)} ${cc["cc-exp-year"]} ${cc.ccNumberFmt.affix}${cc.ccNumberFmt.label}`,
+ })));
+
+ await setInput("#cc-exp-month", "");
+ await expectPopup();
+ synthesizeKey("KEY_ArrowDown");
+ checkMenuEntries(MOCK_STORAGE.map(patchRecordCCNumber).map(cc => JSON.stringify({
+ primary: cc["cc-exp-month"],
+ secondary: cc.ccNumberFmt.affix + cc.ccNumberFmt.label,
+ ariaLabel: `${getCCTypeName(cc)} ${cc["cc-exp-month"]} ${cc.ccNumberFmt.affix}${cc.ccNumberFmt.label}`,
+ })));
+
+ await cleanUpCreditCards();
+});
+
+// Display history search result if no matched data in credit card.
+add_task(async function check_fallback_for_mismatched_field() {
+ await addCreditCard(reducedMockRecord);
+
+ await setInput("#cc-exp-year", "");
+ synthesizeKey("KEY_ArrowDown");
+ await expectPopup();
+ checkMenuEntries(["2023"], false);
+
+ await cleanUpCreditCards();
+});
+
+// Display history search result if credit card autofill is disabled.
+add_task(async function check_search_result_for_pref_off() {
+ await setupCreditCardStorage();
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.formautofill.creditCards.enabled", false]],
+ });
+
+ await setInput("#cc-name", "");
+ synthesizeKey("KEY_ArrowDown");
+ await expectPopup();
+ checkMenuEntries(["John Smith"], false);
+
+ await SpecialPowers.popPrefEnv();
+});
+
+let canTest;
+
+// Autofill the credit card from dropdown menu.
+add_task(async function check_fields_after_form_autofill() {
+ canTest = await canTestOSKeyStoreLogin();
+ if (!canTest) {
+ todo(canTest, "Cannot test OS key store login on official builds.");
+ return;
+ }
+
+ await setInput("#cc-exp-year", 202);
+
+ synthesizeKey("KEY_ArrowDown");
+ // The popup doesn't auto-show on focus because the field isn't empty
+ await expectPopup();
+ checkMenuEntries(MOCK_STORAGE.slice(1).map(patchRecordCCNumber).map(cc => JSON.stringify({
+ primary: cc["cc-exp-year"],
+ secondary: cc.ccNumberFmt.affix + cc.ccNumberFmt.label,
+ ariaLabel: `${getCCTypeName(cc)} ${cc["cc-exp-year"]} ${cc.ccNumberFmt.affix}${cc.ccNumberFmt.label}`,
+ })));
+
+ synthesizeKey("KEY_ArrowDown");
+ let osKeyStoreLoginShown = waitForOSKeyStoreLogin(true);
+ await new Promise(resolve => SimpleTest.executeSoon(resolve));
+ await triggerAutofillAndCheckProfile(MOCK_STORAGE[1]);
+ await osKeyStoreLoginShown;
+});
+
+// Fallback to history search after autofill values (for non-empty fields).
+add_task(async function check_fallback_after_form_autofill() {
+ if (!canTest) {
+ return;
+ }
+
+ await setInput("#cc-name", "J", true);
+ synthesizeKey("KEY_ArrowDown");
+ await expectPopup();
+ checkMenuEntries(["John Smith"], false);
+});
+
+// Present credit card popup immediately when user blanks a field
+add_task(async function check_cc_popup_on_field_blank() {
+ if (!canTest) {
+ return;
+ }
+
+ await setInput("#cc-name", "", true);
+ await expectPopup();
+ checkMenuEntries(MOCK_STORAGE.map(patchRecordCCNumber).map(cc => JSON.stringify({
+ primary: cc["cc-name"],
+ secondary: cc.ccNumberFmt.affix + cc.ccNumberFmt.label,
+ ariaLabel: `${getCCTypeName(cc)} ${cc["cc-name"]} ${cc.ccNumberFmt.affix}${cc.ccNumberFmt.label}`,
+ })));
+});
+
+// Resume form autofill once all the autofilled fileds are changed.
+add_task(async function check_form_autofill_resume() {
+ if (!canTest) {
+ return;
+ }
+
+ document.querySelector("#cc-name").blur();
+ document.querySelector("#form1").reset();
+
+ await setInput("#cc-name", "");
+ synthesizeKey("KEY_ArrowDown");
+ await expectPopup();
+ checkMenuEntries(MOCK_STORAGE.map(patchRecordCCNumber).map(cc => JSON.stringify({
+ primary: cc["cc-name"],
+ secondary: cc.ccNumberFmt.affix + cc.ccNumberFmt.label,
+ ariaLabel: `${getCCTypeName(cc)} ${cc["cc-name"]} ${cc.ccNumberFmt.affix}${cc.ccNumberFmt.label}`,
+ })));
+});
+
+</script>
+
+<p id="display"></p>
+
+<div id="content">
+
+ <form id="form1">
+ <p>This is a basic form.</p>
+ <p><label>Name: <input id="cc-name" autocomplete="cc-name"></label></p>
+ <p><label>Card Number: <input id="cc-number" autocomplete="cc-number"></label></p>
+ <p><label>Expiration month: <input id="cc-exp-month" autocomplete="cc-exp-month"></label></p>
+ <p><label>Expiration year: <input id="cc-exp-year" autocomplete="cc-exp-year"></label></p>
+ <p><label>Card Type: <select id="cc-type" autocomplete="cc-type">
+ <option value="discover">Discover</option>
+ <option value="jcb">JCB</option>
+ <option value="visa">Visa</option>
+ <option value="mastercard">MasterCard</option>
+ </select></label></p>
+ <p><label>CSC: <input id="cc-csc" autocomplete="cc-csc"></label></p>
+ </form>
+</div>
+
+<pre id="test"></pre>
+</body>
+</html>
diff --git a/browser/extensions/formautofill/test/mochitest/creditCard/test_clear_form.html b/browser/extensions/formautofill/test/mochitest/creditCard/test_clear_form.html
new file mode 100644
index 0000000000..3d8049f053
--- /dev/null
+++ b/browser/extensions/formautofill/test/mochitest/creditCard/test_clear_form.html
@@ -0,0 +1,205 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test form autofill - clear form button</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="../formautofill_common.js"></script>
+ <script type="text/javascript" src="../../../../../../toolkit/components/satchel/test/satchel_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Form autofill test: clear form button
+
+<script>
+"use strict";
+
+const MOCK_ADDR_STORAGE = [{
+ organization: "Sesame Street",
+ "street-address": "2 Harrison St\nline2\nline3",
+ tel: "+13453453456",
+}, {
+ organization: "Mozilla",
+ "street-address": "331 E. Evelyn Avenue",
+}, {
+ organization: "Tel org",
+ tel: "+12223334444",
+}];
+const MOCK_CC_STORAGE = [{
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ "cc-exp-month": 4,
+ "cc-exp-year": 2017,
+}, {
+ "cc-name": "Timothy Berners-Lee",
+ "cc-number": "5103059495477870",
+ "cc-exp-month": 12,
+ "cc-exp-year": 2022,
+}];
+
+const MOCK_CC_STORAGE_EXPECTED_FILL = [{
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ "cc-exp-month": "04",
+ "cc-exp-year": 2017,
+}, {
+ "cc-name": "Timothy Berners-Lee",
+ "cc-number": "5103059495477870",
+ "cc-exp-month": "12",
+ "cc-exp-year": 2022,
+}];
+
+initPopupListener();
+
+add_task(async function setup_storage() {
+ await addAddress(MOCK_ADDR_STORAGE[0]);
+ await addAddress(MOCK_ADDR_STORAGE[1]);
+ await addAddress(MOCK_ADDR_STORAGE[2]);
+
+ await addCreditCard(MOCK_CC_STORAGE[0]);
+ await addCreditCard(MOCK_CC_STORAGE[1]);
+});
+
+
+async function checkIsFormCleared(patch = {}) {
+ const form = document.getElementById("form1");
+
+ for (const elem of form.elements) {
+ const expectedValue = patch[elem.id] || "";
+ checkFieldValue(elem, expectedValue);
+ await checkFieldHighlighted(elem, false);
+ await checkFieldPreview(elem, "");
+ }
+}
+
+async function confirmClear(selector) {
+ info("Await for clearing input");
+ let promise = new Promise(resolve => {
+ let beforeInputFired = false;
+ let element = document.querySelector(selector);
+ element.addEventListener("beforeinput", (event) => {
+ beforeInputFired = true;
+ ok(event instanceof InputEvent,
+ '"beforeinput" event should be dispatched with InputEvent interface');
+ is(event.cancelable, SpecialPowers.getBoolPref("dom.input_event.allow_to_cancel_set_user_input"),
+ `"beforeinput" event should be cancelable unless it's disabled by the pref`);
+ is(event.bubbles, true,
+ '"beforeinput" event should always bubble');
+ is(event.inputType, "insertReplacementText",
+ 'inputType value of "beforeinput" should be "insertReplacementText"');
+ is(event.data, "",
+ 'data value of "beforeinput" should be empty string');
+ is(event.dataTransfer, null,
+ 'dataTransfer value of "beforeinput" should be null');
+ is(event.getTargetRanges().length, 0,
+ 'getTargetRanges() of "beforeinput" event should return empty array');
+ }, {once: true});
+ element.addEventListener("input", (event) => {
+ ok(beforeInputFired, `"beforeinput" event should've been fired before "input" on <${element.tagName} type="${element.type}">`);
+ ok(event instanceof InputEvent,
+ '"input" event should be dispatched with InputEvent interface');
+ is(event.cancelable, false,
+ '"input" event should be never cancelable');
+ is(event.bubbles, true,
+ '"input" event should always bubble');
+ is(event.inputType, "insertReplacementText",
+ 'inputType value of "input" should be "insertReplacementText"');
+ is(event.data, "",
+ 'data value of "input" should be empty string');
+ is(event.dataTransfer, null,
+ 'dataTransfer value of "input" should be null');
+ is(event.getTargetRanges().length, 0,
+ 'getTargetRanges() of "input" should return empty array');
+ resolve();
+ }, {once: true})
+ });
+ synthesizeKey("KEY_Enter");
+ await promise;
+}
+
+add_task(async function simple_clear() {
+ await triggerPopupAndHoverItem("#organization", 0);
+ await triggerAutofillAndCheckProfile(MOCK_ADDR_STORAGE[0]);
+
+ await triggerPopupAndHoverItem("#tel", 0);
+ await confirmClear("#tel");
+ await checkIsFormCleared();
+});
+
+add_task(async function clear_adapted_record() {
+ await triggerPopupAndHoverItem("#street-address", 0);
+ await triggerAutofillAndCheckProfile(MOCK_ADDR_STORAGE[0]);
+
+ await triggerPopupAndHoverItem("#street-address", 0);
+ await confirmClear("#street-address");
+ await checkIsFormCleared();
+});
+
+add_task(async function clear_modified_form() {
+ await triggerPopupAndHoverItem("#organization", 0);
+ await triggerAutofillAndCheckProfile(MOCK_ADDR_STORAGE[0]);
+
+ await setInput("#tel", "+1111111111", true);
+
+ await triggerPopupAndHoverItem("#street-address", 0);
+ await confirmClear("#street-address");
+ await checkIsFormCleared({tel: "+1111111111"});
+});
+
+add_task(async function clear_distinct_section() {
+ if (!(await canTestOSKeyStoreLogin())) {
+ todo(false, "Cannot test OS key store login on official builds.");
+ return;
+ }
+
+ document.getElementById("form1").reset();
+ await triggerPopupAndHoverItem("#cc-name", 0);
+ let osKeyStoreLoginShown = waitForOSKeyStoreLogin(true);
+ await triggerAutofillAndCheckProfile(MOCK_CC_STORAGE_EXPECTED_FILL[0]);
+ await osKeyStoreLoginShown;
+
+ await triggerPopupAndHoverItem("#organization", 0);
+ await triggerAutofillAndCheckProfile(MOCK_ADDR_STORAGE[0]);
+ await triggerPopupAndHoverItem("#street-address", 0);
+ await confirmClear("#street-address");
+
+ for (const [id, val] of Object.entries(MOCK_CC_STORAGE_EXPECTED_FILL[0])) {
+ const element = document.getElementById(id);
+ if (!element) {
+ return;
+ }
+ checkFieldValue(element, val);
+ await checkFieldHighlighted(element, true);
+ }
+
+ await triggerPopupAndHoverItem("#cc-name", 0);
+ await confirmClear("#cc-name");
+ await checkIsFormCleared();
+});
+
+</script>
+
+<p id="display"></p>
+
+<div id="content">
+
+ <form id="form1">
+ <p>This is a basic form.</p>
+ <p><label>organization: <input id="organization" autocomplete="organization"></label></p>
+ <p><label>streetAddress: <input id="street-address" autocomplete="street-address"></label></p>
+ <p><label>tel: <input id="tel" autocomplete="tel"></label></p>
+ <p><label>country: <input id="country" autocomplete="country"></label></p>
+
+ <p><label>Name: <input id="cc-name" autocomplete="cc-name"></label></p>
+ <p><label>Card Number: <input id="cc-number" autocomplete="cc-number"></label></p>
+ <p><label>Expiration month: <input id="cc-exp-month" autocomplete="cc-exp-month"></label></p>
+ <p><label>Expiration year: <input id="cc-exp-year" autocomplete="cc-exp-year"></label></p>
+ <p><label>CSC: <input id="cc-csc" autocomplete="cc-csc"></label></p>
+ </form>
+
+</div>
+
+<pre id="test"></pre>
+</body>
+</html>
diff --git a/browser/extensions/formautofill/test/mochitest/creditCard/test_clear_form_expiry_select_elements.html b/browser/extensions/formautofill/test/mochitest/creditCard/test_clear_form_expiry_select_elements.html
new file mode 100644
index 0000000000..4fc989a36e
--- /dev/null
+++ b/browser/extensions/formautofill/test/mochitest/creditCard/test_clear_form_expiry_select_elements.html
@@ -0,0 +1,211 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test form autofill - clear form button with select elements</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="../formautofill_common.js"></script>
+ <script type="text/javascript" src="../../../../../../toolkit/components/satchel/test/satchel_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Form autofill test: clear form button with select elements.
+
+<script>
+"use strict";
+const MOCK_ADDR_STORAGE = [{
+ organization: "Sesame Street",
+ "street-address": "2 Harrison St\nline2\nline3",
+ tel: "+13453453456",
+}, {
+ organization: "Mozilla",
+ "street-address": "331 E. Evelyn Avenue",
+}, {
+ organization: "Tel org",
+ tel: "+12223334444",
+}];
+
+const MOCK_CC_STORAGE = [{
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ "cc-exp-month": 4,
+ "cc-exp-year": 2017,
+}, {
+ "cc-name": "Timothy Berners-Lee",
+ "cc-number": "5103059495477870",
+ "cc-exp-month": 12,
+ "cc-exp-year": 2022,
+}];
+
+initPopupListener();
+
+add_task(async function setup_storage() {
+ await addAddress(MOCK_ADDR_STORAGE[0]);
+ await addCreditCard(MOCK_CC_STORAGE[0]);
+ await addCreditCard(MOCK_CC_STORAGE[1]);
+});
+
+
+async function checkIsFormCleared(patch = {}) {
+ const form = document.getElementById("form1");
+
+ for (const elem of form.elements) {
+ const expectedValue = patch[elem.id] || "";
+ checkFieldValue(elem, expectedValue);
+ await checkFieldHighlighted(elem, false);
+ await checkFieldPreview(elem, "");
+ }
+}
+
+async function confirmClear(selector) {
+ info("Await for clearing input");
+ let promise = new Promise(resolve => {
+ let beforeInputFired = false;
+ let element = document.querySelector(selector);
+ info(`Which element are we clearing? ${element.id}`);
+ element.addEventListener("beforeinput", (event) => {
+ beforeInputFired = true;
+ ok(event instanceof InputEvent,
+ '"beforeinput" event should be dispatched with InputEvent interface');
+ is(event.cancelable, SpecialPowers.getBoolPref("dom.input_event.allow_to_cancel_set_user_input"),
+ `"beforeinput" event should be cancelable unless it's disabled by the pref`);
+ is(event.bubbles, true,
+ '"beforeinput" event should always bubble');
+ is(event.inputType, "insertReplacementText",
+ 'inputType value of "beforeinput" should be "insertReplacementText"');
+ is(event.data, "",
+ 'data value of "beforeinput" should be empty string');
+ is(event.dataTransfer, null,
+ 'dataTransfer value of "beforeinput" should be null');
+ is(event.getTargetRanges().length, 0,
+ 'getTargetRanges() of "beforeinput" event should return empty array');
+ }, {once: true});
+ element.addEventListener("input", (event) => {
+ ok(beforeInputFired, `"beforeinput" event should've been fired before "input" on <${element.tagName} type="${element.type}">`);
+ ok(event instanceof InputEvent,
+ '"input" event should be dispatched with InputEvent interface');
+ is(event.cancelable, false,
+ '"input" event should be never cancelable');
+ is(event.bubbles, true,
+ '"input" event should always bubble');
+ is(event.inputType, "insertReplacementText",
+ 'inputType value of "input" should be "insertReplacementText"');
+ is(event.data, "",
+ 'data value of "input" should be empty string');
+ is(event.dataTransfer, null,
+ 'dataTransfer value of "input" should be null');
+ is(event.getTargetRanges().length, 0,
+ 'getTargetRanges() of "input" should return empty array');
+ resolve();
+ }, {once: true})
+ });
+ synthesizeKey("KEY_Enter");
+ await promise;
+}
+
+// tgiles: We need this task due to timing issues between focusAndWaitForFieldsIdentified and popupShownListener.
+// There's a 300ms delay in focusAndWaitForFieldsIdentified that can cause triggerPopupAndHoverItem to get out of sync
+// and cause the popup to appear before the test expects a popup to appear.
+
+// Without this task we end up either getting a consistent timeout or getting the following exception:
+// 0:20.55 GECKO(31108) JavaScript error: , line 0: uncaught exception: Checking selected index - timed out after 50 tries.
+// This exception appears if you attempt to create the expectPopup promise earlier than it currently is in triggetPopupAndHoverItem
+add_task(async function a_dummy_task() {
+ await triggerPopupAndHoverItem("#organization", 0);
+ await triggerAutofillAndCheckProfile(MOCK_ADDR_STORAGE[0]);
+
+ await triggerPopupAndHoverItem("#tel", 0);
+ await confirmClear("#tel");
+ await checkIsFormCleared({
+ "cc-exp-month": "MM",
+ "cc-exp-year": "YY"
+ });
+});
+
+add_task(async function clear_distinct_section() {
+ if (!(await canTestOSKeyStoreLogin())) {
+ todo(false, "Cannot test OS key store login on official builds.");
+ return;
+ }
+ let osKeyStoreLoginShown = waitForOSKeyStoreLogin(true);
+ await triggerPopupAndHoverItem("#cc-name", 0);
+ await triggerAutofillAndCheckProfile(MOCK_CC_STORAGE[0]);
+ await osKeyStoreLoginShown;
+
+ for (const [id, val] of Object.entries(MOCK_CC_STORAGE[0])) {
+ const element = document.getElementById(id);
+ if (!element) {
+ return;
+ }
+ checkFieldValue(element, val);
+ await checkFieldHighlighted(element, true);
+ }
+
+ await triggerPopupAndHoverItem("#cc-name", 0);
+ await confirmClear("#cc-name");
+ await checkIsFormCleared({
+ "cc-exp-month": "MM",
+ "cc-exp-year": "YY"
+ });
+});
+
+</script>
+
+<p id="display"></p>
+
+<div id="content">
+
+ <form id="form1">
+ <p>This is a basic form.</p>
+ <p><label>organization: <input id="organization" autocomplete="organization"></label></p>
+ <p><label>streetAddress: <input id="street-address" autocomplete="street-address"></label></p>
+ <p><label>tel: <input id="tel" autocomplete="tel"></label></p>
+ <p><label>country: <input id="country" autocomplete="country"></label></p>
+
+ <p><label>Name: <input id="cc-name" autocomplete="cc-name"></label></p>
+ <p><label>Card Number: <input id="cc-number" autocomplete="cc-number"></label></p>
+ <!-- NOTE: If you're going to write a test like this,
+ ensure that the selected option doesn't match the data that you're trying to autofill,
+ otherwise your test will wait forever for an event that will never fire.
+ I.e, if your saved cc-exp-month is 01, make sure your selected option ISN'T 01.
+ -->
+ <p><label>Expiration month: <select id="cc-exp-month" autocomplete="cc-exp-month">
+ <option value="MM" selected>MM</option>
+ <option value="1">01</option>
+ <option value="2">02</option>
+ <option value="3">03</option>
+ <option value="4">04</option>
+ <option value="5">05</option>
+ <option value="6">06</option>
+ <option value="7">07</option>
+ <option value="8">08</option>
+ <option value="9">09</option>
+ <option value="10">10</option>
+ <option value="11">11</option>
+ <option value="12">12</option>
+ </select>
+ </label></p>
+ <!-- NOTE: If you're going to write a test like this,
+ ensure that the selected option doesn't match the data that you're trying to autofill,
+ otherwise your test will wait forever for an event that will never fire.
+ I.e, if your saved cc-exp-year is 2017, make sure your selected option ISN'T 2017.
+ -->
+ <p><label>Expiration year: <select id="cc-exp-year" autocomplete="cc-exp-year">
+ <option value="YY" selected>YY</option>
+ <option value="2017">2017</option>
+ <option value="2018">2018</option>
+ <option value="2019">2019</option>
+ <option value="2020">2020</option>
+ <option value="2021">2021</option>
+ <option value="2022">2022</option>
+ </select>
+ </label></p>
+ <p><label>CSC: <input id="cc-csc" autocomplete="cc-csc"></label></p>
+ </form>
+
+</div>
+
+<pre id="test"></pre>
+</body>
+</html>
diff --git a/browser/extensions/formautofill/test/mochitest/creditCard/test_creditcard_autocomplete_off.html b/browser/extensions/formautofill/test/mochitest/creditCard/test_creditcard_autocomplete_off.html
new file mode 100644
index 0000000000..225d828ccb
--- /dev/null
+++ b/browser/extensions/formautofill/test/mochitest/creditCard/test_creditcard_autocomplete_off.html
@@ -0,0 +1,96 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test basic autofill</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="../formautofill_common.js"></script>
+ <script type="text/javascript" src="../../../../../../toolkit/components/satchel/test/satchel_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Form autofill test: simple form credit card autofill
+
+<script>
+"use strict";
+
+const MOCK_STORAGE = [{
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ "cc-exp-month": 4,
+ "cc-exp-year": 2017,
+}, {
+ "cc-name": "Timothy Berners-Lee",
+ "cc-number": "5103059495477870",
+ "cc-exp-month": 12,
+ "cc-exp-year": 2022,
+}];
+
+async function setupCreditCardStorage() {
+ await addCreditCard(MOCK_STORAGE[0]);
+ await addCreditCard(MOCK_STORAGE[1]);
+}
+
+async function setupFormHistory() {
+ await updateFormHistory([
+ {op: "add", fieldname: "cc-name", value: "John Smith"},
+ {op: "add", fieldname: "cc-number", value: "6011029476355493"},
+ ]);
+}
+
+initPopupListener();
+
+// Show Form History popup for non-autocomplete="off" field only
+add_task(async function history_only_menu_checking() {
+ await setupFormHistory();
+
+ await setInput("#cc-number", "");
+ synthesizeKey("KEY_ArrowDown");
+ await expectPopup();
+ checkMenuEntries(["6011029476355493"], false);
+
+ await setInput("#cc-name", "");
+ synthesizeKey("KEY_ArrowDown");
+ await notExpectPopup();
+});
+
+// Show Form Autofill popup for the credit card fields.
+add_task(async function check_menu_when_both_with_autocomplete_off() {
+ await setupCreditCardStorage();
+
+ await setInput("#cc-number", "");
+ synthesizeKey("KEY_ArrowDown");
+ await expectPopup();
+ checkMenuEntries(MOCK_STORAGE.map(patchRecordCCNumber).map(cc => JSON.stringify({
+ primaryAffix: cc.ccNumberFmt.affix,
+ primary: cc.ccNumberFmt.label,
+ secondary: cc["cc-name"],
+ ariaLabel: `${getCCTypeName(cc)} ${cc.ccNumberFmt.affix} ${cc.ccNumberFmt.label} ${cc["cc-name"]}`,
+ })));
+
+ await setInput("#cc-name", "");
+ synthesizeKey("KEY_ArrowDown");
+ await expectPopup();
+ checkMenuEntries(MOCK_STORAGE.map(patchRecordCCNumber).map(cc => JSON.stringify({
+ primary: cc["cc-name"],
+ secondary: cc.ccNumberFmt.affix + cc.ccNumberFmt.label,
+ ariaLabel: `${getCCTypeName(cc)} ${cc["cc-name"]} ${cc.ccNumberFmt.affix}${cc.ccNumberFmt.label}`,
+ })));
+});
+
+</script>
+
+<p id="display"></p>
+
+<div id="content">
+ <form id="form1">
+ <p>This is a Credit Card form with autocomplete="off" cc-name field.</p>
+ <p><label>Name: <input id="cc-name" autocomplete="off"></label></p>
+ <p><label>Card Number: <input id="cc-number" autocomplete="cc-number"></label></p>
+ </form>
+</div>
+
+<pre id="test"></pre>
+</body>
+</html>
diff --git a/browser/extensions/formautofill/test/mochitest/creditCard/test_preview_highlight_with_multiple_cc_number_fields.html b/browser/extensions/formautofill/test/mochitest/creditCard/test_preview_highlight_with_multiple_cc_number_fields.html
new file mode 100644
index 0000000000..c39877d1b7
--- /dev/null
+++ b/browser/extensions/formautofill/test/mochitest/creditCard/test_preview_highlight_with_multiple_cc_number_fields.html
@@ -0,0 +1,174 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test form autofill - preview and highlight with multiple cc number fields</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="../formautofill_common.js"></script>
+ <script type="text/javascript" src="../../../../../../toolkit/components/satchel/test/satchel_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Form autofill test: preview and highlight multiple cc number fields
+
+<script>
+"use strict";
+
+const MOCK_STORAGE = [{
+ "cc-name": "Test Name",
+ "cc-number": "4929001587121045",
+ "cc-exp-month": 4,
+ "cc-exp-year": 2017,
+}, {
+ "cc-name": "Timothy Berners-Lee",
+ "cc-number": "5103059495477870",
+ "cc-exp-month": 12,
+ "cc-exp-year": 2022,
+}];
+
+const MOCK_STORAGE_EXPECTED_FILL = [{
+ "cc-name": "Test Name",
+ "cc-number": "4929001587121045",
+ "cc-exp-month": "04",
+ "cc-exp-year": 2017,
+}, {
+ "cc-name": "Timothy Berners-Lee",
+ "cc-number": "5103059495477870",
+ "cc-exp-month": "12",
+ "cc-exp-year": 2022,
+}]
+
+const MOCK_STORAGE_PREVIEW = [{
+ "cc-name": "Test Name",
+ "cc-number": "************1045",
+ "cc-exp-month": "04",
+ "cc-exp-year": "2017",
+}, {
+ "cc-name": "Timothy Berners-Lee",
+ "cc-number": "************7870",
+ "cc-exp-month": "12",
+ "cc-exp-year": "2022",
+}];
+
+
+/*
+ This function is similar to checkFormFieldsStyle in formautofill_common.js, but deals with the case
+ when one value is spread across multiple fields.
+
+ This function is needed because of the multiple cc-number filling behavior introduced in Bug 1688607.
+ Since the cc-number is stored as a whole value in the profile,
+ there has to be specific handling in the test to assert the correct fillable value.
+ Otherwise, we would try to grab a value out of profile["cc-number1"] which doesn't exist.
+*/
+async function checkMultipleCCNumberFormStyle(profile, isPreviewing = true) {
+ const elements = document.querySelectorAll("input, select");
+ for (const element of elements) {
+ let fillableValue;
+ if (element.id.includes("cc-number") && isPreviewing) {
+ fillableValue = profile["cc-number"].slice(-8);
+ } else if (element.id.includes("cc-number")) {
+ fillableValue = profile["cc-number"];
+ } else {
+ fillableValue = profile[element.id];
+ }
+ let previewValue = (isPreviewing && fillableValue) || "";
+ await checkFieldHighlighted(element, !!fillableValue);
+ await checkFieldPreview(element, previewValue);
+
+ }
+}
+
+/*
+ This function sets up 'change' event listeners so that we can safely
+ assert an element's value after autofilling has occurred.
+ This is essentially a barebones copy of triggerAutofillAndCheckProfile
+ that exists in formautofill_common.js.
+
+ We can't use triggerAutofillAndCheckProfile because "cc-number1" through "cc-number4"
+ do not exist in the profile.
+ Again, we store the whole cc-number in the profile, not its subsections.
+ So if we tried to grab the element by ID using "cc-number", this element would not exist in the doc,
+ causing triggerAutofillAndCheckProfile to throw an exception.
+*/
+async function setupListeners(elements, profile) {
+ for (const element of elements) {
+ let id = element.id;
+ element.addEventListener("change", () => {
+ let filledValue;
+ if (id == "cc-number1") {
+ filledValue = profile["cc-number"].slice(0, 4);
+ } else if (id == "cc-number2") {
+ filledValue = profile["cc-number"].slice(4, 8);
+ } else if (id == "cc-number3") {
+ filledValue = profile["cc-number"].slice(8, 12);
+ } else if (id == "cc-number4") {
+ filledValue = profile["cc-number"].slice(12, 16);
+ } else {
+ filledValue = profile[element.id];
+ }
+ checkFieldValue(element, filledValue);
+ }, {once: true})
+ }
+
+}
+
+initPopupListener();
+
+add_task(async function setup_storage() {
+ await addCreditCard(MOCK_STORAGE[0]);
+ await addCreditCard(MOCK_STORAGE[1]);
+});
+
+add_task(async function check_preview() {
+ let canTest = await canTestOSKeyStoreLogin();
+ if (!canTest) {
+ todo(canTest, "Cannot test OS key store login on official builds.");
+ return;
+ }
+ let popup = expectPopup();
+ const focusedInput = await setInput("#cc-name", "");
+ await popup;
+ for (let i = 0; i < MOCK_STORAGE_PREVIEW.length; i++) {
+ synthesizeKey("KEY_ArrowDown");
+ await notifySelectedIndex(i);
+ await checkMultipleCCNumberFormStyle(MOCK_STORAGE_PREVIEW[i]);
+ }
+
+ focusedInput.blur();
+});
+
+add_task(async function check_filled_highlight() {
+ let canTest = await canTestOSKeyStoreLogin();
+ if (!canTest) {
+ todo(canTest, "Cannot test OS key store login on official builds.");
+ return;
+ }
+ await triggerPopupAndHoverItem("#cc-name", 0);
+ let osKeyStoreLoginShown = waitForOSKeyStoreLogin(true);
+ // filled 1st credit card option
+ synthesizeKey("KEY_Enter");
+ await osKeyStoreLoginShown;
+ let elements = document.querySelectorAll("input, select");
+ let profile = MOCK_STORAGE_EXPECTED_FILL[0];
+ await setupListeners(elements, profile);
+ await checkMultipleCCNumberFormStyle(profile, false);
+});
+</script>
+<p id="display"></p>
+<div id="content">
+
+ <form id="form1">
+ <p>This is a basic credit card form.</p>
+ <p>card number subsection 1: <input id="cc-number1" maxlength="4"></p>
+ <p>card number subsection 2: <input id="cc-number2" maxlength="4"></p>
+ <p>card number subsection 3: <input id="cc-number3" maxlength="4"></p>
+ <p>card number subsection 4: <input id="cc-number4" maxlength="4"></p>
+ <p>cardholder name: <input id="cc-name" autocomplete="cc-name"></p>
+ <p>expiration month: <input id="cc-exp-month" autocomplete="cc-exp-month"></p>
+ <p>expiration year: <input id="cc-exp-year" autocomplete="cc-exp-year"></p>
+ </form>
+</div>
+<pre id="test"></pre>
+</body>
+</html>
diff --git a/browser/extensions/formautofill/test/mochitest/creditCard/test_preview_highlight_with_site_prefill.html b/browser/extensions/formautofill/test/mochitest/creditCard/test_preview_highlight_with_site_prefill.html
new file mode 100644
index 0000000000..090eb9290e
--- /dev/null
+++ b/browser/extensions/formautofill/test/mochitest/creditCard/test_preview_highlight_with_site_prefill.html
@@ -0,0 +1,110 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test form autofill - preview and highlight with site prefill</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="../formautofill_common.js"></script>
+ <script type="text/javascript" src="../../../../../../toolkit/components/satchel/test/satchel_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Form autofill test: preview and highlight field that has been filled by site
+
+<script>
+"use strict";
+
+const MOCK_STORAGE = [{
+ "cc-name": "Test Name",
+ "cc-number": "4929001587121045",
+ "cc-exp-month": 4,
+ "cc-exp-year": 2017,
+}, {
+ "cc-name": "Timothy Berners-Lee",
+ "cc-number": "5103059495477870",
+ "cc-exp-month": 12,
+ "cc-exp-year": 2022,
+}];
+
+const MOCK_STORAGE_PREVIEW = [{
+ "cc-name": "Test Name",
+ "cc-number": "************1045",
+ "cc-exp-month": "04",
+ "cc-exp-year": "2017",
+}, {
+ "cc-name": "Timothy Berners-Lee",
+ "cc-number": "************7870",
+ "cc-exp-month": "12",
+ "cc-exp-year": "2022",
+}];
+
+const MOCK_STORAGE_EXPECTED_FILL = [{
+ "cc-name": "Test Name",
+ "cc-number": "4929001587121045",
+ "cc-exp-month": "04",
+ "cc-exp-year": 2017,
+}, {
+ "cc-name": "Timothy Berners-Lee",
+ "cc-number": "5103059495477870",
+ "cc-exp-month": "12",
+ "cc-exp-year": 2022,
+}];
+
+initPopupListener();
+
+add_task(async function setup_storage() {
+ await addCreditCard(MOCK_STORAGE[0]);
+ await addCreditCard(MOCK_STORAGE[1]);
+});
+
+add_task(async function check_preview() {
+ let canTest = await canTestOSKeyStoreLogin();
+ if (!canTest) {
+ todo(canTest, "Cannot test OS key store login on official builds.");
+ return;
+ }
+
+ let cardholderName = document.querySelector("#cc-name");
+ let sitePrefillValue = cardholderName.value;
+ let popup = expectPopup();
+ const focusedInput = await setInput("#cc-number", "");
+ await popup;
+ for (let i = 0; i < MOCK_STORAGE_PREVIEW.length; i++) {
+ synthesizeKey("KEY_ArrowDown");
+ await notifySelectedIndex(i);
+ await checkFormFieldsStyle(MOCK_STORAGE_PREVIEW[i]);
+ }
+
+ focusedInput.blur();
+ is(cardholderName.value, sitePrefillValue, "value should not have changed because previous value was a site prefill");
+});
+
+add_task(async function check_filled_highlight() {
+ let canTest = await canTestOSKeyStoreLogin();
+ if (!canTest) {
+ todo(canTest, "Cannot test OS key store login on official builds.");
+ return;
+ }
+ await triggerPopupAndHoverItem("#cc-number", 0);
+ let osKeyStoreLoginShown = waitForOSKeyStoreLogin(true);
+ // filled 1st credit card option
+ await triggerAutofillAndCheckProfile(MOCK_STORAGE_EXPECTED_FILL[0]);
+ await osKeyStoreLoginShown;
+ await checkFormFieldsStyle(MOCK_STORAGE_EXPECTED_FILL[0], false);
+});
+</script>
+<p id="display"></p>
+<div id="content">
+
+ <form id="form1">
+ <p>This is a basic credit card form.</p>
+ <p>card number: <input id="cc-number" autocomplete="cc-number"></p>
+ <p>cardholder name: <input id="cc-name" autocomplete="cc-name" value="JOHN DOE"></p>
+ <p>expiration month: <input id="cc-exp-month" autocomplete="cc-exp-month"></p>
+ <p>expiration year: <input id="cc-exp-year" autocomplete="cc-exp-year"></p>
+ </form>
+</div>
+<pre id="test"></pre>
+</body>
+</html>
diff --git a/browser/extensions/formautofill/test/mochitest/formautofill_common.js b/browser/extensions/formautofill/test/mochitest/formautofill_common.js
new file mode 100644
index 0000000000..d65f0beb49
--- /dev/null
+++ b/browser/extensions/formautofill/test/mochitest/formautofill_common.js
@@ -0,0 +1,478 @@
+/* import-globals-from ../../../../../testing/mochitest/tests/SimpleTest/SimpleTest.js */
+/* import-globals-from ../../../../../testing/mochitest/tests/SimpleTest/EventUtils.js */
+/* import-globals-from ../../../../../toolkit/components/satchel/test/satchel_common.js */
+/* eslint-disable no-unused-vars */
+
+"use strict";
+
+let formFillChromeScript;
+let defaultTextColor;
+let defaultDisabledTextColor;
+let expectingPopup = null;
+
+const { FormAutofillUtils } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://gre/modules/shared/FormAutofillUtils.sys.mjs"
+);
+
+async function sleep(ms = 500, reason = "Intentionally wait for UI ready") {
+ SimpleTest.requestFlakyTimeout(reason);
+ await new Promise(resolve => setTimeout(resolve, ms));
+}
+
+async function focusAndWaitForFieldsIdentified(
+ input,
+ mustBeIdentified = false
+) {
+ info("expecting the target input being focused and indentified");
+ if (typeof input === "string") {
+ input = document.querySelector(input);
+ }
+ const rootElement = input.form || input.ownerDocument.documentElement;
+ const previouslyFocused = input != document.activeElement;
+
+ input.focus();
+
+ if (mustBeIdentified) {
+ rootElement.removeAttribute("test-formautofill-identified");
+ }
+ if (rootElement.hasAttribute("test-formautofill-identified")) {
+ return;
+ }
+ if (!previouslyFocused) {
+ await new Promise(resolve => {
+ formFillChromeScript.addMessageListener(
+ "FormAutofillTest:FieldsIdentified",
+ function onIdentified() {
+ formFillChromeScript.removeMessageListener(
+ "FormAutofillTest:FieldsIdentified",
+ onIdentified
+ );
+ resolve();
+ }
+ );
+ });
+ }
+ // In order to ensure that "markAsAutofillField" is fully executed, a short period
+ // of timeout is still required.
+ await sleep(300, "Guarantee asynchronous identifyAutofillFields is invoked");
+ rootElement.setAttribute("test-formautofill-identified", "true");
+}
+
+async function setInput(selector, value, userInput = false) {
+ const input = document.querySelector("input" + selector);
+ if (userInput) {
+ SpecialPowers.wrap(input).setUserInput(value);
+ } else {
+ input.value = value;
+ }
+ await focusAndWaitForFieldsIdentified(input);
+
+ return input;
+}
+
+function clickOnElement(selector) {
+ let element = document.querySelector(selector);
+
+ if (!element) {
+ throw new Error("Can not find the element");
+ }
+
+ SimpleTest.executeSoon(() => element.click());
+}
+
+// The equivalent helper function to getAdaptedProfiles in FormAutofillHandler.jsm that
+// transforms the given profile to expected filled profile.
+function _getAdaptedProfile(profile) {
+ const adaptedProfile = Object.assign({}, profile);
+
+ if (profile["street-address"]) {
+ adaptedProfile["street-address"] = FormAutofillUtils.toOneLineAddress(
+ profile["street-address"]
+ );
+ }
+
+ return adaptedProfile;
+}
+
+async function checkFieldHighlighted(elem, expectedValue) {
+ let isHighlightApplied;
+ await SimpleTest.promiseWaitForCondition(function checkHighlight() {
+ isHighlightApplied = elem.matches(":autofill");
+ return isHighlightApplied === expectedValue;
+ }, `Checking #${elem.id} highlight style`);
+
+ is(isHighlightApplied, expectedValue, `Checking #${elem.id} highlight style`);
+}
+
+async function checkFieldPreview(elem, expectedValue) {
+ is(
+ SpecialPowers.wrap(elem).previewValue,
+ expectedValue,
+ `Checking #${elem.id} previewValue`
+ );
+ let isTextColorApplied;
+ await SimpleTest.promiseWaitForCondition(function checkPreview() {
+ const computedStyle = window.getComputedStyle(elem);
+ const actualColor = computedStyle.getPropertyValue("color");
+ if (elem.disabled) {
+ isTextColorApplied = actualColor !== defaultDisabledTextColor;
+ } else {
+ isTextColorApplied = actualColor !== defaultTextColor;
+ }
+ return isTextColorApplied === !!expectedValue;
+ }, `Checking #${elem.id} preview style`);
+
+ is(isTextColorApplied, !!expectedValue, `Checking #${elem.id} preview style`);
+}
+
+async function checkFormFieldsStyle(profile, isPreviewing = true) {
+ const elems = document.querySelectorAll("input, select");
+
+ for (const elem of elems) {
+ let fillableValue;
+ let previewValue;
+ let isElementEligible =
+ FormAutofillUtils.isCreditCardOrAddressFieldType(elem) &&
+ FormAutofillUtils.isFieldAutofillable(elem);
+ if (!isElementEligible) {
+ fillableValue = "";
+ previewValue = "";
+ } else {
+ fillableValue = profile && profile[elem.id];
+ previewValue = (isPreviewing && fillableValue) || "";
+ }
+ await checkFieldHighlighted(elem, !!fillableValue);
+ await checkFieldPreview(elem, previewValue);
+ }
+}
+
+function checkFieldValue(elem, expectedValue) {
+ if (typeof elem === "string") {
+ elem = document.querySelector(elem);
+ }
+ is(elem.value, String(expectedValue), "Checking " + elem.id + " field");
+}
+
+async function triggerAutofillAndCheckProfile(profile) {
+ let adaptedProfile = _getAdaptedProfile(profile);
+ const promises = [];
+ for (const [fieldName, value] of Object.entries(adaptedProfile)) {
+ info(`triggerAutofillAndCheckProfile: ${fieldName}`);
+ const element = document.getElementById(fieldName);
+ const expectingEvent =
+ document.activeElement == element ? "input" : "change";
+ const checkFieldAutofilled = Promise.all([
+ new Promise(resolve => {
+ let beforeInputFired = false;
+ let hadEditor = SpecialPowers.wrap(element).hasEditor;
+ element.addEventListener(
+ "beforeinput",
+ event => {
+ beforeInputFired = true;
+ is(
+ event.inputType,
+ "insertReplacementText",
+ 'inputType value should be "insertReplacementText"'
+ );
+ is(
+ event.data,
+ String(value),
+ `data value of "beforeinput" should be "${value}"`
+ );
+ is(
+ event.dataTransfer,
+ null,
+ 'dataTransfer of "beforeinput" should be null'
+ );
+ is(
+ event.getTargetRanges().length,
+ 0,
+ 'getTargetRanges() of "beforeinput" should return empty array'
+ );
+ is(
+ event.cancelable,
+ SpecialPowers.getBoolPref(
+ "dom.input_event.allow_to_cancel_set_user_input"
+ ),
+ `"beforeinput" event should be cancelable on ${element.tagName} unless it's suppressed by the pref`
+ );
+ is(
+ event.bubbles,
+ true,
+ `"beforeinput" event should always bubble on ${element.tagName}`
+ );
+ resolve();
+ },
+ { once: true }
+ );
+ element.addEventListener(
+ "input",
+ event => {
+ if (element.tagName == "INPUT" && element.type == "text") {
+ if (hadEditor) {
+ ok(
+ beforeInputFired,
+ `"beforeinput" event should've been fired before "input" event on ${element.tagName}`
+ );
+ } else {
+ ok(
+ beforeInputFired,
+ `"beforeinput" event should've been fired before "input" event on ${element.tagName}`
+ );
+ }
+ ok(
+ event instanceof InputEvent,
+ `"input" event should be dispatched with InputEvent interface on ${element.tagName}`
+ );
+ is(
+ event.inputType,
+ "insertReplacementText",
+ 'inputType value should be "insertReplacementText"'
+ );
+ is(event.data, String(value), `data value should be "${value}"`);
+ is(event.dataTransfer, null, "dataTransfer should be null");
+ is(
+ event.getTargetRanges().length,
+ 0,
+ "getTargetRanges() should return empty array"
+ );
+ } else {
+ ok(
+ !beforeInputFired,
+ `"beforeinput" event shouldn't be fired on ${element.tagName}`
+ );
+ ok(
+ event instanceof Event && !(event instanceof UIEvent),
+ `"input" event should be dispatched with Event interface on ${element.tagName}`
+ );
+ }
+ is(
+ event.cancelable,
+ false,
+ `"input" event should be never cancelable on ${element.tagName}`
+ );
+ is(
+ event.bubbles,
+ true,
+ `"input" event should always bubble on ${element.tagName}`
+ );
+ resolve();
+ },
+ { once: true }
+ );
+ }),
+ new Promise(resolve =>
+ element.addEventListener(expectingEvent, resolve, { once: true })
+ ),
+ ]).then(() => checkFieldValue(element, value));
+
+ promises.push(checkFieldAutofilled);
+ }
+ // Press Enter key and trigger form autofill.
+ synthesizeKey("KEY_Enter");
+
+ return Promise.all(promises);
+}
+
+async function onStorageChanged(type) {
+ info(`expecting the storage changed: ${type}`);
+ return new Promise(resolve => {
+ formFillChromeScript.addMessageListener(
+ "formautofill-storage-changed",
+ function onChanged(data) {
+ formFillChromeScript.removeMessageListener(
+ "formautofill-storage-changed",
+ onChanged
+ );
+ is(data.data, type, `Receive ${type} storage changed event`);
+ resolve();
+ }
+ );
+ });
+}
+
+function checkMenuEntries(expectedValues, isFormAutofillResult = true) {
+ let actualValues = getMenuEntries();
+ // Expect one more item would appear at the bottom as the footer if the result is from form autofill.
+ let expectedLength = isFormAutofillResult
+ ? expectedValues.length + 1
+ : expectedValues.length;
+
+ is(actualValues.length, expectedLength, " Checking length of expected menu");
+ for (let i = 0; i < expectedValues.length; i++) {
+ is(actualValues[i], expectedValues[i], " Checking menu entry #" + i);
+ }
+}
+
+function invokeAsyncChromeTask(message, payload = {}) {
+ info(`expecting the chrome task finished: ${message}`);
+ return formFillChromeScript.sendQuery(message, payload);
+}
+
+async function addAddress(address) {
+ await invokeAsyncChromeTask("FormAutofillTest:AddAddress", { address });
+ await sleep();
+}
+
+async function removeAddress(guid) {
+ return invokeAsyncChromeTask("FormAutofillTest:RemoveAddress", { guid });
+}
+
+async function updateAddress(guid, address) {
+ return invokeAsyncChromeTask("FormAutofillTest:UpdateAddress", {
+ address,
+ guid,
+ });
+}
+
+async function checkAddresses(expectedAddresses) {
+ return invokeAsyncChromeTask("FormAutofillTest:CheckAddresses", {
+ expectedAddresses,
+ });
+}
+
+async function cleanUpAddresses() {
+ return invokeAsyncChromeTask("FormAutofillTest:CleanUpAddresses");
+}
+
+async function addCreditCard(creditcard) {
+ await invokeAsyncChromeTask("FormAutofillTest:AddCreditCard", { creditcard });
+ await sleep();
+}
+
+async function removeCreditCard(guid) {
+ return invokeAsyncChromeTask("FormAutofillTest:RemoveCreditCard", { guid });
+}
+
+async function checkCreditCards(expectedCreditCards) {
+ return invokeAsyncChromeTask("FormAutofillTest:CheckCreditCards", {
+ expectedCreditCards,
+ });
+}
+
+async function cleanUpCreditCards() {
+ return invokeAsyncChromeTask("FormAutofillTest:CleanUpCreditCards");
+}
+
+async function cleanUpStorage() {
+ await cleanUpAddresses();
+ await cleanUpCreditCards();
+}
+
+async function canTestOSKeyStoreLogin() {
+ let { canTest } = await invokeAsyncChromeTask(
+ "FormAutofillTest:CanTestOSKeyStoreLogin"
+ );
+ return canTest;
+}
+
+async function waitForOSKeyStoreLogin(login = false) {
+ await invokeAsyncChromeTask("FormAutofillTest:OSKeyStoreLogin", { login });
+}
+
+function patchRecordCCNumber(record) {
+ const number = record["cc-number"];
+ const ccNumberFmt = {
+ affix: "****",
+ label: number.substr(-4),
+ };
+
+ return Object.assign({}, record, { ccNumberFmt });
+}
+
+// Utils for registerPopupShownListener(in satchel_common.js) that handles dropdown popup
+// Please call "initPopupListener()" in your test and "await expectPopup()"
+// if you want to wait for dropdown menu displayed.
+function expectPopup() {
+ info("expecting a popup");
+ return new Promise(resolve => {
+ expectingPopup = resolve;
+ });
+}
+
+function notExpectPopup(ms = 500) {
+ info("not expecting a popup");
+ return new Promise((resolve, reject) => {
+ expectingPopup = reject.bind(this, "Unexpected Popup");
+ // TODO: We don't have an event to notify no popup showing, so wait for 500
+ // ms (in default) to predict any unexpected popup showing.
+ setTimeout(resolve, ms);
+ });
+}
+
+function popupShownListener() {
+ info("popup shown for test ");
+ if (expectingPopup) {
+ expectingPopup();
+ expectingPopup = null;
+ }
+}
+
+function initPopupListener() {
+ registerPopupShownListener(popupShownListener);
+}
+
+async function triggerPopupAndHoverItem(fieldSelector, selectIndex) {
+ await focusAndWaitForFieldsIdentified(fieldSelector);
+ synthesizeKey("KEY_ArrowDown");
+ await expectPopup();
+ for (let i = 0; i <= selectIndex; i++) {
+ synthesizeKey("KEY_ArrowDown");
+ }
+ await notifySelectedIndex(selectIndex);
+}
+
+function formAutoFillCommonSetup() {
+ // Remove the /creditCard path segement when referenced from the 'creditCard' subdirectory.
+ let chromeURL = SimpleTest.getTestFileURL(
+ "formautofill_parent_utils.js"
+ ).replace(/\/creditCard/, "");
+ formFillChromeScript = SpecialPowers.loadChromeScript(chromeURL);
+ formFillChromeScript.addMessageListener("onpopupshown", ({ results }) => {
+ gLastAutoCompleteResults = results;
+ if (gPopupShownListener) {
+ gPopupShownListener({ results });
+ }
+ });
+
+ add_setup(async () => {
+ info(`expecting the storage setup`);
+ await formFillChromeScript.sendQuery("setup");
+ });
+
+ SimpleTest.registerCleanupFunction(async () => {
+ info(`expecting the storage cleanup`);
+ await formFillChromeScript.sendQuery("cleanup");
+
+ formFillChromeScript.destroy();
+ expectingPopup = null;
+ });
+
+ document.addEventListener(
+ "DOMContentLoaded",
+ function () {
+ defaultTextColor = window
+ .getComputedStyle(document.querySelector("input"))
+ .getPropertyValue("color");
+
+ // This is needed for test_formautofill_preview_highlight.html to work properly
+ let disabledInput = document.querySelector(`input[disabled]`);
+ if (disabledInput) {
+ defaultDisabledTextColor = window
+ .getComputedStyle(disabledInput)
+ .getPropertyValue("color");
+ }
+ },
+ { once: true }
+ );
+}
+
+/*
+ * Extremely over-simplified detection of card type from card number just for
+ * our tests. This is needed to test the aria-label of credit card menu entries.
+ */
+function getCCTypeName(creditCard) {
+ return creditCard["cc-number"][0] == "4" ? "Visa" : "MasterCard";
+}
+
+formAutoFillCommonSetup();
diff --git a/browser/extensions/formautofill/test/mochitest/formautofill_parent_utils.js b/browser/extensions/formautofill/test/mochitest/formautofill_parent_utils.js
new file mode 100644
index 0000000000..0d9af772b5
--- /dev/null
+++ b/browser/extensions/formautofill/test/mochitest/formautofill_parent_utils.js
@@ -0,0 +1,304 @@
+/* eslint-env mozilla/chrome-script */
+
+"use strict";
+
+const { FormAutofill } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofill.sys.mjs"
+);
+const { FormAutofillUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/FormAutofillUtils.sys.mjs"
+);
+const { OSKeyStoreTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/OSKeyStoreTestUtils.sys.mjs"
+);
+
+let { formAutofillStorage } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillStorage.sys.mjs"
+);
+
+const { ADDRESSES_COLLECTION_NAME, CREDITCARDS_COLLECTION_NAME } =
+ FormAutofillUtils;
+
+let destroyed = false;
+
+var ParentUtils = {
+ getFormAutofillActor() {
+ let win = Services.wm.getMostRecentWindow("navigator:browser");
+ let selectedBrowser = win.gBrowser.selectedBrowser;
+ return selectedBrowser.browsingContext.currentWindowGlobal.getActor(
+ "FormAutofill"
+ );
+ },
+
+ _getRecords(collectionName) {
+ return this.getFormAutofillActor().receiveMessage({
+ name: "FormAutofill:GetRecords",
+ data: {
+ searchString: "",
+ collectionName,
+ },
+ });
+ },
+
+ async _storageChangeObserved({
+ topic = "formautofill-storage-changed",
+ type,
+ times = 1,
+ }) {
+ let count = times;
+
+ return new Promise(resolve => {
+ Services.obs.addObserver(function observer(subject, obsTopic, data) {
+ if ((type && data != type) || !!--count) {
+ return;
+ }
+
+ // every notification type should have the collection name.
+ // We're not allowed to trigger assertions during mochitest
+ // cleanup functions.
+ if (!destroyed) {
+ let allowedNames = [
+ ADDRESSES_COLLECTION_NAME,
+ CREDITCARDS_COLLECTION_NAME,
+ ];
+ assert.ok(
+ allowedNames.includes(subject.wrappedJSObject.collectionName),
+ "should include the collection name"
+ );
+ // every notification except removeAll should have a guid.
+ if (data != "removeAll") {
+ assert.ok(subject.wrappedJSObject.guid, "should have a guid");
+ }
+ }
+ Services.obs.removeObserver(observer, obsTopic);
+ resolve();
+ }, topic);
+ });
+ },
+
+ async _operateRecord(collectionName, type, msgData) {
+ let msgName, times, topic;
+
+ if (collectionName == ADDRESSES_COLLECTION_NAME) {
+ switch (type) {
+ case "add": {
+ msgName = "FormAutofill:SaveAddress";
+ break;
+ }
+ case "update": {
+ msgName = "FormAutofill:SaveAddress";
+ break;
+ }
+ case "remove": {
+ msgName = "FormAutofill:RemoveAddresses";
+ times = msgData.guids.length;
+ break;
+ }
+ default:
+ return;
+ }
+ } else {
+ switch (type) {
+ case "add": {
+ msgData = Object.assign({}, msgData);
+ msgName = "FormAutofill:SaveCreditCard";
+ break;
+ }
+ case "remove": {
+ msgName = "FormAutofill:RemoveCreditCards";
+ times = msgData.guids.length;
+ break;
+ }
+ default:
+ return;
+ }
+ }
+
+ let storageChangePromise = this._storageChangeObserved({
+ type,
+ times,
+ topic,
+ });
+ this.getFormAutofillActor().receiveMessage({
+ name: msgName,
+ data: msgData,
+ });
+ await storageChangePromise;
+ },
+
+ async operateAddress(type, msgData) {
+ await this._operateRecord(ADDRESSES_COLLECTION_NAME, ...arguments);
+ },
+
+ async operateCreditCard(type, msgData) {
+ await this._operateRecord(CREDITCARDS_COLLECTION_NAME, ...arguments);
+ },
+
+ async cleanUpAddresses() {
+ const guids = (await this._getRecords(ADDRESSES_COLLECTION_NAME)).map(
+ record => record.guid
+ );
+
+ if (!guids.length) {
+ return;
+ }
+
+ await this.operateAddress(
+ "remove",
+ { guids },
+ "FormAutofillTest:AddressesCleanedUp"
+ );
+ },
+
+ async cleanUpCreditCards() {
+ if (!FormAutofill.isAutofillCreditCardsAvailable) {
+ return;
+ }
+ const guids = (await this._getRecords(CREDITCARDS_COLLECTION_NAME)).map(
+ record => record.guid
+ );
+
+ if (!guids.length) {
+ return;
+ }
+
+ await this.operateCreditCard(
+ "remove",
+ { guids },
+ "FormAutofillTest:CreditCardsCleanedUp"
+ );
+ },
+
+ setup() {
+ OSKeyStoreTestUtils.setup();
+ },
+
+ async cleanup() {
+ await this.cleanUpAddresses();
+ await this.cleanUpCreditCards();
+ await OSKeyStoreTestUtils.cleanup();
+
+ Services.obs.removeObserver(this, "formautofill-storage-changed");
+ },
+
+ _areRecordsMatching(recordA, recordB, collectionName) {
+ for (let field of formAutofillStorage[collectionName].VALID_FIELDS) {
+ if (recordA[field] !== recordB[field]) {
+ return false;
+ }
+ }
+ // Check the internal field if both addresses have valid value.
+ for (let field of formAutofillStorage.INTERNAL_FIELDS) {
+ if (
+ field in recordA &&
+ field in recordB &&
+ recordA[field] !== recordB[field]
+ ) {
+ return false;
+ }
+ }
+ return true;
+ },
+
+ async _checkRecords(collectionName, expectedRecords) {
+ const records = await this._getRecords(collectionName);
+
+ if (records.length !== expectedRecords.length) {
+ return false;
+ }
+
+ for (let record of records) {
+ let matching = expectedRecords.some(expectedRecord => {
+ return ParentUtils._areRecordsMatching(
+ record,
+ expectedRecord,
+ collectionName
+ );
+ });
+
+ if (!matching) {
+ return false;
+ }
+ }
+
+ return true;
+ },
+
+ async checkAddresses({ expectedAddresses }) {
+ return this._checkRecords(ADDRESSES_COLLECTION_NAME, expectedAddresses);
+ },
+
+ async checkCreditCards({ expectedCreditCards }) {
+ return this._checkRecords(CREDITCARDS_COLLECTION_NAME, expectedCreditCards);
+ },
+
+ observe(subject, topic, data) {
+ if (!destroyed) {
+ assert.ok(topic === "formautofill-storage-changed");
+ }
+ sendAsyncMessage("formautofill-storage-changed", {
+ subject: null,
+ topic,
+ data,
+ });
+ },
+};
+
+Services.obs.addObserver(ParentUtils, "formautofill-storage-changed");
+
+Services.mm.addMessageListener("FormAutofill:FieldsIdentified", () => {
+ return null;
+});
+
+addMessageListener("FormAutofillTest:AddAddress", msg => {
+ return ParentUtils.operateAddress("add", msg);
+});
+
+addMessageListener("FormAutofillTest:RemoveAddress", msg => {
+ return ParentUtils.operateAddress("remove", msg);
+});
+
+addMessageListener("FormAutofillTest:UpdateAddress", msg => {
+ return ParentUtils.operateAddress("update", msg);
+});
+
+addMessageListener("FormAutofillTest:CheckAddresses", msg => {
+ return ParentUtils.checkAddresses(msg);
+});
+
+addMessageListener("FormAutofillTest:CleanUpAddresses", msg => {
+ return ParentUtils.cleanUpAddresses();
+});
+
+addMessageListener("FormAutofillTest:AddCreditCard", msg => {
+ return ParentUtils.operateCreditCard("add", msg);
+});
+
+addMessageListener("FormAutofillTest:RemoveCreditCard", msg => {
+ return ParentUtils.operateCreditCard("remove", msg);
+});
+
+addMessageListener("FormAutofillTest:CheckCreditCards", msg => {
+ return ParentUtils.checkCreditCards(msg);
+});
+
+addMessageListener("FormAutofillTest:CleanUpCreditCards", msg => {
+ return ParentUtils.cleanUpCreditCards();
+});
+
+addMessageListener("FormAutofillTest:CanTestOSKeyStoreLogin", msg => {
+ return { canTest: OSKeyStoreTestUtils.canTestOSKeyStoreLogin() };
+});
+
+addMessageListener("FormAutofillTest:OSKeyStoreLogin", async msg => {
+ await OSKeyStoreTestUtils.waitForOSKeyStoreLogin(msg.login);
+});
+
+addMessageListener("setup", async () => {
+ ParentUtils.setup();
+});
+
+addMessageListener("cleanup", async () => {
+ destroyed = true;
+ await ParentUtils.cleanup();
+});
diff --git a/browser/extensions/formautofill/test/mochitest/mochitest.ini b/browser/extensions/formautofill/test/mochitest/mochitest.ini
new file mode 100644
index 0000000000..2964fd5b13
--- /dev/null
+++ b/browser/extensions/formautofill/test/mochitest/mochitest.ini
@@ -0,0 +1,23 @@
+[DEFAULT]
+prefs =
+ extensions.formautofill.creditCards.supported=on
+ extensions.formautofill.creditCards.enabled=true
+ extensions.formautofill.addresses.supported=on
+ extensions.formautofill.addresses.enabled=true
+skip-if = toolkit == 'android' # bug 1730213
+support-files =
+ ../../../../../toolkit/components/satchel/test/satchel_common.js
+ ../../../../../toolkit/components/satchel/test/parent_utils.js
+ formautofill_common.js
+ formautofill_parent_utils.js
+
+[test_address_level_1_submission.html]
+[test_autofill_and_ordinal_forms.html]
+[test_autofocus_form.html]
+[test_basic_autocomplete_form.html]
+[test_form_changes.html]
+[test_formautofill_preview_highlight.html]
+skip-if = verify
+[test_multi_locale_CA_address_form.html]
+[test_multiple_forms.html]
+[test_on_address_submission.html]
diff --git a/browser/extensions/formautofill/test/mochitest/test_address_level_1_submission.html b/browser/extensions/formautofill/test/mochitest/test_address_level_1_submission.html
new file mode 100644
index 0000000000..9d6ad1f7ea
--- /dev/null
+++ b/browser/extensions/formautofill/test/mochitest/test_address_level_1_submission.html
@@ -0,0 +1,102 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test autofill submission for a country without address-level1</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="formautofill_common.js"></script>
+ <script type="text/javascript" src="satchel_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Form autofill test: Test autofill submission for a country without address-level1
+
+<script>
+/* import-globals-from ../../../../../toolkit/components/satchel/test/satchel_common.js */
+
+"use strict";
+
+const TEST_ADDRESSES = [{
+ organization: "Mozilla",
+ "street-address": "123 Sesame Street",
+ "address-level1": "AL",
+ country: "DE",
+ timesUsed: 1,
+}];
+
+add_task(async function test_DE_is_valid_testcase() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.formautofill.addresses.capture.enabled", true],
+ ["extensions.formautofill.addresses.supportedCountries", "US,CA,DE"],
+ ["extensions.formautofill.creditCards.supportedCountries", "US,CA,DE"],
+ ],
+ });
+ let chromeScript = SpecialPowers.loadChromeScript(function test_country_data() {
+ /* eslint-env mozilla/chrome-script */
+ const {AddressDataLoader} = ChromeUtils.importESModule("resource://gre/modules/shared/FormAutofillUtils.sys.mjs");
+ let data = AddressDataLoader.getData("DE");
+ addMessageListener("CheckSubKeys", () => {
+ return !data.defaultLocale.sub_keys;
+ });
+ });
+
+ SimpleTest.registerCleanupFunction(() => {
+ chromeScript.destroy();
+ });
+
+ let result = await chromeScript.sendQuery("CheckSubKeys");
+ ok(result, "Check that there are no sub_keys for the test country");
+});
+
+add_task(async function test_form_will_submit_without_sub_keys() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // This needs to match the country in the previous test and must have no sub_keys.
+ ["browser.search.region", "DE"],
+ // We already verified the first time use case in browser test
+ ["extensions.formautofill.firstTimeUse", false],
+ ["extensions.formautofill.addresses.capture.enabled", true],
+ ["extensions.formautofill.addresses.supportedCountries", "US,CA,DE"],
+ ["extensions.formautofill.addresses.supported", "detect"]
+ ],
+ });
+ // Click a field to get the form handler created
+ await focusAndWaitForFieldsIdentified("input[autocomplete='organization']");
+
+ let loadPromise = new Promise(resolve => {
+ /* eslint-disable-next-line mozilla/balanced-listeners */
+ document.getElementById("submit_frame").addEventListener("load", resolve);
+ });
+
+ clickOnElement("input[type=submit]");
+ await onStorageChanged("add");
+ // Check if timesUsed is set correctly
+ let matching = await checkAddresses(TEST_ADDRESSES);
+ ok(matching, "Address saved as expected");
+
+ await loadPromise;
+ isnot(window.submit_frame.location.href, "about:blank", "Check form submitted");
+});
+
+</script>
+
+<div>
+ <!-- Submit to the frame so that the test doesn't get replaced. We don't return
+ -- false in onsubmit since we're testing the submission succeeds. -->
+ <iframe id="submit_frame" name="submit_frame"></iframe>
+ <form action="/" target="submit_frame" method="POST">
+ <p><label>organization: <input autocomplete="organization" value="Mozilla"></label></p>
+ <p><label>streetAddress: <input autocomplete="street-address" value="123 Sesame Street"></label></p>
+ <p><label>address-level1: <select autocomplete="address-level1">
+ <option selected>AL</option>
+ <option>AK</option>
+ </select></label></p>
+ <p><label>country: <input autocomplete="country" value="DE"></label></p>
+ <p><input type="submit"></p>
+ </form>
+
+</div>
+</body>
+</html>
diff --git a/browser/extensions/formautofill/test/mochitest/test_autofill_and_ordinal_forms.html b/browser/extensions/formautofill/test/mochitest/test_autofill_and_ordinal_forms.html
new file mode 100644
index 0000000000..5c143c3f4a
--- /dev/null
+++ b/browser/extensions/formautofill/test/mochitest/test_autofill_and_ordinal_forms.html
@@ -0,0 +1,116 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test autofill submit</title>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="formautofill_common.js"></script>
+ <script type="text/javascript" src="satchel_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+
+<script>
+/* import-globals-from ../../../../../toolkit/components/satchel/test/satchel_common.js */
+
+"use strict";
+
+SpecialPowers.pushPrefEnv({"set": [["security.allow_eval_with_system_principal", true]]});
+
+let MOCK_STORAGE = [{
+ "given-name": "John",
+ "additional-name": "R",
+ "family-name": "Smith",
+ "organization": "Sesame Street",
+ "street-address": "123 Sesame Street.",
+ "tel": "+13453453456",
+ "country": "US",
+ "address-level1": "NY",
+}];
+
+initPopupListener();
+
+add_task(async function setupStorage() {
+ await addAddress(MOCK_STORAGE[0]);
+
+ await updateFormHistory([
+ {op: "add", fieldname: "username", value: "petya"},
+ {op: "add", fieldname: "current-password", value: "abrh#25_,K"},
+ ]);
+});
+
+add_task(async function check_switch_autofill_form_popup() {
+ await setInput("#tel", "");
+ synthesizeKey("KEY_ArrowDown");
+ await expectPopup();
+ checkMenuEntries(
+ [
+ `{"primary":"+13453453456","secondary":"123 Sesame Street."}`,
+ `{"primary":"","secondary":"","categories":["name","organization","address","tel"],"focusedCategory":"tel"}`,
+ ],
+ false
+ );
+
+ await testMenuEntry(0, "!(el instanceof MozElements.MozAutocompleteRichlistitem)");
+});
+
+add_task(async function check_switch_oridnal_form_popup() {
+ // We need an intentional wait here before switching form.
+ await sleep();
+ await setInput("#username", "");
+ synthesizeKey("KEY_ArrowDown");
+ await expectPopup();
+ checkMenuEntries(["petya"], false);
+
+ await testMenuEntry(0, "el instanceof MozElements.MozAutocompleteRichlistitem");
+});
+
+add_task(async function check_switch_autofill_form_popup_back() {
+ // We need an intentional wait here before switching form.
+ await sleep();
+ await setInput("#tel", "");
+ synthesizeKey("KEY_ArrowDown");
+ await expectPopup();
+ checkMenuEntries(
+ [
+ `{"primary":"+13453453456","secondary":"123 Sesame Street."}`,
+ `{"primary":"","secondary":"","categories":["name","organization","address","tel"],"focusedCategory":"tel"}`,
+ ],
+ false
+ );
+
+ await testMenuEntry(0, "!(el instanceof MozElements.MozAutocompleteRichlistitem)");
+});
+
+</script>
+
+<div>
+
+ <h2>Address form</h2>
+ <form class="alignedLabels">
+ <label>given-name: <input autocomplete="given-name" autofocus></label>
+ <label>additional-name: <input id="additional-name" autocomplete="additional-name"></label>
+ <label>family-name: <input autocomplete="family-name"></label>
+ <label>organization: <input autocomplete="organization"></label>
+ <label>street-address: <input autocomplete="street-address"></label>
+ <label>address-level1: <input autocomplete="address-level1"></label>
+ <label>postal-code: <input autocomplete="postal-code"></label>
+ <label>country: <input autocomplete="country"></label>
+ <label>country-name: <input autocomplete="country-name"></label>
+ <label>tel: <input id="tel" autocomplete="tel"></label>
+ <p>
+ <input type="submit" value="Submit">
+ <button type="reset">Reset</button>
+ </p>
+ </form>
+
+ <h2>Ordinal form</h2>
+ <form class="alignedLabels">
+ <label>username: <input id="username" autocomplete="username"></label>
+ <p><input type="submit" value="Username"></p>
+ </form>
+
+</div>
+</body>
+</html>
diff --git a/browser/extensions/formautofill/test/mochitest/test_autofocus_form.html b/browser/extensions/formautofill/test/mochitest/test_autofocus_form.html
new file mode 100644
index 0000000000..e2240474c8
--- /dev/null
+++ b/browser/extensions/formautofill/test/mochitest/test_autofocus_form.html
@@ -0,0 +1,69 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test basic autofill</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="formautofill_common.js"></script>
+ <script type="text/javascript" src="satchel_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Form autofill test: autocomplete on an autofocus form
+
+<script>
+/* import-globals-from ../../../../../toolkit/components/satchel/test/satchel_common.js */
+
+"use strict";
+
+let MOCK_STORAGE = [{
+ organization: "Sesame Street",
+ "street-address": "123 Sesame Street.",
+ tel: "1-345-345-3456",
+}, {
+ organization: "Mozilla",
+ "street-address": "331 E. Evelyn Avenue",
+ tel: "1-650-903-0800",
+}];
+
+initPopupListener();
+
+async function setupAddressStorage() {
+ await addAddress(MOCK_STORAGE[0]);
+ await addAddress(MOCK_STORAGE[1]);
+}
+
+add_task(async function check_autocomplete_on_autofocus_field() {
+ await setupAddressStorage();
+ synthesizeKey("KEY_ArrowDown");
+ await expectPopup();
+ checkMenuEntries(MOCK_STORAGE.map(address =>
+ JSON.stringify({primary: address.organization, secondary: address["street-address"]})
+ ));
+});
+
+</script>
+
+<p id="display"></p>
+
+<div id="content">
+
+ <form id="form1">
+ <p>This is a basic form.</p>
+ <p><label>organization: <input id="organization" name="organization" autocomplete="organization" type="text"></label></p>
+ <script>
+ "use strict";
+ // Focuses the input before DOMContentLoaded
+ document.getElementById("organization").focus();
+ </script>
+ <p><label>streetAddress: <input id="street-address" name="street-address" autocomplete="street-address" type="text"></label></p>
+ <p><label>tel: <input id="tel" name="tel" autocomplete="tel" type="text"></label></p>
+ <p><label>country: <input id="country" name="country" autocomplete="country" type="text"></label></p>
+ </form>
+
+</div>
+
+<pre id="test"></pre>
+</body>
+</html>
diff --git a/browser/extensions/formautofill/test/mochitest/test_basic_autocomplete_form.html b/browser/extensions/formautofill/test/mochitest/test_basic_autocomplete_form.html
new file mode 100644
index 0000000000..a642b2abca
--- /dev/null
+++ b/browser/extensions/formautofill/test/mochitest/test_basic_autocomplete_form.html
@@ -0,0 +1,220 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test basic autofill</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="formautofill_common.js"></script>
+ <script type="text/javascript" src="satchel_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Form autofill test: simple form address autofill
+
+<script>
+/* import-globals-from ../../../../../toolkit/components/satchel/test/satchel_common.js */
+
+"use strict";
+
+let MOCK_STORAGE = [{
+ organization: "Sesame Street",
+ "street-address": "123 Sesame Street.\n2-line\n3-line",
+ tel: "+13453453456",
+ country: "US",
+ "address-level1": "NY",
+}, {
+ organization: "Mozilla",
+ "street-address": "331 E. Evelyn Avenue\n2-line\n3-line",
+ tel: "+16509030800",
+ country: "US",
+ "address-level1": "CA",
+}];
+
+async function setupAddressStorage() {
+ await addAddress(MOCK_STORAGE[0]);
+ await addAddress(MOCK_STORAGE[1]);
+}
+
+async function setupFormHistory() {
+ await updateFormHistory([
+ {op: "add", fieldname: "tel", value: "+1234567890"},
+ {op: "add", fieldname: "email", value: "foo@mozilla.com"},
+ ]);
+}
+
+initPopupListener();
+
+// Form with history only.
+add_task(async function history_only_menu_checking() {
+ await setupFormHistory();
+
+ await setInput("#tel", "");
+ await notExpectPopup();
+ synthesizeKey("KEY_ArrowDown");
+ await expectPopup();
+ checkMenuEntries(["+1234567890"], false);
+});
+
+// Display history search result if less than 3 inputs are covered by all saved
+// fields in the storage.
+add_task(async function all_saved_fields_less_than_threshold() {
+ await addAddress({
+ email: "test@test.com",
+ });
+
+ await setInput("#email", "");
+ await notExpectPopup();
+ synthesizeKey("KEY_ArrowDown");
+ await expectPopup();
+ checkMenuEntries(["foo@mozilla.com"], false);
+
+ await cleanUpAddresses();
+});
+
+// Form with both history and address storage.
+add_task(async function check_menu_when_both_existed() {
+ await setupAddressStorage();
+
+ await setInput("#organization", "");
+ await notExpectPopup();
+ synthesizeKey("KEY_ArrowDown");
+ await expectPopup();
+ checkMenuEntries(MOCK_STORAGE.map(address =>
+ JSON.stringify({
+ primary: address.organization,
+ secondary: FormAutofillUtils.toOneLineAddress(address["street-address"]),
+ })
+ ));
+
+ await setInput("#street-address", "");
+ await notExpectPopup();
+ synthesizeKey("KEY_ArrowDown");
+ await expectPopup();
+ checkMenuEntries(MOCK_STORAGE.map(address =>
+ JSON.stringify({
+ primary: FormAutofillUtils.toOneLineAddress(address["street-address"]),
+ secondary: address.organization,
+ })
+ ));
+
+ await setInput("#tel", "");
+ await notExpectPopup();
+ synthesizeKey("KEY_ArrowDown");
+ await expectPopup();
+ checkMenuEntries(MOCK_STORAGE.map(address =>
+ JSON.stringify({
+ primary: address.tel,
+ secondary: FormAutofillUtils.toOneLineAddress(address["street-address"]),
+ })
+ ));
+
+ await setInput("#address-line1", "");
+ await notExpectPopup();
+ synthesizeKey("KEY_ArrowDown");
+ await expectPopup();
+ checkMenuEntries(MOCK_STORAGE.map(address =>
+ JSON.stringify({
+ primary: FormAutofillUtils.toOneLineAddress(address["street-address"]),
+ secondary: address.organization,
+ })
+ ));
+});
+
+// Display history search result if no matched data in addresses.
+add_task(async function check_fallback_for_mismatched_field() {
+ await setInput("#email", "");
+ await notExpectPopup();
+ synthesizeKey("KEY_ArrowDown");
+ await expectPopup();
+ checkMenuEntries(["foo@mozilla.com"], false);
+});
+
+// Display history search result if address autofill is disabled.
+add_task(async function check_search_result_for_pref_off() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.formautofill.addresses.enabled", false]],
+ });
+
+ await setInput("#tel", "");
+ await notExpectPopup();
+ synthesizeKey("KEY_ArrowDown");
+ await expectPopup();
+ checkMenuEntries(["+1234567890"], false);
+
+ await SpecialPowers.popPrefEnv();
+});
+
+// Autofill the address from dropdown menu.
+add_task(async function check_fields_after_form_autofill() {
+ const focusedInput = await setInput("#organization", "Moz");
+ await notExpectPopup();
+ synthesizeKey("KEY_ArrowDown");
+ await expectPopup();
+ checkMenuEntries(MOCK_STORAGE.map(address =>
+ JSON.stringify({
+ primary: address.organization,
+ secondary: FormAutofillUtils.toOneLineAddress(address["street-address"]),
+ })
+ ).slice(1));
+ synthesizeKey("KEY_ArrowDown");
+ await triggerAutofillAndCheckProfile(MOCK_STORAGE[1]);
+ synthesizeKey("KEY_Escape");
+ is(focusedInput.value, "Mozilla", "Filled field shouldn't be reverted by ESC key");
+});
+
+// Fallback to history search after autofill address.
+add_task(async function check_fallback_after_form_autofill() {
+ await setInput("#tel", "", true);
+ await triggerPopupAndHoverItem("#tel", 0);
+ checkMenuEntries(["+1234567890"], false);
+ await triggerAutofillAndCheckProfile({
+ tel: "+1234567890",
+ });
+});
+
+// Resume form autofill once all the autofilled fileds are changed.
+add_task(async function check_form_autofill_resume() {
+ document.querySelector("#tel").blur();
+ document.querySelector("#form1").reset();
+ await setInput("#tel", "");
+ await triggerPopupAndHoverItem("#tel", 0);
+ checkMenuEntries(MOCK_STORAGE.map(address =>
+ JSON.stringify({
+ primary: address.tel,
+ secondary: FormAutofillUtils.toOneLineAddress(address["street-address"]),
+ })
+ ));
+ await triggerAutofillAndCheckProfile(MOCK_STORAGE[0]);
+});
+
+</script>
+
+<p id="display"></p>
+
+<div id="content">
+
+ <form id="form1">
+ <p>This is a basic form.</p>
+ <p><label>organization: <input id="organization" name="organization" autocomplete="organization" type="text"></label></p>
+ <p><label>streetAddress: <input id="street-address" name="street-address" autocomplete="street-address" type="text"></label></p>
+ <p><label>address-line1: <input id="address-line1" name="address-line1" autocomplete="address-line1" type="text"></label></p>
+ <p><label>tel: <input id="tel" name="tel" autocomplete="tel" type="text"></label></p>
+ <p><label>email: <input id="email" name="email" autocomplete="email" type="text"></label></p>
+ <p><label>country: <select id="country" name="country" autocomplete="country">
+ <option/>
+ <option value="US">United States</option>
+ </select></label></p>
+ <p><label>states: <select id="address-level1" name="address-level1" autocomplete="address-level1">
+ <option/>
+ <option value="CA">California</option>
+ <option value="NY">New York</option>
+ <option value="WA">Washington</option>
+ </select></label></p>
+ </form>
+
+</div>
+
+<pre id="test"></pre>
+</body>
+</html>
diff --git a/browser/extensions/formautofill/test/mochitest/test_form_changes.html b/browser/extensions/formautofill/test/mochitest/test_form_changes.html
new file mode 100644
index 0000000000..2eace91a53
--- /dev/null
+++ b/browser/extensions/formautofill/test/mochitest/test_form_changes.html
@@ -0,0 +1,128 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test basic autofill</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="formautofill_common.js"></script>
+ <script type="text/javascript" src="satchel_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Form autofill test: autocomplete on an autofocus form
+
+<script>
+/* import-globals-from ../../../../../toolkit/components/satchel/test/satchel_common.js */
+
+"use strict";
+
+let MOCK_STORAGE = [{
+ name: "John Doe",
+ organization: "Sesame Street",
+ "address-level2": "Austin",
+ tel: "+13453453456",
+}, {
+ name: "Foo Bar",
+ organization: "Mozilla",
+ "address-level2": "San Francisco",
+ tel: "+16509030800",
+}];
+
+initPopupListener();
+
+async function setupAddressStorage() {
+ await addAddress(MOCK_STORAGE[0]);
+ await addAddress(MOCK_STORAGE[1]);
+}
+
+function addInputField(form, className) {
+ let newElem = document.createElement("input");
+ newElem.name = className;
+ newElem.autocomplete = className;
+ newElem.type = "text";
+ form.appendChild(newElem);
+}
+
+async function checkFieldsAutofilled(formId, profile) {
+ const elements = document.querySelectorAll(`#${formId} input`);
+ for (const element of elements) {
+ await SimpleTest.promiseWaitForCondition(() => {
+ return element.value == profile[element.name];
+ });
+ await checkFieldHighlighted(element, true);
+ }
+}
+
+async function checkFormChangeHappened(formId) {
+ info("expecting form changed");
+ await focusAndWaitForFieldsIdentified(`#${formId} input[name=tel]`);
+ synthesizeKey("KEY_ArrowDown");
+ await expectPopup();
+ synthesizeKey("KEY_ArrowDown");
+ checkMenuEntries(MOCK_STORAGE.map(address =>
+ JSON.stringify({primary: address.tel, secondary: address.name})
+ ));
+
+ // Click the first entry of the autocomplete popup and make sure all fields are autofilled
+ synthesizeKey("KEY_Enter");
+ await checkFieldsAutofilled(formId, MOCK_STORAGE[0]);
+ // This is for checking the changes of element count.
+ addInputField(document.querySelector(`#${formId}`), "address-level2");
+
+ await focusAndWaitForFieldsIdentified(`#${formId} input[name=name]`);
+ synthesizeKey("KEY_ArrowDown");
+ await expectPopup();
+
+ // Click on an autofilled field would show an autocomplete popup with "clear form" entry
+ checkMenuEntries([
+ JSON.stringify({primary: "", secondary: ""}), // Clear Autofill Form
+ JSON.stringify({primary: "", secondary: ""}) // FormAutofill Preferemce
+ ], false);
+
+ // This is for checking the changes of element removed and added then.
+ document.querySelector(`#${formId} input[name=address-level2]`).remove();
+ addInputField(document.querySelector(`#${formId}`), "address-level2");
+
+ await focusAndWaitForFieldsIdentified(`#${formId} input[name=address-level2]`, true);
+ synthesizeKey("KEY_ArrowDown");
+ await expectPopup();
+ checkMenuEntries(MOCK_STORAGE.map(address =>
+ JSON.stringify({primary: address["address-level2"], secondary: address.name})
+ ));
+
+ // Make sure everything is autofilled in the end
+ synthesizeKey("KEY_ArrowDown");
+ synthesizeKey("KEY_Enter");
+ await checkFieldsAutofilled(formId, MOCK_STORAGE[0]);
+}
+
+add_task(async function init_storage() {
+ await setupAddressStorage();
+});
+
+add_task(async function check_change_happened_in_form() {
+ await checkFormChangeHappened("form1");
+});
+
+add_task(async function check_change_happened_in_body() {
+ await checkFormChangeHappened("form2");
+});
+</script>
+
+<p id="display"></p>
+<div id="content">
+ <form id="form1">
+ <p><label>organization: <input name="organization" autocomplete="organization" type="text"></label></p>
+ <p><label>tel: <input name="tel" autocomplete="tel" type="text"></label></p>
+ <p><label>name: <input name="name" autocomplete="name" type="text"></label></p>
+ </form>
+ <div id="form2">
+ <p><label>organization: <input name="organization" autocomplete="organization" type="text"></label></p>
+ <p><label>tel: <input name="tel" autocomplete="tel" type="text"></label></p>
+ <p><label>name: <input name="name" autocomplete="name" type="text"></label></p>
+ </div>
+</div>
+<pre id="test"></pre>
+</body>
+</html>
diff --git a/browser/extensions/formautofill/test/mochitest/test_formautofill_preview_highlight.html b/browser/extensions/formautofill/test/mochitest/test_formautofill_preview_highlight.html
new file mode 100644
index 0000000000..b32b036c9c
--- /dev/null
+++ b/browser/extensions/formautofill/test/mochitest/test_formautofill_preview_highlight.html
@@ -0,0 +1,121 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test form autofill - preview and highlight</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="formautofill_common.js"></script>
+ <script type="text/javascript" src="satchel_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Form autofill test: preview and highlight
+
+<script>
+/* import-globals-from ../../../../../toolkit/components/satchel/test/satchel_common.js */
+
+"use strict";
+
+const MOCK_STORAGE = [
+ {
+ organization: "Sesame Street",
+ "street-address": "123 Sesame Street.",
+ tel: "+13453453456",
+ },
+ {
+ organization: "Mozilla",
+ "street-address": "331 E. Evelyn Avenue",
+ },
+ {
+ organization: "Tel org",
+ tel: "+12223334444",
+ },
+ {
+ organization: "Random Org",
+ "address-level1": "First Admin Level",
+ tel: "+13453453456",
+ },
+ {
+ organization: "readonly Org",
+ "address-level1": "First Admin Level",
+ tel: "+13453453456",
+ name: "John Doe",
+ },
+ {
+ organization: "test org",
+ "address-level2": "Not a Town",
+ tel: "+13453453456",
+ name: "John Doe",
+ }
+];
+
+
+initPopupListener();
+
+add_task(async function setup_storage() {
+ for (const storage of MOCK_STORAGE) {
+ await addAddress(storage);
+ }
+});
+
+add_task(async function check_preview() {
+ const focusedInput = await setInput("#organization", "");
+
+ synthesizeKey("KEY_ArrowDown");
+ await expectPopup();
+ await checkFormFieldsStyle(null);
+
+ for (let i = 0; i < MOCK_STORAGE.length; i++) {
+ info(`Checking organization: ${MOCK_STORAGE[i].organization} preview`);
+ synthesizeKey("KEY_ArrowDown");
+ await notifySelectedIndex(i);
+ await checkFormFieldsStyle(MOCK_STORAGE[i]);
+ }
+
+ // Navigate to the footer
+ synthesizeKey("KEY_ArrowDown");
+ await notifySelectedIndex(MOCK_STORAGE.length);
+ await checkFormFieldsStyle(null);
+
+ synthesizeKey("KEY_ArrowDown");
+ await notifySelectedIndex(-1);
+ await checkFormFieldsStyle(null);
+
+ focusedInput.blur();
+});
+
+add_task(async function check_filled_highlight() {
+ await triggerPopupAndHoverItem("#organization", 0);
+ // filled 1st address
+ await triggerAutofillAndCheckProfile(MOCK_STORAGE[0]);
+ await checkFormFieldsStyle(MOCK_STORAGE[0], false);
+});
+
+</script>
+
+<p id="display"></p>
+
+<div id="content">
+
+ <form id="form1">
+ <p>This is a basic form.</p>
+ <p><label>organization: <input id="organization" autocomplete="organization"></label></p>
+ <p><label>streetAddress: <input id="street-address" autocomplete="street-address"></label></p>
+ <p><label>tel: <input id="tel" autocomplete="tel"></label></p>
+ <p><label>country: <input id="country" autocomplete="country"></label></p>
+ <p><label>address-level1:
+ <select id="address-level1" autocomplete="address-level1">
+ <option>First Admin Level</option>
+ <option>Second Admin Level</option>
+ </select>
+ </label></p>
+ <p><label>full name: <input id="name" autocomplete="name" readonly value="UNCHANGED"></label></p>
+ <p><label>address-level2: <input id="address-level2" autocomplete="address-level2" disabled value="Town"></label></p>
+ </form>
+
+</div>
+
+<pre id="test"></pre>
+</body>
+</html>
diff --git a/browser/extensions/formautofill/test/mochitest/test_multi_locale_CA_address_form.html b/browser/extensions/formautofill/test/mochitest/test_multi_locale_CA_address_form.html
new file mode 100644
index 0000000000..48e0caa785
--- /dev/null
+++ b/browser/extensions/formautofill/test/mochitest/test_multi_locale_CA_address_form.html
@@ -0,0 +1,273 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test basic autofill</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="formautofill_common.js"></script>
+ <script type="text/javascript" src="satchel_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Form autofill test: simple form address autofill
+
+<script>
+/* import-globals-from ../../../../../toolkit/components/satchel/test/satchel_common.js */
+
+"use strict";
+
+let MOCK_STORAGE = [{
+ organization: "Mozilla Vancouver",
+ "street-address": "163 W Hastings St.\n#209\n3-line",
+ tel: "+17787851540",
+ country: "CA",
+ "address-level1": "BC",
+}, {
+ organization: "Mozilla Toronto",
+ "street-address": "366 Adelaide St.\nW Suite 500\n3-line",
+ tel: "+14168483114",
+ country: "CA",
+ "address-level1": "ON",
+}, {
+ organization: "Prince of Wales Northern Heritage",
+ "street-address": "4750 48 St.\nYellowknife\n3-line",
+ tel: "+18677679347",
+ country: "CA",
+ "address-level1": "Northwest Territories",
+}, {
+ organization: "ExpoCité",
+ "street-address": "250 Boulevard Wilfrid-Hamel\nVille de Québec\n3-line",
+ tel: "+14186917110",
+ country: "CA",
+ "address-level1": "Québec",
+}];
+
+function checkElementFilled(element, expectedvalue) {
+ let focusFired = false;
+ let inputFired = false;
+ let changeFired = false;
+ return [
+ new Promise(resolve => {
+ element.addEventListener("focus", function onChange() {
+ ok(true, "Checking " + element.name + " field fires focus event");
+ focusFired = true;
+ resolve();
+ }, {once: true});
+ }),
+ new Promise(resolve => {
+ let beforeInputFired = false;
+ let oldValue = element.value;
+ element.addEventListener("beforeinput", function onBeforeInput(event) {
+ ok(true, "Checking " + element.name + " field fires beforeinput event");
+ ok(focusFired, "Focus fired before `beforeinput` event");
+ beforeInputFired = true;
+ ok(event instanceof InputEvent,
+ `"beforeinput" event should be dispatched with InputEvent interface on ${element.name}`);
+ is(event.inputType, "insertReplacementText",
+ 'inputType value of "beforeinput" event should be "insertReplacementText"');
+ is(event.data, expectedvalue,
+ 'data value of "beforeinput" event should be same as expected value');
+ is(event.dataTransfer, null,
+ 'dataTransfer value of "beforeinput" event should be null');
+ is(event.getTargetRanges().length, 0,
+ 'getTargetRanges() of "beforeinput" event should return empty array');
+ is(event.cancelable, SpecialPowers.getBoolPref("dom.input_event.allow_to_cancel_set_user_input"),
+ `"beforeinput" event should be cancelable on ${element.name} unless it's suppressed by the pref`);
+ is(event.bubbles, true,
+ `"input" event should always bubble on ${element.name}`);
+ is(element.value, oldValue,
+ 'value of the element should not be modified at "beforeinput" event yet');
+ }, {once: true});
+ element.addEventListener("input", function onInput(event) {
+ ok(true, "Checking " + element.name + " field fires input event");
+ if (element.tagName == "INPUT" && element.type == "text") {
+ ok(beforeInputFired, `"beforeinput" event shoud've been fired on ${element.name} before "input" event`);
+ ok(event instanceof InputEvent,
+ `"input" event should be dispatched with InputEvent interface on ${element.name}`);
+ is(event.inputType, "insertReplacementText",
+ "inputType value should be \"insertReplacementText\"");
+ is(event.data, expectedvalue,
+ "data value should be same as expected value");
+ is(event.dataTransfer, null,
+ "dataTransfer value should be null");
+ is(event.getTargetRanges().length, 0,
+ 'getTargetRanges() should return empty array');
+ is(element.value, expectedvalue,
+ 'value of the element should be modified at "input" event');
+ } else {
+ ok(!beforeInputFired, `"beforeinput" event shoudn't be fired on ${element.name} before "input" event`);
+ ok(event instanceof Event && !(event instanceof UIEvent),
+ `"input" event should be dispatched with Event interface on ${element.name}`);
+ }
+ is(event.cancelable, false,
+ `"input" event should be never cancelable on ${element.name}`);
+ is(event.bubbles, true,
+ `"input" event should always bubble on ${element.name}`);
+ inputFired = true;
+ resolve();
+ }, {once: true});
+ }),
+ new Promise(resolve => {
+ element.addEventListener("change", function onChange() {
+ ok(true, "Checking " + element.name + " field fires change event");
+ is(element.value, expectedvalue, "Checking " + element.name + " field");
+ ok(focusFired, "Focus fired before `change` event");
+ changeFired = true;
+ resolve();
+ }, {once: true});
+ }),
+ new Promise(resolve => {
+ element.addEventListener("blur", function onChange() {
+ ok(true, "Checking " + element.name + " field fires blur event");
+ ok(changeFired, "Change fired before `blur` event");
+ ok(inputFired, "Input fired before `blur` event");
+ is(element.value, expectedvalue, "Checking " + element.name + " field");
+ resolve();
+ }, {once: true});
+ }),
+ ];
+}
+
+function checkAutoCompleteInputFilled(element, expectedvalue) {
+ return new Promise(resolve => {
+ element.addEventListener("input", function onInput() {
+ is(element.value, expectedvalue, "Checking " + element.name + " field");
+ resolve();
+ }, {once: true});
+ });
+}
+
+function checkFormFilled(selector, address) {
+ info("expecting form filled");
+ let promises = [];
+ let form = document.querySelector(selector);
+ for (let prop in address) {
+ let element = form.querySelector(`[name=${prop}]`);
+ if (document.activeElement == element) {
+ promises.push(checkAutoCompleteInputFilled(element, address[prop]));
+ } else {
+ let converted = address[prop];
+ if (prop == "street-address") {
+ converted = FormAutofillUtils.toOneLineAddress(converted);
+ }
+ promises.push(...checkElementFilled(element, converted));
+ }
+ }
+ synthesizeKey("KEY_Enter");
+ return Promise.all(promises);
+}
+
+async function setupAddressStorage() {
+ for (let address of MOCK_STORAGE) {
+ await addAddress(address);
+ }
+}
+
+initPopupListener();
+
+add_setup(async () => {
+ // This test relies on being able to fill a Canadian address which isn't possible
+ // without `supportedCountries` allowing Canada
+ await SpecialPowers.pushPrefEnv({"set": [["extensions.formautofill.supportedCountries", "US,CA"]]});
+
+ await setupAddressStorage();
+});
+
+// Autofill the address with address level 1 code.
+add_task(async function autofill_with_level1_code() {
+ await setInput("#organization-en", "Mozilla Toront");
+ synthesizeKey("KEY_ArrowDown");
+ await expectPopup();
+
+ synthesizeKey("KEY_ArrowDown");
+ // Replace address level 1 code with full name in English for test result
+ let result = Object.assign({}, MOCK_STORAGE[1], {"address-level1": "Ontario"});
+ await checkFormFilled("#form-en", result);
+
+ await setInput("#organization-fr", "Mozilla Vancouve");
+ synthesizeKey("KEY_ArrowDown");
+ await expectPopup();
+
+ synthesizeKey("KEY_ArrowDown");
+ // Replace address level 1 code with full name in French for test result
+ result = Object.assign({}, MOCK_STORAGE[0], {"address-level1": "Colombie-Britannique"});
+ await checkFormFilled("#form-fr", result);
+ document.querySelector("#form-en").reset();
+ document.querySelector("#form-fr").reset();
+});
+
+// Autofill the address with address level 1 full name.
+add_task(async function autofill_with_level1_full_name() {
+ await setInput("#organization-en", "ExpoCit");
+ synthesizeKey("KEY_ArrowDown");
+ await expectPopup();
+
+ synthesizeKey("KEY_ArrowDown");
+ // Replace address level 1 code with full name in French for test result
+ let result = Object.assign({}, MOCK_STORAGE[3], {"address-level1": "Quebec"});
+ await checkFormFilled("#form-en", result);
+
+ await setInput("#organization-fr", "Prince of Wales");
+ synthesizeKey("KEY_ArrowDown");
+ await expectPopup();
+
+ synthesizeKey("KEY_ArrowDown");
+ // Replace address level 1 code with full name in English for test result
+ result = Object.assign({}, MOCK_STORAGE[2], {"address-level1": "Territoires du Nord-Ouest"});
+ await checkFormFilled("#form-fr", result);
+});
+
+</script>
+
+<p id="display"></p>
+
+<div id="content">
+
+ <form id="form-en">
+ <p>This is a basic CA form with en address level 1 select.</p>
+ <p><label>organization: <input id="organization-en" name="organization" autocomplete="organization" type="text"></label></p>
+ <p><label>streetAddress: <input id="street-address-en" name="street-address" autocomplete="street-address" type="text"></label></p>
+ <p><label>address-line1: <input id="address-line1-en" name="address-line1" autocomplete="address-line1" type="text"></label></p>
+ <p><label>tel: <input id="tel-en" name="tel" autocomplete="tel" type="text"></label></p>
+ <p><label>email: <input id="email-en" name="email" autocomplete="email" type="text"></label></p>
+ <p><label>country: <select id="country-en" name="country" autocomplete="country">
+ <option/>
+ <option value="US">United States</option>
+ <option value="CA">Canada</option>
+ </select></label></p>
+ <p><label>states: <select id="address-level1-en" name="address-level1" autocomplete="address-level1">
+ <option/>
+ <option value="British Columbia">British Columbia</option>
+ <option value="Ontario">Ontario</option>
+ <option value="Northwest Territories">Northwest Territories</option>
+ <option value="Quebec">Quebec</option>
+ </select></label></p>
+ </form>
+
+ <form id="form-fr">
+ <p>This is a basic CA form with fr address level 1 select.</p>
+ <p><label>organization: <input id="organization-fr" name="organization" autocomplete="organization" type="text"></label></p>
+ <p><label>streetAddress: <input id="street-address-fr" name="street-address" autocomplete="street-address" type="text"></label></p>
+ <p><label>address-line1: <input id="address-line1-fr" name="address-line1" autocomplete="address-line1" type="text"></label></p>
+ <p><label>tel: <input id="tel-fr" name="tel" autocomplete="tel" type="text"></label></p>
+ <p><label>email: <input id="email-fr" name="email" autocomplete="email" type="text"></label></p>
+ <p><label>country: <select id="country-fr" name="country" autocomplete="country">
+ <option/>
+ <option value="US">United States</option>
+ <option value="CA">Canada</option>
+ </select></label></p>
+ <p><label>states: <select id="address-level1-fr" name="address-level1" autocomplete="address-level1">
+ <option/>
+ <option value="Colombie-Britannique">Colombie-Britannique</option>
+ <option value="Ontario">Ontario</option>
+ <option value="Territoires du Nord-Ouest">Territoires du Nord-Ouest</option>
+ <option value="Québec">Québec</option>
+ </select></label></p>
+ </form>
+
+</div>
+
+<pre id="test"></pre>
+</body>
+</html>
diff --git a/browser/extensions/formautofill/test/mochitest/test_multiple_forms.html b/browser/extensions/formautofill/test/mochitest/test_multiple_forms.html
new file mode 100644
index 0000000000..feea55aae6
--- /dev/null
+++ b/browser/extensions/formautofill/test/mochitest/test_multiple_forms.html
@@ -0,0 +1,67 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test autofill submit</title>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="formautofill_common.js"></script>
+ <script type="text/javascript" src="satchel_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+
+<script>
+/* import-globals-from ../../../../../toolkit/components/satchel/test/satchel_common.js */
+
+"use strict";
+
+let MOCK_STORAGE = [{
+ "given-name": "John",
+ "additional-name": "R",
+ "family-name": "Smith",
+}];
+
+initPopupListener();
+
+add_task(async function setupStorage() {
+ await addAddress(MOCK_STORAGE[0]);
+});
+
+add_task(async function check_switch_form_popup() {
+ await setInput("#additional-name", "");
+ synthesizeKey("KEY_ArrowDown");
+ await expectPopup();
+
+ // We need an intentional wait here before switching form.
+ await sleep();
+ await setInput("#organization", "");
+ synthesizeKey("KEY_ArrowDown");
+ const {open: popupOpen} = await getPopupState();
+ is(popupOpen, false);
+
+ await sleep();
+ await setInput("#given-name", "");
+ synthesizeKey("KEY_ArrowDown");
+ await expectPopup();
+});
+
+</script>
+
+<div>
+
+ <form>
+ <label>Name:<input id="name" autocomplete="name"></label>
+ <label>Organization:<input id="organization" autocomplete="organization"></label>
+ <label>City:<input autocomplete="address-level2"></label>
+ </form>
+
+ <form>
+ <label>Given-Name: <input id="given-name" autocomplete="given-name"></label>
+ <label>Additional-Name/Middle: <input id="additional-name" autocomplete="additional-name"></label>
+ <label>FamilyName-LastName: <input id="family-name" autocomplete="family-name"></label>
+ </form>
+
+</div>
+</body>
+</html>
diff --git a/browser/extensions/formautofill/test/mochitest/test_on_address_submission.html b/browser/extensions/formautofill/test/mochitest/test_on_address_submission.html
new file mode 100644
index 0000000000..79da48c77b
--- /dev/null
+++ b/browser/extensions/formautofill/test/mochitest/test_on_address_submission.html
@@ -0,0 +1,121 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test autofill submit</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+ <script type="text/javascript" src="formautofill_common.js"></script>
+ <script type="text/javascript" src="satchel_common.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+</head>
+<body>
+Form autofill test: check if address is saved/updated correctly
+
+<script>
+/* import-globals-from ../../../../../toolkit/components/satchel/test/satchel_common.js */
+
+"use strict";
+
+let TEST_ADDRESSES = [{
+ organization: "Sesame Street",
+ "street-address": "123 Sesame Street.",
+ tel: "+13453453456",
+}, {
+ organization: "Mozilla",
+ "street-address": "331 E. Evelyn Avenue",
+ tel: "+16509030800",
+}];
+
+add_task(async function setup_prefs() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.formautofill.addresses.enabled", true],
+ ["extensions.formautofill.addresses.capture.enabled", true],
+ ],
+ });
+});
+
+initPopupListener();
+
+// Submit first address for saving.
+add_task(async function check_storage_after_form_submitted() {
+ // We already verified the first time use case in browser test
+ await SpecialPowers.pushPrefEnv({
+ "set": [["extensions.formautofill.firstTimeUse", false]],
+ });
+
+ for (let key in TEST_ADDRESSES[0]) {
+ await setInput("#" + key, TEST_ADDRESSES[0][key]);
+ }
+
+ clickOnElement("input[type=submit]");
+
+ let expectedAddresses = TEST_ADDRESSES.slice(0, 1);
+ await onStorageChanged("add");
+ // Check if timesUsed is set correctly
+ expectedAddresses[0].timesUsed = 1;
+ let matching = await checkAddresses(expectedAddresses);
+ ok(matching, "Address saved as expected");
+ delete expectedAddresses[0].timesUsed;
+});
+
+// Submit another new address.
+add_task(async function check_storage_after_another_address_submitted() {
+ await SpecialPowers.pushPrefEnv({"set": [["privacy.reduceTimerPrecision", false]]});
+
+ document.querySelector("form").reset();
+ for (let key in TEST_ADDRESSES[1]) {
+ await setInput("#" + key, TEST_ADDRESSES[1][key]);
+ }
+
+ clickOnElement("input[type=submit]");
+
+ // The 2nd test address should be on the top since it's the last used one.
+ let addressesInMenu = TEST_ADDRESSES.slice(1);
+ addressesInMenu.push(TEST_ADDRESSES[0]);
+
+ // let expectedAddresses = TEST_ADDRESSES.slice(0);
+ await onStorageChanged("add");
+ let matching = await checkAddresses(TEST_ADDRESSES);
+ ok(matching, "New address saved as expected");
+
+ await setInput("#organization", "");
+ synthesizeKey("KEY_ArrowDown");
+ await expectPopup();
+ checkMenuEntries(addressesInMenu.map(address =>
+ JSON.stringify({primary: address.organization, secondary: address["street-address"]})
+ ));
+});
+
+// Submit another new address that is mergeable.
+add_task(async function new_address_submitted_and_merged() {
+ // TODO: Bug Bug 1812294
+});
+
+// Submit an updated autofill address and merge.
+add_task(async function check_storage_after_form_submitted() {
+ // TODO: Bug Bug 1812294
+});
+
+// Submit a subset address manually.
+add_task(async function submit_subset_manually() {
+ // TODO: Bug Bug 1812294
+});
+
+</script>
+
+<div>
+
+ <form onsubmit="return false">
+ <p>This is a basic form for submitting test.</p>
+ <p><label>organization: <input id="organization" name="organization" autocomplete="organization" type="text"></label></p>
+ <p><label>streetAddress: <input id="street-address" name="street-address" autocomplete="street-address" type="text"></label></p>
+ <p><label>tel: <input id="tel" name="tel" autocomplete="tel" type="text"></label></p>
+ <p><label>country: <input id="country" name="country" autocomplete="country" type="text"></label></p>
+ <p><input type="submit"></p>
+ </form>
+
+</div>
+</body>
+</html>
diff --git a/browser/extensions/formautofill/test/unit/head.js b/browser/extensions/formautofill/test/unit/head.js
new file mode 100644
index 0000000000..e5353833ef
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/head.js
@@ -0,0 +1,357 @@
+/**
+ * Provides infrastructure for automated formautofill components tests.
+ */
+
+"use strict";
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+var { ObjectUtils } = ChromeUtils.import(
+ "resource://gre/modules/ObjectUtils.jsm"
+);
+var { FormLikeFactory } = ChromeUtils.importESModule(
+ "resource://gre/modules/FormLikeFactory.sys.mjs"
+);
+var { FormAutofillHandler } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/FormAutofillHandler.sys.mjs"
+);
+var { AddonTestUtils, MockAsyncShutdown } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+var { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+var { FileTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/FileTestUtils.sys.mjs"
+);
+var { MockDocument } = ChromeUtils.importESModule(
+ "resource://testing-common/MockDocument.sys.mjs"
+);
+var { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+var { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs",
+ ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs",
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+});
+
+{
+ // We're going to register a mock file source
+ // with region names based on en-US. This is
+ // necessary for tests that expect to match
+ // on region code display names.
+ const fs = [
+ {
+ path: "toolkit/intl/regionNames.ftl",
+ source: `
+region-name-us = United States
+region-name-nz = New Zealand
+region-name-au = Australia
+region-name-ca = Canada
+region-name-tw = Taiwan
+ `,
+ },
+ ];
+
+ let locales = Services.locale.packagedLocales;
+ const mockSource = L10nFileSource.createMock(
+ "mock",
+ "app",
+ locales,
+ "resource://mock_path",
+ fs
+ );
+ L10nRegistry.getInstance().registerSources([mockSource]);
+}
+
+do_get_profile();
+
+const EXTENSION_ID = "formautofill@mozilla.org";
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+
+function SetPref(name, value) {
+ switch (typeof value) {
+ case "string":
+ Services.prefs.setCharPref(name, value);
+ break;
+ case "number":
+ Services.prefs.setIntPref(name, value);
+ break;
+ case "boolean":
+ Services.prefs.setBoolPref(name, value);
+ break;
+ default:
+ throw new Error("Unknown type");
+ }
+}
+
+// Return the current date rounded in the manner that sync does.
+function getDateForSync() {
+ return Math.round(Date.now() / 10) / 100;
+}
+
+async function loadExtension() {
+ AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "1",
+ "1.9.2"
+ );
+ await AddonTestUtils.promiseStartupManager();
+
+ let extensionPath = Services.dirsvc.get("GreD", Ci.nsIFile);
+ extensionPath.append("browser");
+ extensionPath.append("features");
+ extensionPath.append(EXTENSION_ID);
+
+ if (!extensionPath.exists()) {
+ extensionPath.leafName = `${EXTENSION_ID}.xpi`;
+ }
+
+ let startupPromise = new Promise(resolve => {
+ const { apiManager } = ExtensionParent;
+ function onReady(event, extension) {
+ if (extension.id == EXTENSION_ID) {
+ apiManager.off("ready", onReady);
+ resolve();
+ }
+ }
+
+ apiManager.on("ready", onReady);
+ });
+
+ await AddonManager.installTemporaryAddon(extensionPath);
+ await startupPromise;
+}
+
+// Returns a reference to a temporary file that is guaranteed not to exist and
+// is cleaned up later. See FileTestUtils.getTempFile for details.
+function getTempFile(leafName) {
+ return FileTestUtils.getTempFile(leafName);
+}
+
+async function initProfileStorage(
+ fileName,
+ records,
+ collectionName = "addresses"
+) {
+ let { FormAutofillStorage } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillStorage.sys.mjs"
+ );
+ let path = getTempFile(fileName).path;
+ let profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ // AddonTestUtils inserts its own directory provider that manages TmpD.
+ // It removes that directory at shutdown, which races with shutdown
+ // handing in JSONFile/DeferredTask (which is used by FormAutofillStorage).
+ // Avoid the race by explicitly finalizing any formautofill JSONFile
+ // instances created manually by individual tests when the test finishes.
+ registerCleanupFunction(function finalizeAutofillStorage() {
+ return profileStorage._finalize();
+ });
+
+ if (!records || !Array.isArray(records)) {
+ return profileStorage;
+ }
+
+ let onChanged = TestUtils.topicObserved(
+ "formautofill-storage-changed",
+ (subject, data) =>
+ data == "add" && subject.wrappedJSObject.collectionName == collectionName
+ );
+ for (let record of records) {
+ Assert.ok(await profileStorage[collectionName].add(record));
+ await onChanged;
+ }
+ await profileStorage._saveImmediately();
+ return profileStorage;
+}
+
+function verifySectionAutofillResult(sections, expectedSectionsInfo) {
+ sections.forEach((section, index) => {
+ const expectedSection = expectedSectionsInfo[index];
+
+ const fieldDetails = section.fieldDetails;
+ const expectedFieldDetails = expectedSection.fields;
+
+ info(`verify autofill section[${index}]`);
+
+ fieldDetails.forEach((field, fieldIndex) => {
+ const expeceted = expectedFieldDetails[fieldIndex];
+
+ Assert.equal(
+ expeceted.autofill,
+ field.element.value,
+ `Autofilled value for element(id=${field.element.id}, field name=${field.fieldName}) should be equal`
+ );
+ });
+ });
+}
+
+function verifySectionFieldDetails(sections, expectedSectionsInfo) {
+ sections.forEach((section, index) => {
+ const expectedSection = expectedSectionsInfo[index];
+
+ const fieldDetails = section.fieldDetails;
+ const expectedFieldDetails = expectedSection.fields;
+
+ info(`section[${index}] ${expectedSection.description ?? ""}:`);
+ info(`FieldName Prediction Results: ${fieldDetails.map(i => i.fieldName)}`);
+ info(
+ `FieldName Expected Results: ${expectedFieldDetails.map(
+ detail => detail.fieldName
+ )}`
+ );
+ Assert.equal(
+ fieldDetails.length,
+ expectedFieldDetails.length,
+ `Expected field count.`
+ );
+
+ fieldDetails.forEach((field, fieldIndex) => {
+ const expectedFieldDetail = expectedFieldDetails[fieldIndex];
+
+ const expected = {
+ ...{
+ reason: "autocomplete",
+ section: "",
+ contactType: "",
+ addressType: "",
+ },
+ ...expectedSection.default,
+ ...expectedFieldDetail,
+ };
+
+ const keys = new Set([...Object.keys(field), ...Object.keys(expected)]);
+ ["autofill", "elementWeakRef", "confidence", "part"].forEach(k =>
+ keys.delete(k)
+ );
+
+ for (const key of keys) {
+ const expectedValue = expected[key];
+ const actualValue = field[key];
+ Assert.equal(
+ expectedValue,
+ actualValue,
+ `${key} should be equal, expect ${expectedValue}, got ${actualValue}`
+ );
+ }
+ });
+
+ Assert.equal(
+ section.isValidSection(),
+ !expectedSection.invalid,
+ `Should be an ${expectedSection.invalid ? "invalid" : "valid"} section`
+ );
+ });
+}
+
+var FormAutofillHeuristics, LabelUtils;
+var AddressDataLoader, FormAutofillUtils;
+
+function autofillFieldSelector(doc) {
+ return doc.querySelectorAll("input, select");
+}
+
+/**
+ * Returns the Sync change counter for a profile storage record. Synced records
+ * store additional metadata for tracking changes and resolving merge conflicts.
+ * Deleting a synced record replaces the record with a tombstone.
+ *
+ * @param {AutofillRecords} records
+ * The `AutofillRecords` instance to query.
+ * @param {string} guid
+ * The GUID of the record or tombstone.
+ * @returns {number}
+ * The change counter, or -1 if the record doesn't exist or hasn't
+ * been synced yet.
+ */
+function getSyncChangeCounter(records, guid) {
+ let record = records._findByGUID(guid, { includeDeleted: true });
+ if (!record) {
+ return -1;
+ }
+ let sync = records._getSyncMetaData(record);
+ if (!sync) {
+ return -1;
+ }
+ return sync.changeCounter;
+}
+
+/**
+ * Performs a partial deep equality check to determine if an object contains
+ * the given fields.
+ *
+ * @param {object} object
+ * The object to check. Unlike `ObjectUtils.deepEqual`, properties in
+ * `object` that are not in `fields` will be ignored.
+ * @param {object} fields
+ * The fields to match.
+ * @returns {boolean}
+ * Does `object` contain `fields` with matching values?
+ */
+function objectMatches(object, fields) {
+ let actual = {};
+ for (let key in fields) {
+ if (!object.hasOwnProperty(key)) {
+ return false;
+ }
+ actual[key] = object[key];
+ }
+ return ObjectUtils.deepEqual(actual, fields);
+}
+
+add_setup(async function head_initialize() {
+ Services.prefs.setBoolPref("extensions.experiments.enabled", true);
+ Services.prefs.setBoolPref("dom.forms.autocomplete.formautofill", true);
+
+ Services.prefs.setCharPref(
+ "extensions.formautofill.addresses.supported",
+ "on"
+ );
+ Services.prefs.setCharPref(
+ "extensions.formautofill.creditCards.supported",
+ "on"
+ );
+ Services.prefs.setBoolPref("extensions.formautofill.addresses.enabled", true);
+ Services.prefs.setBoolPref(
+ "extensions.formautofill.creditCards.enabled",
+ true
+ );
+
+ // Clean up after every test.
+ registerCleanupFunction(function head_cleanup() {
+ Services.prefs.clearUserPref("extensions.experiments.enabled");
+ Services.prefs.clearUserPref(
+ "extensions.formautofill.creditCards.supported"
+ );
+ Services.prefs.clearUserPref("extensions.formautofill.addresses.supported");
+ Services.prefs.clearUserPref("extensions.formautofill.creditCards.enabled");
+ Services.prefs.clearUserPref("dom.forms.autocomplete.formautofill");
+ Services.prefs.clearUserPref("extensions.formautofill.addresses.enabled");
+ Services.prefs.clearUserPref("extensions.formautofill.creditCards.enabled");
+ });
+
+ await loadExtension();
+});
+
+let OSKeyStoreTestUtils;
+add_setup(async function os_key_store_setup() {
+ ({ OSKeyStoreTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/OSKeyStoreTestUtils.sys.mjs"
+ ));
+ OSKeyStoreTestUtils.setup();
+ registerCleanupFunction(async function cleanup() {
+ await OSKeyStoreTestUtils.cleanup();
+ });
+});
diff --git a/browser/extensions/formautofill/test/unit/head_addressComponent.js b/browser/extensions/formautofill/test/unit/head_addressComponent.js
new file mode 100644
index 0000000000..472e4ee589
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/head_addressComponent.js
@@ -0,0 +1,69 @@
+"use strict";
+
+/* exported BOTH_EMPTY, A_IS_EMPTY, B_IS_EMPTY, A_CONTAINS_B, B_CONTAINS_A, SIMILAR, SAME, DIFFERENT, runIsValidTest, runCompareTest */
+
+const { AddressComparison, AddressComponent } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/AddressComponent.sys.mjs"
+);
+
+const { FormAutofill } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofill.sys.mjs"
+);
+
+const BOTH_EMPTY = AddressComparison.BOTH_EMPTY;
+const A_IS_EMPTY = AddressComparison.A_IS_EMPTY;
+const B_IS_EMPTY = AddressComparison.B_IS_EMPTY;
+const A_CONTAINS_B = AddressComparison.A_CONTAINS_B;
+const B_CONTAINS_A = AddressComparison.B_CONTAINS_A;
+const SIMILAR = AddressComparison.SIMILAR;
+const SAME = AddressComparison.SAME;
+const DIFFERENT = AddressComparison.DIFFERENT;
+
+function runIsValidTest(tests, fieldName, funcSetupRecord) {
+ let region = FormAutofill.DEFAULT_REGION;
+ for (const test of tests) {
+ if (!Array.isArray(test)) {
+ region = test.region;
+ info(`Change region to ${JSON.stringify(test.region)}`);
+ continue;
+ }
+
+ const [testValue, expected] = test;
+ const record = funcSetupRecord(testValue);
+
+ const field = new AddressComponent(record, region).getField(fieldName);
+ const result = field.isValid();
+ Assert.equal(
+ result,
+ expected,
+ `Expect isValid returns ${expected} for ${testValue}`
+ );
+ }
+}
+
+function runCompareTest(tests, fieldName, funcSetupRecord) {
+ let region = FormAutofill.DEFAULT_REGION;
+ for (const test of tests) {
+ if (!Array.isArray(test)) {
+ info(`change region to ${JSON.stringify(test.region)}`);
+ region = test.region;
+ continue;
+ }
+
+ const [v1, v2, expected] = test;
+ const r1 = funcSetupRecord(v1);
+ const f1 = new AddressComponent(r1, region).getField(fieldName);
+
+ const r2 = funcSetupRecord(v2);
+ const f2 = new AddressComponent(r2, region).getField(fieldName);
+
+ const result = AddressComparison.compare(f1, f2);
+ const resultString = AddressComparison.resultToString(result);
+ const expectedString = AddressComparison.resultToString(expected);
+ Assert.equal(
+ result,
+ expected,
+ `Expect ${expectedString} when comparing "${v1}" & "${v2}", got ${resultString}`
+ );
+ }
+}
diff --git a/browser/extensions/formautofill/test/unit/test_activeStatus.js b/browser/extensions/formautofill/test/unit/test_activeStatus.js
new file mode 100644
index 0000000000..47d79b02e5
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_activeStatus.js
@@ -0,0 +1,176 @@
+/*
+ * Test for status handling in Form Autofill Parent.
+ */
+
+"use strict";
+
+let FormAutofillStatus;
+
+add_setup(async () => {
+ ({ FormAutofillStatus } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillParent.sys.mjs"
+ ));
+});
+
+add_task(async function test_activeStatus_init() {
+ sinon.spy(FormAutofillStatus, "updateStatus");
+
+ // Default status is null before initialization
+ Assert.equal(FormAutofillStatus._active, null);
+ Assert.equal(Services.ppmm.sharedData.get("FormAutofill:enabled"), undefined);
+
+ FormAutofillStatus.init();
+ // init shouldn't call updateStatus since that requires storage which will
+ // lead to startup time regressions.
+ Assert.equal(FormAutofillStatus.updateStatus.called, false);
+ Assert.equal(Services.ppmm.sharedData.get("FormAutofill:enabled"), undefined);
+
+ // Initialize profile storage
+ await FormAutofillStatus.formAutofillStorage.initialize();
+ await FormAutofillStatus.updateSavedFieldNames();
+ // Upon first initializing profile storage, status should be computed.
+ Assert.equal(FormAutofillStatus.updateStatus.called, true);
+ Assert.equal(Services.ppmm.sharedData.get("FormAutofill:enabled"), false);
+
+ FormAutofillStatus.uninit();
+});
+
+add_task(async function test_activeStatus_observe() {
+ FormAutofillStatus.init();
+ sinon.stub(FormAutofillStatus, "computeStatus");
+ sinon.spy(FormAutofillStatus, "onStatusChanged");
+
+ // _active = _computeStatus() => No need to trigger _onStatusChanged
+ FormAutofillStatus._active = true;
+ FormAutofillStatus.computeStatus.returns(true);
+ FormAutofillStatus.observe(
+ null,
+ "nsPref:changed",
+ "extensions.formautofill.addresses.enabled"
+ );
+ FormAutofillStatus.observe(
+ null,
+ "nsPref:changed",
+ "extensions.formautofill.creditCards.enabled"
+ );
+ Assert.equal(FormAutofillStatus.onStatusChanged.called, false);
+
+ // _active != computeStatus() => Need to trigger onStatusChanged
+ FormAutofillStatus.computeStatus.returns(false);
+ FormAutofillStatus.onStatusChanged.resetHistory();
+ FormAutofillStatus.observe(
+ null,
+ "nsPref:changed",
+ "extensions.formautofill.addresses.enabled"
+ );
+ FormAutofillStatus.observe(
+ null,
+ "nsPref:changed",
+ "extensions.formautofill.creditCards.enabled"
+ );
+ Assert.equal(FormAutofillStatus.onStatusChanged.called, true);
+
+ // profile changed => Need to trigger _onStatusChanged
+ await Promise.all(
+ ["add", "update", "remove", "reconcile"].map(async event => {
+ FormAutofillStatus.computeStatus.returns(!FormAutofillStatus._active);
+ FormAutofillStatus.onStatusChanged.resetHistory();
+ await FormAutofillStatus.observe(
+ null,
+ "formautofill-storage-changed",
+ event
+ );
+ Assert.equal(FormAutofillStatus.onStatusChanged.called, true);
+ })
+ );
+
+ // profile metadata updated => No need to trigger onStatusChanged
+ FormAutofillStatus.computeStatus.returns(!FormAutofillStatus._active);
+ FormAutofillStatus.onStatusChanged.resetHistory();
+ await FormAutofillStatus.observe(
+ null,
+ "formautofill-storage-changed",
+ "notifyUsed"
+ );
+ Assert.equal(FormAutofillStatus.onStatusChanged.called, false);
+
+ FormAutofillStatus.computeStatus.restore();
+});
+
+add_task(async function test_activeStatus_computeStatus() {
+ registerCleanupFunction(function cleanup() {
+ Services.prefs.clearUserPref("extensions.formautofill.addresses.enabled");
+ Services.prefs.clearUserPref("extensions.formautofill.creditCards.enabled");
+ });
+
+ sinon.stub(
+ FormAutofillStatus.formAutofillStorage.addresses,
+ "getSavedFieldNames"
+ );
+ FormAutofillStatus.formAutofillStorage.addresses.getSavedFieldNames.returns(
+ Promise.resolve(new Set())
+ );
+ sinon.stub(
+ FormAutofillStatus.formAutofillStorage.creditCards,
+ "getSavedFieldNames"
+ );
+ FormAutofillStatus.formAutofillStorage.creditCards.getSavedFieldNames.returns(
+ Promise.resolve(new Set())
+ );
+
+ // pref is enabled and profile is empty.
+ Services.prefs.setBoolPref("extensions.formautofill.addresses.enabled", true);
+ Services.prefs.setBoolPref(
+ "extensions.formautofill.creditCards.enabled",
+ true
+ );
+ Assert.equal(FormAutofillStatus.computeStatus(), false);
+
+ // pref is disabled and profile is empty.
+ Services.prefs.setBoolPref(
+ "extensions.formautofill.addresses.enabled",
+ false
+ );
+ Services.prefs.setBoolPref(
+ "extensions.formautofill.creditCards.enabled",
+ false
+ );
+ Assert.equal(FormAutofillStatus.computeStatus(), false);
+
+ FormAutofillStatus.formAutofillStorage.addresses.getSavedFieldNames.returns(
+ Promise.resolve(new Set(["given-name"]))
+ );
+ await FormAutofillStatus.observe(null, "formautofill-storage-changed", "add");
+
+ // pref is enabled and profile is not empty.
+ Services.prefs.setBoolPref("extensions.formautofill.addresses.enabled", true);
+ Assert.equal(FormAutofillStatus.computeStatus(), true);
+
+ // pref is partial enabled and profile is not empty.
+ Services.prefs.setBoolPref("extensions.formautofill.addresses.enabled", true);
+ Services.prefs.setBoolPref(
+ "extensions.formautofill.creditCards.enabled",
+ false
+ );
+ Assert.equal(FormAutofillStatus.computeStatus(), true);
+ Services.prefs.setBoolPref(
+ "extensions.formautofill.addresses.enabled",
+ false
+ );
+ Services.prefs.setBoolPref(
+ "extensions.formautofill.creditCards.enabled",
+ true
+ );
+ Assert.equal(FormAutofillStatus.computeStatus(), true);
+
+ // pref is disabled and profile is not empty.
+ Services.prefs.setBoolPref(
+ "extensions.formautofill.addresses.enabled",
+ false
+ );
+ Services.prefs.setBoolPref(
+ "extensions.formautofill.creditCards.enabled",
+ false
+ );
+ Assert.equal(FormAutofillStatus.computeStatus(), false);
+});
diff --git a/browser/extensions/formautofill/test/unit/test_addressComponent_city.js b/browser/extensions/formautofill/test/unit/test_addressComponent_city.js
new file mode 100644
index 0000000000..8bb448cfe4
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_addressComponent_city.js
@@ -0,0 +1,27 @@
+"use strict";
+
+const VALID_TESTS = [["New York City", true]];
+
+const COMPARE_TESTS = [
+ ["New York City", "New York City", SAME],
+ ["New York City", "new york city", SAME],
+ ["New York City", "New York City", SIMILAR], // Merge whitespace
+ ["Happy Valley-Goose Bay", "Happy Valley Goose Bay", SIMILAR], // Replace punctuation with whitespace
+ ["New York City", "New York", A_CONTAINS_B],
+ ["New York", "NewYork", DIFFERENT],
+ ["New York City", "City New York", DIFFERENT],
+];
+
+const TEST_FIELD_NAME = "City";
+
+add_task(async function test_isValid() {
+ runIsValidTest(VALID_TESTS, TEST_FIELD_NAME, value => {
+ return { "address-level2": value };
+ });
+});
+
+add_task(async function test_compare() {
+ runCompareTest(COMPARE_TESTS, TEST_FIELD_NAME, value => {
+ return { "address-level2": value };
+ });
+});
diff --git a/browser/extensions/formautofill/test/unit/test_addressComponent_country.js b/browser/extensions/formautofill/test/unit/test_addressComponent_country.js
new file mode 100644
index 0000000000..bf73309c60
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_addressComponent_country.js
@@ -0,0 +1,47 @@
+"use strict";
+
+const VALID_TESTS = [
+ ["United States", true],
+ ["Not United States", true], // Invalid country name will be replaced with the default region, so
+ // it is still valid
+];
+
+const COMPARE_TESTS = [
+ // United Stats, US, USA, America, U.S.A.
+ { region: "US" },
+ ["United States", "United States", SAME],
+ ["United States", "united states", SAME],
+ ["United States", "US", SAME],
+ ["America", "United States", SAME],
+ ["America", "US", SAME],
+ ["US", "USA", SAME],
+ ["United States", "U.S.A.", SAME], // Normalize
+
+ ["USB", "US", SAME],
+
+ // Canada, Can, CA
+ ["CA", "Canada", SAME],
+ ["CA", "CAN", SAME],
+ ["CA", "US", DIFFERENT],
+
+ { region: "DE" },
+ ["USB", "US", DIFFERENT],
+ ["United States", "Germany", DIFFERENT],
+
+ ["Invalid Country Name", "Germany", SAME],
+ ["AAA", "BBB", SAME],
+];
+
+const TEST_FIELD_NAME = "Country";
+
+add_task(async function test_isValid() {
+ runIsValidTest(VALID_TESTS, TEST_FIELD_NAME, value => {
+ return { country: value };
+ });
+});
+
+add_task(async function test_compare() {
+ runCompareTest(COMPARE_TESTS, TEST_FIELD_NAME, value => {
+ return { country: value };
+ });
+});
diff --git a/browser/extensions/formautofill/test/unit/test_addressComponent_email.js b/browser/extensions/formautofill/test/unit/test_addressComponent_email.js
new file mode 100644
index 0000000000..2c4b07a542
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_addressComponent_email.js
@@ -0,0 +1,74 @@
+"use strict";
+
+// TODO:
+// https://help.xmatters.com/ondemand/trial/valid_email_format.htm what is the allow characters???
+const VALID_TESTS = [
+ // Is Valid Test
+ // [email1, expected]
+ ["john.doe@mozilla.org", true],
+
+ ["", false], // empty
+ ["@mozilla.org", false], // without username
+ ["john.doe@", false], // without domain
+ ["john.doe@-mozilla.org", false], // domain starts with '-'
+ ["john.doe@mozilla.org-", false], // domain ends with '-'
+ ["john.doe@mozilla.-com.au", false], // sub-domain starts with '-'
+ ["john.doe@mozilla.com-.au", false], // sub-domain ends with '-'
+
+ ["john-doe@mozilla.org", true], // dash (ok)
+
+ // Special characters check
+ ["john.!#$%&'*+-/=?^_`{|}~doe@gmail.com", true],
+
+ ["john.doe@work@mozilla.org", false],
+ ["äbc@mail.com", false],
+
+ ["john.doe@" + "a".repeat(63) + ".org", true],
+ ["john.doe@" + "a".repeat(64) + ".org", false],
+
+ // The following are commented out since we're using a more relax email
+ // validation algorithm now.
+ /*
+ ["-john.doe@mozilla.org", false], // username starts with '-'
+ [".john.doe@mozilla.org", false], // username starts with '.'
+ ["john.doe-@mozilla.org", true], // username ends with '-' ???
+ ["john.doe.@mozilla.org", true], // username ends with '.' ???
+ ["john.doe@-mozilla.org", true], // domain starts with '.' ???
+ ["john..doe@mozilla.org", false], // consecutive period
+ ["john.-doe@mozilla.org", false], // period + dash
+ ["john-.doe@mozilla.org", false], // dash + period
+ ["john.doe@school.123", false],
+ ["peter-parker@spiderman", false],
+
+ ["a".repeat(64) + "@mydomain.com", true], // length of username
+ ["b".repeat(65) + "@mydomain.com", false],
+*/
+];
+
+const COMPARE_TESTS = [
+ // Same
+ ["test@mozilla.org", "test@mozilla.org", SAME],
+ ["john.doe@example.com", "jOhn.doE@example.com", SAME],
+ ["jan@gmail.com", "JAN@gmail.com", SAME],
+
+ // Different
+ ["jan@gmail.com", "jan1@gmail.com", DIFFERENT],
+ ["jan@gmail.com", "jan@gmail.com.au", DIFFERENT],
+ ["john#smith@gmail.com", "johnsmith@gmail.com", DIFFERENT],
+];
+
+const TEST_FIELD_NAME = "Email";
+
+add_setup(async () => {});
+
+add_task(async function test_isValid() {
+ runIsValidTest(VALID_TESTS, TEST_FIELD_NAME, value => {
+ return { email: value };
+ });
+});
+
+add_task(async function test_compare() {
+ runCompareTest(COMPARE_TESTS, TEST_FIELD_NAME, value => {
+ return { email: value };
+ });
+});
diff --git a/browser/extensions/formautofill/test/unit/test_addressComponent_name.js b/browser/extensions/formautofill/test/unit/test_addressComponent_name.js
new file mode 100644
index 0000000000..79fb1879d2
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_addressComponent_name.js
@@ -0,0 +1,101 @@
+"use strict";
+
+const VALID_TESTS = [
+ ["John Doe", true],
+ ["John O'Brian'", true],
+ ["John O-Brian'", true],
+ ["John Doe", true],
+];
+
+// prettier-ignore
+const COMPARE_TESTS = [
+ // Same
+ ["John", "John", SAME], // first name
+ ["John Doe", "John Doe", SAME], // first and last name
+ ["John Middle Doe", "John Middle Doe", SAME], // first, middle, and last name
+ ["John Mid1 Mid2 Doe", "John Mid1 Mid2 Doe", SAME],
+
+ // Same: case insenstive
+ ["John Doe", "john doe", SAME],
+
+ // Similar: whitespaces are merged
+ ["John Doe", "John Doe", SIMILAR],
+
+ // Similar: asscent and base
+ ["John Doe", "John Döe", SIMILAR], // asscent and base
+
+ // A Contains B
+ ["John Doe", "Doe", A_CONTAINS_B], // first + family name contains family name
+ ["John Doe", "John", A_CONTAINS_B], // first + family name contains first name
+ ["John Middle Doe", "Doe", A_CONTAINS_B], // [first, middle, last] contains [last]
+ ["John Middle Doe", "John", A_CONTAINS_B], // [first, middle, last] contains [first]
+ ["John Middle Doe", "Middle", A_CONTAINS_B], // [first, middle, last] contains [middle]
+ ["John Middle Doe", "Middle Doe", A_CONTAINS_B], // [first, middle, last] contains [middle, last]
+ ["John Middle Doe", "John Middle", A_CONTAINS_B], // [first, middle, last] contains [fisrt, middle]
+ ["John Middle Doe", "John Doe", A_CONTAINS_B], // [first, middle, last] contains [fisrt, last]
+ ["John Mary Jane Doe", "John Doe", A_CONTAINS_B], // [first, middle, last] contains [fisrt, last]
+
+ // Different
+ ["John Doe", "Jane Roe", DIFFERENT],
+ ["John Doe", "Doe John", DIFFERENT], // swap order
+ ["John Middle Doe", "Middle John", DIFFERENT],
+ ["John Middle Doe", "Doe Middle", DIFFERENT],
+ ["John Doe", "John Roe.", DIFFERENT], // different family name
+ ["John Doe", "Jane Doe", DIFFERENT], // different given name
+ ["John Middle Doe", "Jane Michael Doe", DIFFERENT], // different middle name
+
+ // Puncuation is either removed or replaced with white space
+ ["John O'Brian", "John OBrian", SIMILAR],
+ ["John O'Brian", "John O-Brian", SIMILAR],
+ ["John O'Brian", "John O Brian", SIMILAR],
+ ["John-Mary Doe", "JohnMary Doe", SIMILAR],
+ ["John-Mary Doe", "John'Mary Doe", SIMILAR],
+ ["John-Mary Doe", "John Mary Doe", SIMILAR],
+ ["John-Mary Doe", "John Mary", A_CONTAINS_B],
+
+ // Test Name Variants
+ ["John Doe", "J. Doe", A_CONTAINS_B], // first name to initial
+ ["John Doe", "J. doe", A_CONTAINS_B],
+ ["John Doe", "J. Doe", A_CONTAINS_B], // first name to initial without '.'
+
+ ["John Middle Doe", "J. Middle Doe", A_CONTAINS_B], // first name to initial, middle name unchanged
+ ["John Middle Doe", "J. Doe", A_CONTAINS_B], // first name to initial, no middle name
+
+ ["John Middle Doe", "John M. Doe", A_CONTAINS_B], // middle name to initial, first name unchanged
+ ["John Middle Doe", "J. M. Doe", A_CONTAINS_B], // first and middle name to initial
+ ["John Middle Doe", "J M Doe", A_CONTAINS_B], // first and middle name to initial without '.'
+ ["John Middle Doe", "John M. Doe", A_CONTAINS_B], // middle name with initial
+
+ // Test Name Variants: multiple middle name
+ ["John Mary Jane Doe", "J. MARY JANE Doe", A_CONTAINS_B], // first to initial
+ ["John Mary Jane Doe", "john. M. J. doe", A_CONTAINS_B], // middle name to initial
+ ["John Mary Jane Doe", "J. M. J. Doe", A_CONTAINS_B], // first & middle name to initial
+ ["John Mary Jane Doe", "J. M. Doe", A_CONTAINS_B], // first & part of the middle name to initial
+ ["John Mary Jane Doe", "John M. Doe", A_CONTAINS_B],
+ ["John Mary Jane Doe", "J. Doe", A_CONTAINS_B],
+
+ // Test Name Variants: merge initials
+ ["John Middle Doe", "JM Doe", A_CONTAINS_B],
+ ["John Mary Jane Doe", "JMJ. doe", A_CONTAINS_B],
+
+ // Different: Don't consider the cases when family name is abbreviated
+ ["John Middle Doe", "JMD", DIFFERENT],
+ ["John Middle Doe", "John Middle D.", DIFFERENT],
+ ["John Middle Doe", "J. M. D.", DIFFERENT],
+];
+
+const TEST_FIELD_NAME = "Name";
+
+add_setup(async () => {});
+
+add_task(async function test_isValid() {
+ runIsValidTest(VALID_TESTS, TEST_FIELD_NAME, value => {
+ return { name: value };
+ });
+});
+
+add_task(async function test_compare() {
+ runCompareTest(COMPARE_TESTS, TEST_FIELD_NAME, value => {
+ return { name: value };
+ });
+});
diff --git a/browser/extensions/formautofill/test/unit/test_addressComponent_organization.js b/browser/extensions/formautofill/test/unit/test_addressComponent_organization.js
new file mode 100644
index 0000000000..6790b83599
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_addressComponent_organization.js
@@ -0,0 +1,55 @@
+"use strict";
+
+// prettier-ignore
+const VALID_TESTS = [
+ ["Mozilla", true],
+ ["mozilla", true],
+ ["@Mozilla", true],
+ [" ", true], // A string only contains whitespace is treated as empty, which is considered as valid
+ ["-!@#%&*_(){}[:;\"',.?]", false], // Not valid when the organization name only contains punctuations
+];
+
+const COMPARE_TESTS = [
+ // Same
+ ["Mozilla", "Mozilla", SAME], // Exact the same
+
+ // Similar
+ ["Mozilla", "mozilla", SIMILAR], // Ignore case
+ ["Casavant Frères", "Casavant Freres", SIMILAR], // asscent and base
+ ["Graphik Dimensions, Ltd.", "Graphik Dimensions Ltd", SIMILAR], // Punctuation is stripped and trim space in the end
+ ["T & T Supermarket", "T&T Supermarket", SIMILAR], // & is stripped and merged consecutive whitespace
+ ["Food & Pharmacy", "Pharmacy & Food", SIMILAR], // Same tokens, different order
+ ["Johnson & Johnson", "Johnson", SIMILAR], // Can always find the same token in the other
+
+ // A Contains B
+ ["Mozilla Inc.", "Mozilla", A_CONTAINS_B], // Contain, the same prefix
+ ["The Walt Disney", "Walt Disney", A_CONTAINS_B], // Contain, the same suffix
+ ["Coca-Cola Company", "Coca Cola", A_CONTAINS_B], // Contain, strip punctuation
+
+ // Different
+ ["Meta", "facebook", DIFFERENT], // Completely different
+ ["Metro Inc.", "CGI Inc.", DIFFERENT], // Different prefix
+ ["AT&T Corp.", "AT&T Inc.", DIFFERENT], // Different suffix
+ ["AT&T Corp.", "AT&T Corporation", DIFFERENT], // Different suffix
+ ["Ben & Jerry's", "Ben & Jerrys", DIFFERENT], // Different because Jerry's becomes ["Jerry", "s"]
+ ["Arc'teryx", "Arcteryx", DIFFERENT], // Different because Arc'teryx' becomes ["Arc", "teryx"]
+ ["BMW", "Bayerische Motoren Werke", DIFFERENT],
+
+ ["Linens 'n Things", "Linens'n Things", SIMILAR], // Punctuation is replaced with whitespace, so both strings become "Linesns n Things"
+];
+
+const TEST_FIELD_NAME = "Organization";
+
+add_setup(async () => {});
+
+add_task(async function test_isValid() {
+ runIsValidTest(VALID_TESTS, TEST_FIELD_NAME, value => {
+ return { organization: value };
+ });
+});
+
+add_task(async function test_compare() {
+ runCompareTest(COMPARE_TESTS, TEST_FIELD_NAME, value => {
+ return { organization: value };
+ });
+});
diff --git a/browser/extensions/formautofill/test/unit/test_addressComponent_postal_code.js b/browser/extensions/formautofill/test/unit/test_addressComponent_postal_code.js
new file mode 100644
index 0000000000..a3150362d2
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_addressComponent_postal_code.js
@@ -0,0 +1,57 @@
+"use strict";
+
+const VALID_TESTS = [
+ { region: "US" },
+ ["1234", false], // too short
+ ["12345", true],
+ ["123456", false], // too long
+ ["1234A", false], // contain non-digit character
+ ["12345-123", false],
+ ["12345-1234", true],
+ ["12345-12345", false],
+ ["12345-1234A", false],
+ ["12345 1234", true], // Do we want to allow this?
+ ["12345_1234", false], // Do we want to allow this?
+
+ { region: "CA" },
+ ["M5T 1R5", true],
+ ["M5T1R5", true], // no space between the first and second parts is allowed
+ ["S4S 6X3", true],
+ ["M5T", false], // Only the first part
+ ["1R5", false], // Only the second part
+ ["D1B 1A1", false], // invalid first character, D
+ ["M5T 1R5A", false], // extra character at the end
+ ["M5T 1R5-", false], // extra character at the end
+ ["M5T-1R5", false], // hyphen in the wrong place
+ ["MT5 1R5", false], // missing letter in the first part
+ ["M5T 1R", false], // missing letter in the second part
+ ["M5T 1R55", false], // extra digit at the end
+ ["M5T 1R", false], // missing digit in the second part
+ ["M5T 1R5Q", false], // invalid second-to-last letter, Q
+];
+
+const COMPARE_TESTS = [
+ { region: "US" },
+ ["12345", "12345", SAME],
+ ["M5T 1R5", "m5t 1r5", SAME],
+ ["12345-1234", "12345 1234", SAME],
+ ["12345-1234", "12345", A_CONTAINS_B],
+ ["12345-1234", "12345#1234", SAME], // B is invalid
+ ["12345-1234", "1234", A_CONTAINS_B], // B is invalid
+];
+
+const TEST_FIELD_NAME = "PostalCode";
+
+add_setup(async () => {});
+
+add_task(async function test_isValid() {
+ runIsValidTest(VALID_TESTS, TEST_FIELD_NAME, value => {
+ return { "postal-code": value };
+ });
+});
+
+add_task(async function test_compare() {
+ runCompareTest(COMPARE_TESTS, TEST_FIELD_NAME, value => {
+ return { "postal-code": value };
+ });
+});
diff --git a/browser/extensions/formautofill/test/unit/test_addressComponent_state.js b/browser/extensions/formautofill/test/unit/test_addressComponent_state.js
new file mode 100644
index 0000000000..43e1de84dc
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_addressComponent_state.js
@@ -0,0 +1,32 @@
+"use strict";
+
+const VALID_TESTS = [
+ ["California", true],
+ ["california", true],
+ ["Californib", false],
+ ["CA", true],
+ ["CA.", true],
+ ["CC", false],
+];
+
+const COMPARE_TESTS = [
+ ["California", "california", SAME], // case insensitive
+ ["CA", "california", SAME],
+ ["CA", "ca", SAME],
+ ["California", "New Jersey", DIFFERENT],
+ ["New York", "New Jersey", DIFFERENT],
+];
+
+const TEST_FIELD_NAME = "State";
+
+add_task(async function test_isValid() {
+ runIsValidTest(VALID_TESTS, TEST_FIELD_NAME, value => {
+ return { "address-level1": value };
+ });
+});
+
+add_task(async function test_compare() {
+ runCompareTest(COMPARE_TESTS, TEST_FIELD_NAME, value => {
+ return { "address-level1": value };
+ });
+});
diff --git a/browser/extensions/formautofill/test/unit/test_addressComponent_street_address.js b/browser/extensions/formautofill/test/unit/test_addressComponent_street_address.js
new file mode 100644
index 0000000000..83f1cd7ef3
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_addressComponent_street_address.js
@@ -0,0 +1,56 @@
+"use strict";
+
+const VALID_TESTS = [
+ ["123 Main St. Apt 4, Floor 2", true],
+ ["This is a street", true],
+ ["A", true],
+ ["住址", true],
+ ["!#%&'*+", false],
+ ["1234", false],
+];
+
+const COMPARE_TESTS = [
+ { region: "US" },
+ ["123 Main St.", "123 Main St.", SAME], // Exactly the same with only street number and street name
+ ["123 Main St. Apt 4, Floor 2", "123 Main St. Apt 4, Floor 2", SAME],
+ ["123 Main St. Apt 4A, Floor 2", "123 main St. Apt 4a, Floor 2", SAME],
+ ["123 Main St. Apt 4, Floor 2", "123 Main St. Suite 4, 2nd fl", SAME], // Exactly the same after parsing
+ ["Main St.", "Main St.", SAME], // Exactly the same with only street name
+ ["Main St.", "main st.", SAME], // Exactly the same with only street name (case-insenstive)
+
+ ["123 Main St.", "Main St.", A_CONTAINS_B], // Street number is mergeable
+ ["123 Main Lane St.", "123 Main St.", A_CONTAINS_B], // Street name is mergeable
+ ["123 Main St. Apt 4", "Main St.", A_CONTAINS_B], // Apartment number is mergeable
+ ["123 Main St. Apt 4, Floor 2", "123 Main St., Floor 2", A_CONTAINS_B],
+ ["123 Main St. Floor 2", "Main St.", A_CONTAINS_B], // Floor number is mergeable
+ ["123 Main St. Apt 4, Floor 2", "123 Main St. Apt 4", A_CONTAINS_B],
+ ["123 North-South Road", "123 North South Road", SIMILAR], // Street number is mergeable
+
+ ["123 Main St. Apt 4, Floor 2", "1234 Main St. Apt 4, Floor 2", DIFFERENT], // Street number is different
+ ["123 Main St. Apt 4, Floor 2", "123 Mainn St. Apt 4, Floor 2", DIFFERENT], // Street name is different
+ [
+ "123 Lane Main St. Apt 4, Floor 2",
+ "123 Main Lane St. Apt 4, Floor 2",
+ DIFFERENT,
+ ], // Street name is different (token not in order)
+ ["123 Main St. Apt 4, Floor 2", "123 Main St. Apt 41, Floor 2", DIFFERENT], // Apartment number is different
+ ["123 Main St. Apt 4, Floor 2", "123 Main St. Apt 4, Floor 22", DIFFERENT], // Floor number is different
+
+ ["123 Main St. Apt 4, Floor 2", "123 Main St. Floor 2, Apt 4", DIFFERENT], //
+];
+
+const TEST_FIELD_NAME = "StreetAddress";
+
+add_setup(async () => {});
+
+add_task(async function test_isValid() {
+ runIsValidTest(VALID_TESTS, TEST_FIELD_NAME, value => {
+ return { "street-address": value };
+ });
+});
+
+add_task(async function test_compare() {
+ runCompareTest(COMPARE_TESTS, TEST_FIELD_NAME, value => {
+ return { "street-address": value };
+ });
+});
diff --git a/browser/extensions/formautofill/test/unit/test_addressComponent_tel.js b/browser/extensions/formautofill/test/unit/test_addressComponent_tel.js
new file mode 100644
index 0000000000..584ab9f8f3
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_addressComponent_tel.js
@@ -0,0 +1,76 @@
+/* import-globals-from head_addressComponent.js */
+
+"use strict";
+
+// prettier-ignore
+const VALID_TESTS = [
+ // US Valid format (XXX-XXX-XXXX) and first digit is between 2-9
+ ["200-234-5678", true], // First digit should between 2-9
+ ["100-234-5678", true], // First digit is not between 2-9, but currently not being checked
+ // when no country code is specified
+ ["555-abc-1234", true], // Non-digit characters are normalized according to ITU E.161 standard
+ ["55-555-5555", false], // The national number is too short (9 digits)
+
+ ["2-800-555-1234", false], // "2" is not US country code so we treat
+ // 2-800-555-1234 as the national number, which is too long (11 digits)
+
+ // Phone numbers with country code
+ ["1-800-555-1234", true], // Country code without plus sign
+ ["+1 200-234-5678", true], // Country code with plus sign and with a valid national number
+ ["+1 100-234-5678", false], // National number should be between 2-9
+ ["+1 55-555-5555", false], // National number is too short (9 digits)
+ ["+1 1-800-555-1234", true], // "+1" and "1" are both treated as coutnry code so national number
+ // is a valid number (800-555-1234)
+ ["+1 2-800-555-1234", false], // The national number is too long (11 digits)
+ ["+1 555-abc-1234", true], // Non-digit characters are normalized according to ITU E.161 standard
+];
+
+const COMPARE_TESTS = [
+ ["+1 520-248-6621", "+15202486621", SAME],
+ ["+1 520-248-6621", "1-520-248-6621", SAME],
+ ["+1 520-248-6621", "1(520)248-6621", SAME],
+ ["520-248-6621", "520-248-6621", SAME], // Both phone numbers don't have coutry code
+ ["520-248-6621", "+1 520-248-6621", SAME], // Compare phone number with and without country code
+
+ ["+1 520-248-6621", "248-6621", A_CONTAINS_B],
+ ["520-248-6621", "248-6621", A_CONTAINS_B],
+ ["0520-248-6621", "520-248-6621", A_CONTAINS_B],
+ ["48-6621", "6621", A_CONTAINS_B], // Both phone number are invalid
+
+ ["+1 520-248-6621", "+91 520-248-6622", DIFFERENT], // different national prefix and number
+ ["+1 520-248-6621", "+91 520-248-6621", DIFFERENT], // Same number, different national prefix
+ ["+1 520-248-6621", "+1 520-248-6622", DIFFERENT], // Same national prefix, different number
+ ["520-248-6621", "+91 520-248-6622", DIFFERENT], // Same test as above but with default region
+ ["520-248-6621", "+91 520-248-6621", DIFFERENT], // Same test as above but with default region
+ ["520-248-6621", "+1 520-248-6622", DIFFERENT], // Same test as above but with default region
+ ["520-248-6621", "520-248-6622", DIFFERENT],
+
+ // Normalize
+ ["+1 520-248-6621", "+1 ja0-bgt-mnc1", SAME],
+ ["+1 1-800-555-1234", "+1 800-555-1234", SAME],
+
+ // TODO: Support extension
+ //["+64 3 331-6005", "3 331 6005#1234", A_CONTAINS_B],
+];
+
+const TEST_FIELD_NAME = "Tel";
+
+add_setup(async () => {
+ Services.prefs.setBoolPref("browser.search.region", "US");
+
+ registerCleanupFunction(function head_cleanup() {
+ Services.prefs.clearUserPref("browser.search.region");
+ });
+});
+
+add_task(async function test_isValid() {
+ runIsValidTest(VALID_TESTS, TEST_FIELD_NAME, value => {
+ return { tel: value };
+ });
+});
+
+add_task(async function test_compare() {
+ runCompareTest(COMPARE_TESTS, TEST_FIELD_NAME, value => {
+ return { tel: value };
+ });
+});
diff --git a/browser/extensions/formautofill/test/unit/test_addressDataLoader.js b/browser/extensions/formautofill/test/unit/test_addressDataLoader.js
new file mode 100644
index 0000000000..6b9cfb102c
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_addressDataLoader.js
@@ -0,0 +1,102 @@
+"use strict";
+
+const SUPPORT_COUNTRIES_TESTCASES = [
+ {
+ country: "US",
+ properties: ["languages", "alternative_names", "sub_keys", "sub_names"],
+ },
+ {
+ country: "CA",
+ properties: ["languages", "name", "sub_keys", "sub_names"],
+ },
+ {
+ country: "DE",
+ properties: ["name"],
+ },
+];
+
+var AddressDataLoader, FormAutofillUtils;
+add_setup(async () => {
+ ({ AddressDataLoader, FormAutofillUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/FormAutofillUtils.sys.mjs"
+ ));
+});
+
+add_task(async function test_initalState() {
+ // addressData should not exist
+ Assert.equal(AddressDataLoader._addressData, undefined);
+ // Verify _dataLoaded state
+ Assert.equal(AddressDataLoader._dataLoaded.country, false);
+ Assert.equal(AddressDataLoader._dataLoaded.level1.size, 0);
+});
+
+add_task(async function test_loadDataState() {
+ sinon.spy(AddressDataLoader, "_loadScripts");
+ let metadata = FormAutofillUtils.getCountryAddressData("US");
+ Assert.ok(AddressDataLoader._addressData, "addressData exists");
+ // Verify _dataLoaded state
+ Assert.equal(AddressDataLoader._dataLoaded.country, true);
+ Assert.equal(AddressDataLoader._dataLoaded.level1.size, 0);
+ // _loadScripts should be called
+ sinon.assert.called(AddressDataLoader._loadScripts);
+ // Verify metadata
+ Assert.equal(metadata.id, "data/US");
+ Assert.ok(
+ metadata.alternative_names,
+ "US alternative names should be loaded from extension"
+ );
+ AddressDataLoader._loadScripts.resetHistory();
+
+ // Load data without country
+ let newMetadata = FormAutofillUtils.getCountryAddressData();
+ // _loadScripts should not be called
+ sinon.assert.notCalled(AddressDataLoader._loadScripts);
+ Assert.deepEqual(
+ metadata,
+ newMetadata,
+ "metadata should be US if country is not specified"
+ );
+ AddressDataLoader._loadScripts.resetHistory();
+
+ // Load level 1 data that does not exist
+ let undefinedMetadata = FormAutofillUtils.getCountryAddressData("US", "CA");
+ // _loadScripts should be called
+ sinon.assert.called(AddressDataLoader._loadScripts);
+ Assert.equal(undefinedMetadata, undefined, "metadata should be undefined");
+ Assert.ok(
+ AddressDataLoader._dataLoaded.level1.has("US"),
+ "level 1 state array should be set even there's no valid metadata"
+ );
+ AddressDataLoader._loadScripts.resetHistory();
+
+ // Load level 1 data again
+ undefinedMetadata = FormAutofillUtils.getCountryAddressData("US", "AS");
+ Assert.equal(undefinedMetadata, undefined, "metadata should be undefined");
+ // _loadScripts should not be called
+ sinon.assert.notCalled(AddressDataLoader._loadScripts);
+});
+
+SUPPORT_COUNTRIES_TESTCASES.forEach(testcase => {
+ add_task(async function test_support_country() {
+ info("Starting testcase: Check " + testcase.country + " metadata");
+ let metadata = FormAutofillUtils.getCountryAddressData(testcase.country);
+ Assert.ok(
+ testcase.properties.every(key => metadata[key]),
+ "These properties should exist: " + testcase.properties
+ );
+ // Verify the multi-locale country
+ if (metadata.languages && metadata.languages.length > 1) {
+ let locales = FormAutofillUtils.getCountryAddressDataWithLocales(
+ testcase.country
+ );
+ Assert.equal(
+ metadata.languages.length,
+ locales.length,
+ "Total supported locales should be matched"
+ );
+ metadata.languages.forEach((lang, index) => {
+ Assert.equal(lang, locales[index].lang, `Should support ${lang}`);
+ });
+ }
+ });
+});
diff --git a/browser/extensions/formautofill/test/unit/test_addressRecords.js b/browser/extensions/formautofill/test/unit/test_addressRecords.js
new file mode 100644
index 0000000000..53db04ee38
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_addressRecords.js
@@ -0,0 +1,858 @@
+/**
+ * Tests FormAutofillStorage object with addresses records.
+ */
+
+"use strict";
+
+const TEST_STORE_FILE_NAME = "test-profile.json";
+const COLLECTION_NAME = "addresses";
+
+const TEST_ADDRESS_1 = {
+ "given-name": "Timothy",
+ "additional-name": "John",
+ "family-name": "Berners-Lee",
+ organization: "World Wide Web Consortium",
+ "street-address": "32 Vassar Street\nMIT Room 32-G524",
+ "address-level2": "Cambridge",
+ "address-level1": "MA",
+ "postal-code": "02139",
+ country: "US",
+ tel: "+16172535702",
+ email: "timbl@w3.org",
+ "unknown-1": "an unknown field from another client",
+};
+
+const TEST_ADDRESS_2 = {
+ "street-address": "Some Address",
+ country: "US",
+};
+
+const TEST_ADDRESS_3 = {
+ "given-name": "Timothy",
+ "family-name": "Berners-Lee",
+ "street-address": "Other Address",
+ "postal-code": "12345",
+};
+
+const TEST_ADDRESS_4 = {
+ "given-name": "Timothy",
+ "additional-name": "John",
+ "family-name": "Berners-Lee",
+ organization: "World Wide Web Consortium",
+};
+
+const TEST_ADDRESS_WITH_EMPTY_FIELD = {
+ name: "Tim Berners",
+ "street-address": "",
+};
+
+const TEST_ADDRESS_WITH_EMPTY_COMPUTED_FIELD = {
+ name: "",
+ "address-line1": "",
+ "address-line2": "",
+ "address-line3": "",
+ "country-name": "",
+ "tel-country-code": "",
+ "tel-national": "",
+ "tel-area-code": "",
+ "tel-local": "",
+ "tel-local-prefix": "",
+ "tel-local-suffix": "",
+ email: "timbl@w3.org",
+};
+
+const TEST_ADDRESS_WITH_INVALID_FIELD = {
+ "street-address": "Another Address",
+ email: { email: "invalidemail" },
+};
+
+const TEST_ADDRESS_EMPTY_AFTER_NORMALIZE = {
+ country: "XXXXXX",
+};
+
+const TEST_ADDRESS_EMPTY_AFTER_UPDATE_ADDRESS_2 = {
+ "street-address": "",
+ country: "XXXXXX",
+};
+
+const MERGE_TESTCASES = [
+ {
+ description: "Merge a superset",
+ addressInStorage: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue",
+ tel: "+16509030800",
+ "unknown-1": "an unknown field from another client",
+ },
+ addressToMerge: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue",
+ tel: "+16509030800",
+ country: "US",
+ "unknown-1": "an unknown field from another client",
+ },
+ expectedAddress: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue",
+ tel: "+16509030800",
+ country: "US",
+ "unknown-1": "an unknown field from another client",
+ },
+ },
+ {
+ description: "Loose merge a subset",
+ addressInStorage: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue",
+ tel: "+16509030800",
+ country: "US",
+ },
+ addressToMerge: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue",
+ tel: "+16509030800",
+ },
+ expectedAddress: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue",
+ tel: "+16509030800",
+ country: "US",
+ },
+ noNeedToUpdate: true,
+ },
+ {
+ description: "Strict merge a subset without empty string",
+ addressInStorage: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue",
+ tel: "+16509030800",
+ country: "US",
+ },
+ addressToMerge: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue",
+ tel: "+16509030800",
+ },
+ expectedAddress: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue",
+ tel: "+16509030800",
+ country: "US",
+ },
+ strict: true,
+ noNeedToUpdate: true,
+ },
+ {
+ description: "Merge an address with partial overlaps",
+ addressInStorage: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue",
+ tel: "+16509030800",
+ },
+ addressToMerge: {
+ "street-address": "331 E. Evelyn Avenue",
+ tel: "+16509030800",
+ country: "US",
+ },
+ expectedAddress: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue",
+ tel: "+16509030800",
+ country: "US",
+ },
+ },
+ {
+ description:
+ "Merge an address with multi-line street-address in storage and single-line incoming one",
+ addressInStorage: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue\nLine2",
+ tel: "+16509030800",
+ },
+ addressToMerge: {
+ "street-address": "331 E. Evelyn Avenue Line2",
+ tel: "+16509030800",
+ country: "US",
+ },
+ expectedAddress: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue\nLine2",
+ tel: "+16509030800",
+ country: "US",
+ },
+ },
+ {
+ description:
+ "Merge an address with 3-line street-address in storage and 2-line incoming one",
+ addressInStorage: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue\nLine2\nLine3",
+ tel: "+16509030800",
+ },
+ addressToMerge: {
+ "street-address": "331 E. Evelyn Avenue\nLine2 Line3",
+ tel: "+16509030800",
+ country: "US",
+ },
+ expectedAddress: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue\nLine2\nLine3",
+ tel: "+16509030800",
+ country: "US",
+ },
+ },
+ {
+ description:
+ "Merge an address with single-line street-address in storage and multi-line incoming one",
+ addressInStorage: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue Line2",
+ tel: "+16509030800",
+ },
+ addressToMerge: {
+ "street-address": "331 E. Evelyn Avenue\nLine2",
+ tel: "+16509030800",
+ country: "US",
+ },
+ expectedAddress: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue\nLine2",
+ tel: "+16509030800",
+ country: "US",
+ },
+ },
+ {
+ description:
+ "Merge an address with 2-line street-address in storage and 3-line incoming one",
+ addressInStorage: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue\nLine2 Line3",
+ tel: "+16509030800",
+ },
+ addressToMerge: {
+ "street-address": "331 E. Evelyn Avenue\nLine2\nLine3",
+ tel: "+16509030800",
+ country: "US",
+ },
+ expectedAddress: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue\nLine2\nLine3",
+ tel: "+16509030800",
+ country: "US",
+ },
+ },
+ {
+ description: "Merge an address with the same amount of lines",
+ addressInStorage: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue\nLine2\nLine3",
+ tel: "+16509030800",
+ },
+ addressToMerge: {
+ "street-address": "331 E. Evelyn\nAvenue Line2\nLine3",
+ tel: "+16509030800",
+ country: "US",
+ },
+ expectedAddress: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue\nLine2\nLine3",
+ tel: "+16509030800",
+ country: "US",
+ },
+ },
+ {
+ description:
+ "Merge an address with superfluous external and internal whitespace in the street-address",
+ addressInStorage: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue\nLine2\nLine3",
+ tel: "+16509030800",
+ },
+ addressToMerge: {
+ "street-address": " 331 E. Evelyn\n Avenue Line2\n Line3 ",
+ country: "US",
+ },
+ expectedAddress: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue\nLine2\nLine3",
+ tel: "+16509030800",
+ country: "US",
+ },
+ },
+ {
+ description: "Merge an address with collapsed whitespace",
+ addressInStorage: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue",
+ tel: "+16509030800",
+ },
+ addressToMerge: {
+ "street-address": "331 E.Evelyn Avenue",
+ country: "US",
+ },
+ expectedAddress: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue",
+ tel: "+16509030800",
+ country: "US",
+ },
+ },
+ {
+ description: "Merge an address with punctuation and mIxEd-cAsE",
+ addressInStorage: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue",
+ tel: "+16509030800",
+ },
+ addressToMerge: {
+ "street-address": "331.e.EVELYN AVENUE",
+ country: "US",
+ },
+ expectedAddress: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Avenue",
+ tel: "+16509030800",
+ country: "US",
+ },
+ },
+ {
+ description: "Merge an address with accent characters",
+ addressInStorage: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Straße",
+ tel: "+16509030800",
+ },
+ addressToMerge: {
+ "street-address": "331.e.EVELYN Strasse",
+ country: "US",
+ },
+ expectedAddress: {
+ "given-name": "Timothy",
+ "street-address": "331 E. Evelyn Straße",
+ tel: "+16509030800",
+ country: "US",
+ },
+ },
+ {
+ description: "Merge an address with a mIxEd-cAsE name",
+ addressInStorage: {
+ "given-name": "Timothy",
+ tel: "+16509030800",
+ },
+ addressToMerge: {
+ "given-name": "TIMOTHY",
+ tel: "+16509030800",
+ country: "US",
+ },
+ expectedAddress: {
+ "given-name": "Timothy",
+ tel: "+16509030800",
+ country: "US",
+ },
+ },
+];
+
+ChromeUtils.defineESModuleGetters(this, {
+ Preferences: "resource://gre/modules/Preferences.sys.mjs",
+});
+
+let do_check_record_matches = (recordWithMeta, record) => {
+ for (let key in record) {
+ Assert.equal(recordWithMeta[key], record[key]);
+ }
+};
+
+add_task(async function test_initialize() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME);
+
+ Assert.equal(profileStorage._store.data.version, 1);
+ Assert.equal(profileStorage._store.data.addresses.length, 0);
+
+ let data = profileStorage._store.data;
+ Assert.deepEqual(data.addresses, []);
+
+ await profileStorage._saveImmediately();
+
+ profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME);
+
+ Assert.deepEqual(profileStorage._store.data, data);
+ for (let { _sync } of profileStorage._store.data.addresses) {
+ Assert.ok(_sync);
+ Assert.equal(_sync.changeCounter, 1);
+ }
+});
+
+add_task(async function test_getAll() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [
+ TEST_ADDRESS_1,
+ TEST_ADDRESS_2,
+ ]);
+
+ let addresses = await profileStorage.addresses.getAll();
+
+ Assert.equal(addresses.length, 2);
+ do_check_record_matches(addresses[0], TEST_ADDRESS_1);
+ do_check_record_matches(addresses[1], TEST_ADDRESS_2);
+
+ // Check computed fields.
+ Assert.equal(addresses[0].name, "Timothy John Berners-Lee");
+ Assert.equal(addresses[0]["address-line1"], "32 Vassar Street");
+ Assert.equal(addresses[0]["address-line2"], "MIT Room 32-G524");
+
+ // Test with rawData set.
+ addresses = await profileStorage.addresses.getAll({ rawData: true });
+ Assert.equal(addresses[0].name, undefined);
+ Assert.equal(addresses[0]["address-line1"], undefined);
+ Assert.equal(addresses[0]["address-line2"], undefined);
+
+ // Modifying output shouldn't affect the storage.
+ addresses[0].organization = "test";
+ do_check_record_matches(
+ (await profileStorage.addresses.getAll())[0],
+ TEST_ADDRESS_1
+ );
+});
+
+add_task(async function test_get() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [
+ TEST_ADDRESS_1,
+ TEST_ADDRESS_2,
+ ]);
+
+ let addresses = await profileStorage.addresses.getAll();
+ let guid = addresses[0].guid;
+
+ let address = await profileStorage.addresses.get(guid);
+ do_check_record_matches(address, TEST_ADDRESS_1);
+
+ // Test with rawData set.
+ address = await profileStorage.addresses.get(guid, { rawData: true });
+ Assert.equal(address.name, undefined);
+ Assert.equal(address["address-line1"], undefined);
+ Assert.equal(address["address-line2"], undefined);
+
+ // Modifying output shouldn't affect the storage.
+ address.organization = "test";
+ do_check_record_matches(
+ await profileStorage.addresses.get(guid),
+ TEST_ADDRESS_1
+ );
+
+ Assert.equal(await profileStorage.addresses.get("INVALID_GUID"), null);
+});
+
+add_task(async function test_add() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [
+ TEST_ADDRESS_1,
+ TEST_ADDRESS_2,
+ ]);
+
+ let addresses = await profileStorage.addresses.getAll();
+
+ Assert.equal(addresses.length, 2);
+
+ do_check_record_matches(addresses[0], TEST_ADDRESS_1);
+ do_check_record_matches(addresses[1], TEST_ADDRESS_2);
+
+ Assert.notEqual(addresses[0].guid, undefined);
+ Assert.equal(addresses[0].version, 1);
+ Assert.notEqual(addresses[0].timeCreated, undefined);
+ Assert.equal(addresses[0].timeLastModified, addresses[0].timeCreated);
+ Assert.equal(addresses[0].timeLastUsed, 0);
+ Assert.equal(addresses[0].timesUsed, 0);
+
+ // Empty string should be deleted before saving.
+ await profileStorage.addresses.add(TEST_ADDRESS_WITH_EMPTY_FIELD);
+ let address = profileStorage.addresses._data[2];
+ Assert.equal(address.name, TEST_ADDRESS_WITH_EMPTY_FIELD.name);
+ Assert.equal(address["street-address"], undefined);
+
+ // Empty computed fields shouldn't cause any problem.
+ await profileStorage.addresses.add(TEST_ADDRESS_WITH_EMPTY_COMPUTED_FIELD);
+ address = profileStorage.addresses._data[3];
+ Assert.equal(address.email, TEST_ADDRESS_WITH_EMPTY_COMPUTED_FIELD.email);
+
+ await Assert.rejects(
+ profileStorage.addresses.add(TEST_ADDRESS_WITH_INVALID_FIELD),
+ /"email" contains invalid data type: object/
+ );
+
+ await Assert.rejects(
+ profileStorage.addresses.add({}),
+ /Record contains no valid field\./
+ );
+
+ await Assert.rejects(
+ profileStorage.addresses.add(TEST_ADDRESS_EMPTY_AFTER_NORMALIZE),
+ /Record contains no valid field\./
+ );
+});
+
+add_task(async function test_update() {
+ // Test assumes that when an entry is saved a second time, it's last modified date will
+ // be different from the first. With high values of precision reduction, we execute too
+ // fast for that to be true.
+ let timerPrecision = Preferences.get("privacy.reduceTimerPrecision");
+ Preferences.set("privacy.reduceTimerPrecision", false);
+
+ registerCleanupFunction(function () {
+ Preferences.set("privacy.reduceTimerPrecision", timerPrecision);
+ });
+
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [
+ TEST_ADDRESS_1,
+ TEST_ADDRESS_2,
+ ]);
+
+ let addresses = await profileStorage.addresses.getAll();
+ let guid = addresses[1].guid;
+ // We need to cheat a little due to race conditions of Date.now() when
+ // we're running these tests, so we subtract one and test accordingly
+ // in the times Date.now() returns the same timestamp
+ let timeLastModified = addresses[1].timeLastModified - 1;
+
+ let onChanged = TestUtils.topicObserved(
+ "formautofill-storage-changed",
+ (subject, data) =>
+ data == "update" &&
+ subject.wrappedJSObject.guid == guid &&
+ subject.wrappedJSObject.collectionName == COLLECTION_NAME
+ );
+
+ Assert.notEqual(addresses[1].country, undefined);
+
+ await profileStorage.addresses.update(guid, TEST_ADDRESS_3);
+ await onChanged;
+ await profileStorage._saveImmediately();
+
+ profileStorage.addresses.pullSyncChanges(); // force sync metadata, which we check below.
+
+ let address = await profileStorage.addresses.get(guid, { rawData: true });
+
+ Assert.equal(address.country, undefined);
+ Assert.ok(address.timeLastModified > timeLastModified);
+ do_check_record_matches(address, TEST_ADDRESS_3);
+ Assert.equal(getSyncChangeCounter(profileStorage.addresses, guid), 1);
+
+ // Test preserveOldProperties parameter and field with empty string.
+ await profileStorage.addresses.update(
+ guid,
+ TEST_ADDRESS_WITH_EMPTY_FIELD,
+ true
+ );
+ await onChanged;
+ await profileStorage._saveImmediately();
+
+ profileStorage.addresses.pullSyncChanges(); // force sync metadata, which we check below.
+
+ address = await profileStorage.addresses.get(guid, { rawData: true });
+
+ Assert.equal(address["given-name"], "Tim");
+ Assert.equal(address["family-name"], "Berners");
+ Assert.equal(address["street-address"], undefined);
+ Assert.equal(address["postal-code"], "12345");
+ Assert.notEqual(address.timeLastModified, timeLastModified);
+ Assert.equal(getSyncChangeCounter(profileStorage.addresses, guid), 2);
+
+ // Empty string should be deleted while updating.
+ await profileStorage.addresses.update(
+ profileStorage.addresses._data[0].guid,
+ TEST_ADDRESS_WITH_EMPTY_FIELD
+ );
+ address = profileStorage.addresses._data[0];
+ Assert.equal(address.name, TEST_ADDRESS_WITH_EMPTY_FIELD.name);
+ Assert.equal(address["street-address"], undefined);
+ Assert.equal(address[("unknown-1", "an unknown field from another client")]);
+
+ // Empty computed fields shouldn't cause any problem.
+ await profileStorage.addresses.update(
+ profileStorage.addresses._data[0].guid,
+ TEST_ADDRESS_WITH_EMPTY_COMPUTED_FIELD,
+ false
+ );
+ address = profileStorage.addresses._data[0];
+ Assert.equal(address.email, TEST_ADDRESS_WITH_EMPTY_COMPUTED_FIELD.email);
+ await profileStorage.addresses.update(
+ profileStorage.addresses._data[1].guid,
+ TEST_ADDRESS_WITH_EMPTY_COMPUTED_FIELD,
+ true
+ );
+ address = profileStorage.addresses._data[1];
+ Assert.equal(address.email, TEST_ADDRESS_WITH_EMPTY_COMPUTED_FIELD.email);
+
+ await Assert.rejects(
+ profileStorage.addresses.update("INVALID_GUID", TEST_ADDRESS_3),
+ /No matching record\./
+ );
+
+ await Assert.rejects(
+ profileStorage.addresses.update(guid, TEST_ADDRESS_WITH_INVALID_FIELD),
+ /"email" contains invalid data type: object/
+ );
+
+ await Assert.rejects(
+ profileStorage.addresses.update(guid, {}),
+ /Record contains no valid field\./
+ );
+
+ await Assert.rejects(
+ profileStorage.addresses.update(guid, TEST_ADDRESS_EMPTY_AFTER_NORMALIZE),
+ /Record contains no valid field\./
+ );
+
+ profileStorage.addresses.update(guid, TEST_ADDRESS_2);
+ await Assert.rejects(
+ profileStorage.addresses.update(
+ guid,
+ TEST_ADDRESS_EMPTY_AFTER_UPDATE_ADDRESS_2
+ ),
+ /Record contains no valid field\./
+ );
+});
+
+add_task(async function test_notifyUsed() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [
+ TEST_ADDRESS_1,
+ TEST_ADDRESS_2,
+ ]);
+
+ let addresses = await profileStorage.addresses.getAll();
+ let guid = addresses[1].guid;
+ let timeLastUsed = addresses[1].timeLastUsed;
+ let timesUsed = addresses[1].timesUsed;
+
+ profileStorage.addresses.pullSyncChanges(); // force sync metadata, which we check below.
+ let changeCounter = getSyncChangeCounter(profileStorage.addresses, guid);
+
+ let onChanged = TestUtils.topicObserved(
+ "formautofill-storage-changed",
+ (subject, data) =>
+ data == "notifyUsed" &&
+ subject.wrappedJSObject.guid == guid &&
+ subject.wrappedJSObject.collectionName == COLLECTION_NAME
+ );
+
+ profileStorage.addresses.notifyUsed(guid);
+ await onChanged;
+
+ let address = await profileStorage.addresses.get(guid);
+
+ Assert.equal(address.timesUsed, timesUsed + 1);
+ Assert.notEqual(address.timeLastUsed, timeLastUsed);
+
+ // Using a record should not bump its change counter.
+ Assert.equal(
+ getSyncChangeCounter(profileStorage.addresses, guid),
+ changeCounter
+ );
+
+ Assert.throws(
+ () => profileStorage.addresses.notifyUsed("INVALID_GUID"),
+ /No matching record\./
+ );
+});
+
+add_task(async function test_remove() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [
+ TEST_ADDRESS_1,
+ TEST_ADDRESS_2,
+ ]);
+
+ let addresses = await profileStorage.addresses.getAll();
+ let guid = addresses[1].guid;
+
+ let onChanged = TestUtils.topicObserved(
+ "formautofill-storage-changed",
+ (subject, data) =>
+ data == "remove" &&
+ subject.wrappedJSObject.guid == guid &&
+ subject.wrappedJSObject.collectionName == COLLECTION_NAME
+ );
+
+ Assert.equal(addresses.length, 2);
+
+ profileStorage.addresses.remove(guid);
+ await onChanged;
+
+ addresses = await profileStorage.addresses.getAll();
+
+ Assert.equal(addresses.length, 1);
+
+ Assert.equal(await profileStorage.addresses.get(guid), null);
+});
+
+MERGE_TESTCASES.forEach(testcase => {
+ add_task(async function test_merge() {
+ info("Starting testcase: " + testcase.description);
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [
+ testcase.addressInStorage,
+ ]);
+ let addresses = await profileStorage.addresses.getAll();
+ let guid = addresses[0].guid;
+ // We need to cheat a little due to race conditions of Date.now() when
+ // we're running these tests, so we subtract one and test accordingly
+ // in the times Date.now() returns the same timestamp
+ let timeLastModified = addresses[0].timeLastModified - 1;
+
+ // Merge address and verify the guid in notifyObservers subject
+ let onMerged = TestUtils.topicObserved(
+ "formautofill-storage-changed",
+ (subject, data) =>
+ data == "update" &&
+ subject.wrappedJSObject.guid == guid &&
+ subject.wrappedJSObject.collectionName == COLLECTION_NAME
+ );
+
+ // Force to create sync metadata.
+ profileStorage.addresses.pullSyncChanges();
+ Assert.equal(getSyncChangeCounter(profileStorage.addresses, guid), 1);
+
+ Assert.ok(
+ profileStorage.addresses.mergeIfPossible(
+ guid,
+ testcase.addressToMerge,
+ testcase.strict
+ )
+ );
+ if (!testcase.noNeedToUpdate) {
+ await onMerged;
+ }
+
+ addresses = await profileStorage.addresses.getAll();
+ Assert.equal(addresses.length, 1);
+ do_check_record_matches(addresses[0], testcase.expectedAddress);
+ if (testcase.noNeedToUpdate) {
+ // see timeLastModified for why we check -1
+ Assert.equal(addresses[0].timeLastModified - 1, timeLastModified);
+
+ // No need to bump the change counter if the data is unchanged.
+ Assert.equal(getSyncChangeCounter(profileStorage.addresses, guid), 1);
+ } else {
+ Assert.ok(addresses[0].timeLastModified > timeLastModified);
+
+ // Record merging should bump the change counter.
+ Assert.equal(getSyncChangeCounter(profileStorage.addresses, guid), 2);
+ }
+ });
+});
+
+add_task(async function test_merge_same_address() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [
+ TEST_ADDRESS_1,
+ ]);
+ let addresses = await profileStorage.addresses.getAll();
+ let guid = addresses[0].guid;
+ let timeLastModified = addresses[0].timeLastModified;
+
+ // Force to create sync metadata.
+ profileStorage.addresses.pullSyncChanges();
+ Assert.equal(getSyncChangeCounter(profileStorage.addresses, guid), 1);
+
+ // Merge same address will still return true but it won't update timeLastModified.
+ Assert.ok(profileStorage.addresses.mergeIfPossible(guid, TEST_ADDRESS_1));
+ Assert.equal(addresses[0].timeLastModified, timeLastModified);
+
+ // ... and won't bump the change counter, either.
+ Assert.equal(getSyncChangeCounter(profileStorage.addresses, guid), 1);
+});
+
+add_task(async function test_merge_unable_merge() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [
+ TEST_ADDRESS_1,
+ TEST_ADDRESS_2,
+ ]);
+
+ let addresses = await profileStorage.addresses.getAll();
+ let guid = addresses[1].guid;
+
+ // Force to create sync metadata.
+ profileStorage.addresses.pullSyncChanges();
+ Assert.equal(getSyncChangeCounter(profileStorage.addresses, guid), 1);
+
+ // Unable to merge because of conflict
+ Assert.equal(
+ await profileStorage.addresses.mergeIfPossible(guid, TEST_ADDRESS_3),
+ false
+ );
+
+ // Unable to merge because no overlap
+ Assert.equal(
+ await profileStorage.addresses.mergeIfPossible(guid, TEST_ADDRESS_4),
+ false
+ );
+
+ // Unable to strict merge because subset with empty string
+ let subset = Object.assign({}, TEST_ADDRESS_1);
+ subset.organization = "";
+ Assert.equal(
+ await profileStorage.addresses.mergeIfPossible(guid, subset, true),
+ false
+ );
+
+ // Shouldn't bump the change counter
+ Assert.equal(getSyncChangeCounter(profileStorage.addresses, guid), 1);
+});
+
+add_task(async function test_mergeToStorage() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [
+ TEST_ADDRESS_1,
+ TEST_ADDRESS_2,
+ ]);
+ // Merge an address to storage
+ let anotherAddress = profileStorage.addresses._clone(TEST_ADDRESS_2);
+ await profileStorage.addresses.add(anotherAddress);
+ anotherAddress.email = "timbl@w3.org";
+ Assert.equal(
+ (await profileStorage.addresses.mergeToStorage(anotherAddress)).length,
+ 2
+ );
+
+ Assert.equal(
+ (await profileStorage.addresses.getAll())[1].email,
+ anotherAddress.email
+ );
+ Assert.equal(
+ (await profileStorage.addresses.getAll())[2].email,
+ anotherAddress.email
+ );
+
+ // Empty computed fields shouldn't cause any problem.
+ Assert.equal(
+ (
+ await profileStorage.addresses.mergeToStorage(
+ TEST_ADDRESS_WITH_EMPTY_COMPUTED_FIELD
+ )
+ ).length,
+ 3
+ );
+});
+
+add_task(async function test_mergeToStorage_strict() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [
+ TEST_ADDRESS_1,
+ TEST_ADDRESS_2,
+ ]);
+ // Try to merge a subset with empty string
+ let anotherAddress = profileStorage.addresses._clone(TEST_ADDRESS_1);
+ anotherAddress.email = "";
+ Assert.equal(
+ (await profileStorage.addresses.mergeToStorage(anotherAddress, true))
+ .length,
+ 0
+ );
+ Assert.equal(
+ (await profileStorage.addresses.getAll())[0].email,
+ TEST_ADDRESS_1.email
+ );
+
+ // Empty computed fields shouldn't cause any problem.
+ Assert.equal(
+ (
+ await profileStorage.addresses.mergeToStorage(
+ TEST_ADDRESS_WITH_EMPTY_COMPUTED_FIELD,
+ true
+ )
+ ).length,
+ 1
+ );
+});
diff --git a/browser/extensions/formautofill/test/unit/test_autofillFormFields.js b/browser/extensions/formautofill/test/unit/test_autofillFormFields.js
new file mode 100644
index 0000000000..70de21cfe5
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_autofillFormFields.js
@@ -0,0 +1,1078 @@
+/*
+ * Test for form auto fill content helper fill all inputs function.
+ */
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+
+"use strict";
+
+const { setTimeout, clearTimeout } = ChromeUtils.importESModule(
+ "resource://gre/modules/Timer.sys.mjs"
+);
+const { OSKeyStore } = ChromeUtils.importESModule(
+ "resource://gre/modules/OSKeyStore.sys.mjs"
+);
+
+const TESTCASES = [
+ {
+ description: "Form without autocomplete property",
+ document: `<form><input id="given-name"><input id="family-name">
+ <input id="street-addr"><input id="city"><select id="country"></select>
+ <input id='email'><input id="tel"></form>`,
+ focusedInputId: "given-name",
+ profileData: {},
+ expectedResult: {
+ "street-addr": "",
+ city: "",
+ country: "",
+ email: "",
+ tel: "",
+ },
+ },
+ {
+ description: "Form with autocomplete properties and 1 token",
+ document: `<form><input id="given-name" autocomplete="given-name">
+ <input id="family-name" autocomplete="family-name">
+ <input id="street-addr" autocomplete="street-address">
+ <input id="city" autocomplete="address-level2">
+ <select id="country" autocomplete="country">
+ <option/>
+ <option value="US">United States</option>
+ </select>
+ <input id="email" autocomplete="email">
+ <input id="tel" autocomplete="tel"></form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ guid: "123",
+ "street-address": "2 Harrison St line2",
+ "-moz-street-address-one-line": "2 Harrison St line2",
+ "address-level2": "San Francisco",
+ country: "US",
+ email: "foo@mozilla.com",
+ tel: "1234567",
+ },
+ expectedResult: {
+ "street-addr": "2 Harrison St line2",
+ city: "San Francisco",
+ country: "US",
+ email: "foo@mozilla.com",
+ tel: "1234567",
+ },
+ },
+ {
+ description: "Form with autocomplete properties and 2 tokens",
+ document: `<form><input id="given-name" autocomplete="shipping given-name">
+ <input id="family-name" autocomplete="shipping family-name">
+ <input id="street-addr" autocomplete="shipping street-address">
+ <input id="city" autocomplete="shipping address-level2">
+ <select id="country" autocomplete="shipping country">
+ <option/>
+ <option value="US">United States</option>
+ </select>
+ <input id='email' autocomplete="shipping email">
+ <input id="tel" autocomplete="shipping tel"></form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ guid: "123",
+ "street-address": "2 Harrison St",
+ "address-level2": "San Francisco",
+ country: "US",
+ email: "foo@mozilla.com",
+ tel: "1234567",
+ },
+ expectedResult: {
+ "street-addr": "2 Harrison St",
+ city: "San Francisco",
+ country: "US",
+ email: "foo@mozilla.com",
+ tel: "1234567",
+ },
+ },
+ {
+ description:
+ "Form with autocomplete properties and profile is partly matched",
+ document: `<form><input id="given-name" autocomplete="shipping given-name">
+ <input id="family-name" autocomplete="shipping family-name">
+ <input id="street-addr" autocomplete="shipping street-address">
+ <input id="city" autocomplete="shipping address-level2">
+ <input id="country" autocomplete="shipping country">
+ <input id='email' autocomplete="shipping email">
+ <input id="tel" autocomplete="shipping tel"></form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ guid: "123",
+ "street-address": "2 Harrison St",
+ "address-level2": "San Francisco",
+ country: "US",
+ email: "",
+ tel: "",
+ },
+ expectedResult: {
+ "street-addr": "2 Harrison St",
+ city: "San Francisco",
+ country: "US",
+ email: "",
+ tel: "",
+ },
+ },
+ {
+ description: "Form with autocomplete properties but mismatched",
+ document: `<form><input id="given-name" autocomplete="shipping given-name">
+ <input id="family-name" autocomplete="shipping family-name">
+ <input id="street-addr" autocomplete="billing street-address">
+ <input id="city" autocomplete="billing address-level2">
+ <input id="country" autocomplete="billing country">
+ <input id='email' autocomplete="shipping email">
+ <input id="tel" autocomplete="shipping tel"></form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ guid: "123",
+ "street-address": "",
+ "address-level2": "",
+ country: "",
+ email: "foo@mozilla.com",
+ tel: "1234567",
+ },
+ expectedResult: {
+ "street-addr": "",
+ city: "",
+ country: "",
+ email: "foo@mozilla.com",
+ tel: "1234567",
+ },
+ },
+ {
+ description: `Form with elements that have autocomplete set to "off"`,
+ document: `<form>
+ <input id="given-name" autocomplete="off">
+ <input id="family-name" autocomplete="off">
+ <input id="street-address" autocomplete="off">
+ <input id="organization" autocomplete="off">
+ <input id="country" autocomplete="off">
+ </form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ guid: "123",
+ "given-name": "John",
+ "family-name": "Doe",
+ "street-address": "2 Harrison St",
+ country: "US",
+ organization: "Test organization",
+ },
+ expectedResult: {
+ "given-name": "John",
+ "family-name": "Doe",
+ "street-address": "2 Harrison St",
+ organization: "Test organization",
+ country: "US",
+ },
+ },
+ {
+ description: `Form with autocomplete set to "off" and no autocomplete attribute on the form's elements`,
+ document: `<form autocomplete="off">
+ <input id="given-name">
+ <input id="family-name">
+ <input id="street-address">
+ <input id="city">
+ <input id="country">
+ </form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ guid: "123",
+ "given-name": "John",
+ "family-name": "Doe",
+ "street-address": "2 Harrison St",
+ country: "US",
+ "address-level2": "Somewhere",
+ },
+ expectedResult: {
+ "given-name": "John",
+ "family-name": "Doe",
+ "street-address": "2 Harrison St",
+ city: "Somewhere",
+ country: "US",
+ },
+ },
+ {
+ description:
+ "Form with autocomplete select elements and matching option values",
+ document: `<form>
+ <input id="given-name" autocomplete="shipping given-name">
+ <select id="country" autocomplete="shipping country">
+ <option value=""></option>
+ <option value="US">United States</option>
+ </select>
+ <select id="state" autocomplete="shipping address-level1">
+ <option value=""></option>
+ <option value="CA">California</option>
+ <option value="WA">Washington</option>
+ </select>
+ </form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ guid: "123",
+ country: "US",
+ "address-level1": "CA",
+ },
+ expectedResult: {
+ country: "US",
+ state: "CA",
+ },
+ },
+ {
+ description:
+ "Form with autocomplete select elements and matching option texts",
+ document: `<form>
+ <input id="given-name" autocomplete="shipping given-name">
+ <select id="country" autocomplete="shipping country">
+ <option value=""></option>
+ <option value="US">United States</option>
+ </select>
+ <select id="state" autocomplete="shipping address-level1">
+ <option value=""></option>
+ <option value="CA">California</option>
+ <option value="WA">Washington</option>
+ </select>
+ </form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ guid: "123",
+ country: "United States",
+ "address-level1": "California",
+ },
+ expectedResult: {
+ country: "US",
+ state: "CA",
+ },
+ },
+ {
+ description: "Form with a readonly input and non-readonly inputs",
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="family-name" autocomplete="family-name">
+ <input id="street-addr" autocomplete="street-address">
+ <input id="city" autocomplete="address-level2" readonly value="TEST CITY">
+ </form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ guid: "123",
+ "given-name": "John",
+ "family-name": "Doe",
+ "street-address": "100 Main Street",
+ city: "Hamilton",
+ },
+ expectedResult: {
+ "given-name": "John",
+ "family-name": "Doe",
+ "street-addr": "100 Main Street",
+ city: "TEST CITY",
+ },
+ },
+ {
+ description: "Fill address fields in a form with addr and CC fields.",
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="family-name" autocomplete="family-name">
+ <input id="street-addr" autocomplete="street-address">
+ <input id="city" autocomplete="address-level2">
+ <select id="country" autocomplete="country">
+ <option/>
+ <option value="US">United States</option>
+ </select>
+ <input id="email" autocomplete="email">
+ <input id="tel" autocomplete="tel">
+ <input id="cc-number" autocomplete="cc-number">
+ <input id="cc-name" autocomplete="cc-name">
+ <input id="cc-exp-month" autocomplete="cc-exp-month">
+ <input id="cc-exp-year" autocomplete="cc-exp-year">
+ </form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ guid: "123",
+ "street-address": "2 Harrison St line2",
+ "-moz-street-address-one-line": "2 Harrison St line2",
+ "address-level2": "San Francisco",
+ country: "US",
+ email: "foo@mozilla.com",
+ tel: "1234567",
+ },
+ expectedResult: {
+ "street-addr": "2 Harrison St line2",
+ city: "San Francisco",
+ country: "US",
+ email: "foo@mozilla.com",
+ tel: "1234567",
+ "cc-number": "",
+ "cc-name": "",
+ "cc-exp-month": "",
+ "cc-exp-year": "",
+ },
+ },
+ {
+ description:
+ "Fill credit card fields in a form with address and CC fields.",
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="family-name" autocomplete="family-name">
+ <input id="street-addr" autocomplete="street-address">
+ <input id="city" autocomplete="address-level2">
+ <select id="country" autocomplete="country">
+ <option/>
+ <option value="US">United States</option>
+ </select>
+ <input id="email" autocomplete="email">
+ <input id="tel" autocomplete="tel">
+ <input id="cc-number" autocomplete="cc-number">
+ <input id="cc-name" autocomplete="cc-name">
+ <input id="cc-exp-month" autocomplete="cc-exp-month">
+ <input id="cc-exp-year" autocomplete="cc-exp-year">
+ </form>`,
+ focusedInputId: "cc-number",
+ profileData: {
+ guid: "123",
+ "cc-number": "4111111111111111",
+ "cc-name": "test name",
+ "cc-exp-month": 6,
+ "cc-exp-year": 25,
+ },
+ expectedResult: {
+ "street-addr": "",
+ city: "",
+ country: "",
+ email: "",
+ tel: "",
+ "cc-number": "4111111111111111",
+ "cc-name": "test name",
+ "cc-exp-month": "06",
+ "cc-exp-year": "25",
+ },
+ },
+ {
+ description:
+ "Fill credit card fields in a form with a placeholder on expiration month input field",
+ document: `<form>
+ <input id="cc-number" autocomplete="cc-number">
+ <input id="cc-name" autocomplete="cc-name">
+ <input id="cc-exp-month" autocomplete="cc-exp-month" placeholder="MM">
+ <input id="cc-exp-year" autocomplete="cc-exp-year">
+ </form>
+ `,
+ focusedInputId: "cc-number",
+ profileData: {
+ guid: "123",
+ "cc-number": "4111111111111111",
+ "cc-name": "test name",
+ "cc-exp-month": 6,
+ "cc-exp-year": 25,
+ },
+ expectedResult: {
+ "cc-number": "4111111111111111",
+ "cc-name": "test name",
+ "cc-exp-month": "06",
+ "cc-exp-year": "25",
+ },
+ },
+ {
+ description:
+ "Fill credit card fields in a form without a placeholder on expiration month and expiration year input fields",
+ document: `<form>
+ <input id="cc-number" autocomplete="cc-number">
+ <input id="cc-name" autocomplete="cc-name">
+ <input id="cc-exp-month" autocomplete="cc-exp-month">
+ <input id="cc-exp-year" autocomplete="cc-exp-year">
+ </form>
+ `,
+ focusedInputId: "cc-number",
+ profileData: {
+ guid: "123",
+ "cc-number": "4111111111111111",
+ "cc-name": "test name",
+ "cc-exp-month": 6,
+ "cc-exp-year": 25,
+ },
+ expectedResult: {
+ "cc-number": "4111111111111111",
+ "cc-name": "test name",
+ "cc-exp-month": "06",
+ "cc-exp-year": "25",
+ },
+ },
+ {
+ description:
+ "Fill credit card fields in a form with a placeholder on expiration year input field",
+ document: `<form>
+ <input id="cc-number" autocomplete="cc-number">
+ <input id="cc-name" autocomplete="cc-name">
+ <input id="cc-exp-month" autocomplete="cc-exp-month">
+ <input id="cc-exp-year" autocomplete="cc-exp-year" placeholder="YY">
+ </form>
+ `,
+ focusedInputId: "cc-number",
+ profileData: {
+ guid: "123",
+ "cc-number": "4111111111111111",
+ "cc-name": "test name",
+ "cc-exp-month": 6,
+ "cc-exp-year": 2025,
+ },
+ expectedResult: {
+ "cc-number": "4111111111111111",
+ "cc-name": "test name",
+ "cc-exp-month": "06",
+ "cc-exp-year": "25",
+ },
+ },
+ {
+ description:
+ "Form with hidden input and visible input that share the same autocomplete attribute",
+ document: `<form>
+ <input id="hidden-cc" autocomplete="cc-number" hidden>
+ <input id="hidden-cc-2" autocomplete="cc-number" style="display:none">
+ <input id="visible-cc" autocomplete="cc-number">
+ <input id="hidden-name" autocomplete="cc-name" hidden>
+ <input id="hidden-name-2" autocomplete="cc-name" style="display:none">
+ <input id="visible-name" autocomplete="cc-name">
+ <input id="cc-exp-month" autocomplete="cc-exp-month">
+ <input id="cc-exp-year" autocomplete="cc-exp-year">
+ </form>`,
+ focusedInputId: "visible-cc",
+ profileData: {
+ guid: "123",
+ "cc-number": "4111111111111111",
+ "cc-name": "test name",
+ "cc-exp-month": 6,
+ "cc-exp-year": 25,
+ },
+ expectedResult: {
+ guid: "123",
+ "visible-cc": "4111111111111111",
+ "visible-name": "test name",
+ "cc-exp-month": "06",
+ "cc-exp-year": "25",
+ "hidden-cc": undefined,
+ "hidden-cc-2": undefined,
+ "hidden-name": undefined,
+ "hidden-name-2": undefined,
+ },
+ },
+ {
+ description:
+ "Fill credit card fields in a form where the value property is being used as a placeholder for cardholder name",
+ document: `<form>
+ <input id="cc-number" autocomplete="cc-number">
+ <input id="cc-name" autocomplete="cc-name" value="JOHN DOE">
+ <input id="cc-exp-month" autocomplete="cc-exp-month">
+ <input id="cc-exp-year" autocomplete="cc-exp-year">
+ </form>`,
+ focusedInputId: "cc-number",
+ profileData: {
+ guid: "123",
+ "cc-number": "4111111111111111",
+ "cc-name": "test name",
+ "cc-exp-month": 6,
+ "cc-exp-year": 25,
+ },
+ expectedResult: {
+ guid: "123",
+ "cc-number": "4111111111111111",
+ "cc-name": "test name",
+ "cc-exp-month": "06",
+ "cc-exp-year": "25",
+ },
+ },
+ {
+ description:
+ "Fill credit card number fields in a form with multiple cc-number inputs",
+ document: `<form>
+ <input id="cc-number1" maxlength="4">
+ <input id="cc-number2" maxlength="4">
+ <input id="cc-number3" maxlength="4">
+ <input id="cc-number4" maxlength="4">
+ <input id="cc-exp-month" autocomplete="cc-exp-month">
+ <input id="cc-exp-year" autocomplete="cc-exp-year">
+ </form>`,
+ focusedInputId: "cc-number1",
+ profileData: {
+ guid: "123",
+ "cc-number": "371449635398431",
+ "cc-exp-month": 6,
+ "cc-exp-year": 25,
+ },
+ expectedResult: {
+ guid: "123",
+ "cc-number1": "3714",
+ "cc-number2": "4963",
+ "cc-number3": "5398",
+ "cc-number4": "431",
+ "cc-exp-month": "06",
+ "cc-exp-year": "25",
+ },
+ },
+ {
+ description:
+ "Fill credit card number fields in a form with multiple valid credit card sections",
+ document: `<form>
+ <input id="cc-type1">
+ <input id="cc-number1" maxlength="4">
+ <input id="cc-number2" maxlength="4">
+ <input id="cc-number3" maxlength="4">
+ <input id="cc-number4" maxlength="4">
+ <input id="cc-exp-month1">
+ <input id="cc-exp-year1">
+ <input id="cc-type2">
+ <input id="cc-number5" maxlength="4">
+ <input id="cc-number6" maxlength="4">
+ <input id="cc-number7" maxlength="4">
+ <input id="cc-number8" maxlength="4">
+ <input id="cc-exp-month2">
+ <input id="cc-exp-year2">
+ <input>
+ <input>
+ <input>
+ </form>
+ `,
+ focusedInputId: "cc-number1",
+ profileData: {
+ guid: "123",
+ "cc-type": "mastercard",
+ "cc-number": "371449635398431",
+ "cc-exp-month": 6,
+ "cc-exp-year": 25,
+ },
+ expectedResult: {
+ guid: "123",
+ "cc-type1": "mastercard",
+ "cc-number1": "3714",
+ "cc-number2": "4963",
+ "cc-number3": "5398",
+ "cc-number4": "431",
+ "cc-exp-month1": "06",
+ "cc-exp-year1": "25",
+ "cc-type2": "",
+ "cc-number-5": "",
+ "cc-number-6": "",
+ "cc-number-7": "",
+ "cc-number-8": "",
+ "cc-exp-month2": "",
+ "cc-exp-year2": "",
+ },
+ },
+ {
+ description:
+ "Fill credit card fields in a form with placeholders on month and year and these inputs are type=tel",
+ document: `<form>
+ <input id="cardHolder">
+ <input id="cardNumber">
+ <input id="month" type="tel" name="month" placeholder="MM">
+ <input id="year" type="tel" name="year" placeholder="YY">
+ </form>
+ `,
+ focusedInputId: "cardHolder",
+ profileData: {
+ guid: "123",
+ "cc-number": "4111111111111111",
+ "cc-name": "test name",
+ "cc-exp-month": 6,
+ "cc-exp-year": 2025,
+ },
+ expectedResult: {
+ cardHolder: "test name",
+ cardNumber: "4111111111111111",
+ month: "06",
+ year: "25",
+ },
+ },
+];
+
+const TESTCASES_INPUT_UNCHANGED = [
+ {
+ description:
+ "Form with autocomplete select elements; with default and no matching options",
+ document: `<form>
+ <input id="given-name" autocomplete="shipping given-name">
+ <select id="country" autocomplete="shipping country">
+ <option value="US">United States</option>
+ </select>
+ <select id="state" autocomplete="shipping address-level1">
+ <option value=""></option>
+ <option value="CA">California</option>
+ <option value="WA">Washington</option>
+ </select>
+ </form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ guid: "123",
+ country: "US",
+ "address-level1": "unknown state",
+ },
+ expectedResult: {
+ country: "US",
+ state: "",
+ },
+ },
+];
+
+const TESTCASES_FILL_SELECT = [
+ // US States
+ {
+ description: "Form with US states select elements",
+ document: `<form>
+ <input id="given-name" autocomplete="shipping given-name">
+ <input id="family-name" autocomplete="shipping family-name">
+ <select id="state" autocomplete="shipping address-level1">
+ <option value=""></option>
+ <option value="CA">California</option>
+ </select></form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ guid: "123",
+ country: "US",
+ "address-level1": "CA",
+ },
+ expectedResult: {
+ state: "CA",
+ },
+ },
+ {
+ description:
+ "Form with US states select elements; with lower case state key",
+ document: `<form>
+ <input id="given-name" autocomplete="shipping given-name">
+ <input id="family-name" autocomplete="shipping family-name">
+ <select id="state" autocomplete="shipping address-level1">
+ <option value=""></option>
+ <option value="ca">ca</option>
+ </select></form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ guid: "123",
+ country: "US",
+ "address-level1": "CA",
+ },
+ expectedResult: {
+ state: "ca",
+ },
+ },
+ {
+ description:
+ "Form with US states select elements; with state name and extra spaces",
+ document: `<form>
+ <input id="given-name" autocomplete="shipping given-name">
+ <input id="family-name" autocomplete="shipping family-name">
+ <select id="state" autocomplete="shipping address-level1">
+ <option value=""></option>
+ <option value="CA">CA</option>
+ </select></form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ guid: "123",
+ country: "US",
+ "address-level1": " California ",
+ },
+ expectedResult: {
+ state: "CA",
+ },
+ },
+ {
+ description:
+ "Form with US states select elements; with partial state key match",
+ document: `<form>
+ <input id="given-name" autocomplete="shipping given-name">
+ <input id="family-name" autocomplete="shipping family-name">
+ <select id="state" autocomplete="shipping address-level1">
+ <option value=""></option>
+ <option value="US-WA">WA-Washington</option>
+ </select></form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ guid: "123",
+ country: "US",
+ "address-level1": "WA",
+ },
+ expectedResult: {
+ state: "US-WA",
+ },
+ },
+
+ // Country
+ {
+ description: "Form with country select elements",
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="family-name" autocomplete="family-name">
+ <select id="country" autocomplete="country">
+ <option value=""></option>
+ <option value="US">United States</option>
+ </select></form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ guid: "123",
+ country: "US",
+ },
+ expectedResult: {
+ country: "US",
+ },
+ },
+ {
+ description: "Form with country select elements; with lower case key",
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="family-name" autocomplete="family-name">
+ <select id="country" autocomplete="country">
+ <option value=""></option>
+ <option value="us">us</option>
+ </select></form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ guid: "123",
+ country: "US",
+ },
+ expectedResult: {
+ country: "us",
+ },
+ },
+ {
+ description: "Form with country select elements; with alternative name 1",
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="family-name" autocomplete="family-name">
+ <select id="country" autocomplete="country">
+ <option value=""></option>
+ <option value="XX">United States</option>
+ </select></form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ guid: "123",
+ country: "US",
+ },
+ expectedResult: {
+ country: "XX",
+ },
+ },
+ {
+ description: "Form with country select elements; with alternative name 2",
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="family-name" autocomplete="family-name">
+ <select id="country" autocomplete="country">
+ <option value=""></option>
+ <option value="XX">America</option>
+ </select></form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ guid: "123",
+ country: "US",
+ },
+ expectedResult: {
+ country: "XX",
+ },
+ },
+ {
+ description:
+ "Form with country select elements; with partial matching value",
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="family-name" autocomplete="family-name">
+ <select id="country" autocomplete="country">
+ <option value=""></option>
+ <option value="XX">Ship to America</option>
+ </select></form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ guid: "123",
+ country: "US",
+ },
+ expectedResult: {
+ country: "XX",
+ },
+ },
+ {
+ description:
+ "Fill credit card expiration month field in a form with select field",
+ document: `<form>
+ <input id="cc-number" autocomplete="cc-number">
+ <input id="cc-name" autocomplete="cc-name">
+ <select id="cc-exp-month" autocomplete="cc-exp-month">
+ <option value="">MM</option>
+ <option value="6">06</option>
+ </select></form>`,
+ focusedInputId: "cc-number",
+ profileData: {
+ guid: "123",
+ "cc-number": "4111111111111111",
+ "cc-name": "test name",
+ "cc-exp-month": 6,
+ "cc-exp-year": 25,
+ },
+ expectedResult: {
+ "cc-number": "4111111111111111",
+ "cc-name": "test name",
+ "cc-exp-month": "6",
+ "cc-exp-year": "2025",
+ },
+ },
+ {
+ description:
+ "Fill credit card information correctly when one of the card type options is 'American Express'",
+ document: `<form>
+ <select id="cc-type" autocomplete="cc-type">
+ <option value="">Please select</option>
+ <option value="MA">Mastercard</option>
+ <option value="AX">American Express</option>
+ </select>
+ <input id="cc-number" autocomplete="cc-number">
+ <input id="cc-name" autocomplete="cc-name">
+ <input id="cc-exp-month" autocomplete="cc-exp-month">
+ <input id="cc-exp-year" autocomplete="cc-exp-year">
+ </form>`,
+ focusedInputId: "cc-number",
+ profileData: {
+ guid: "123",
+ "cc-number": "378282246310005",
+ "cc-type": "amex",
+ "cc-name": "test name",
+ "cc-exp-month": 8,
+ "cc-exp-year": 26,
+ },
+ expectedResult: {
+ guid: "123",
+ "cc-number": "378282246310005",
+ "cc-type": "AX",
+ "cc-name": "test name",
+ "cc-exp-month": 8,
+ "cc-exp-year": 26,
+ },
+ },
+];
+
+const TESTCASES_BOTH_CHANGED_AND_UNCHANGED = [
+ {
+ description:
+ "Form with a disabled input and non-disabled inputs. The 'country' field should not change",
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="family-name" autocomplete="family-name">
+ <input id="street-addr" autocomplete="street-address">
+ <input id="country" autocomplete="country" disabled value="DE">
+ </form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ guid: "123",
+ "given-name": "John",
+ "family-name": "Doe",
+ "street-address": "100 Main Street",
+ country: "CA",
+ },
+ expectedResult: {
+ "given-name": "John",
+ "family-name": "Doe",
+ "street-addr": "100 Main Street",
+ country: "DE",
+ },
+ },
+];
+
+function do_test(testcases, testFn) {
+ for (let tc of testcases) {
+ (function () {
+ let testcase = tc;
+ add_task(async function () {
+ info("Starting testcase: " + testcase.description);
+ let ccNumber = testcase.profileData["cc-number"];
+ if (ccNumber) {
+ testcase.profileData["cc-number-encrypted"] =
+ await OSKeyStore.encrypt(ccNumber);
+ delete testcase.profileData["cc-number"];
+ }
+
+ let doc = MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ testcase.document
+ );
+ let form = doc.querySelector("form");
+ let formLike = FormLikeFactory.createFromForm(form);
+ let handler = new FormAutofillHandler(formLike);
+ let promises = [];
+ // Replace the internal decrypt method with OSKeyStore API,
+ // but don't pass the reauth parameter to avoid triggering
+ // reauth login dialog in these tests.
+ let decryptHelper = async (cipherText, reauth) => {
+ return OSKeyStore.decrypt(cipherText, false);
+ };
+ handler.collectFormFields();
+
+ let focusedInput = doc.getElementById(testcase.focusedInputId);
+ try {
+ handler.focusedInput = focusedInput;
+ } catch (e) {
+ if (e.message.includes("WeakMap key must be an object")) {
+ throw new Error(
+ `Couldn't find the focusedInputId in the current form! Make sure focusedInputId exists in your test form! testcase description:${testcase.description}`
+ );
+ } else {
+ throw e;
+ }
+ }
+
+ for (let section of handler.sections) {
+ section._decrypt = decryptHelper;
+ }
+
+ handler.activeSection.fieldDetails.forEach(field => {
+ let element = field.elementWeakRef.get();
+ if (!testcase.profileData[field.fieldName]) {
+ // Avoid waiting for `change` event of a input with a blank value to
+ // be filled.
+ return;
+ }
+ promises.push(...testFn(testcase, element));
+ });
+
+ let [adaptedProfile] = handler.activeSection.getAdaptedProfiles([
+ testcase.profileData,
+ ]);
+ await handler.autofillFormFields(adaptedProfile, focusedInput);
+ Assert.equal(
+ handler.activeSection.filledRecordGUID,
+ testcase.profileData.guid,
+ "Check if filledRecordGUID is set correctly"
+ );
+ await Promise.all(promises);
+ });
+ })();
+ }
+}
+
+do_test(TESTCASES, (testcase, element) => {
+ let id = element.id;
+ return [
+ new Promise(resolve => {
+ element.addEventListener(
+ "input",
+ () => {
+ Assert.ok(true, "Checking " + id + " field fires input event");
+ resolve();
+ },
+ { once: true }
+ );
+ }),
+ new Promise(resolve => {
+ element.addEventListener(
+ "change",
+ () => {
+ Assert.ok(true, "Checking " + id + " field fires change event");
+ Assert.equal(
+ element.value,
+ testcase.expectedResult[id],
+ "Check the " + id + " field was filled with correct data"
+ );
+ resolve();
+ },
+ { once: true }
+ );
+ }),
+ ];
+});
+
+do_test(TESTCASES_INPUT_UNCHANGED, (testcase, element) => {
+ return [
+ new Promise((resolve, reject) => {
+ // Make sure no change or input event is fired when no change occurs.
+ let cleaner;
+ let timer = setTimeout(() => {
+ let id = element.id;
+ element.removeEventListener("change", cleaner);
+ element.removeEventListener("input", cleaner);
+ Assert.equal(
+ element.value,
+ testcase.expectedResult[id],
+ "Check no value is changed on the " + id + " field"
+ );
+ resolve();
+ }, 1000);
+ cleaner = event => {
+ clearTimeout(timer);
+ reject(`${event.type} event should not fire`);
+ };
+ element.addEventListener("change", cleaner);
+ element.addEventListener("input", cleaner);
+ }),
+ ];
+});
+
+do_test(TESTCASES_FILL_SELECT, (testcase, element) => {
+ let id = element.id;
+ return [
+ new Promise(resolve => {
+ element.addEventListener(
+ "input",
+ () => {
+ Assert.equal(
+ element.value,
+ testcase.expectedResult[id],
+ "Check the " + id + " field was filled with correct data"
+ );
+ resolve();
+ },
+ { once: true }
+ );
+ }),
+ ];
+});
+
+do_test(TESTCASES_BOTH_CHANGED_AND_UNCHANGED, (testcase, element) => {
+ // Ensure readonly and disabled inputs are not autofilled
+ if (element.readOnly || element.disabled) {
+ return [
+ new Promise((resolve, reject) => {
+ // Make sure no change or input event is fired when no change occurs.
+ let cleaner;
+ let timer = setTimeout(() => {
+ let id = element.id;
+ element.removeEventListener("change", cleaner);
+ element.removeEventListener("input", cleaner);
+ Assert.equal(
+ element.value,
+ testcase.expectedResult[id],
+ "Check no value is changed on the " + id + " field"
+ );
+ resolve();
+ }, 1000);
+ cleaner = event => {
+ clearTimeout(timer);
+ reject(`${event.type} event should not fire`);
+ };
+ element.addEventListener("change", cleaner);
+ element.addEventListener("input", cleaner);
+ }),
+ ];
+ }
+ let id = element.id;
+ // Ensure that non-disabled and non-readonly fields are filled correctly
+ return [
+ new Promise(resolve => {
+ element.addEventListener(
+ "input",
+ () => {
+ Assert.ok(true, "Checking " + id + " field fires input event");
+ resolve();
+ },
+ { once: true }
+ );
+ }),
+ new Promise(resolve => {
+ element.addEventListener(
+ "change",
+ () => {
+ Assert.ok(true, "Checking " + id + " field fires change event");
+ Assert.equal(
+ element.value,
+ testcase.expectedResult[id],
+ "Check the " + id + " field was filled with correct data"
+ );
+ resolve();
+ },
+ { once: true }
+ );
+ }),
+ ];
+});
diff --git a/browser/extensions/formautofill/test/unit/test_clearPopulatedForm.js b/browser/extensions/formautofill/test/unit/test_clearPopulatedForm.js
new file mode 100644
index 0000000000..db8ccb7621
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_clearPopulatedForm.js
@@ -0,0 +1,116 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TESTCASES = [
+ {
+ description: "Clear populated address form with text inputs",
+ document: `<form>
+ <input id="given-name">
+ <input id="family-name">
+ <input id="street-addr">
+ <input id="city">
+ </form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ "given-name": "John",
+ "family-name": "Doe",
+ "street-addr": "1000 Main Street",
+ city: "Nowhere",
+ },
+ expectedResult: {
+ "given-name": "",
+ "family-name": "",
+ "street-addr": "",
+ city: "",
+ },
+ },
+ {
+ description: "Clear populated address form with select and text inputs",
+ document: `<form>
+ <input id="given-name">
+ <input id="family-name">
+ <input id="street-addr">
+ <select id="state">
+ <option value="AL">Alabama</option>
+ <option value="AK">Alaska</option>
+ <option value="OH">Ohio</option>
+ </select>
+ </form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ "given-name": "John",
+ "family-name": "Doe",
+ "street-addr": "1000 Main Street",
+ state: "OH",
+ },
+ expectedResult: {
+ "given-name": "",
+ "family-name": "",
+ "street-addr": "",
+ state: "AL",
+ },
+ },
+ {
+ description:
+ "Clear populated address form with select element with selected attribute and text inputs",
+ document: `<form>
+ <input id="given-name">
+ <input id="family-name">
+ <input id="street-addr">
+ <select id="state">
+ <option value="AL">Alabama</option>
+ <option selected value="AK">Alaska</option>
+ <option value="OH">Ohio</option>
+ </select>
+ </form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ "given-name": "John",
+ "family-name": "Doe",
+ "street-addr": "1000 Main Street",
+ state: "OH",
+ },
+ expectedResult: {
+ "given-name": "",
+ "family-name": "",
+ "street-addr": "",
+ state: "AK",
+ },
+ },
+];
+
+add_task(async function do_test() {
+ let { FormAutofillHandler } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/FormAutofillHandler.sys.mjs"
+ );
+ for (let test of TESTCASES) {
+ info("Test case: " + test.description);
+ let testDoc = MockDocument.createTestDocument(
+ "http://localhost:8080/test",
+ test.document
+ );
+ let form = testDoc.querySelector("form");
+ let formLike = FormLikeFactory.createFromForm(form);
+ let handler = new FormAutofillHandler(formLike);
+ handler.collectFormFields();
+ let focusedInput = testDoc.getElementById(test.focusedInputId);
+ handler.focusedInput = focusedInput;
+ let [adaptedProfile] = handler.activeSection.getAdaptedProfiles([
+ test.profileData,
+ ]);
+ await handler.autofillFormFields(adaptedProfile, focusedInput);
+
+ handler.activeSection.clearPopulatedForm();
+ handler.activeSection.fieldDetails.forEach(detail => {
+ let element = detail.elementWeakRef.get();
+ let id = element.id;
+ Assert.equal(
+ element.value,
+ test.expectedResult[id],
+ `Check the ${id} field was restored to the correct value`
+ );
+ });
+ }
+});
diff --git a/browser/extensions/formautofill/test/unit/test_collectFormFields.js b/browser/extensions/formautofill/test/unit/test_collectFormFields.js
new file mode 100644
index 0000000000..ac56d29c69
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_collectFormFields.js
@@ -0,0 +1,638 @@
+/*
+ * Test for form auto fill content helper collectFormFields functions.
+ */
+
+"use strict";
+
+var FormAutofillHandler;
+add_setup(async () => {
+ ({ FormAutofillHandler } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/FormAutofillHandler.sys.mjs"
+ ));
+});
+
+const TESTCASES = [
+ {
+ description: "Form without autocomplete property",
+ document: `<form>
+ <input id="given-name">
+ <input id="family-name">
+ <input id="street-addr">
+ <input id="city">
+ <select id="country"></select>
+ <input id='email'>
+ <input id="phone">
+ </form>`,
+ sections: [
+ [
+ { fieldName: "given-name" },
+ { fieldName: "family-name" },
+ { fieldName: "address-line1" },
+ { fieldName: "address-level2" },
+ { fieldName: "country" },
+ { fieldName: "email" },
+ { fieldName: "tel" },
+ ],
+ ],
+ ids: [
+ "given-name",
+ "family-name",
+ "street-addr",
+ "city",
+ "country",
+ "email",
+ "phone",
+ ],
+ },
+ {
+ description:
+ "An address and credit card form with autocomplete properties and 1 token",
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="family-name" autocomplete="family-name">
+ <input id="street-address" autocomplete="street-address">
+ <input id="address-level2" autocomplete="address-level2">
+ <select id="country" autocomplete="country"></select>
+ <input id="email" autocomplete="email">
+ <input id="tel" autocomplete="tel">
+ <input id="cc-number" autocomplete="cc-number">
+ <input id="cc-name" autocomplete="cc-name">
+ <input id="cc-exp-month" autocomplete="cc-exp-month">
+ <input id="cc-exp-year" autocomplete="cc-exp-year">
+ </form>`,
+ sections: [
+ [
+ { fieldName: "given-name" },
+ { fieldName: "family-name" },
+ { fieldName: "street-address" },
+ { fieldName: "address-level2" },
+ { fieldName: "country" },
+ { fieldName: "email" },
+ { fieldName: "tel" },
+ ],
+ [
+ { fieldName: "cc-number" },
+ { fieldName: "cc-name" },
+ { fieldName: "cc-exp-month" },
+ { fieldName: "cc-exp-year" },
+ ],
+ ],
+ },
+ {
+ description: "An address form with autocomplete properties and 2 tokens",
+ document: `<form><input id="given-name" autocomplete="shipping given-name">
+ <input id="family-name" autocomplete="shipping family-name">
+ <input id="street-address" autocomplete="shipping street-address">
+ <input id="address-level2" autocomplete="shipping address-level2">
+ <input id="country" autocomplete="shipping country">
+ <input id='email' autocomplete="shipping email">
+ <input id="tel" autocomplete="shipping tel"></form>`,
+ sections: [
+ [
+ { addressType: "shipping", fieldName: "given-name" },
+ { addressType: "shipping", fieldName: "family-name" },
+ { addressType: "shipping", fieldName: "street-address" },
+ { addressType: "shipping", fieldName: "address-level2" },
+ { addressType: "shipping", fieldName: "country" },
+ { addressType: "shipping", fieldName: "email" },
+ { addressType: "shipping", fieldName: "tel" },
+ ],
+ ],
+ },
+ {
+ description:
+ "Form with autocomplete properties and profile is partly matched",
+ document: `<form><input id="given-name" autocomplete="shipping given-name">
+ <input id="family-name" autocomplete="shipping family-name">
+ <input id="street-address" autocomplete="shipping street-address">
+ <input id="address-level2" autocomplete="shipping address-level2">
+ <select id="country" autocomplete="shipping country"></select>
+ <input id='email' autocomplete="shipping email">
+ <input id="tel" autocomplete="shipping tel"></form>`,
+ sections: [
+ [
+ { addressType: "shipping", fieldName: "given-name" },
+ { addressType: "shipping", fieldName: "family-name" },
+ { addressType: "shipping", fieldName: "street-address" },
+ { addressType: "shipping", fieldName: "address-level2" },
+ { addressType: "shipping", fieldName: "country" },
+ { addressType: "shipping", fieldName: "email" },
+ { addressType: "shipping", fieldName: "tel" },
+ ],
+ ],
+ },
+ {
+ description: "It's a valid address and credit card form.",
+ document: `<form>
+ <input id="given-name" autocomplete="shipping given-name">
+ <input id="family-name" autocomplete="shipping family-name">
+ <input id="street-address" autocomplete="shipping street-address">
+ <input id="cc-number" autocomplete="shipping cc-number">
+ </form>`,
+ sections: [
+ [
+ { addressType: "shipping", fieldName: "given-name" },
+ { addressType: "shipping", fieldName: "family-name" },
+ { addressType: "shipping", fieldName: "street-address" },
+ ],
+ [{ addressType: "shipping", fieldName: "cc-number" }],
+ ],
+ },
+ {
+ description: "An invalid address form due to less than 3 fields.",
+ document: `<form>
+ <input id="given-name" autocomplete="shipping given-name">
+ <input autocomplete="shipping address-level2">
+ </form>`,
+ sections: [],
+ },
+ /*
+ * Valid Credit Card Form with autocomplete attribute
+ */
+ {
+ description: "@autocomplete - A valid credit card form",
+ document: `<form>
+ <input id="cc-number" autocomplete="cc-number">
+ <input id="cc-name" autocomplete="cc-name">
+ <input id="cc-exp" autocomplete="cc-exp">
+ </form>`,
+ sections: [
+ [
+ { fieldName: "cc-number" },
+ { fieldName: "cc-name" },
+ { fieldName: "cc-exp" },
+ ],
+ ],
+ },
+ {
+ description: "@autocomplete - A valid credit card form without cc-numner",
+ document: `<form>
+ <input id="cc-name" autocomplete="cc-name">
+ <input id="cc-exp" autocomplete="cc-exp">
+ </form>`,
+ sections: [[{ fieldName: "cc-name" }, { fieldName: "cc-exp" }]],
+ },
+ {
+ description: "@autocomplete - A valid cc-number only form",
+ document: `<form><input id="cc-number" autocomplete="cc-number"></form>`,
+ sections: [[{ fieldName: "cc-number" }]],
+ },
+ {
+ description: "@autocomplete - A valid cc-name only form",
+ document: `<form><input id="cc-name" autocomplete="cc-name"></form>`,
+ sections: [[{ fieldName: "cc-name" }]],
+ },
+ {
+ description: "@autocomplete - A valid cc-exp only form",
+ document: `<form><input id="cc-exp" autocomplete="cc-exp"></form>`,
+ sections: [[{ fieldName: "cc-exp" }]],
+ },
+ {
+ description: "@autocomplete - A valid cc-exp-month + cc-exp-year form",
+ document: `<form>
+ <input id="cc-exp-month" autocomplete="cc-exp-month">
+ <input id="cc-exp-year" autocomplete="cc-exp-year">
+ </form>`,
+ sections: [[{ fieldName: "cc-exp-month" }, { fieldName: "cc-exp-year" }]],
+ },
+ {
+ description: "@autocomplete - A valid cc-exp-month only form",
+ document: `<form><input id="cc-exp-month" autocomplete="cc-exp-month"></form>`,
+ sections: [[{ fieldName: "cc-exp-month" }]],
+ },
+ {
+ description: "@autocomplete - A valid cc-exp-year only form",
+ document: `<form><input id="cc-exp-year" autocomplete="cc-exp-year"></form>`,
+ sections: [[{ fieldName: "cc-exp-year" }]],
+ },
+ /*
+ * Valid Credit Card Form when cc-number or cc-name is detected by fathom
+ */
+ {
+ description:
+ "A valid credit card form without autocomplete attribute (cc-number is detected by fathom)",
+ document: `<form>
+ <input id="cc-number" name="cc-number">
+ <input id="cc-name" name="cc-name">
+ <input id="cc-exp" name="cc-exp">
+ </form>`,
+ sections: [
+ [
+ { fieldName: "cc-number" },
+ { fieldName: "cc-name" },
+ { fieldName: "cc-exp" },
+ ],
+ ],
+ prefs: [
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.testConfidence",
+ "0.8",
+ ],
+ ],
+ },
+ {
+ description:
+ "A valid credit card form without autocomplete attribute (only cc-number is detected by fathom)",
+ document: `<form>
+ <input id="cc-number" name="cc-number">
+ <input id="cc-name" name="cc-name">
+ <input id="cc-exp" name="cc-exp">
+ </form>`,
+ sections: [
+ [
+ { fieldName: "cc-number" },
+ { fieldName: "cc-name" },
+ { fieldName: "cc-exp" },
+ ],
+ ],
+ prefs: [
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.testConfidence",
+ "0.8",
+ ],
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.types",
+ "cc-number",
+ ],
+ ],
+ },
+ {
+ description:
+ "A valid credit card form without autocomplete attribute (only cc-name is detected by fathom)",
+ document: `<form>
+ <input id="cc-name" name="cc-name">
+ <input id="cc-exp" name="cc-exp">
+ </form>`,
+ sections: [[{ fieldName: "cc-name" }, { fieldName: "cc-exp" }]],
+ prefs: [
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.testConfidence",
+ "0.8",
+ ],
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.types",
+ "cc-name",
+ ],
+ ],
+ },
+ /*
+ * Invalid Credit Card Form when a cc-number or cc-name is detected by fathom
+ */
+ {
+ description:
+ "A credit card form is invalid when a fathom detected cc-number field is the only field in the form",
+ document: `<form><input id="cc-number" name="cc-number"></form>`,
+ sections: [],
+ prefs: [
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.highConfidenceThreshold",
+ "0.9",
+ ],
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.testConfidence",
+ "0.8",
+ ],
+ ],
+ },
+ {
+ description:
+ "A credit card form is invalid when a fathom detected cc-name field is the only field in the form",
+ document: `<form><input id="cc-name" name="cc-name"></form>`,
+ sections: [],
+ prefs: [
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.highConfidenceThreshold",
+ "0.9",
+ ],
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.testConfidence",
+ "0.8",
+ ],
+ ],
+ },
+ /*
+ * Valid Credit Card Form when a cc-number or cc-name only form is detected by fathom (field is high confidence)
+ */
+ {
+ description:
+ "A cc-number only form is considered a valid credit card form when fathom is confident and there is no other <input> in the form",
+ document: `<form><input id="cc-number" name="cc-number"></form>`,
+ sections: [[{ fieldName: "cc-number" }]],
+ prefs: [
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.highConfidenceThreshold",
+ "0.95",
+ ],
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.testConfidence",
+ "0.99",
+ ],
+ ],
+ },
+ {
+ description:
+ "A cc-name only form is considered a valid credit card form when fathom is confident and there is no other <input> in the form",
+ document: `<form><input id="cc-name" name="cc-name"></form>`,
+ sections: [[{ fieldName: "cc-name" }]],
+ prefs: [
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.highConfidenceThreshold",
+ "0.95",
+ ],
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.testConfidence",
+ "0.99",
+ ],
+ ],
+ },
+ /*
+ * Invalid Credit Card Form when none of the fields is identified by fathom
+ */
+ {
+ description:
+ "A credit card form is invalid when none of the fields are identified by fathom or autocomplete",
+ document: `<form>
+ <input id="cc-number" name="cc-number">
+ <input id="cc-name" name="cc-name">
+ <input id="cc-exp" name="cc-exp">
+ </form>`,
+ sections: [],
+ prefs: [
+ ["extensions.formautofill.creditCards.heuristics.fathom.types", ""],
+ ],
+ },
+ // Special Cases
+ {
+ description:
+ "A credit card form with a high-confidence cc-name field is still considered invalid when there is another <input> field",
+ document: `<form>
+ <input id="cc-name" name="cc-name">
+ <input id="password" type="password">
+ </form>`,
+ sections: [],
+ prefs: [
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.highConfidenceThreshold",
+ "0.95",
+ ],
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.testConfidence",
+ "0.96",
+ ],
+ ],
+ },
+ {
+ description: "A valid credit card form with multiple cc-number fields",
+ document: `<form>
+ <input id="cc-number1" maxlength="4">
+ <input id="cc-number2" maxlength="4">
+ <input id="cc-number3" maxlength="4">
+ <input id="cc-number4" maxlength="4">
+ <input id="cc-exp-month" autocomplete="cc-exp-month">
+ <input id="cc-exp-year" autocomplete="cc-exp-year">
+ </form>`,
+ sections: [
+ [
+ { fieldName: "cc-number" },
+ { fieldName: "cc-number" },
+ { fieldName: "cc-number" },
+ { fieldName: "cc-number" },
+ { fieldName: "cc-exp-month" },
+ { fieldName: "cc-exp-year" },
+ ],
+ ],
+ ids: [
+ "cc-number1",
+ "cc-number2",
+ "cc-number3",
+ "cc-number4",
+ "cc-exp-month",
+ "cc-exp-year",
+ ],
+ },
+ {
+ description: "Three sets of adjacent phone number fields",
+ document: `<form>
+ <input id="shippingAC" name="phone" maxlength="3">
+ <input id="shippingPrefix" name="phone" maxlength="3">
+ <input id="shippingSuffix" name="phone" maxlength="4">
+ <input id="shippingTelExt" name="extension">
+
+ <input id="billingAC" name="phone" maxlength="3">
+ <input id="billingPrefix" name="phone" maxlength="3">
+ <input id="billingSuffix" name="phone" maxlength="4">
+
+ <input id="otherCC" name="phone" maxlength="3">
+ <input id="otherAC" name="phone" maxlength="3">
+ <input id="otherPrefix" name="phone" maxlength="3">
+ <input id="otherSuffix" name="phone" maxlength="4">
+ </form>`,
+ sections: [
+ [
+ { fieldName: "tel-area-code" },
+ { fieldName: "tel-local-prefix" },
+ { fieldName: "tel-local-suffix" },
+ { fieldName: "tel-extension" },
+ ],
+ [
+ { fieldName: "tel-area-code" },
+ { fieldName: "tel-local-prefix" },
+ { fieldName: "tel-local-suffix" },
+
+ // TODO Bug 1421181 - "tel-country-code" field should belong to the next
+ // section. There should be a way to group the related fields during the
+ // parsing stage.
+ { fieldName: "tel-country-code" },
+ ],
+ [
+ { fieldName: "tel-area-code" },
+ { fieldName: "tel-local-prefix" },
+ { fieldName: "tel-local-suffix" },
+ ],
+ ],
+ ids: [
+ "shippingAC",
+ "shippingPrefix",
+ "shippingSuffix",
+ "shippingTelExt",
+ "billingAC",
+ "billingPrefix",
+ "billingSuffix",
+ "otherCC",
+ "otherAC",
+ "otherPrefix",
+ "otherSuffix",
+ ],
+ },
+ {
+ description:
+ "Do not dedup the same field names of the different telephone fields.",
+ document: `<form>
+ <input id="i1" autocomplete="given-name">
+ <input id="i2" autocomplete="family-name">
+ <input id="i3" autocomplete="street-address">
+ <input id="i4" autocomplete="email">
+
+ <input id="homePhone" maxlength="10">
+ <input id="mobilePhone" maxlength="10">
+ <input id="officePhone" maxlength="10">
+ </form>`,
+ sections: [
+ [
+ { fieldName: "given-name" },
+ { fieldName: "family-name" },
+ { fieldName: "street-address" },
+ { fieldName: "email" },
+ { fieldName: "tel" },
+ { fieldName: "tel" },
+ { fieldName: "tel" },
+ ],
+ ],
+ ids: ["i1", "i2", "i3", "i4", "homePhone", "mobilePhone", "officePhone"],
+ },
+ {
+ description:
+ "The duplicated phones of a single one and a set with ac, prefix, suffix.",
+ document: `<form>
+ <input id="i1" autocomplete="shipping given-name">
+ <input id="i2" autocomplete="shipping family-name">
+ <input id="i3" autocomplete="shipping street-address">
+ <input id="i4" autocomplete="shipping email">
+ <input id="singlePhone" autocomplete="shipping tel">
+ <input id="shippingAreaCode" autocomplete="shipping tel-area-code">
+ <input id="shippingPrefix" autocomplete="shipping tel-local-prefix">
+ <input id="shippingSuffix" autocomplete="shipping tel-local-suffix">
+ </form>`,
+ sections: [
+ [
+ { addressType: "shipping", fieldName: "given-name" },
+ { addressType: "shipping", fieldName: "family-name" },
+ { addressType: "shipping", fieldName: "street-address" },
+ { addressType: "shipping", fieldName: "email" },
+
+ // NOTES: Ideally, there is only one full telephone field(s) in a form for
+ // this case. We can see if there is any better solution later.
+ { addressType: "shipping", fieldName: "tel" },
+ { addressType: "shipping", fieldName: "tel-area-code" },
+ { addressType: "shipping", fieldName: "tel-local-prefix" },
+ { addressType: "shipping", fieldName: "tel-local-suffix" },
+ ],
+ ],
+ ids: [
+ "i1",
+ "i2",
+ "i3",
+ "i4",
+ "singlePhone",
+ "shippingAreaCode",
+ "shippingPrefix",
+ "shippingSuffix",
+ ],
+ },
+ {
+ description: "Always adopt the info from autocomplete attribute.",
+ document: `<form>
+ <input id="given-name" autocomplete="shipping given-name">
+ <input id="family-name" autocomplete="shipping family-name">
+ <input id="dummyAreaCode" autocomplete="shipping tel" maxlength="3">
+ <input id="dummyPrefix" autocomplete="shipping tel" maxlength="3">
+ <input id="dummySuffix" autocomplete="shipping tel" maxlength="4">
+ </form>`,
+ sections: [
+ [
+ { addressType: "shipping", fieldName: "given-name" },
+ { addressType: "shipping", fieldName: "family-name" },
+ { addressType: "shipping", fieldName: "tel" },
+ { addressType: "shipping", fieldName: "tel" },
+ { addressType: "shipping", fieldName: "tel" },
+ ],
+ ],
+ ids: [
+ "given-name",
+ "family-name",
+ "dummyAreaCode",
+ "dummyPrefix",
+ "dummySuffix",
+ ],
+ },
+];
+
+function verifyDetails(handlerDetails, testCaseDetails) {
+ if (handlerDetails === null) {
+ Assert.equal(handlerDetails, testCaseDetails);
+ return;
+ }
+ Assert.equal(handlerDetails.length, testCaseDetails.length, "field count");
+ handlerDetails.forEach((detail, index) => {
+ Assert.equal(
+ detail.fieldName,
+ testCaseDetails[index].fieldName,
+ "fieldName"
+ );
+ Assert.equal(
+ detail.section,
+ testCaseDetails[index].section ?? "",
+ "section"
+ );
+ Assert.equal(
+ detail.addressType,
+ testCaseDetails[index].addressType ?? "",
+ "addressType"
+ );
+ Assert.equal(
+ detail.contactType,
+ testCaseDetails[index].contactType ?? "",
+ "contactType"
+ );
+ Assert.equal(
+ detail.elementWeakRef.get(),
+ testCaseDetails[index].elementWeakRef.get(),
+ "DOM reference"
+ );
+ });
+}
+
+for (let tc of TESTCASES) {
+ (function () {
+ let testcase = tc;
+ add_task(async function () {
+ info("Starting testcase: " + testcase.description);
+
+ if (testcase.prefs) {
+ testcase.prefs.forEach(pref => SetPref(pref[0], pref[1]));
+ }
+
+ let doc = MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ testcase.document
+ );
+ testcase.sections.flat().forEach((field, idx) => {
+ let elementRef = doc.getElementById(
+ testcase.ids?.[idx] ?? field.fieldName
+ );
+ field.elementWeakRef = Cu.getWeakReference(elementRef);
+ });
+
+ let form = doc.querySelector("form");
+ let formLike = FormLikeFactory.createFromForm(form);
+
+ let handler = new FormAutofillHandler(formLike);
+ let validFieldDetails = handler.collectFormFields();
+
+ Assert.equal(
+ handler.sections.length,
+ testcase.sections.length,
+ "section count"
+ );
+ for (let i = 0; i < handler.sections.length; i++) {
+ let section = handler.sections[i];
+ verifyDetails(section.fieldDetails, testcase.sections[i]);
+ }
+ verifyDetails(validFieldDetails, testcase.sections.flat());
+
+ if (testcase.prefs) {
+ testcase.prefs.forEach(pref => Services.prefs.clearUserPref(pref[0]));
+ }
+ });
+ })();
+}
diff --git a/browser/extensions/formautofill/test/unit/test_createRecords.js b/browser/extensions/formautofill/test/unit/test_createRecords.js
new file mode 100644
index 0000000000..3d028e808e
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_createRecords.js
@@ -0,0 +1,525 @@
+/*
+ * Test for the normalization of records created by FormAutofillHandler.
+ */
+
+"use strict";
+
+var FormAutofillHandler;
+add_task(async function seutp() {
+ ({ FormAutofillHandler } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/FormAutofillHandler.sys.mjs"
+ ));
+});
+
+const TESTCASES = [
+ {
+ description:
+ "Don't contain a field whose length of value is greater than 200",
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="organization" autocomplete="organization">
+ <input id="address-level1" autocomplete="address-level1">
+ <input id="country" autocomplete="country">
+ <input id="cc-number" autocomplete="cc-number">
+ <input id="cc-name" autocomplete="cc-name">
+ </form>`,
+ formValue: {
+ "given-name": "John",
+ organization: "*".repeat(200),
+ "address-level1": "*".repeat(201),
+ country: "US",
+ "cc-number": "1111222233334444",
+ "cc-name": "*".repeat(201),
+ },
+ expectedRecord: {
+ address: [
+ {
+ "given-name": "John",
+ organization: "*".repeat(200),
+ "address-level1": "",
+ country: "US",
+ },
+ ],
+ creditCard: [
+ {
+ "cc-number": "1111222233334444",
+ "cc-name": "",
+ },
+ ],
+ },
+ },
+ {
+ description: "Don't create address record if filled data is less than 3",
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="organization" autocomplete="organization">
+ <input id="country" autocomplete="country">
+ </form>`,
+ formValue: {
+ "given-name": "John",
+ organization: "Mozilla",
+ },
+ expectedRecord: {
+ address: [],
+ creditCard: [],
+ },
+ },
+ {
+ description: `"country" using @autocomplete shouldn't be identified aggressively`,
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="organization" autocomplete="organization">
+ <input id="country" autocomplete="country">
+ </form>`,
+ formValue: {
+ "given-name": "John",
+ organization: "Mozilla",
+ country: "United States",
+ },
+ expectedRecord: {
+ // "United States" is not a valid country, only country-name. See isRecordCreatable.
+ address: [],
+ creditCard: [],
+ },
+ },
+ {
+ description: `"country" using heuristics should be identified aggressively`,
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="organization" autocomplete="organization">
+ <input id="country" name="country">
+ </form>`,
+ formValue: {
+ "given-name": "John",
+ organization: "Mozilla",
+ country: "United States",
+ },
+ expectedRecord: {
+ address: [
+ {
+ "given-name": "John",
+ organization: "Mozilla",
+ country: "US",
+ },
+ ],
+ creditCard: [],
+ },
+ },
+ {
+ description: `"tel" related fields should be concatenated`,
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="organization" autocomplete="organization">
+ <input id="tel-country-code" autocomplete="tel-country-code">
+ <input id="tel-national" autocomplete="tel-national">
+ </form>`,
+ formValue: {
+ "given-name": "John",
+ organization: "Mozilla",
+ "tel-country-code": "+1",
+ "tel-national": "1234567890",
+ },
+ expectedRecord: {
+ address: [
+ {
+ "given-name": "John",
+ organization: "Mozilla",
+ tel: "+11234567890",
+ },
+ ],
+ creditCard: [],
+ },
+ },
+ {
+ description: `"tel" should be removed if it's too short`,
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="organization" autocomplete="organization">
+ <input id="country" autocomplete="country">
+ <input id="tel" autocomplete="tel-national">
+ </form>`,
+ formValue: {
+ "given-name": "John",
+ organization: "Mozilla",
+ country: "US",
+ tel: "1234",
+ },
+ expectedRecord: {
+ address: [
+ {
+ "given-name": "John",
+ organization: "Mozilla",
+ country: "US",
+ tel: "",
+ },
+ ],
+ creditCard: [],
+ },
+ },
+ {
+ description: `"tel" should be removed if it's too long`,
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="organization" autocomplete="organization">
+ <input id="country" autocomplete="country">
+ <input id="tel" autocomplete="tel-national">
+ </form>`,
+ formValue: {
+ "given-name": "John",
+ organization: "Mozilla",
+ country: "US",
+ tel: "1234567890123456",
+ },
+ expectedRecord: {
+ address: [
+ {
+ "given-name": "John",
+ organization: "Mozilla",
+ country: "US",
+ tel: "",
+ },
+ ],
+ creditCard: [],
+ },
+ },
+ {
+ description: `"tel" should be removed if it contains invalid characters`,
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="organization" autocomplete="organization">
+ <input id="country" autocomplete="country">
+ <input id="tel" autocomplete="tel-national">
+ </form>`,
+ formValue: {
+ "given-name": "John",
+ organization: "Mozilla",
+ country: "US",
+ tel: "12345###!!!",
+ },
+ expectedRecord: {
+ address: [
+ {
+ "given-name": "John",
+ organization: "Mozilla",
+ country: "US",
+ tel: "",
+ },
+ ],
+ creditCard: [],
+ },
+ },
+ {
+ description: "All name related fields should be counted as 1 field only.",
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="family-name" autocomplete="family-name">
+ <input id="organization" autocomplete="organization">
+ </form>`,
+ formValue: {
+ "given-name": "John",
+ "family-name": "Doe",
+ organization: "Mozilla",
+ },
+ expectedRecord: {
+ address: [],
+ creditCard: [],
+ },
+ },
+ {
+ description:
+ "All telephone related fields should be counted as 1 field only.",
+ document: `<form>
+ <input id="tel-country-code" autocomplete="tel-country-code">
+ <input id="tel-area-code" autocomplete="tel-area-code">
+ <input id="tel-local" autocomplete="tel-local">
+ <input id="organization" autocomplete="organization">
+ </form>`,
+ formValue: {
+ "tel-country-code": "+1",
+ "tel-area-code": "123",
+ "tel-local": "4567890",
+ organization: "Mozilla",
+ },
+ expectedRecord: {
+ address: [],
+ creditCard: [],
+ },
+ },
+ {
+ description:
+ "A credit card form with the value of cc-number, cc-exp, cc-name and cc-type.",
+ document: `<form>
+ <input id="cc-number" autocomplete="cc-number">
+ <input id="cc-name" autocomplete="cc-name">
+ <input id="cc-exp" autocomplete="cc-exp">
+ <input id="cc-type" autocomplete="cc-type">
+ </form>`,
+ formValue: {
+ "cc-number": "5105105105105100",
+ "cc-name": "Foo Bar",
+ "cc-exp": "2022-06",
+ "cc-type": "Visa",
+ },
+ expectedRecord: {
+ address: [],
+ creditCard: [
+ {
+ "cc-number": "5105105105105100",
+ "cc-name": "Foo Bar",
+ "cc-exp": "2022-06",
+ "cc-type": "Visa",
+ "cc-exp-month": "6",
+ "cc-exp-year": "2022",
+ },
+ ],
+ },
+ },
+ {
+ description: "A credit card form with cc-number value only.",
+ document: `<form>
+ <input id="cc-number" autocomplete="cc-number">
+ </form>`,
+ formValue: {
+ "cc-number": "4111111111111111",
+ },
+ expectedRecord: {
+ address: [],
+ creditCard: [
+ {
+ "cc-number": "4111111111111111",
+ },
+ ],
+ },
+ },
+ {
+ description: "A credit card form must have cc-number value.",
+ document: `<form>
+ <input id="cc-number" autocomplete="cc-number">
+ <input id="cc-name" autocomplete="cc-name">
+ <input id="cc-exp" autocomplete="cc-exp">
+ </form>`,
+ formValue: {
+ "cc-number": "",
+ "cc-name": "Foo Bar",
+ "cc-exp": "2022-06",
+ },
+ expectedRecord: {
+ address: [],
+ creditCard: [],
+ },
+ },
+ {
+ description: "A credit card form must have cc-number field.",
+ document: `<form>
+ <input id="cc-name" autocomplete="cc-name">
+ <input id="cc-exp" autocomplete="cc-exp">
+ </form>`,
+ formValue: {
+ "cc-name": "Foo Bar",
+ "cc-exp": "2022-06",
+ },
+ expectedRecord: {
+ address: [],
+ creditCard: [],
+ },
+ },
+ {
+ description: "A form with multiple sections",
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="organization" autocomplete="organization">
+ <input id="country" autocomplete="country">
+
+ <input id="given-name-shipping" autocomplete="shipping given-name">
+ <input id="family-name-shipping" autocomplete="shipping family-name">
+ <input id="organization-shipping" autocomplete="shipping organization">
+ <input id="country-shipping" autocomplete="shipping country">
+
+ <input id="given-name-billing" autocomplete="billing given-name">
+ <input id="organization-billing" autocomplete="billing organization">
+ <input id="country-billing" autocomplete="billing country">
+
+ <input id="cc-number-section-one" autocomplete="section-one cc-number">
+ <input id="cc-name-section-one" autocomplete="section-one cc-name">
+
+ <input id="cc-number-section-two" autocomplete="section-two cc-number">
+ <input id="cc-name-section-two" autocomplete="section-two cc-name">
+ <input id="cc-exp-section-two" autocomplete="section-two cc-exp">
+ </form>`,
+ formValue: {
+ "given-name": "Bar",
+ organization: "Foo",
+ country: "US",
+
+ "given-name-shipping": "John",
+ "family-name-shipping": "Doe",
+ "organization-shipping": "Mozilla",
+ "country-shipping": "US",
+
+ "given-name-billing": "Foo",
+ "organization-billing": "Bar",
+ "country-billing": "US",
+
+ "cc-number-section-one": "4111111111111111",
+ "cc-name-section-one": "John",
+
+ "cc-number-section-two": "5105105105105100",
+ "cc-name-section-two": "Foo Bar",
+ "cc-exp-section-two": "2026-26",
+ },
+ expectedRecord: {
+ address: [
+ {
+ "given-name": "Bar",
+ organization: "Foo",
+ country: "US",
+ },
+ {
+ "given-name": "John",
+ "family-name": "Doe",
+ organization: "Mozilla",
+ country: "US",
+ },
+ {
+ "given-name": "Foo",
+ organization: "Bar",
+ country: "US",
+ },
+ ],
+ creditCard: [
+ {
+ "cc-number": "4111111111111111",
+ "cc-name": "John",
+ },
+ {
+ "cc-number": "5105105105105100",
+ "cc-name": "Foo Bar",
+ "cc-exp": "2026-26",
+ },
+ ],
+ },
+ },
+ {
+ description: "A credit card form with a cc-type select.",
+ document: `<form>
+ <input id="cc-number" autocomplete="cc-number">
+ <label for="field1">Card Type:</label>
+ <select id="field1">
+ <option value="visa" selected>Visa</option>
+ </select>
+ </form>`,
+ formValue: {
+ "cc-number": "5105105105105100",
+ },
+ expectedRecord: {
+ address: [],
+ creditCard: [
+ {
+ "cc-number": "5105105105105100",
+ "cc-type": "visa",
+ },
+ ],
+ },
+ },
+ {
+ description: "A credit card form with a cc-type select from label.",
+ document: `<form>
+ <input id="cc-number" autocomplete="cc-number">
+ <label for="cc-type">Card Type:</label>
+ <select id="cc-type">
+ <option value="V" selected>Visa</option>
+ <option value="A">American Express</option>
+ </select>
+ </form>`,
+ formValue: {
+ "cc-number": "5105105105105100",
+ "cc-type": "A",
+ },
+ expectedRecord: {
+ address: [],
+ creditCard: [
+ {
+ "cc-number": "5105105105105100",
+ "cc-type": "amex",
+ },
+ ],
+ },
+ },
+ {
+ description:
+ "A credit card form with separate expiry fields should have normalized expiry data.",
+ document: `<form>
+ <input id="cc-number" autocomplete="cc-number">
+ <input id="cc-exp-month" autocomplete="cc-exp-month">
+ <input id="cc-exp-year" autocomplete="cc-exp-year">
+ </form>`,
+ formValue: {
+ "cc-number": "5105105105105100",
+ "cc-exp-month": "05",
+ "cc-exp-year": "26",
+ },
+ expectedRecord: {
+ address: [],
+ creditCard: [
+ {
+ "cc-number": "5105105105105100",
+ "cc-exp-month": "5",
+ "cc-exp-year": "2026",
+ },
+ ],
+ },
+ },
+ {
+ description:
+ "A credit card form with combined expiry fields should have normalized expiry data.",
+ document: `<form>
+ <input id="cc-number" autocomplete="cc-number">
+ <input id="cc-exp" autocomplete="cc-exp">
+ </form>`,
+ formValue: {
+ "cc-number": "5105105105105100",
+ "cc-exp": "07/27",
+ },
+ expectedRecord: {
+ address: [],
+ creditCard: [
+ {
+ "cc-number": "5105105105105100",
+ "cc-exp": "07/27",
+ "cc-exp-month": "7",
+ "cc-exp-year": "2027",
+ },
+ ],
+ },
+ },
+];
+
+for (let testcase of TESTCASES) {
+ add_task(async function () {
+ info("Starting testcase: " + testcase.description);
+
+ let doc = MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ testcase.document
+ );
+ let form = doc.querySelector("form");
+ let formLike = FormLikeFactory.createFromForm(form);
+ let handler = new FormAutofillHandler(formLike);
+
+ handler.collectFormFields();
+
+ for (let id in testcase.formValue) {
+ doc.getElementById(id).value = testcase.formValue[id];
+ }
+
+ let record = handler.createRecords();
+
+ let expectedRecord = testcase.expectedRecord;
+ for (let type in record) {
+ Assert.deepEqual(
+ record[type].map(secRecord => secRecord.record),
+ expectedRecord[type]
+ );
+ }
+ });
+}
diff --git a/browser/extensions/formautofill/test/unit/test_creditCardRecords.js b/browser/extensions/formautofill/test/unit/test_creditCardRecords.js
new file mode 100644
index 0000000000..3011fe885f
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_creditCardRecords.js
@@ -0,0 +1,926 @@
+/**
+ * Tests FormAutofillStorage object with creditCards records.
+ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ Preferences: "resource://gre/modules/Preferences.sys.mjs",
+});
+const { CreditCard } = ChromeUtils.importESModule(
+ "resource://gre/modules/CreditCard.sys.mjs"
+);
+
+let FormAutofillStorage;
+let CREDIT_CARD_SCHEMA_VERSION;
+add_setup(async () => {
+ ({ FormAutofillStorage } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillStorage.sys.mjs"
+ ));
+ ({ CREDIT_CARD_SCHEMA_VERSION } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillStorageBase.sys.mjs"
+ ));
+});
+
+const TEST_STORE_FILE_NAME = "test-credit-card.json";
+const COLLECTION_NAME = "creditCards";
+
+const TEST_CREDIT_CARD_1 = {
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ "cc-exp-month": 4,
+ "cc-exp-year": 2017,
+};
+
+const TEST_CREDIT_CARD_2 = {
+ "cc-name": "Timothy Berners-Lee",
+ "cc-number": "5103059495477870",
+ "cc-exp-month": 12,
+ "cc-exp-year": 2022,
+};
+
+const TEST_CREDIT_CARD_3 = {
+ "cc-number": "3589993783099582",
+ "cc-exp-month": 1,
+ "cc-exp-year": 2000,
+};
+
+const TEST_CREDIT_CARD_4 = {
+ "cc-name": "Foo Bar",
+ "cc-number": "3589993783099582",
+};
+
+const TEST_CREDIT_CARD_WITH_BILLING_ADDRESS = {
+ "cc-name": "J. Smith",
+ "cc-number": "4111111111111111",
+ billingAddressGUID: "9m6hf4gfr6ge",
+};
+
+const TEST_CREDIT_CARD_WITH_EMPTY_FIELD = {
+ billingAddressGUID: "",
+ "cc-name": "",
+ "cc-number": "344060747836806",
+ "cc-exp-month": 1,
+};
+
+const TEST_CREDIT_CARD_WITH_EMPTY_COMPUTED_FIELD = {
+ "cc-given-name": "",
+ "cc-additional-name": "",
+ "cc-family-name": "",
+ "cc-exp": "",
+ "cc-number": "5415425865751454",
+};
+
+const TEST_CREDIT_CARD_WITH_2_DIGITS_YEAR = {
+ "cc-number": "344060747836806",
+ "cc-exp-month": 1,
+ "cc-exp-year": 12,
+};
+
+const TEST_CREDIT_CARD_WITH_INVALID_FIELD = {
+ "cc-name": "John Doe",
+ "cc-number": "344060747836806",
+ "cc-type": { invalid: "invalid" },
+};
+
+const TEST_CREDIT_CARD_WITH_INVALID_EXPIRY_DATE = {
+ "cc-name": "John Doe",
+ "cc-number": "5103059495477870",
+ "cc-exp-month": 13,
+ "cc-exp-year": -3,
+};
+
+const TEST_CREDIT_CARD_WITH_SPACES_BETWEEN_DIGITS = {
+ "cc-name": "John Doe",
+ "cc-number": "5103 0594 9547 7870",
+};
+
+const TEST_CREDIT_CARD_EMPTY_AFTER_NORMALIZE = {
+ "cc-exp-month": 13,
+};
+
+const TEST_CREDIT_CARD_EMPTY_AFTER_UPDATE_CREDIT_CARD_1 = {
+ "cc-name": "",
+ "cc-number": "",
+ "cc-exp-month": 13,
+ "cc-exp-year": "",
+};
+
+const MERGE_TESTCASES = [
+ {
+ description: "Merge a superset",
+ creditCardInStorage: {
+ "cc-number": "4929001587121045",
+ "cc-exp-month": 4,
+ "cc-exp-year": 2017,
+ "unknown-1": "an unknown field from another client",
+ },
+ creditCardToMerge: {
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ "cc-exp-month": 4,
+ "cc-exp-year": 2017,
+ "unknown-1": "an unknown field from another client",
+ },
+ expectedCreditCard: {
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ "cc-exp-month": 4,
+ "cc-exp-year": 2017,
+ "unknown-1": "an unknown field from another client",
+ },
+ },
+ {
+ description: "Merge a superset with billingAddressGUID",
+ creditCardInStorage: {
+ "cc-number": "4929001587121045",
+ },
+ creditCardToMerge: {
+ "cc-number": "4929001587121045",
+ billingAddressGUID: "ijsnbhfr",
+ },
+ expectedCreditCard: {
+ "cc-number": "4929001587121045",
+ billingAddressGUID: "ijsnbhfr",
+ },
+ },
+ {
+ description: "Merge a subset",
+ creditCardInStorage: {
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ "cc-exp-month": 4,
+ "cc-exp-year": 2017,
+ },
+ creditCardToMerge: {
+ "cc-number": "4929001587121045",
+ "cc-exp-month": 4,
+ "cc-exp-year": 2017,
+ },
+ expectedCreditCard: {
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ "cc-exp-month": 4,
+ "cc-exp-year": 2017,
+ },
+ noNeedToUpdate: true,
+ },
+ {
+ description: "Merge a subset with billingAddressGUID",
+ creditCardInStorage: {
+ "cc-number": "4929001587121045",
+ billingAddressGUID: "8fhdb3ug6",
+ },
+ creditCardToMerge: {
+ "cc-number": "4929001587121045",
+ },
+ expectedCreditCard: {
+ billingAddressGUID: "8fhdb3ug6",
+ "cc-number": "4929001587121045",
+ },
+ noNeedToUpdate: true,
+ },
+ {
+ description: "Merge an creditCard with partial overlaps",
+ creditCardInStorage: {
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ },
+ creditCardToMerge: {
+ "cc-number": "4929001587121045",
+ "cc-exp-month": 4,
+ "cc-exp-year": 2017,
+ },
+ expectedCreditCard: {
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ "cc-exp-month": 4,
+ "cc-exp-year": 2017,
+ },
+ },
+];
+
+let prepareTestCreditCards = async function (path) {
+ let profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ let onChanged = TestUtils.topicObserved(
+ "formautofill-storage-changed",
+ (subject, data) =>
+ data == "add" &&
+ subject.wrappedJSObject.guid &&
+ subject.wrappedJSObject.collectionName == COLLECTION_NAME
+ );
+ Assert.ok(await profileStorage.creditCards.add(TEST_CREDIT_CARD_1));
+ await onChanged;
+ Assert.ok(await profileStorage.creditCards.add(TEST_CREDIT_CARD_2));
+ await onChanged;
+ await profileStorage._saveImmediately();
+};
+
+let reCCNumber = /^(\*+)(.{4})$/;
+
+let do_check_credit_card_matches = (creditCardWithMeta, creditCard) => {
+ for (let key in creditCard) {
+ if (key == "cc-number") {
+ let matches = reCCNumber.exec(creditCardWithMeta["cc-number"]);
+ Assert.notEqual(matches, null);
+ Assert.equal(
+ creditCardWithMeta["cc-number"].length,
+ creditCard["cc-number"].length
+ );
+ Assert.equal(creditCard["cc-number"].endsWith(matches[2]), true);
+ Assert.notEqual(creditCard["cc-number-encrypted"], "");
+ } else {
+ Assert.equal(creditCardWithMeta[key], creditCard[key], "Testing " + key);
+ }
+ }
+};
+
+add_task(async function test_initialize() {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+ let profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ Assert.equal(profileStorage._store.data.version, 1);
+ Assert.equal(profileStorage._store.data.creditCards.length, 0);
+
+ let data = profileStorage._store.data;
+ Assert.deepEqual(data.creditCards, []);
+
+ await profileStorage._saveImmediately();
+
+ profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ Assert.deepEqual(profileStorage._store.data, data);
+});
+
+add_task(async function test_getAll() {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+ await prepareTestCreditCards(path);
+
+ let profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ let creditCards = await profileStorage.creditCards.getAll();
+
+ Assert.equal(creditCards.length, 2);
+ do_check_credit_card_matches(creditCards[0], TEST_CREDIT_CARD_1);
+ do_check_credit_card_matches(creditCards[1], TEST_CREDIT_CARD_2);
+
+ // Check computed fields.
+ Assert.equal(creditCards[0]["cc-given-name"], "John");
+ Assert.equal(creditCards[0]["cc-family-name"], "Doe");
+ Assert.equal(creditCards[0]["cc-exp"], "2017-04");
+
+ // Test with rawData set.
+ creditCards = await profileStorage.creditCards.getAll({ rawData: true });
+ Assert.equal(creditCards[0]["cc-given-name"], undefined);
+ Assert.equal(creditCards[0]["cc-family-name"], undefined);
+ Assert.equal(creditCards[0]["cc-exp"], undefined);
+
+ // Modifying output shouldn't affect the storage.
+ creditCards[0]["cc-name"] = "test";
+ do_check_credit_card_matches(
+ (await profileStorage.creditCards.getAll())[0],
+ TEST_CREDIT_CARD_1
+ );
+});
+
+add_task(async function test_get() {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+ await prepareTestCreditCards(path);
+
+ let profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ let creditCards = await profileStorage.creditCards.getAll();
+ let guid = creditCards[0].guid;
+
+ let creditCard = await profileStorage.creditCards.get(guid);
+ do_check_credit_card_matches(creditCard, TEST_CREDIT_CARD_1);
+
+ // Modifying output shouldn't affect the storage.
+ creditCards[0]["cc-name"] = "test";
+ do_check_credit_card_matches(
+ await profileStorage.creditCards.get(guid),
+ TEST_CREDIT_CARD_1
+ );
+
+ Assert.equal(await profileStorage.creditCards.get("INVALID_GUID"), null);
+});
+
+add_task(async function test_add() {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+ await prepareTestCreditCards(path);
+
+ let profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ let creditCards = await profileStorage.creditCards.getAll();
+
+ Assert.equal(creditCards.length, 2);
+
+ do_check_credit_card_matches(creditCards[0], TEST_CREDIT_CARD_1);
+ do_check_credit_card_matches(creditCards[1], TEST_CREDIT_CARD_2);
+
+ Assert.notEqual(creditCards[0].guid, undefined);
+ Assert.equal(creditCards[0].version, CREDIT_CARD_SCHEMA_VERSION);
+ Assert.notEqual(creditCards[0].timeCreated, undefined);
+ Assert.equal(creditCards[0].timeLastModified, creditCards[0].timeCreated);
+ Assert.equal(creditCards[0].timeLastUsed, 0);
+ Assert.equal(creditCards[0].timesUsed, 0);
+
+ // Empty string should be deleted before saving.
+ await profileStorage.creditCards.add(TEST_CREDIT_CARD_WITH_EMPTY_FIELD);
+ let creditCard = profileStorage.creditCards._data[2];
+ Assert.equal(
+ creditCard["cc-exp-month"],
+ TEST_CREDIT_CARD_WITH_EMPTY_FIELD["cc-exp-month"]
+ );
+ Assert.equal(creditCard["cc-name"], undefined);
+ Assert.equal(creditCard.billingAddressGUID, undefined);
+
+ // Empty computed fields shouldn't cause any problem.
+ await profileStorage.creditCards.add(
+ TEST_CREDIT_CARD_WITH_EMPTY_COMPUTED_FIELD
+ );
+ creditCard = profileStorage.creditCards._data[3];
+ Assert.equal(
+ creditCard["cc-number"],
+ CreditCard.getLongMaskedNumber(
+ TEST_CREDIT_CARD_WITH_EMPTY_COMPUTED_FIELD["cc-number"]
+ )
+ );
+
+ await Assert.rejects(
+ profileStorage.creditCards.add(TEST_CREDIT_CARD_WITH_INVALID_FIELD),
+ /"cc-type" contains invalid data type: object/
+ );
+
+ await Assert.rejects(
+ profileStorage.creditCards.add({}),
+ /Record contains no valid field\./
+ );
+
+ await Assert.rejects(
+ profileStorage.creditCards.add(TEST_CREDIT_CARD_EMPTY_AFTER_NORMALIZE),
+ /Record contains no valid field\./
+ );
+});
+
+add_task(async function test_addWithBillingAddress() {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+ let profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ let creditCards = await profileStorage.creditCards.getAll();
+
+ Assert.equal(creditCards.length, 0);
+
+ await profileStorage.creditCards.add(TEST_CREDIT_CARD_WITH_BILLING_ADDRESS);
+
+ creditCards = await profileStorage.creditCards.getAll();
+ Assert.equal(creditCards.length, 1);
+ do_check_credit_card_matches(
+ creditCards[0],
+ TEST_CREDIT_CARD_WITH_BILLING_ADDRESS
+ );
+});
+
+add_task(async function test_update() {
+ // Test assumes that when an entry is saved a second time, it's last modified date will
+ // be different from the first. With high values of precision reduction, we execute too
+ // fast for that to be true.
+ let timerPrecision = Preferences.get("privacy.reduceTimerPrecision");
+ Preferences.set("privacy.reduceTimerPrecision", false);
+
+ registerCleanupFunction(function () {
+ Preferences.set("privacy.reduceTimerPrecision", timerPrecision);
+ });
+
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+ await prepareTestCreditCards(path);
+
+ let profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ let creditCards = await profileStorage.creditCards.getAll();
+ let guid = creditCards[1].guid;
+ let timeLastModified = creditCards[1].timeLastModified;
+
+ let onChanged = TestUtils.topicObserved(
+ "formautofill-storage-changed",
+ (subject, data) =>
+ data == "update" &&
+ subject.wrappedJSObject.guid == guid &&
+ subject.wrappedJSObject.collectionName == COLLECTION_NAME
+ );
+
+ Assert.notEqual(creditCards[1]["cc-name"], undefined);
+ await profileStorage.creditCards.update(guid, TEST_CREDIT_CARD_3);
+ await onChanged;
+ await profileStorage._saveImmediately();
+
+ profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ let creditCard = await profileStorage.creditCards.get(guid);
+
+ Assert.equal(creditCard["cc-name"], undefined);
+ Assert.notEqual(creditCard.timeLastModified, timeLastModified);
+ do_check_credit_card_matches(creditCard, TEST_CREDIT_CARD_3);
+
+ // Empty string should be deleted while updating.
+ await profileStorage.creditCards.update(
+ profileStorage.creditCards._data[0].guid,
+ TEST_CREDIT_CARD_WITH_EMPTY_FIELD
+ );
+ creditCard = profileStorage.creditCards._data[0];
+ Assert.equal(
+ creditCard["cc-exp-month"],
+ TEST_CREDIT_CARD_WITH_EMPTY_FIELD["cc-exp-month"]
+ );
+ Assert.equal(creditCard["cc-name"], undefined);
+ Assert.equal(creditCard["cc-type"], "amex");
+ Assert.equal(creditCard.billingAddressGUID, undefined);
+
+ // Empty computed fields shouldn't cause any problem.
+ await profileStorage.creditCards.update(
+ profileStorage.creditCards._data[0].guid,
+ TEST_CREDIT_CARD_WITH_EMPTY_COMPUTED_FIELD,
+ false
+ );
+ creditCard = profileStorage.creditCards._data[0];
+ Assert.equal(
+ creditCard["cc-number"],
+ CreditCard.getLongMaskedNumber(
+ TEST_CREDIT_CARD_WITH_EMPTY_COMPUTED_FIELD["cc-number"]
+ )
+ );
+ await profileStorage.creditCards.update(
+ profileStorage.creditCards._data[1].guid,
+ TEST_CREDIT_CARD_WITH_EMPTY_COMPUTED_FIELD,
+ true
+ );
+ creditCard = profileStorage.creditCards._data[1];
+ Assert.equal(
+ creditCard["cc-number"],
+ CreditCard.getLongMaskedNumber(
+ TEST_CREDIT_CARD_WITH_EMPTY_COMPUTED_FIELD["cc-number"]
+ )
+ );
+
+ // Decryption failure of existing record should not prevent it from being updated.
+ creditCard = profileStorage.creditCards._data[0];
+ creditCard["cc-number-encrypted"] = "INVALID";
+ await profileStorage.creditCards.update(
+ profileStorage.creditCards._data[0].guid,
+ TEST_CREDIT_CARD_WITH_EMPTY_COMPUTED_FIELD,
+ false
+ );
+ creditCard = profileStorage.creditCards._data[0];
+ Assert.equal(
+ creditCard["cc-number"],
+ CreditCard.getLongMaskedNumber(
+ TEST_CREDIT_CARD_WITH_EMPTY_COMPUTED_FIELD["cc-number"]
+ )
+ );
+
+ await Assert.rejects(
+ profileStorage.creditCards.update("INVALID_GUID", TEST_CREDIT_CARD_3),
+ /No matching record\./
+ );
+
+ await Assert.rejects(
+ profileStorage.creditCards.update(
+ guid,
+ TEST_CREDIT_CARD_WITH_INVALID_FIELD
+ ),
+ /"cc-type" contains invalid data type: object/
+ );
+
+ await Assert.rejects(
+ profileStorage.creditCards.update(guid, {}),
+ /Record contains no valid field\./
+ );
+
+ await Assert.rejects(
+ profileStorage.creditCards.update(
+ guid,
+ TEST_CREDIT_CARD_EMPTY_AFTER_NORMALIZE
+ ),
+ /Record contains no valid field\./
+ );
+
+ await profileStorage.creditCards.update(guid, TEST_CREDIT_CARD_1);
+ await Assert.rejects(
+ profileStorage.creditCards.update(
+ guid,
+ TEST_CREDIT_CARD_EMPTY_AFTER_UPDATE_CREDIT_CARD_1
+ ),
+ /Record contains no valid field\./
+ );
+});
+
+add_task(async function test_validate() {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+
+ let profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ await profileStorage.creditCards.add(
+ TEST_CREDIT_CARD_WITH_INVALID_EXPIRY_DATE
+ );
+ await profileStorage.creditCards.add(TEST_CREDIT_CARD_WITH_2_DIGITS_YEAR);
+ await profileStorage.creditCards.add(
+ TEST_CREDIT_CARD_WITH_SPACES_BETWEEN_DIGITS
+ );
+ let creditCards = await profileStorage.creditCards.getAll();
+
+ Assert.equal(creditCards[0]["cc-exp-month"], undefined);
+ Assert.equal(creditCards[0]["cc-exp-year"], undefined);
+ Assert.equal(creditCards[0]["cc-exp"], undefined);
+
+ let month = TEST_CREDIT_CARD_WITH_2_DIGITS_YEAR["cc-exp-month"];
+ let year =
+ parseInt(TEST_CREDIT_CARD_WITH_2_DIGITS_YEAR["cc-exp-year"], 10) + 2000;
+ Assert.equal(creditCards[1]["cc-exp-month"], month);
+ Assert.equal(creditCards[1]["cc-exp-year"], year);
+ Assert.equal(
+ creditCards[1]["cc-exp"],
+ year + "-" + month.toString().padStart(2, "0")
+ );
+
+ Assert.equal(creditCards[2]["cc-number"].length, 16);
+});
+
+add_task(async function test_notifyUsed() {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+ await prepareTestCreditCards(path);
+
+ let profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ let creditCards = await profileStorage.creditCards.getAll();
+ let guid = creditCards[1].guid;
+ let timeLastUsed = creditCards[1].timeLastUsed;
+ let timesUsed = creditCards[1].timesUsed;
+
+ let onChanged = TestUtils.topicObserved(
+ "formautofill-storage-changed",
+ (subject, data) =>
+ data == "notifyUsed" &&
+ subject.wrappedJSObject.collectionName == COLLECTION_NAME &&
+ subject.wrappedJSObject.guid == guid
+ );
+
+ profileStorage.creditCards.notifyUsed(guid);
+ await onChanged;
+ await profileStorage._saveImmediately();
+
+ profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ let creditCard = await profileStorage.creditCards.get(guid);
+
+ Assert.equal(creditCard.timesUsed, timesUsed + 1);
+ Assert.notEqual(creditCard.timeLastUsed, timeLastUsed);
+
+ Assert.throws(
+ () => profileStorage.creditCards.notifyUsed("INVALID_GUID"),
+ /No matching record\./
+ );
+});
+
+add_task(async function test_remove() {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+ await prepareTestCreditCards(path);
+
+ let profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ let creditCards = await profileStorage.creditCards.getAll();
+ let guid = creditCards[1].guid;
+
+ let onChanged = TestUtils.topicObserved(
+ "formautofill-storage-changed",
+ (subject, data) =>
+ data == "remove" &&
+ subject.wrappedJSObject.guid == guid &&
+ subject.wrappedJSObject.collectionName == COLLECTION_NAME
+ );
+
+ Assert.equal(creditCards.length, 2);
+
+ profileStorage.creditCards.remove(guid);
+ await onChanged;
+ await profileStorage._saveImmediately();
+
+ profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ creditCards = await profileStorage.creditCards.getAll();
+
+ Assert.equal(creditCards.length, 1);
+
+ Assert.equal(await profileStorage.creditCards.get(guid), null);
+});
+
+MERGE_TESTCASES.forEach(testcase => {
+ add_task(async function test_merge() {
+ info("Starting testcase: " + testcase.description);
+ let profileStorage = await initProfileStorage(
+ TEST_STORE_FILE_NAME,
+ [testcase.creditCardInStorage],
+ "creditCards"
+ );
+ let creditCards = await profileStorage.creditCards.getAll();
+ let guid = creditCards[0].guid;
+ let timeLastModified = creditCards[0].timeLastModified;
+ // Merge creditCard and verify the guid in notifyObservers subject
+ let onMerged = TestUtils.topicObserved(
+ "formautofill-storage-changed",
+ (subject, data) =>
+ data == "update" &&
+ subject.wrappedJSObject.guid == guid &&
+ subject.wrappedJSObject.collectionName == COLLECTION_NAME
+ );
+ // Force to create sync metadata.
+ profileStorage.creditCards.pullSyncChanges();
+ Assert.equal(getSyncChangeCounter(profileStorage.creditCards, guid), 1);
+ Assert.ok(
+ await profileStorage.creditCards.mergeIfPossible(
+ guid,
+ testcase.creditCardToMerge
+ )
+ );
+ if (!testcase.noNeedToUpdate) {
+ await onMerged;
+ }
+ creditCards = await profileStorage.creditCards.getAll();
+ Assert.equal(creditCards.length, 1);
+ do_check_credit_card_matches(creditCards[0], testcase.expectedCreditCard);
+ if (!testcase.noNeedToUpdate) {
+ // Record merging should update timeLastModified and bump the change counter.
+ Assert.notEqual(creditCards[0].timeLastModified, timeLastModified);
+ Assert.equal(getSyncChangeCounter(profileStorage.creditCards, guid), 2);
+ } else {
+ // Subset record merging should not update timeLastModified and the change
+ // counter is still the same.
+ Assert.equal(creditCards[0].timeLastModified, timeLastModified);
+ Assert.equal(getSyncChangeCounter(profileStorage.creditCards, guid), 1);
+ }
+ });
+});
+
+add_task(async function test_merge_unable_merge() {
+ let profileStorage = await initProfileStorage(
+ TEST_STORE_FILE_NAME,
+ [TEST_CREDIT_CARD_1],
+ "creditCards"
+ );
+
+ let creditCards = await profileStorage.creditCards.getAll();
+ let guid = creditCards[0].guid;
+ // Force to create sync metadata.
+ profileStorage.creditCards.pullSyncChanges();
+ Assert.equal(getSyncChangeCounter(profileStorage.creditCards, guid), 1);
+
+ // Unable to merge because of conflict
+ let anotherCreditCard = profileStorage.creditCards._clone(TEST_CREDIT_CARD_1);
+ anotherCreditCard["cc-name"] = "Foo Bar";
+ Assert.equal(
+ await profileStorage.creditCards.mergeIfPossible(guid, anotherCreditCard),
+ false
+ );
+ // The change counter is unchanged.
+ Assert.equal(getSyncChangeCounter(profileStorage.creditCards, guid), 1);
+
+ // Unable to merge because no credit card number
+ anotherCreditCard = profileStorage.creditCards._clone(TEST_CREDIT_CARD_1);
+ anotherCreditCard["cc-number"] = "";
+ Assert.equal(
+ await profileStorage.creditCards.mergeIfPossible(guid, anotherCreditCard),
+ false
+ );
+ // The change counter is still unchanged.
+ Assert.equal(getSyncChangeCounter(profileStorage.creditCards, guid), 1);
+});
+
+add_task(async function test_mergeToStorage() {
+ let profileStorage = await initProfileStorage(
+ TEST_STORE_FILE_NAME,
+ [TEST_CREDIT_CARD_3, TEST_CREDIT_CARD_4],
+ "creditCards"
+ );
+ // Merge a creditCard to storage
+ let anotherCreditCard = profileStorage.creditCards._clone(TEST_CREDIT_CARD_3);
+ anotherCreditCard["cc-name"] = "Foo Bar";
+ Assert.equal(
+ (await profileStorage.creditCards.mergeToStorage(anotherCreditCard)).length,
+ 2
+ );
+ Assert.equal(
+ (await profileStorage.creditCards.getAll())[0]["cc-name"],
+ "Foo Bar"
+ );
+ Assert.equal(
+ (await profileStorage.creditCards.getAll())[0]["cc-exp"],
+ "2000-01"
+ );
+ Assert.equal(
+ (await profileStorage.creditCards.getAll())[1]["cc-name"],
+ "Foo Bar"
+ );
+ Assert.equal(
+ (await profileStorage.creditCards.getAll())[1]["cc-exp"],
+ "2000-01"
+ );
+
+ // Empty computed fields shouldn't cause any problem.
+ Assert.equal(
+ (
+ await profileStorage.creditCards.mergeToStorage(
+ TEST_CREDIT_CARD_WITH_EMPTY_COMPUTED_FIELD
+ )
+ ).length,
+ 0
+ );
+});
+
+add_task(async function test_getDuplicateRecords() {
+ let profileStorage = await initProfileStorage(
+ TEST_STORE_FILE_NAME,
+ [TEST_CREDIT_CARD_3],
+ "creditCards"
+ );
+ let guid = profileStorage.creditCards._data[0].guid;
+
+ // Absolutely a duplicate.
+ let getDuplicateRecords =
+ profileStorage.creditCards.getDuplicateRecords(TEST_CREDIT_CARD_3);
+ let dupe = (await getDuplicateRecords.next()).value;
+ Assert.equal(dupe.guid, guid);
+
+ // Absolutely not a duplicate.
+ getDuplicateRecords =
+ profileStorage.creditCards.getDuplicateRecords(TEST_CREDIT_CARD_1);
+ dupe = (await getDuplicateRecords.next()).value;
+ Assert.equal(dupe, null);
+
+ // Subset with the same number is a duplicate.
+ let record = Object.assign({}, TEST_CREDIT_CARD_3);
+ delete record["cc-exp-month"];
+ getDuplicateRecords = profileStorage.creditCards.getDuplicateRecords(record);
+ dupe = (await getDuplicateRecords.next()).value;
+ Assert.equal(dupe.guid, guid);
+
+ // Superset with the same number is a duplicate.
+ record = Object.assign({}, TEST_CREDIT_CARD_3);
+ record["cc-name"] = "John Doe";
+ getDuplicateRecords = profileStorage.creditCards.getDuplicateRecords(record);
+ dupe = (await getDuplicateRecords.next()).value;
+ Assert.equal(dupe.guid, guid);
+
+ // Numbers with the same last 4 digits shouldn't be treated as a duplicate.
+ record = Object.assign({}, TEST_CREDIT_CARD_3);
+ let last4Digits = record["cc-number"].substr(-4);
+ getDuplicateRecords = profileStorage.creditCards.getDuplicateRecords(record);
+ dupe = (await getDuplicateRecords.next()).value;
+ Assert.equal(dupe.guid, guid);
+
+ // This number differs from TEST_CREDIT_CARD_3 by swapping the order of the
+ // 09 and 90 adjacent digits, which is still a valid credit card number.
+ record["cc-number"] = "358999378390" + last4Digits;
+
+ // We don't treat numbers with the same last 4 digits as a duplicate.
+ getDuplicateRecords = profileStorage.creditCards.getDuplicateRecords(record);
+ dupe = (await getDuplicateRecords.next()).value;
+ Assert.equal(dupe, null);
+});
+
+add_task(async function test_getDuplicateRecordsMatch() {
+ let profileStorage = await initProfileStorage(
+ TEST_STORE_FILE_NAME,
+ [TEST_CREDIT_CARD_2],
+ "creditCards"
+ );
+ let guid = profileStorage.creditCards._data[0].guid;
+
+ // Absolutely a duplicate.
+ let getDuplicateRecords =
+ profileStorage.creditCards.getDuplicateRecords(TEST_CREDIT_CARD_2);
+ let dupe = (await getDuplicateRecords.next()).value;
+ Assert.equal(dupe.guid, guid);
+
+ // Absolutely not a duplicate.
+ getDuplicateRecords =
+ profileStorage.creditCards.getDuplicateRecords(TEST_CREDIT_CARD_1);
+ dupe = (await getDuplicateRecords.next()).value;
+ Assert.equal(dupe, null);
+
+ record = Object.assign({}, TEST_CREDIT_CARD_2);
+
+ // We change month from `1` to `2`
+ record["cc-exp-month"] = 2;
+ getDuplicateRecords = profileStorage.creditCards.getDuplicateRecords(record);
+ dupe = (await getDuplicateRecords.next()).value;
+ Assert.equal(dupe.guid, guid);
+
+ // We change year from `2000` to `2001`
+ record["cc-exp-year"] = 2001;
+ getDuplicateRecords = profileStorage.creditCards.getDuplicateRecords(record);
+ dupe = (await getDuplicateRecords.next()).value;
+ Assert.equal(dupe.guid, guid);
+
+ // New name, same card
+ record["cc-name"] = "John Doe";
+ getDuplicateRecords = profileStorage.creditCards.getDuplicateRecords(record);
+ dupe = (await getDuplicateRecords.next()).value;
+ Assert.equal(dupe.guid, guid);
+});
+
+add_task(async function test_getMatchRecord() {
+ let profileStorage = await initProfileStorage(
+ TEST_STORE_FILE_NAME,
+ [TEST_CREDIT_CARD_2],
+ "creditCards"
+ );
+ let guid = profileStorage.creditCards._data[0].guid;
+
+ const TEST_FIELDS = {
+ "cc-name": "John Doe",
+ "cc-exp-month": 10,
+ "cc-exp-year": 2001,
+ };
+
+ // Absolutely a match.
+ let getMatchRecords =
+ profileStorage.creditCards.getMatchRecords(TEST_CREDIT_CARD_2);
+ let match = (await getMatchRecords.next()).value;
+ Assert.equal(match.guid, guid);
+
+ // Subset with the same number is a match.
+ for (const field of Object.keys(TEST_FIELDS)) {
+ let record = Object.assign({}, TEST_CREDIT_CARD_2);
+ delete record[field];
+ getMatchRecords = profileStorage.creditCards.getMatchRecords(record);
+ match = (await getMatchRecords.next()).value;
+ Assert.equal(match.guid, guid);
+ }
+
+ // Subset with different number is not a match.
+ for (const field of Object.keys(TEST_FIELDS)) {
+ let record = Object.assign({}, TEST_CREDIT_CARD_2, {
+ "cc-number": TEST_CREDIT_CARD_1["cc-number"],
+ });
+ delete record[field];
+ getMatchRecords = profileStorage.creditCards.getMatchRecords(record);
+ match = (await getMatchRecords.next()).value;
+ Assert.equal(match, null);
+ }
+
+ // Superset with the same number is not a match.
+ for (const [field, value] of Object.entries(TEST_FIELDS)) {
+ let record = Object.assign({}, TEST_CREDIT_CARD_2);
+ record[field] = value;
+ getMatchRecords = profileStorage.creditCards.getMatchRecords(record);
+ match = (await getMatchRecords.next()).value;
+ Assert.equal(match, null);
+ }
+
+ // Superset with different number is not a match.
+ for (const [field, value] of Object.entries(TEST_FIELDS)) {
+ let record = Object.assign({}, TEST_CREDIT_CARD_2, {
+ "cc-number": TEST_CREDIT_CARD_1["cc-number"],
+ });
+ record[field] = value;
+ getMatchRecords = profileStorage.creditCards.getMatchRecords(record);
+ match = (await getMatchRecords.next()).value;
+ Assert.equal(match, null);
+ }
+});
+
+add_task(async function test_creditCardFillDisabled() {
+ Services.prefs.setBoolPref(
+ "extensions.formautofill.creditCards.enabled",
+ false
+ );
+
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+ let profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ Assert.equal(
+ !!profileStorage.creditCards,
+ true,
+ "credit card records initialized and available."
+ );
+
+ Services.prefs.setBoolPref(
+ "extensions.formautofill.creditCards.enabled",
+ true
+ );
+});
diff --git a/browser/extensions/formautofill/test/unit/test_extractLabelStrings.js b/browser/extensions/formautofill/test/unit/test_extractLabelStrings.js
new file mode 100644
index 0000000000..4c03c263f8
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_extractLabelStrings.js
@@ -0,0 +1,77 @@
+"use strict";
+
+var { LabelUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/LabelUtils.sys.mjs"
+);
+
+const TESTCASES = [
+ {
+ description: "A label element contains one input element.",
+ document: `<label id="typeA"> label type A
+ <!-- This comment should not be extracted. -->
+ <input type="text">
+ <script>FOO</script>
+ <noscript>FOO</noscript>
+ <option>FOO</option>
+ <style>FOO</style>
+ </label>`,
+ inputId: "typeA",
+ expectedStrings: ["label type A"],
+ },
+ {
+ description: "A label element with inner div contains one input element.",
+ document: `<label id="typeB"> label type B
+ <!-- This comment should not be extracted. -->
+ <script>FOO</script>
+ <noscript>FOO</noscript>
+ <option>FOO</option>
+ <style>FOO</style>
+ <div> inner div
+ <input type="text">
+ </div>
+ </label>`,
+ inputId: "typeB",
+ expectedStrings: ["label type B", "inner div"],
+ },
+ {
+ description:
+ "A label element with inner prefix/postfix strings contains span elements.",
+ document: `<label id="typeC"> label type C
+ <!-- This comment should not be extracted. -->
+ <script>FOO</script>
+ <noscript>FOO</noscript>
+ <option>FOO</option>
+ <style>FOO</style>
+ <div> inner div prefix
+ <span>test C-1 </span>
+ <span> test C-2</span>
+ inner div postfix
+ </div>
+ </label>`,
+ inputId: "typeC",
+ expectedStrings: [
+ "label type C",
+ "inner div prefix",
+ "test C-1",
+ "test C-2",
+ "inner div postfix",
+ ],
+ },
+];
+
+TESTCASES.forEach(testcase => {
+ add_task(async function () {
+ info("Starting testcase: " + testcase.description);
+ LabelUtils._labelStrings = new WeakMap();
+
+ let doc = MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ testcase.document
+ );
+
+ let element = doc.getElementById(testcase.inputId);
+ let strings = LabelUtils.extractLabelStrings(element);
+
+ Assert.deepEqual(strings, testcase.expectedStrings);
+ });
+});
diff --git a/browser/extensions/formautofill/test/unit/test_findLabelElements.js b/browser/extensions/formautofill/test/unit/test_findLabelElements.js
new file mode 100644
index 0000000000..956ace83f6
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_findLabelElements.js
@@ -0,0 +1,100 @@
+"use strict";
+
+var { LabelUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/LabelUtils.sys.mjs"
+);
+
+const TESTCASES = [
+ {
+ description: "Input contains in a label element.",
+ document: `<form>
+ <label id="labelA"> label type A
+ <input id="typeA" type="text">
+ </label>
+ </form>`,
+ inputId: "typeA",
+ expectedLabelIds: ["labelA"],
+ },
+ {
+ description: "Input contains in a label element.",
+ document: `<label id="labelB"> label type B
+ <div> inner div
+ <input id="typeB" type="text">
+ </div>
+ </label>`,
+ inputId: "typeB",
+ expectedLabelIds: ["labelB"],
+ },
+ {
+ description: '"for" attribute used to indicate input by one label.',
+ document: `<label id="labelC" for="typeC">label type C</label>
+ <input id="typeC" type="text">`,
+ inputId: "typeC",
+ expectedLabelIds: ["labelC"],
+ },
+ {
+ description: '"for" attribute used to indicate input by multiple labels.',
+ document: `<form>
+ <label id="labelD1" for="typeD">label type D1</label>
+ <label id="labelD2" for="typeD">label type D2</label>
+ <label id="labelD3" for="typeD">label type D3</label>
+ <input id="typeD" type="text">
+ </form>`,
+ inputId: "typeD",
+ expectedLabelIds: ["labelD1", "labelD2", "labelD3"],
+ },
+ {
+ description:
+ '"for" attribute used to indicate input by multiple labels with space prefix/postfix.',
+ document: `<label id="labelE1" for="typeE">label type E1</label>
+ <label id="labelE2" for="typeE ">label type E2</label>
+ <label id="labelE3" for=" TYPEe">label type E3</label>
+ <label id="labelE4" for=" typeE ">label type E4</label>
+ <input id=" typeE " type="text">`,
+ inputId: " typeE ",
+ expectedLabelIds: [],
+ },
+ {
+ description: "Input contains in a label element.",
+ document: `<label id="labelF"> label type F
+ <label for="dummy"> inner label
+ <input id="typeF" type="text">
+ <input id="dummy" type="text">
+ </div>
+ </label>`,
+ inputId: "typeF",
+ expectedLabelIds: ["labelF"],
+ },
+ {
+ description:
+ '"for" attribute used to indicate input by labels out of the form.',
+ document: `<label id="labelG1" for="typeG">label type G1</label>
+ <form>
+ <label id="labelG2" for="typeG">label type G2</label>
+ <input id="typeG" type="text">
+ </form>
+ <label id="labelG3" for="typeG">label type G3</label>`,
+ inputId: "typeG",
+ expectedLabelIds: ["labelG1", "labelG2", "labelG3"],
+ },
+];
+
+TESTCASES.forEach(testcase => {
+ add_task(async function () {
+ info("Starting testcase: " + testcase.description);
+
+ let doc = MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ testcase.document
+ );
+
+ let input = doc.getElementById(testcase.inputId);
+ let labels = LabelUtils.findLabelElements(input);
+
+ Assert.deepEqual(
+ labels.map(l => l.id),
+ testcase.expectedLabelIds
+ );
+ LabelUtils.clearLabelMap();
+ });
+});
diff --git a/browser/extensions/formautofill/test/unit/test_getAdaptedProfiles.js b/browser/extensions/formautofill/test/unit/test_getAdaptedProfiles.js
new file mode 100644
index 0000000000..63def3d8d9
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_getAdaptedProfiles.js
@@ -0,0 +1,1300 @@
+/*
+ * Test for form auto fill content helper fill all inputs function.
+ */
+
+"use strict";
+
+var FormAutofillHandler;
+add_task(async function () {
+ ({ FormAutofillHandler } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/FormAutofillHandler.sys.mjs"
+ ));
+});
+
+const DEFAULT_ADDRESS_RECORD = {
+ guid: "123",
+ "street-address": "2 Harrison St\nline2\nline3",
+ "address-line1": "2 Harrison St",
+ "address-line2": "line2",
+ "address-line3": "line3",
+ "address-level1": "CA",
+ country: "US",
+ tel: "+19876543210",
+ "tel-national": "9876543210",
+};
+
+const ADDRESS_RECORD_2 = {
+ guid: "address2",
+ "given-name": "John",
+ "additional-name": "Middle",
+ "family-name": "Doe",
+ "postal-code": "940012345",
+};
+
+const DEFAULT_CREDITCARD_RECORD = {
+ guid: "123",
+ "cc-exp-month": 1,
+ "cc-exp-year": 2025,
+ "cc-exp": "2025-01",
+};
+
+const DEFAULT_EXPECTED_CREDITCARD_RECORD = {
+ guid: "123",
+ "cc-exp-month": 1,
+ "cc-exp-year": 2025,
+ "cc-exp": "01/2025",
+};
+
+const getCCExpMonthFormatted = () => {
+ return DEFAULT_CREDITCARD_RECORD["cc-exp-month"].toString().padStart(2, "0");
+};
+
+const getCCExpYearFormatted = () => {
+ return DEFAULT_CREDITCARD_RECORD["cc-exp-year"].toString().substring(2);
+};
+
+// Bug 1767130: If a form has separate inputs for expiry month and year,
+// we will always transform month into MM
+const DEFAULT_EXPECTED_CREDITCARD_RECORD_SEPARATE_EXPIRY = {
+ ...DEFAULT_CREDITCARD_RECORD,
+ "cc-exp-month-formatted": getCCExpMonthFormatted(),
+};
+
+const TESTCASES = [
+ {
+ description: "Address form with street-address",
+ document: `<form>
+ <input autocomplete="given-name">
+ <input autocomplete="family-name">
+ <input id="street-addr" autocomplete="street-address">
+ </form>`,
+ profileData: [{ ...DEFAULT_ADDRESS_RECORD }],
+ expectedResult: [
+ {
+ guid: "123",
+ "street-address": "2 Harrison St line2 line3",
+ "-moz-street-address-one-line": "2 Harrison St line2 line3",
+ "address-line1": "2 Harrison St",
+ "address-line2": "line2",
+ "address-line3": "line3",
+ "address-level1": "CA",
+ country: "US",
+ tel: "+19876543210",
+ "tel-national": "9876543210",
+ },
+ ],
+ },
+ {
+ description: "Address form with street-address, address-line[1, 2, 3]",
+ document: `<form>
+ <input id="street-addr" autocomplete="street-address">
+ <input id="line1" autocomplete="address-line1">
+ <input id="line2" autocomplete="address-line2">
+ <input id="line3" autocomplete="address-line3">
+ </form>`,
+ profileData: [{ ...DEFAULT_ADDRESS_RECORD }],
+ expectedResult: [
+ {
+ guid: "123",
+ "street-address": "2 Harrison St line2 line3",
+ "-moz-street-address-one-line": "2 Harrison St line2 line3",
+ "address-line1": "2 Harrison St",
+ "address-line2": "line2",
+ "address-line3": "line3",
+ "address-level1": "CA",
+ country: "US",
+ tel: "+19876543210",
+ "tel-national": "9876543210",
+ },
+ ],
+ },
+ {
+ description: "Address form with street-address, address-line1",
+ document: `<form>
+ <input autocomplete="given-name">
+ <input id="street-addr" autocomplete="street-address">
+ <input id="line1" autocomplete="address-line1">
+ </form>`,
+ profileData: [{ ...DEFAULT_ADDRESS_RECORD }],
+ expectedResult: [
+ {
+ guid: "123",
+ "street-address": "2 Harrison St line2 line3",
+ "-moz-street-address-one-line": "2 Harrison St line2 line3",
+ "address-line1": "2 Harrison St line2 line3",
+ "address-line2": "line2",
+ "address-line3": "line3",
+ "address-level1": "CA",
+ country: "US",
+ tel: "+19876543210",
+ "tel-national": "9876543210",
+ },
+ ],
+ },
+ {
+ description: "Address form with street-address, address-line[1, 2]",
+ document: `<form>
+ <input id="street-addr" autocomplete="street-address">
+ <input id="line1" autocomplete="address-line1">
+ <input id="line2" autocomplete="address-line2">
+ </form>`,
+ profileData: [{ ...DEFAULT_ADDRESS_RECORD }],
+ expectedResult: [
+ {
+ guid: "123",
+ "street-address": "2 Harrison St line2 line3",
+ "-moz-street-address-one-line": "2 Harrison St line2 line3",
+ "address-line1": "2 Harrison St",
+ "address-line2": "line2 line3",
+ "address-line3": "line3",
+ "address-level1": "CA",
+ country: "US",
+ tel: "+19876543210",
+ "tel-national": "9876543210",
+ },
+ ],
+ },
+ {
+ description:
+ "Address form with street-address, address-line[1, 3]" +
+ ", determined by autocomplete attr",
+ document: `<form>
+ <input id="street-addr" autocomplete="street-address">
+ <input id="line1" autocomplete="address-line1">
+ <input id="line3" autocomplete="address-line3">
+ </form>`,
+ profileData: [{ ...DEFAULT_ADDRESS_RECORD }],
+ expectedResult: [
+ {
+ guid: "123",
+ "street-address": "2 Harrison St line2 line3",
+ "-moz-street-address-one-line": "2 Harrison St line2 line3",
+ // Since the form is missing address-line2 field, the value of
+ // address-line1 should contain line2 value as well.
+ "address-line1": "2 Harrison St line2",
+ "address-line2": "line2",
+ "address-line3": "line3",
+ "address-level1": "CA",
+ country: "US",
+ tel: "+19876543210",
+ "tel-national": "9876543210",
+ },
+ ],
+ },
+ {
+ description:
+ "Address form with street-address, address-line[1, 3]" +
+ ", determined by heuristics",
+ document: `<form>
+ <input id="street-address">
+ <input id="address-line1">
+ <input id="address-line3">
+ </form>`,
+ profileData: [{ ...DEFAULT_ADDRESS_RECORD }],
+ expectedResult: [
+ {
+ guid: "123",
+ "street-address": "2 Harrison St line2 line3",
+ "-moz-street-address-one-line": "2 Harrison St line2 line3",
+ // Since the form is missing address-line2 field, the value of
+ // address-line1 should contain line2 value as well.
+ "address-line1": "2 Harrison St line2",
+ "address-line2": "line2",
+ "address-line3": "line3",
+ "address-level1": "CA",
+ country: "US",
+ tel: "+19876543210",
+ "tel-national": "9876543210",
+ },
+ ],
+ },
+ {
+ description: "Address form with exact matching options in select",
+ document: `<form>
+ <input autocomplete="given-name">
+ <select autocomplete="address-level1">
+ <option id="option-address-level1-XX" value="XX">Dummy</option>
+ <option id="option-address-level1-CA" value="CA">California</option>
+ </select>
+ <select autocomplete="country">
+ <option id="option-country-XX" value="XX">Dummy</option>
+ <option id="option-country-US" value="US">United States</option>
+ </select>
+ </form>`,
+ profileData: [{ ...DEFAULT_ADDRESS_RECORD }],
+ expectedResult: [
+ {
+ guid: "123",
+ "street-address": "2 Harrison St\nline2\nline3",
+ "-moz-street-address-one-line": "2 Harrison St line2 line3",
+ "address-line1": "2 Harrison St",
+ "address-line2": "line2",
+ "address-line3": "line3",
+ "address-level1": "CA",
+ country: "US",
+ tel: "+19876543210",
+ "tel-national": "9876543210",
+ },
+ ],
+ expectedOptionElements: [
+ {
+ "address-level1": "option-address-level1-CA",
+ country: "option-country-US",
+ },
+ ],
+ },
+ {
+ description: "Address form with inexact matching options in select",
+ document: `<form>
+ <input autocomplete="given-name">
+ <select autocomplete="address-level1">
+ <option id="option-address-level1-XX" value="XX">Dummy</option>
+ <option id="option-address-level1-OO" value="OO">California</option>
+ </select>
+ <select autocomplete="country">
+ <option id="option-country-XX" value="XX">Dummy</option>
+ <option id="option-country-OO" value="OO">United States</option>
+ </select>
+ </form>`,
+ profileData: [{ ...DEFAULT_ADDRESS_RECORD }],
+ expectedResult: [
+ {
+ guid: "123",
+ "street-address": "2 Harrison St\nline2\nline3",
+ "-moz-street-address-one-line": "2 Harrison St line2 line3",
+ "address-line1": "2 Harrison St",
+ "address-line2": "line2",
+ "address-line3": "line3",
+ "address-level1": "CA",
+ country: "US",
+ tel: "+19876543210",
+ "tel-national": "9876543210",
+ },
+ ],
+ expectedOptionElements: [
+ {
+ "address-level1": "option-address-level1-OO",
+ country: "option-country-OO",
+ },
+ ],
+ },
+ {
+ description: "Address form with value-omitted options in select",
+ document: `<form>
+ <input autocomplete="given-name">
+ <select autocomplete="address-level1">
+ <option id="option-address-level1-1" value="">Dummy</option>
+ <option id="option-address-level1-2" value="">California</option>
+ </select>
+ <select autocomplete="country">
+ <option id="option-country-1" value="">Dummy</option>
+ <option id="option-country-2" value="">United States</option>
+ </select>
+ </form>`,
+ profileData: [{ ...DEFAULT_ADDRESS_RECORD }],
+ expectedResult: [
+ {
+ guid: "123",
+ "street-address": "2 Harrison St\nline2\nline3",
+ "-moz-street-address-one-line": "2 Harrison St line2 line3",
+ "address-line1": "2 Harrison St",
+ "address-line2": "line2",
+ "address-line3": "line3",
+ "address-level1": "CA",
+ country: "US",
+ tel: "+19876543210",
+ "tel-national": "9876543210",
+ },
+ ],
+ expectedOptionElements: [
+ {
+ "address-level1": "option-address-level1-2",
+ country: "option-country-2",
+ },
+ ],
+ },
+ {
+ description: "Address form with options with the same value in select ",
+ document: `<form>
+ <input autocomplete="given-name">
+ <select autocomplete="address-level1">
+ <option id="option-address-level1-same1" value="same">Dummy</option>
+ <option id="option-address-level1-same2" value="same">California</option>
+ </select>
+ <select autocomplete="country">
+ <option id="option-country-same1" value="sametoo">Dummy</option>
+ <option id="option-country-same2" value="sametoo">United States</option>
+ </select>
+ </form>`,
+ profileData: [{ ...DEFAULT_ADDRESS_RECORD }],
+ expectedResult: [
+ {
+ guid: "123",
+ "street-address": "2 Harrison St\nline2\nline3",
+ "-moz-street-address-one-line": "2 Harrison St line2 line3",
+ "address-line1": "2 Harrison St",
+ "address-line2": "line2",
+ "address-line3": "line3",
+ "address-level1": "CA",
+ country: "US",
+ tel: "+19876543210",
+ "tel-national": "9876543210",
+ },
+ ],
+ expectedOptionElements: [
+ {
+ "address-level1": "option-address-level1-same2",
+ country: "option-country-same2",
+ },
+ ],
+ },
+ {
+ description:
+ "Address form without matching options in select for address-level1 and country",
+ document: `<form>
+ <input autocomplete="given-name">
+ <select autocomplete="address-level1">
+ <option id="option-address-level1-dummy1" value="">Dummy</option>
+ <option id="option-address-level1-dummy2" value="">Dummy 2</option>
+ </select>
+ <select autocomplete="country">
+ <option id="option-country-dummy1" value="">Dummy</option>
+ <option id="option-country-dummy2" value="">Dummy 2</option>
+ </select>
+ </form>`,
+ profileData: [{ ...DEFAULT_ADDRESS_RECORD }],
+ expectedResult: [
+ {
+ guid: "123",
+ "street-address": "2 Harrison St\nline2\nline3",
+ "-moz-street-address-one-line": "2 Harrison St line2 line3",
+ "address-line1": "2 Harrison St",
+ "address-line2": "line2",
+ "address-line3": "line3",
+ tel: "+19876543210",
+ "tel-national": "9876543210",
+ },
+ ],
+ },
+ {
+ description:
+ "Change the tel value of a profile to tel-national for a field without pattern and maxlength.",
+ document: `<form>
+ <input id="telephone">
+ <input id="line1" autocomplete="address-line1">
+ <input id="line2" autocomplete="address-line2">
+ </form>`,
+ profileData: [{ ...DEFAULT_ADDRESS_RECORD }],
+ expectedResult: [
+ {
+ guid: "123",
+ "street-address": "2 Harrison St\nline2\nline3",
+ "-moz-street-address-one-line": "2 Harrison St line2 line3",
+ "address-line1": "2 Harrison St",
+ "address-line2": "line2 line3",
+ "address-line3": "line3",
+ "address-level1": "CA",
+ country: "US",
+ tel: "9876543210",
+ "tel-national": "9876543210",
+ },
+ ],
+ },
+ {
+ description:
+ 'Do not change the profile for an autocomplete="tel" field without patern and maxlength.',
+ document: `<form>
+ <input id="tel" autocomplete="tel">
+ <input id="line1" autocomplete="address-line1">
+ <input id="line2" autocomplete="address-line2">
+ </form>`,
+ profileData: [{ ...DEFAULT_ADDRESS_RECORD }],
+ expectedResult: [
+ {
+ guid: "123",
+ "street-address": "2 Harrison St\nline2\nline3",
+ "-moz-street-address-one-line": "2 Harrison St line2 line3",
+ "address-line1": "2 Harrison St",
+ "address-line2": "line2 line3",
+ "address-line3": "line3",
+ "address-level1": "CA",
+ country: "US",
+ tel: "+19876543210",
+ "tel-national": "9876543210",
+ },
+ ],
+ },
+ {
+ description:
+ 'autocomplete="tel" field with `maxlength` can be filled with `tel` value.',
+ document: `<form>
+ <input id="telephone" autocomplete="tel" maxlength="12">
+ <input id="line1" autocomplete="address-line1">
+ <input id="line2" autocomplete="address-line2">
+ </form>`,
+ profileData: [{ ...DEFAULT_ADDRESS_RECORD }],
+ expectedResult: [
+ {
+ guid: "123",
+ "street-address": "2 Harrison St\nline2\nline3",
+ "-moz-street-address-one-line": "2 Harrison St line2 line3",
+ "address-line1": "2 Harrison St",
+ "address-line2": "line2 line3",
+ "address-line3": "line3",
+ "address-level1": "CA",
+ country: "US",
+ tel: "+19876543210",
+ "tel-national": "9876543210",
+ },
+ ],
+ },
+ {
+ description:
+ "Still fill `tel-national` in a `tel` field with `maxlength` can be filled with `tel` value.",
+ document: `<form>
+ <input id="telephone" maxlength="12">
+ <input id="line1" autocomplete="address-line1">
+ <input id="line2" autocomplete="address-line2">
+ </form>`,
+ profileData: [{ ...DEFAULT_ADDRESS_RECORD }],
+ expectedResult: [
+ {
+ guid: "123",
+ "street-address": "2 Harrison St\nline2\nline3",
+ "-moz-street-address-one-line": "2 Harrison St line2 line3",
+ "address-line1": "2 Harrison St",
+ "address-line2": "line2 line3",
+ "address-line3": "line3",
+ "address-level1": "CA",
+ country: "US",
+ tel: "9876543210",
+ "tel-national": "9876543210",
+ },
+ ],
+ },
+ {
+ description:
+ "`tel` field with `maxlength` can be filled with `tel-national` value.",
+ document: `<form>
+ <input id="telephone" maxlength="10">
+ <input id="line1" autocomplete="address-line1">
+ <input id="line2" autocomplete="address-line2">
+ </form>`,
+ profileData: [{ ...DEFAULT_ADDRESS_RECORD }],
+ expectedResult: [
+ {
+ guid: "123",
+ "street-address": "2 Harrison St\nline2\nline3",
+ "-moz-street-address-one-line": "2 Harrison St line2 line3",
+ "address-line1": "2 Harrison St",
+ "address-line2": "line2 line3",
+ "address-line3": "line3",
+ "address-level1": "CA",
+ country: "US",
+ tel: "9876543210",
+ "tel-national": "9876543210",
+ },
+ ],
+ },
+ {
+ description:
+ "`tel` field with `pattern` attr can be filled with `tel` value.",
+ document: `<form>
+ <input id="telephone" pattern="[+][0-9]+">
+ <input id="line1" autocomplete="address-line1">
+ <input id="line2" autocomplete="address-line2">
+ </form>`,
+ profileData: [{ ...DEFAULT_ADDRESS_RECORD }],
+ expectedResult: [
+ {
+ guid: "123",
+ "street-address": "2 Harrison St\nline2\nline3",
+ "-moz-street-address-one-line": "2 Harrison St line2 line3",
+ "address-line1": "2 Harrison St",
+ "address-line2": "line2 line3",
+ "address-line3": "line3",
+ "address-level1": "CA",
+ country: "US",
+ tel: "+19876543210",
+ "tel-national": "9876543210",
+ },
+ ],
+ },
+ {
+ description:
+ "Change the tel value of a profile to tel-national one when the pattern is matched.",
+ document: `<form>
+ <input id="telephone" pattern="\d*">
+ <input id="line1" autocomplete="address-line1">
+ <input id="line2" autocomplete="address-line2">
+ </form>`,
+ profileData: [{ ...DEFAULT_ADDRESS_RECORD }],
+ expectedResult: [
+ {
+ guid: "123",
+ "street-address": "2 Harrison St\nline2\nline3",
+ "-moz-street-address-one-line": "2 Harrison St line2 line3",
+ "address-line1": "2 Harrison St",
+ "address-line2": "line2 line3",
+ "address-line3": "line3",
+ "address-level1": "CA",
+ country: "US",
+ tel: "9876543210",
+ "tel-national": "9876543210",
+ },
+ ],
+ },
+ {
+ description: 'Matching pattern when a field is with autocomplete="tel".',
+ document: `<form>
+ <input id="tel" autocomplete="tel" pattern="[0-9]+">
+ <input id="line1" autocomplete="address-line1">
+ <input id="line2" autocomplete="address-line2">
+ </form>`,
+ profileData: [{ ...DEFAULT_ADDRESS_RECORD }],
+ expectedResult: [
+ {
+ guid: "123",
+ "street-address": "2 Harrison St\nline2\nline3",
+ "-moz-street-address-one-line": "2 Harrison St line2 line3",
+ "address-line1": "2 Harrison St",
+ "address-line2": "line2 line3",
+ "address-line3": "line3",
+ "address-level1": "CA",
+ country: "US",
+ tel: "9876543210",
+ "tel-national": "9876543210",
+ },
+ ],
+ },
+ {
+ description:
+ "Checking maxlength of tel field first when a field is with maxlength.",
+ document: `<form>
+ <input id="tel" autocomplete="tel" maxlength="10">
+ <input id="line1" autocomplete="address-line1">
+ <input id="line2" autocomplete="address-line2">
+ </form>`,
+ profileData: [{ ...DEFAULT_ADDRESS_RECORD }],
+ expectedResult: [
+ {
+ guid: "123",
+ "street-address": "2 Harrison St\nline2\nline3",
+ "-moz-street-address-one-line": "2 Harrison St line2 line3",
+ "address-line1": "2 Harrison St",
+ "address-line2": "line2 line3",
+ "address-line3": "line3",
+ "address-level1": "CA",
+ country: "US",
+ tel: "9876543210",
+ "tel-national": "9876543210",
+ },
+ ],
+ },
+ {
+ description: "Address form with maxlength restriction",
+ document: `<form>
+ <input autocomplete="given-name" maxlength="1">
+ <input autocomplete="additional-name" maxlength="1">
+ <input autocomplete="family-name" maxlength="1">
+ <input autocomplete="postal-code" maxlength="5">
+ </form>`,
+ profileData: [{ ...ADDRESS_RECORD_2 }],
+ expectedResult: [
+ {
+ guid: "address2",
+ "given-name": "J",
+ "additional-name": "M",
+ "family-name": "D",
+ "postal-code": "94001",
+ },
+ ],
+ },
+ {
+ description:
+ "Address form with the special cases of the maxlength restriction",
+ document: `<form>
+ <input autocomplete="given-name" maxlength="-1">
+ <input autocomplete="additional-name" maxlength="0">
+ <input autocomplete="family-name" maxlength="1">
+ </form>`,
+ profileData: [{ ...ADDRESS_RECORD_2 }],
+ expectedResult: [
+ {
+ guid: "address2",
+ "given-name": "John",
+ "family-name": "D",
+ "postal-code": "940012345",
+ },
+ ],
+ },
+ {
+ description:
+ "Credit card form with separate fields for expiration month and year",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <input autocomplete="cc-exp-month">
+ <input autocomplete="cc-exp-year">
+ </form`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [{ ...DEFAULT_EXPECTED_CREDITCARD_RECORD_SEPARATE_EXPIRY }],
+ },
+ {
+ description:
+ "Credit Card form with matching options of cc-exp-year and cc-exp-month",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <select autocomplete="cc-exp-month">
+ <option id="option-cc-exp-month-01" value="1">01</option>
+ <option id="option-cc-exp-month-02" value="2">02</option>
+ <option id="option-cc-exp-month-03" value="3">03</option>
+ <option id="option-cc-exp-month-04" value="4">04</option>
+ <option id="option-cc-exp-month-05" value="5">05</option>
+ <option id="option-cc-exp-month-06" value="6">06</option>
+ <option id="option-cc-exp-month-07" value="7">07</option>
+ <option id="option-cc-exp-month-08" value="8">08</option>
+ <option id="option-cc-exp-month-09" value="9">09</option>
+ <option id="option-cc-exp-month-10" value="10">10</option>
+ <option id="option-cc-exp-month-11" value="11">11</option>
+ <option id="option-cc-exp-month-12" value="12">12</option>
+ </select>
+ <select autocomplete="cc-exp-year">
+ <option id="option-cc-exp-year-17" value="2017">17</option>
+ <option id="option-cc-exp-year-18" value="2018">18</option>
+ <option id="option-cc-exp-year-19" value="2019">19</option>
+ <option id="option-cc-exp-year-20" value="2020">20</option>
+ <option id="option-cc-exp-year-21" value="2021">21</option>
+ <option id="option-cc-exp-year-22" value="2022">22</option>
+ <option id="option-cc-exp-year-23" value="2023">23</option>
+ <option id="option-cc-exp-year-24" value="2024">24</option>
+ <option id="option-cc-exp-year-25" value="2025">25</option>
+ <option id="option-cc-exp-year-26" value="2026">26</option>
+ <option id="option-cc-exp-year-27" value="2027">27</option>
+ <option id="option-cc-exp-year-28" value="2028">28</option>
+ </select>
+ </form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [DEFAULT_CREDITCARD_RECORD],
+ expectedOptionElements: [
+ {
+ "cc-exp-month": "option-cc-exp-month-01",
+ "cc-exp-year": "option-cc-exp-year-25",
+ },
+ ],
+ },
+ {
+ description: "Credit Card form with matching options which contain labels",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <select autocomplete="cc-exp-month">
+ <option value="" selected="selected">Month</option>
+ <option label="01 - January" id="option-cc-exp-month-01" value="object:17">dummy</option>
+ <option label="02 - February" id="option-cc-exp-month-02" value="object:18">dummy</option>
+ <option label="03 - March" id="option-cc-exp-month-03" value="object:19">dummy</option>
+ <option label="04 - April" id="option-cc-exp-month-04" value="object:20">dummy</option>
+ <option label="05 - May" id="option-cc-exp-month-05" value="object:21">dummy</option>
+ <option label="06 - June" id="option-cc-exp-month-06" value="object:22">dummy</option>
+ <option label="07 - July" id="option-cc-exp-month-07" value="object:23">dummy</option>
+ <option label="08 - August" id="option-cc-exp-month-08" value="object:24">dummy</option>
+ <option label="09 - September" id="option-cc-exp-month-09" value="object:25">dummy</option>
+ <option label="10 - October" id="option-cc-exp-month-10" value="object:26">dummy</option>
+ <option label="11 - November" id="option-cc-exp-month-11" value="object:27">dummy</option>
+ <option label="12 - December" id="option-cc-exp-month-12" value="object:28">dummy</option>
+ </select>
+ <select autocomplete="cc-exp-year">
+ <option value="" selected="selected">Year</option>
+ <option label="2017" id="option-cc-exp-year-17" value="object:29">dummy</option>
+ <option label="2018" id="option-cc-exp-year-18" value="object:30">dummy</option>
+ <option label="2019" id="option-cc-exp-year-19" value="object:31">dummy</option>
+ <option label="2020" id="option-cc-exp-year-20" value="object:32">dummy</option>
+ <option label="2021" id="option-cc-exp-year-21" value="object:33">dummy</option>
+ <option label="2022" id="option-cc-exp-year-22" value="object:34">dummy</option>
+ <option label="2023" id="option-cc-exp-year-23" value="object:35">dummy</option>
+ <option label="2024" id="option-cc-exp-year-24" value="object:36">dummy</option>
+ <option label="2025" id="option-cc-exp-year-25" value="object:37">dummy</option>
+ <option label="2026" id="option-cc-exp-year-26" value="object:38">dummy</option>
+ <option label="2027" id="option-cc-exp-year-27" value="object:39">dummy</option>
+ <option label="2028" id="option-cc-exp-year-28" value="object:40">dummy</option>
+ <option label="2029" id="option-cc-exp-year-29" value="object:41">dummy</option>
+ <option label="2030" id="option-cc-exp-year-30" value="object:42">dummy</option>
+ <option label="2031" id="option-cc-exp-year-31" value="object:43">dummy</option>
+ <option label="2032" id="option-cc-exp-year-32" value="object:44">dummy</option>
+ <option label="2033" id="option-cc-exp-year-33" value="object:45">dummy</option>
+ <option label="2034" id="option-cc-exp-year-34" value="object:46">dummy</option>
+ <option label="2035" id="option-cc-exp-year-35" value="object:47">dummy</option>
+ </select>
+ </form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [DEFAULT_CREDITCARD_RECORD],
+ expectedOptionElements: [
+ {
+ "cc-exp-month": "option-cc-exp-month-01",
+ "cc-exp-year": "option-cc-exp-year-25",
+ },
+ ],
+ },
+ {
+ description: "Compound cc-exp: {MON1}/{YEAR2}",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <select autocomplete="cc-exp">
+ <option value="3/17">3/17</option>
+ <option value="1/25" id="selected-cc-exp">1/25</option>
+ </select></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD],
+ expectedOptionElements: [{ "cc-exp": "selected-cc-exp" }],
+ },
+ {
+ description: "Compound cc-exp: {MON1}/{YEAR4}",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <select autocomplete="cc-exp">
+ <option value="3/2017">3/2017</option>
+ <option value="1/2025" id="selected-cc-exp">1/2025</option>
+ </select></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD],
+ expectedOptionElements: [{ "cc-exp": "selected-cc-exp" }],
+ },
+ {
+ description: "Compound cc-exp: {MON2}/{YEAR2}",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <select autocomplete="cc-exp">
+ <option value="03/17">03/17</option>
+ <option value="01/25" id="selected-cc-exp">01/25</option>
+ </select></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD],
+ expectedOptionElements: [{ "cc-exp": "selected-cc-exp" }],
+ },
+ {
+ description: "Compound cc-exp: {MON2}/{YEAR4}",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <select autocomplete="cc-exp">
+ <option value="03/2017">03/2017</option>
+ <option value="01/2025" id="selected-cc-exp">01/2025</option>
+ </select></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD],
+ expectedOptionElements: [{ "cc-exp": "selected-cc-exp" }],
+ },
+ {
+ description: "Compound cc-exp: {MON1}-{YEAR2}",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <select autocomplete="cc-exp">
+ <option value="3-17">3-17</option>
+ <option value="1-25" id="selected-cc-exp">1-25</option>
+ </select></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD],
+ expectedOptionElements: [{ "cc-exp": "selected-cc-exp" }],
+ },
+ {
+ description: "Compound cc-exp: {MON1}-{YEAR4}",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <select autocomplete="cc-exp">
+ <option value="3-2017">3-2017</option>
+ <option value="1-2025" id="selected-cc-exp">1-2025</option>
+ </select></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD],
+ expectedOptionElements: [{ "cc-exp": "selected-cc-exp" }],
+ },
+ {
+ description: "Compound cc-exp: {MON2}-{YEAR2}",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <select autocomplete="cc-exp">
+ <option value="03-17">03-17</option>
+ <option value="01-25" id="selected-cc-exp">01-25</option>
+ </select></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD],
+ expectedOptionElements: [{ "cc-exp": "selected-cc-exp" }],
+ },
+ {
+ description: "Compound cc-exp: {MON2}-{YEAR4}",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <select autocomplete="cc-exp">
+ <option value="03-2017">03-2017</option>
+ <option value="01-2025" id="selected-cc-exp">01-2025</option>
+ </select></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD],
+ expectedOptionElements: [{ "cc-exp": "selected-cc-exp" }],
+ },
+ {
+ description: "Compound cc-exp: {YEAR2}-{MON2}",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <select autocomplete="cc-exp">
+ <option value="17-03">17-03</option>
+ <option value="25-01" id="selected-cc-exp">25-01</option>
+ </select></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD],
+ expectedOptionElements: [{ "cc-exp": "selected-cc-exp" }],
+ },
+ {
+ description: "Compound cc-exp: {YEAR4}-{MON2}",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <select autocomplete="cc-exp">
+ <option value="2017-03">2017-03</option>
+ <option value="2025-01" id="selected-cc-exp">2025-01</option>
+ </select></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD],
+ expectedOptionElements: [{ "cc-exp": "selected-cc-exp" }],
+ },
+ {
+ description: "Compound cc-exp: {YEAR4}/{MON2}",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <select autocomplete="cc-exp">
+ <option value="2017/3">2017/3</option>
+ <option value="2025/1" id="selected-cc-exp">2025/1</option>
+ </select></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD],
+ expectedOptionElements: [{ "cc-exp": "selected-cc-exp" }],
+ },
+ {
+ description: "Compound cc-exp: {MON2}{YEAR2}",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <select autocomplete="cc-exp">
+ <option value="0317">0317</option>
+ <option value="0125" id="selected-cc-exp">0125</option>
+ </select></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD],
+ expectedOptionElements: [{ "cc-exp": "selected-cc-exp" }],
+ },
+ {
+ description: "Compound cc-exp: {YEAR2}{MON2}",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <select autocomplete="cc-exp">
+ <option value="1703">1703</option>
+ <option value="2501" id="selected-cc-exp">2501</option>
+ </select></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD],
+ expectedOptionElements: [{ "cc-exp": "selected-cc-exp" }],
+ },
+ {
+ description: "Fill a cc-exp without cc-exp-month value in the profile",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <select autocomplete="cc-exp">
+ <option value="03/17">03/17</option>
+ <option value="01/25">01/25</option>
+ </select></form>`,
+ profileData: [
+ {
+ guid: "123",
+ "cc-exp-year": 2025,
+ },
+ ],
+ expectedResult: [
+ {
+ guid: "123",
+ "cc-exp-year": 2025,
+ },
+ ],
+ expectedOptionElements: [],
+ },
+ {
+ description: "Fill a cc-exp without cc-exp-year value in the profile",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <select autocomplete="cc-exp">
+ <option value="03/17">03/17</option>
+ <option value="01/25">01/25</option>
+ </select></form>`,
+ profileData: [
+ {
+ guid: "123",
+ "cc-exp-month": 1,
+ },
+ ],
+ expectedResult: [
+ {
+ guid: "123",
+ "cc-exp-month": 1,
+ },
+ ],
+ expectedOptionElements: [],
+ },
+ {
+ description: "Fill a cc-exp* without cc-exp-month value in the profile",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <select autocomplete="cc-exp-month">
+ <option value="03">03</option>
+ <option value="01">01</option>
+ </select>
+ <select autocomplete="cc-exp-year">
+ <option value="17">2017</option>
+ <option value="25">2025</option>
+ </select>
+ </form>`,
+ profileData: [
+ {
+ guid: "123",
+ "cc-exp-year": 2025,
+ },
+ ],
+ expectedResult: [
+ {
+ guid: "123",
+ "cc-exp-year": 2025,
+ },
+ ],
+ expectedOptionElements: [],
+ },
+ {
+ description: "Fill a cc-exp* without cc-exp-year value in the profile",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <select autocomplete="cc-exp-month">
+ <option value="03">03</option>
+ <option value="01">01</option>
+ </select>
+ <select autocomplete="cc-exp-year">
+ <option value="17">2017</option>
+ <option value="25">2025</option>
+ </select>
+ </form>`,
+ profileData: [
+ {
+ guid: "123",
+ "cc-exp-month": 1,
+ },
+ ],
+ expectedResult: [
+ {
+ guid: "123",
+ "cc-exp-month": 1,
+ },
+ ],
+ expectedOptionElements: [],
+ },
+ {
+ description:
+ "Fill a cc-exp field using adjacent label (MM/YY) as expiry string placeholder",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <label>Expiry (MM/YY)</label>
+ <input autocomplete="cc-exp">
+ </form>
+ `,
+ profileData: [DEFAULT_CREDITCARD_RECORD],
+ expectedResult: [
+ { ...DEFAULT_EXPECTED_CREDITCARD_RECORD, "cc-exp": "01/25" },
+ ],
+ },
+ {
+ description:
+ "Fill a cc-exp field using adjacent label (MM - YY) as expiry string placeholder",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <label>Expiry (MM - YY)</label>
+ <input autocomplete="cc-exp">
+ </form>
+ `,
+ profileData: [DEFAULT_CREDITCARD_RECORD],
+ expectedResult: [
+ { ...DEFAULT_EXPECTED_CREDITCARD_RECORD, "cc-exp": "01-25" },
+ ],
+ },
+ {
+ description: "Fill a cc-exp field correctly while ignoring unrelated label",
+ document: `<form>
+ <label>Credit card number label</label>
+ <input autocomplete="cc-number">
+ <input autocomplete="cc-exp">
+ </form>
+ `,
+ profileData: [DEFAULT_CREDITCARD_RECORD],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD],
+ },
+ {
+ description: "Fill a cc-exp without placeholder on the cc-exp field",
+ document: `<form><input autocomplete="cc-number">
+ <input autocomplete="cc-exp"></form>`,
+ profileData: [DEFAULT_CREDITCARD_RECORD],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD],
+ },
+ {
+ description:
+ "Fill a cc-exp with whitespace placeholder on the cc-exp field",
+ document: `<form><input autocomplete="cc-number">
+ <input autocomplete="cc-exp" placeholder=" "></form>`,
+ profileData: [DEFAULT_CREDITCARD_RECORD],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [mm/yy].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="mm/yy" autocomplete="cc-exp"></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [
+ {
+ ...DEFAULT_CREDITCARD_RECORD,
+ "cc-exp": "01/25",
+ },
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [mm / yy].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="mm / yy" autocomplete="cc-exp"></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [
+ {
+ ...DEFAULT_CREDITCARD_RECORD,
+ "cc-exp": "01/25",
+ },
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [MM / YY].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="MM / YY" autocomplete="cc-exp"></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [
+ {
+ ...DEFAULT_CREDITCARD_RECORD,
+ "cc-exp": "01/25",
+ },
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [mm / yyyy].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="mm / yyyy" autocomplete="cc-exp"></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [
+ {
+ ...DEFAULT_CREDITCARD_RECORD,
+ "cc-exp": "01/2025",
+ },
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [mm - yyyy].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="mm - yyyy" autocomplete="cc-exp"></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [
+ {
+ ...DEFAULT_CREDITCARD_RECORD,
+ "cc-exp": "01-2025",
+ },
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [yyyy-mm].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="yyyy-mm" autocomplete="cc-exp"></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [
+ {
+ ...DEFAULT_CREDITCARD_RECORD,
+ "cc-exp": "2025-01",
+ },
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [mmm yyyy].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="mmm yyyy" autocomplete="cc-exp"></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [mm foo yyyy].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="mm foo yyyy" autocomplete="cc-exp"></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [mm - - yyyy].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="mm - - yyyy" autocomplete="cc-exp"></form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp-month field [mm].",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <input autocomplete="cc-exp-month" placeholder="MM">
+ <input autocomplete="cc-exp-year">
+ </form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [
+ {
+ ...DEFAULT_CREDITCARD_RECORD,
+ "cc-exp-month-formatted": getCCExpMonthFormatted(),
+ },
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp-year field [yy].",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <input autocomplete="cc-exp-month">
+ <input autocomplete="cc-exp-year" placeholder="YY">
+ </form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [
+ {
+ ...DEFAULT_EXPECTED_CREDITCARD_RECORD_SEPARATE_EXPIRY,
+ "cc-exp-year-formatted": getCCExpYearFormatted(),
+ },
+ ],
+ },
+ {
+ description: "Test maxlength=2 on numeric fields.",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <input autocomplete="cc-exp-month" maxlength="2">
+ <input autocomplete="cc-exp-year" maxlength="2">
+ </form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [
+ {
+ ...DEFAULT_EXPECTED_CREDITCARD_RECORD_SEPARATE_EXPIRY,
+ "cc-exp-year": 25,
+ },
+ ],
+ },
+ {
+ description: "Test maxlength=4 on numeric fields.",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <input autocomplete="cc-exp-month" maxlength="4">
+ <input autocomplete="cc-exp-year" maxlength="4">
+ </form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD_SEPARATE_EXPIRY],
+ },
+ // Bug 1687679: The default value of an expiration month, when filled in an input element,
+ // is a two character length string. Because of this, testing a maxlength of 1 is invalid.
+ {
+ description: "Test maxlength=1 on numeric fields.",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <input autocomplete="cc-exp-month" maxlength="1">
+ <input autocomplete="cc-exp-year" maxlength="1">
+ </form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [
+ {
+ ...DEFAULT_EXPECTED_CREDITCARD_RECORD_SEPARATE_EXPIRY,
+ "cc-exp-year": 5,
+ "cc-exp-month": 1,
+ },
+ ],
+ },
+ {
+ description: "Test maxlength=0 on numeric fields.",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <input autocomplete="cc-exp-month" maxlength="0">
+ <input autocomplete="cc-exp-year" maxlength="0">
+ </form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [
+ {
+ guid: DEFAULT_CREDITCARD_RECORD.guid,
+ "cc-exp": DEFAULT_CREDITCARD_RECORD["cc-exp"],
+ },
+ ],
+ },
+ {
+ // It appears that negative values do not get propagated.
+ description: "Test maxlength=-2 on numeric fields.",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <input autocomplete="cc-exp-month" maxlength="-2">
+ <input autocomplete="cc-exp-year" maxlength="-2">
+ </form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD_SEPARATE_EXPIRY],
+ },
+ {
+ description: "Test maxlength=10 on numeric fields.",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <input autocomplete="cc-exp-month" maxlength="10">
+ <input autocomplete="cc-exp-year" maxlength="10">
+ </form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [DEFAULT_EXPECTED_CREDITCARD_RECORD_SEPARATE_EXPIRY],
+ },
+ {
+ description: "Test (special case) maxlength=5 on cc-exp field.",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <input autocomplete="cc-exp" maxlength="5">
+ </form>`,
+ profileData: [{ ...DEFAULT_CREDITCARD_RECORD }],
+ expectedResult: [
+ {
+ ...DEFAULT_CREDITCARD_RECORD,
+ "cc-exp": "01/25",
+ },
+ ],
+ },
+];
+
+for (let testcase of TESTCASES) {
+ add_task(async function () {
+ info("Starting testcase: " + testcase.description);
+
+ let doc = MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ testcase.document
+ );
+ let form = doc.querySelector("form");
+ let formLike = FormLikeFactory.createFromForm(form);
+ let handler = new FormAutofillHandler(formLike);
+
+ handler.collectFormFields();
+ handler.focusedInput = form.elements[0];
+
+ let adaptedRecords = handler.activeSection.getAdaptedProfiles(
+ testcase.profileData
+ );
+ Assert.deepEqual(adaptedRecords, testcase.expectedResult);
+
+ if (testcase.expectedOptionElements) {
+ testcase.expectedOptionElements.forEach((expectedOptionElement, i) => {
+ for (let field in expectedOptionElement) {
+ let select = form.querySelector(`[autocomplete=${field}]`);
+ let expectedOption = doc.getElementById(expectedOptionElement[field]);
+ Assert.notEqual(expectedOption, null);
+
+ let value = testcase.profileData[i][field];
+ let cache =
+ handler.activeSection._cacheValue.matchingSelectOption.get(select);
+ let targetOption = cache[value] && cache[value].get();
+ Assert.notEqual(targetOption, null);
+
+ Assert.equal(targetOption, expectedOption);
+ }
+ });
+ }
+ });
+}
diff --git a/browser/extensions/formautofill/test/unit/test_getAdaptedProfiles_locales.js b/browser/extensions/formautofill/test/unit/test_getAdaptedProfiles_locales.js
new file mode 100644
index 0000000000..476559c43d
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_getAdaptedProfiles_locales.js
@@ -0,0 +1,272 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * Test to ensure locale specific placeholders for credit card fields are properly used
+ * to transform various values in the profile.
+ */
+
+"use strict";
+
+const DEFAULT_CREDITCARD_RECORD = {
+ guid: "123",
+ "cc-exp-month": 1,
+ "cc-exp-year": 2025,
+ "cc-exp": "2025-01",
+};
+
+const getCCExpYearFormatted = () => {
+ return DEFAULT_CREDITCARD_RECORD["cc-exp-year"].toString().substring(2);
+};
+
+const getCCExpMonthFormatted = () => {
+ return DEFAULT_CREDITCARD_RECORD["cc-exp-month"].toString().padStart(2, "0");
+};
+
+const DEFAULT_EXPECTED_CREDITCARD_RECORD_SEPARATE_EXPIRY_FIELDS = {
+ ...DEFAULT_CREDITCARD_RECORD,
+ "cc-exp-month-formatted": getCCExpMonthFormatted(),
+ "cc-exp-year-formatted": getCCExpYearFormatted(),
+};
+
+const FR_TESTCASES = [
+ {
+ description: "Use placeholder to adjust cc-exp format [mm/aa].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="mm/aa" autocomplete="cc-exp"></form>`,
+ profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)],
+ expectedResult: [
+ Object.assign({}, DEFAULT_CREDITCARD_RECORD, {
+ "cc-exp": "01/25",
+ }),
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [mm / aa].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="mm / aa" autocomplete="cc-exp"></form>`,
+ profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)],
+ expectedResult: [
+ Object.assign({}, DEFAULT_CREDITCARD_RECORD, {
+ "cc-exp": "01/25",
+ }),
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [MM / AA].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="MM / AA" autocomplete="cc-exp"></form>`,
+ profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)],
+ expectedResult: [
+ Object.assign({}, DEFAULT_CREDITCARD_RECORD, {
+ "cc-exp": "01/25",
+ }),
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [mm / aaaa].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="mm / aaaa" autocomplete="cc-exp"></form>`,
+ profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)],
+ expectedResult: [
+ Object.assign({}, DEFAULT_CREDITCARD_RECORD, {
+ "cc-exp": "01/2025",
+ }),
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [mm - aaaa].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="mm - aaaa" autocomplete="cc-exp"></form>`,
+ profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)],
+ expectedResult: [
+ Object.assign({}, DEFAULT_CREDITCARD_RECORD, {
+ "cc-exp": "01-2025",
+ }),
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [aaaa-mm].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="aaaa-mm" autocomplete="cc-exp"></form>`,
+ profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)],
+ expectedResult: [
+ Object.assign({}, DEFAULT_CREDITCARD_RECORD, {
+ "cc-exp": "2025-01",
+ }),
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp-year field [aa].",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <input autocomplete="cc-exp-month">
+ <input autocomplete="cc-exp-year" placeholder="AA">
+ </form>`,
+ profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)],
+ expectedResult: [
+ { ...DEFAULT_EXPECTED_CREDITCARD_RECORD_SEPARATE_EXPIRY_FIELDS },
+ ],
+ },
+];
+
+const DE_TESTCASES = [
+ {
+ description: "Use placeholder to adjust cc-exp format [mm / jj].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="mm / jj" autocomplete="cc-exp"></form>`,
+ profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)],
+ expectedResult: [
+ Object.assign({}, DEFAULT_CREDITCARD_RECORD, {
+ "cc-exp": "01/25",
+ }),
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [MM / JJ].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="MM / JJ" autocomplete="cc-exp"></form>`,
+ profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)],
+ expectedResult: [
+ Object.assign({}, DEFAULT_CREDITCARD_RECORD, {
+ "cc-exp": "01/25",
+ }),
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [mm / jjjj].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="mm / jjjj" autocomplete="cc-exp"></form>`,
+ profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)],
+ expectedResult: [
+ Object.assign({}, DEFAULT_CREDITCARD_RECORD, {
+ "cc-exp": "01/2025",
+ }),
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [MM / JJJJ].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="MM / JJJJ" autocomplete="cc-exp"></form>`,
+ profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)],
+ expectedResult: [
+ Object.assign({}, DEFAULT_CREDITCARD_RECORD, {
+ "cc-exp": "01/2025",
+ }),
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [mm - jj].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="mm - jj" autocomplete="cc-exp"></form>`,
+ profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)],
+ expectedResult: [
+ Object.assign({}, DEFAULT_CREDITCARD_RECORD, {
+ "cc-exp": "01-25",
+ }),
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [MM - JJ].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="MM - JJ" autocomplete="cc-exp"></form>`,
+ profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)],
+ expectedResult: [
+ Object.assign({}, DEFAULT_CREDITCARD_RECORD, {
+ "cc-exp": "01-25",
+ }),
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [mm - jjjj].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="mm - jjjj" autocomplete="cc-exp"></form>`,
+ profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)],
+ expectedResult: [
+ Object.assign({}, DEFAULT_CREDITCARD_RECORD, {
+ "cc-exp": "01-2025",
+ }),
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [MM - JJJJ].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="MM - JJJJ" autocomplete="cc-exp"></form>`,
+ profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)],
+ expectedResult: [
+ Object.assign({}, DEFAULT_CREDITCARD_RECORD, {
+ "cc-exp": "01-2025",
+ }),
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp format [jjjj - mm].",
+ document: `<form><input autocomplete="cc-number">
+ <input placeholder="jjjj - mm" autocomplete="cc-exp"></form>`,
+ profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)],
+ expectedResult: [
+ Object.assign({}, DEFAULT_CREDITCARD_RECORD, {
+ "cc-exp": "2025-01",
+ }),
+ ],
+ },
+ {
+ description: "Use placeholder to adjust cc-exp-year field [jj].",
+ document: `<form>
+ <input autocomplete="cc-number">
+ <input autocomplete="cc-exp-month">
+ <input autocomplete="cc-exp-year" placeholder="JJ">
+ </form>`,
+ profileData: [Object.assign({}, DEFAULT_CREDITCARD_RECORD)],
+ expectedResult: [
+ { ...DEFAULT_EXPECTED_CREDITCARD_RECORD_SEPARATE_EXPIRY_FIELDS },
+ ],
+ },
+];
+
+const TESTCASES = [FR_TESTCASES, DE_TESTCASES];
+
+for (let localeTests of TESTCASES) {
+ for (let testcase of localeTests) {
+ add_task(async function () {
+ info("Starting testcase: " + testcase.description);
+
+ let doc = MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ testcase.document
+ );
+ let form = doc.querySelector("form");
+ let formLike = FormLikeFactory.createFromForm(form);
+ let handler = new FormAutofillHandler(formLike);
+
+ handler.collectFormFields();
+ handler.focusedInput = form.elements[0];
+ let adaptedRecords = handler.activeSection.getAdaptedProfiles(
+ testcase.profileData
+ );
+ Assert.deepEqual(adaptedRecords, testcase.expectedResult);
+
+ if (testcase.expectedOptionElements) {
+ testcase.expectedOptionElements.forEach((expectedOptionElement, i) => {
+ for (let field in expectedOptionElement) {
+ let select = form.querySelector(`[autocomplete=${field}]`);
+ let expectedOption = doc.getElementById(
+ expectedOptionElement[field]
+ );
+ Assert.notEqual(expectedOption, null);
+
+ let value = testcase.profileData[i][field];
+ let cache =
+ handler.activeSection._cacheValue.matchingSelectOption.get(
+ select
+ );
+ let targetOption = cache[value] && cache[value].get();
+ Assert.notEqual(targetOption, null);
+
+ Assert.equal(targetOption, expectedOption);
+ }
+ });
+ }
+ });
+ }
+}
diff --git a/browser/extensions/formautofill/test/unit/test_getCategoriesFromFieldNames.js b/browser/extensions/formautofill/test/unit/test_getCategoriesFromFieldNames.js
new file mode 100644
index 0000000000..66f4c18ea9
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_getCategoriesFromFieldNames.js
@@ -0,0 +1,95 @@
+"use strict";
+
+var FormAutofillUtils;
+add_task(async function () {
+ ({ FormAutofillUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/FormAutofillUtils.sys.mjs"
+ ));
+});
+
+add_task(async function test_isAddressField_isCreditCardField() {
+ const TEST_CASES = {
+ "given-name": {
+ isAddressField: true,
+ isCreditCardField: false,
+ },
+ organization: {
+ isAddressField: true,
+ isCreditCardField: false,
+ },
+ "address-line2": {
+ isAddressField: true,
+ isCreditCardField: false,
+ },
+ tel: {
+ isAddressField: true,
+ isCreditCardField: false,
+ },
+ email: {
+ isAddressField: true,
+ isCreditCardField: false,
+ },
+ "cc-number": {
+ isAddressField: false,
+ isCreditCardField: true,
+ },
+ UNKNOWN: {
+ isAddressField: false,
+ isCreditCardField: false,
+ },
+ "": {
+ isAddressField: false,
+ isCreditCardField: false,
+ },
+ };
+
+ for (let fieldName of Object.keys(TEST_CASES)) {
+ info("Starting testcase: " + fieldName);
+ let field = TEST_CASES[fieldName];
+ Assert.equal(
+ FormAutofillUtils.isAddressField(fieldName),
+ field.isAddressField,
+ "isAddressField"
+ );
+ Assert.equal(
+ FormAutofillUtils.isCreditCardField(fieldName),
+ field.isCreditCardField,
+ "isCreditCardField"
+ );
+ }
+});
+
+add_task(async function test_getCategoriesFromFieldNames() {
+ const TEST_CASES = [
+ {
+ fieldNames: ["given-name", "family-name", "name", "tel", "organization"],
+ set: ["name", "tel", "organization"],
+ },
+ {
+ fieldNames: [
+ "address-line2",
+ "family-name",
+ "name",
+ "tel",
+ "organization",
+ "email",
+ ],
+ set: ["address", "name", "tel", "organization", "email"],
+ },
+ {
+ fieldNames: ["address-line2", "family-name", "", "name", "tel", "UNKOWN"],
+ set: ["address", "name", "tel"],
+ },
+ {
+ fieldNames: ["tel", "family-name", "", "name", "tel", "UNKOWN"],
+ set: ["tel", "name"],
+ },
+ ];
+
+ for (let tc of TEST_CASES) {
+ let categories = FormAutofillUtils.getCategoriesFromFieldNames(
+ tc.fieldNames
+ );
+ Assert.deepEqual(Array.from(categories), tc.set);
+ }
+});
diff --git a/browser/extensions/formautofill/test/unit/test_getCreditCardLogo.js b/browser/extensions/formautofill/test/unit/test_getCreditCardLogo.js
new file mode 100644
index 0000000000..e740d6102a
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_getCreditCardLogo.js
@@ -0,0 +1,25 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_getCreditCardLogo() {
+ const { CreditCard } = ChromeUtils.importESModule(
+ "resource://gre/modules/CreditCard.sys.mjs"
+ );
+ // Credit card logos can be either PNG or SVG
+ // so we construct an array that includes both of these file extensions
+ // and test to see if the logo from getCreditCardLogo matches.
+ for (let network of CreditCard.getSupportedNetworks()) {
+ const PATH_PREFIX = "chrome://formautofill/content/third-party/cc-logo-";
+ let actual = CreditCard.getCreditCardLogo(network);
+ Assert.ok(
+ [".png", ".svg"].map(x => PATH_PREFIX + network + x).includes(actual)
+ );
+ }
+ let genericLogo = CreditCard.getCreditCardLogo("null");
+ Assert.equal(
+ genericLogo,
+ "chrome://formautofill/content/icon-credit-card-generic.svg"
+ );
+});
diff --git a/browser/extensions/formautofill/test/unit/test_getFormInputDetails.js b/browser/extensions/formautofill/test/unit/test_getFormInputDetails.js
new file mode 100644
index 0000000000..0ca9def464
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_getFormInputDetails.js
@@ -0,0 +1,204 @@
+"use strict";
+
+var FormAutofillContent;
+add_task(async function () {
+ ({ FormAutofillContent } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillContent.sys.mjs"
+ ));
+});
+
+const TESTCASES = [
+ {
+ description: "Form containing 5 fields with autocomplete attribute.",
+ document: `<form id="form1">
+ <input id="street-addr" autocomplete="street-address">
+ <input id="city" autocomplete="address-level2">
+ <select id="country" autocomplete="country"></select>
+ <input id="email" autocomplete="email">
+ <input id="tel" autocomplete="tel">
+ </form>`,
+ targetInput: ["street-addr", "email"],
+ expectedResult: [
+ {
+ input: {
+ section: "",
+ addressType: "",
+ contactType: "",
+ fieldName: "street-address",
+ },
+ formId: "form1",
+ form: [
+ {
+ section: "",
+ addressType: "",
+ contactType: "",
+ fieldName: "street-address",
+ },
+ {
+ section: "",
+ addressType: "",
+ contactType: "",
+ fieldName: "address-level2",
+ },
+ {
+ section: "",
+ addressType: "",
+ contactType: "",
+ fieldName: "country",
+ },
+ { section: "", addressType: "", contactType: "", fieldName: "email" },
+ { section: "", addressType: "", contactType: "", fieldName: "tel" },
+ ],
+ },
+ {
+ input: {
+ section: "",
+ addressType: "",
+ contactType: "",
+ fieldName: "email",
+ },
+ formId: "form1",
+ form: [
+ {
+ section: "",
+ addressType: "",
+ contactType: "",
+ fieldName: "street-address",
+ },
+ {
+ section: "",
+ addressType: "",
+ contactType: "",
+ fieldName: "address-level2",
+ },
+ {
+ section: "",
+ addressType: "",
+ contactType: "",
+ fieldName: "country",
+ },
+ { section: "", addressType: "", contactType: "", fieldName: "email" },
+ { section: "", addressType: "", contactType: "", fieldName: "tel" },
+ ],
+ },
+ ],
+ },
+ {
+ description: "2 forms that are able to be auto filled",
+ document: `<form id="form2">
+ <input id="home-addr" autocomplete="street-address">
+ <input id="city" autocomplete="address-level2">
+ <select id="country" autocomplete="country"></select>
+ </form>
+ <form id="form3">
+ <input id="office-addr" autocomplete="street-address">
+ <input id="email" autocomplete="email">
+ <input id="tel" autocomplete="tel">
+ </form>`,
+ targetInput: ["home-addr", "office-addr"],
+ expectedResult: [
+ {
+ input: {
+ section: "",
+ addressType: "",
+ contactType: "",
+ fieldName: "street-address",
+ },
+ formId: "form2",
+ form: [
+ {
+ section: "",
+ addressType: "",
+ contactType: "",
+ fieldName: "street-address",
+ },
+ {
+ section: "",
+ addressType: "",
+ contactType: "",
+ fieldName: "address-level2",
+ },
+ {
+ section: "",
+ addressType: "",
+ contactType: "",
+ fieldName: "country",
+ },
+ ],
+ },
+ {
+ input: {
+ section: "",
+ addressType: "",
+ contactType: "",
+ fieldName: "street-address",
+ },
+ formId: "form3",
+ form: [
+ {
+ section: "",
+ addressType: "",
+ contactType: "",
+ fieldName: "street-address",
+ },
+ { section: "", addressType: "", contactType: "", fieldName: "email" },
+ { section: "", addressType: "", contactType: "", fieldName: "tel" },
+ ],
+ },
+ ],
+ },
+];
+
+function inputDetailAssertion(detail, expected) {
+ Assert.equal(detail.section, expected.section);
+ Assert.equal(detail.addressType, expected.addressType);
+ Assert.equal(detail.contactType, expected.contactType);
+ Assert.equal(detail.fieldName, expected.fieldName);
+ Assert.equal(detail.elementWeakRef.get(), expected.elementWeakRef.get());
+}
+
+TESTCASES.forEach(testcase => {
+ add_task(async function () {
+ info("Starting testcase: " + testcase.description);
+
+ let doc = MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ testcase.document
+ );
+
+ for (let i in testcase.targetInput) {
+ let input = doc.getElementById(testcase.targetInput[i]);
+ FormAutofillContent.identifyAutofillFields(input);
+ FormAutofillContent.updateActiveInput(input);
+
+ // Put the input element reference to `element` to make sure the result of
+ // `activeFieldDetail` contains the same input element.
+ testcase.expectedResult[i].input.elementWeakRef =
+ Cu.getWeakReference(input);
+
+ inputDetailAssertion(
+ FormAutofillContent.activeFieldDetail,
+ testcase.expectedResult[i].input
+ );
+
+ let formDetails = testcase.expectedResult[i].form;
+ for (let formDetail of formDetails) {
+ // Compose a query string to get the exact reference of <input>/<select>
+ // element, e.g. #form1 > *[autocomplete="street-address"]
+ let queryString =
+ "#" +
+ testcase.expectedResult[i].formId +
+ " > *[autocomplete=" +
+ formDetail.fieldName +
+ "]";
+ formDetail.elementWeakRef = Cu.getWeakReference(
+ doc.querySelector(queryString)
+ );
+ }
+
+ FormAutofillContent.activeFormDetails.forEach((detail, index) => {
+ inputDetailAssertion(detail, formDetails[index]);
+ });
+ }
+ });
+});
diff --git a/browser/extensions/formautofill/test/unit/test_getInfo.js b/browser/extensions/formautofill/test/unit/test_getInfo.js
new file mode 100644
index 0000000000..802fcd79e9
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_getInfo.js
@@ -0,0 +1,363 @@
+"use strict";
+
+var { FormAutofillHeuristics } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/FormAutofillHeuristics.sys.mjs"
+);
+var { LabelUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/LabelUtils.sys.mjs"
+);
+var { FormAutofill } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofill.sys.mjs"
+);
+
+const TESTCASES = [
+ {
+ description: "Input element in a label element",
+ document: `<form>
+ <label> E-Mail
+ <input id="targetElement" type="text">
+ </label>
+ </form>`,
+ elementId: "targetElement",
+ expectedReturnValue: ["email", null, null],
+ },
+ {
+ description:
+ "A label element is out of the form contains the related input",
+ document: `<label for="targetElement"> E-Mail</label>
+ <form>
+ <input id="targetElement" type="text">
+ </form>`,
+ elementId: "targetElement",
+ expectedReturnValue: ["email", null, null],
+ },
+ {
+ description: "A label element contains span element",
+ document: `<label for="targetElement">FOO<span>E-Mail</span>BAR</label>
+ <form>
+ <input id="targetElement" type="text">
+ </form>`,
+ elementId: "targetElement",
+ expectedReturnValue: ["email", null, null],
+ },
+ {
+ description: "The signature in 'name' attr of an input",
+ document: `<input id="targetElement" name="email" type="text">`,
+ elementId: "targetElement",
+ expectedReturnValue: ["email", null, null],
+ },
+ {
+ description: "The signature in 'id' attr of an input",
+ document: `<input id="targetElement_email" name="tel" type="text">`,
+ elementId: "targetElement_email",
+ expectedReturnValue: ["email", null, null],
+ },
+ {
+ description: "Select element in a label element",
+ document: `<form>
+ <label> State
+ <select id="targetElement"></select>
+ </label>
+ </form>`,
+ elementId: "targetElement",
+ expectedReturnValue: ["address-level1", null, null],
+ },
+ {
+ description: "A select element without a form wrapped",
+ document: `<label for="targetElement">State</label>
+ <select id="targetElement"></select>`,
+ elementId: "targetElement",
+ expectedReturnValue: ["address-level1", null, null],
+ },
+ {
+ description: "address line input",
+ document: `<label for="targetElement">street</label>
+ <input id="targetElement" type="text">`,
+ elementId: "targetElement",
+ expectedReturnValue: ["street-address", null, null],
+ },
+ {
+ description: "CJK character - Traditional Chinese",
+ document: `<label> 郵遞區號
+ <input id="targetElement" />
+ </label>`,
+ elementId: "targetElement",
+ expectedReturnValue: ["postal-code", null, null],
+ },
+ {
+ description: "CJK character - Japanese",
+ document: `<label> 郵便番号
+ <input id="targetElement" />
+ </label>`,
+ elementId: "targetElement",
+ expectedReturnValue: ["postal-code", null, null],
+ },
+ {
+ description: "CJK character - Korean",
+ document: `<label> 우편 번호
+ <input id="targetElement" />
+ </label>`,
+ elementId: "targetElement",
+ expectedReturnValue: ["postal-code", null, null],
+ },
+ {
+ description: "",
+ document: `<input id="targetElement" name="fullname">`,
+ elementId: "targetElement",
+ expectedReturnValue: ["name", null, null],
+ },
+ {
+ description: 'input element with "submit" type',
+ document: `<input id="targetElement" type="submit" />`,
+ elementId: "targetElement",
+ expectedReturnValue: [null, null, null],
+ },
+ {
+ description: "The signature in 'name' attr of an email input",
+ document: `<input id="targetElement" name="email" type="number">`,
+ elementId: "targetElement",
+ expectedReturnValue: ["email", null, null],
+ },
+ {
+ description: 'input element with "email" type',
+ document: `<input id="targetElement" type="email" />`,
+ elementId: "targetElement",
+ expectedReturnValue: ["email", null, null],
+ },
+ {
+ description: "Exclude United State string",
+ document: `<label>United State
+ <input id="targetElement" />
+ </label>`,
+ elementId: "targetElement",
+ expectedReturnValue: [null, null, null],
+ },
+ {
+ description: '"County" field with "United State" string',
+ document: `<label>United State County
+ <input id="targetElement" />
+ </label>`,
+ elementId: "targetElement",
+ expectedReturnValue: ["address-level1", null, null],
+ },
+ {
+ description: '"city" field with double "United State" string',
+ document: `<label>United State united sTATE city
+ <input id="targetElement" />
+ </label>`,
+ elementId: "targetElement",
+ expectedReturnValue: ["address-level2", null, null],
+ },
+ {
+ description: "Verify credit card number",
+ document: `<form>
+ <label for="targetElement"> Card Number</label>
+ <input id="targetElement" type="text">
+ </form>`,
+ elementId: "targetElement",
+ expectedReturnValue: ["cc-number", null, 1],
+ },
+ {
+ description: "Identify credit card type field",
+ document: `<form>
+ <label for="targetElement">Card Type</label>
+ <input id="targetElement" type="text">
+ </form>`,
+ elementId: "targetElement",
+ expectedReturnValue: ["cc-type", null, null],
+ },
+ {
+ description: `Identify address field when contained in a form with autocomplete="off"`,
+ document: `<form autocomplete="off">
+ <input id="given-name">
+ </form>`,
+ elementId: "given-name",
+ expectedReturnValue: ["given-name", null, null],
+ },
+ {
+ description: `Identify address field that has a placeholder but no label associated with it`,
+ document: `<form>
+ <input id="targetElement" placeholder="Name">
+ </form>`,
+ elementId: "targetElement",
+ expectedReturnValue: ["name", null, null],
+ },
+ {
+ description: `Identify address field that has a placeholder, no associated label, and its autocomplete attribute is "off"`,
+ document: `<form>
+ <input id="targetElement" placeholder="Address" autocomplete="off">
+ </form>`,
+ elementId: "targetElement",
+ expectedReturnValue: ["street-address", null, null],
+ },
+ {
+ description: `Identify address field that has a placeholder, no associated label, and the form's autocomplete attribute is "off"`,
+ document: `<form autocomplete="off">
+ <input id="targetElement" placeholder="Country">
+ </form>`,
+ elementId: "targetElement",
+ expectedReturnValue: ["country", null, null],
+ },
+];
+
+add_setup(async function () {
+ Services.prefs.setStringPref(
+ "extensions.formautofill.creditCards.heuristics.fathom.testConfidence",
+ "1"
+ );
+
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref(
+ "extensions.formautofill.creditCards.heuristics.fathom.testConfidence"
+ );
+ });
+});
+
+TESTCASES.forEach(testcase => {
+ add_task(async function () {
+ info("Starting testcase: " + testcase.description);
+
+ let doc = MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ testcase.document
+ );
+
+ let element = doc.getElementById(testcase.elementId);
+ let value = FormAutofillHeuristics.inferFieldInfo(element);
+
+ Assert.deepEqual(value, testcase.expectedReturnValue);
+ LabelUtils.clearLabelMap();
+ });
+});
+
+add_task(async function test_regexp_list() {
+ info("Verify the fieldName support for select element.");
+ let SUPPORT_LIST = {
+ email: null, // email
+ "tel-extension": null, // tel-extension
+ phone: null, // tel
+ organization: null, // organization
+ "street-address": null, // street-address
+ address1: null, // address-line1
+ address2: null, // address-line2
+ address3: null, // address-line3
+ city: "address-level2",
+ region: "address-level1",
+ "postal-code": null, // postal-code
+ country: "country",
+ fullname: null, // name
+ fname: null, // given-name
+ mname: null, // additional-name
+ lname: null, // family-name
+ cardholder: null, // cc-name
+ "cc-number": null, // cc-number
+ addmonth: "cc-exp-month",
+ addyear: "cc-exp-year",
+ };
+ for (let label of Object.keys(SUPPORT_LIST)) {
+ let testcase = {
+ description: `A select element supports ${label} or not`,
+ document: `<select id="${label}"></select>`,
+ elementId: label,
+ expectedReturnValue: SUPPORT_LIST[label]
+ ? [SUPPORT_LIST[label], null, null]
+ : [null, null, null],
+ };
+ info(testcase.description);
+ info(testcase.document);
+ let doc = MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ testcase.document
+ );
+
+ let element = doc.getElementById(testcase.elementId);
+ let value = FormAutofillHeuristics.inferFieldInfo(element);
+
+ Assert.deepEqual(value, testcase.expectedReturnValue, label);
+ }
+ LabelUtils.clearLabelMap();
+});
+
+add_task(async function test_autofill_creditCards_autocomplete_off_pref() {
+ let document = `<form autocomplete="off">
+ <label for="targetElement"> Card Number</label>
+ <input id="targetElement" type="text">
+ </form>`;
+ let expected = [null, null, null];
+ info(`Set pref so that credit card autofill respects autocomplete="off"`);
+ Services.prefs.setBoolPref(
+ FormAutofill.AUTOFILL_CREDITCARDS_AUTOCOMPLETE_OFF_PREF,
+ false
+ );
+ let doc = MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ document
+ );
+ let element = doc.getElementById("targetElement");
+ let value = FormAutofillHeuristics.inferFieldInfo(element);
+
+ Assert.deepEqual(value, expected);
+ document = `<form>
+ <label for="targetElement"> Card Number</label>
+ <input id="targetElement" type="text">
+ </form>`;
+ expected = ["cc-number", null, 1];
+ info(
+ `Set pref so that credit card autofill does not respect autocomplete="off"`
+ );
+ Services.prefs.setBoolPref(
+ FormAutofill.AUTOFILL_CREDITCARDS_AUTOCOMPLETE_OFF_PREF,
+ true
+ );
+ doc = MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ document
+ );
+ element = doc.getElementById("targetElement");
+ value = FormAutofillHeuristics.inferFieldInfo(element);
+
+ Assert.deepEqual(value, expected);
+ Services.prefs.clearUserPref(
+ FormAutofill.AUTOFILL_CREDITCARDS_AUTOCOMPLETE_OFF_PREF
+ );
+});
+
+add_task(async function test_autofill_addresses_autocomplete_off_pref() {
+ let document = `<form autocomplete="off">
+ <input id="given-name">
+ </form>`;
+ let expected = [null, null, null];
+ info(`Set pref so that address autofill respects autocomplete="off"`);
+ Services.prefs.setBoolPref(
+ FormAutofill.AUTOFILL_ADDRESSES_AUTOCOMPLETE_OFF_PREF,
+ false
+ );
+ let doc = MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ document
+ );
+ let element = doc.getElementById("given-name");
+ let value = FormAutofillHeuristics.inferFieldInfo(element);
+
+ Assert.deepEqual(value, expected);
+ document = `<form>
+ <input id="given-name">
+ </form>`;
+ expected = ["given-name", null, null];
+ info(`Set pref so that address autofill does not respect autocomplete="off"`);
+ Services.prefs.setBoolPref(
+ FormAutofill.AUTOFILL_ADDRESSES_AUTOCOMPLETE_OFF_PREF,
+ true
+ );
+ doc = MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ document
+ );
+ element = doc.getElementById("given-name");
+ value = FormAutofillHeuristics.inferFieldInfo(element);
+
+ Assert.deepEqual(value, expected);
+ Services.prefs.clearUserPref(
+ FormAutofill.AUTOFILL_ADDRESSES_AUTOCOMPLETE_OFF_PREF
+ );
+});
diff --git a/browser/extensions/formautofill/test/unit/test_getRecords.js b/browser/extensions/formautofill/test/unit/test_getRecords.js
new file mode 100644
index 0000000000..9a7e5e6ac7
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_getRecords.js
@@ -0,0 +1,258 @@
+/*
+ * Test for make sure getRecords can retrieve right collection from storage.
+ */
+
+"use strict";
+
+const { CreditCard } = ChromeUtils.importESModule(
+ "resource://gre/modules/CreditCard.sys.mjs"
+);
+
+let FormAutofillParent, FormAutofillStatus;
+let OSKeyStore;
+add_setup(async () => {
+ ({ FormAutofillParent, FormAutofillStatus } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillParent.sys.mjs"
+ ));
+ ({ OSKeyStore } = ChromeUtils.importESModule(
+ "resource://gre/modules/OSKeyStore.sys.mjs"
+ ));
+});
+
+const TEST_ADDRESS_1 = {
+ "given-name": "Timothy",
+ "additional-name": "John",
+ "family-name": "Berners-Lee",
+ organization: "World Wide Web Consortium",
+ "street-address": "32 Vassar Street\nMIT Room 32-G524",
+ "address-level2": "Cambridge",
+ "address-level1": "MA",
+ "postal-code": "02139",
+ country: "US",
+ tel: "+16172535702",
+ email: "timbl@w3.org",
+};
+
+const TEST_ADDRESS_2 = {
+ "street-address": "Some Address",
+ country: "US",
+};
+
+let TEST_CREDIT_CARD_1 = {
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 4,
+ "cc-exp-year": 2017,
+ "cc-type": "visa",
+};
+
+let TEST_CREDIT_CARD_2 = {
+ "cc-name": "John Dai",
+ "cc-number": "4929001587121045",
+ "cc-exp-month": 2,
+ "cc-exp-year": 2017,
+ "cc-type": "visa",
+};
+
+add_task(async function test_getRecords() {
+ FormAutofillStatus.init();
+
+ await FormAutofillStatus.formAutofillStorage.initialize();
+ let fakeResult = {
+ addresses: [
+ {
+ "given-name": "Timothy",
+ "additional-name": "John",
+ "family-name": "Berners-Lee",
+ organization: "World Wide Web Consortium",
+ },
+ ],
+ creditCards: [
+ {
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 4,
+ "cc-exp-year": 2017,
+ },
+ ],
+ };
+
+ for (let collectionName of ["addresses", "creditCards", "nonExisting"]) {
+ let collection = FormAutofillStatus.formAutofillStorage[collectionName];
+ let expectedResult = fakeResult[collectionName] || [];
+
+ if (collection) {
+ sinon.stub(collection, "getAll");
+ collection.getAll.returns(Promise.resolve(expectedResult));
+ }
+ await FormAutofillParent._getRecords({ collectionName });
+ if (collection) {
+ Assert.equal(collection.getAll.called, true);
+ collection.getAll.restore();
+ }
+ }
+});
+
+add_task(async function test_getRecords_addresses() {
+ await FormAutofillStatus.formAutofillStorage.initialize();
+ let mockAddresses = [TEST_ADDRESS_1, TEST_ADDRESS_2];
+ let collection = FormAutofillStatus.formAutofillStorage.addresses;
+ sinon.stub(collection, "getAll");
+ collection.getAll.returns(Promise.resolve(mockAddresses));
+
+ let testCases = [
+ {
+ description: "If the search string could match 1 address",
+ filter: {
+ collectionName: "addresses",
+ info: { fieldName: "street-address" },
+ searchString: "Some",
+ },
+ expectedResult: [TEST_ADDRESS_2],
+ },
+ {
+ description: "If the search string could match multiple addresses",
+ filter: {
+ collectionName: "addresses",
+ info: { fieldName: "country" },
+ searchString: "u",
+ },
+ expectedResult: [TEST_ADDRESS_1, TEST_ADDRESS_2],
+ },
+ {
+ description: "If the search string could not match any address",
+ filter: {
+ collectionName: "addresses",
+ info: { fieldName: "street-address" },
+ searchString: "test",
+ },
+ expectedResult: [],
+ },
+ {
+ description: "If the search string is empty",
+ filter: {
+ collectionName: "addresses",
+ info: { fieldName: "street-address" },
+ searchString: "",
+ },
+ expectedResult: [TEST_ADDRESS_1, TEST_ADDRESS_2],
+ },
+ {
+ description:
+ "Check if the filtering logic is free from searching special chars",
+ filter: {
+ collectionName: "addresses",
+ info: { fieldName: "street-address" },
+ searchString: ".*",
+ },
+ expectedResult: [],
+ },
+ {
+ description:
+ "Prevent broken while searching the property that does not exist",
+ filter: {
+ collectionName: "addresses",
+ info: { fieldName: "tel" },
+ searchString: "1",
+ },
+ expectedResult: [],
+ },
+ ];
+
+ for (let testCase of testCases) {
+ info("Starting testcase: " + testCase.description);
+ let result = await FormAutofillParent._getRecords(testCase.filter);
+ Assert.deepEqual(result, testCase.expectedResult);
+ }
+});
+
+add_task(async function test_getRecords_creditCards() {
+ await FormAutofillStatus.formAutofillStorage.initialize();
+ let collection = FormAutofillStatus.formAutofillStorage.creditCards;
+ let encryptedCCRecords = await Promise.all(
+ [TEST_CREDIT_CARD_1, TEST_CREDIT_CARD_2].map(async record => {
+ let clonedRecord = Object.assign({}, record);
+ clonedRecord["cc-number"] = CreditCard.getLongMaskedNumber(
+ record["cc-number"]
+ );
+ clonedRecord["cc-number-encrypted"] = await OSKeyStore.encrypt(
+ record["cc-number"]
+ );
+ return clonedRecord;
+ })
+ );
+ sinon
+ .stub(collection, "getAll")
+ .callsFake(() =>
+ Promise.resolve([
+ Object.assign({}, encryptedCCRecords[0]),
+ Object.assign({}, encryptedCCRecords[1]),
+ ])
+ );
+
+ let testCases = [
+ {
+ description: "If the search string could match multiple creditCards",
+ filter: {
+ collectionName: "creditCards",
+ info: { fieldName: "cc-name" },
+ searchString: "John",
+ },
+ expectedResult: encryptedCCRecords,
+ },
+ {
+ description: "If the search string could not match any creditCard",
+ filter: {
+ collectionName: "creditCards",
+ info: { fieldName: "cc-name" },
+ searchString: "T",
+ },
+ expectedResult: [],
+ },
+ {
+ description:
+ "Return all creditCards if focused field is cc number; " +
+ "if the search string could match multiple creditCards",
+ filter: {
+ collectionName: "creditCards",
+ info: { fieldName: "cc-number" },
+ searchString: "4",
+ },
+ expectedResult: encryptedCCRecords,
+ },
+ {
+ description: "If the search string could match 1 creditCard",
+ filter: {
+ collectionName: "creditCards",
+ info: { fieldName: "cc-name" },
+ searchString: "John Doe",
+ },
+ mpEnabled: true,
+ expectedResult: encryptedCCRecords.slice(0, 1),
+ },
+ {
+ description: "Return all creditCards if focused field is cc number",
+ filter: {
+ collectionName: "creditCards",
+ info: { fieldName: "cc-number" },
+ searchString: "411",
+ },
+ mpEnabled: true,
+ expectedResult: encryptedCCRecords,
+ },
+ ];
+
+ for (let testCase of testCases) {
+ info("Starting testcase: " + testCase.description);
+ if (testCase.mpEnabled) {
+ let tokendb = Cc["@mozilla.org/security/pk11tokendb;1"].createInstance(
+ Ci.nsIPK11TokenDB
+ );
+ let token = tokendb.getInternalKeyToken();
+ token.reset();
+ token.initPassword("password");
+ }
+ let result = await FormAutofillParent._getRecords(testCase.filter);
+ Assert.deepEqual(result, testCase.expectedResult);
+ }
+});
diff --git a/browser/extensions/formautofill/test/unit/test_isAddressAutofillAvailable.js b/browser/extensions/formautofill/test/unit/test_isAddressAutofillAvailable.js
new file mode 100644
index 0000000000..2d2940c33e
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_isAddressAutofillAvailable.js
@@ -0,0 +1,74 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Test enabling address autofill in specific locales and regions.
+ */
+
+"use strict";
+
+const { FormAutofill } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofill.sys.mjs"
+);
+
+add_task(async function test_defaultTestEnvironment() {
+ Assert.equal(
+ Services.prefs.getCharPref("extensions.formautofill.addresses.supported"),
+ "on"
+ );
+});
+
+add_task(async function test_default_supported_module_and_autofill_region() {
+ Services.prefs.setCharPref("browser.search.region", "US");
+ registerCleanupFunction(function cleanupRegion() {
+ Services.prefs.clearUserPref("browser.search.region");
+ });
+
+ let addon = await AddonManager.getAddonByID(EXTENSION_ID);
+ await addon.reload();
+
+ Assert.equal(FormAutofill.isAutofillAddressesAvailable, true);
+ Assert.equal(FormAutofill.isAutofillAddressesEnabled, true);
+});
+
+add_task(
+ async function test_supported_creditCard_region_unsupported_address_region() {
+ Services.prefs.setCharPref(
+ "extensions.formautofill.addresses.supported",
+ "detect"
+ );
+ Services.prefs.setCharPref(
+ "extensions.formautofill.creditCards.supported",
+ "detect"
+ );
+ Services.prefs.setCharPref("browser.search.region", "FR");
+ Services.prefs.setCharPref(
+ "extensions.formautofill.addresses.supportedCountries",
+ "US,CA"
+ );
+ Services.prefs.setCharPref(
+ "extensions.formautofill.creditCards.supportedCountries",
+ "US,CA,FR"
+ );
+ registerCleanupFunction(function cleanupPrefs() {
+ Services.prefs.clearUserPref("browser.search.region");
+ Services.prefs.clearUserPref(
+ "extensions.formautofill.addresses.supportedCountries"
+ );
+ Services.prefs.clearUserPref(
+ "extensions.formautofill.addresses.supported"
+ );
+ Services.prefs.clearUserPref(
+ "extensions.formautofill.creditCards.supported"
+ );
+ });
+
+ let addon = await AddonManager.getAddonByID(EXTENSION_ID);
+ await addon.reload();
+ Assert.ok(
+ Services.prefs.getBoolPref("extensions.formautofill.creditCards.enabled")
+ );
+ Assert.equal(FormAutofill.isAutofillAddressesAvailable, false);
+ Assert.equal(FormAutofill.isAutofillAddressesEnabled, false);
+ }
+);
diff --git a/browser/extensions/formautofill/test/unit/test_isCJKName.js b/browser/extensions/formautofill/test/unit/test_isCJKName.js
new file mode 100644
index 0000000000..f0e50b60f9
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_isCJKName.js
@@ -0,0 +1,80 @@
+/**
+ * Tests the "isCJKName" function of FormAutofillNameUtils object.
+ */
+
+"use strict";
+
+var FormAutofillNameUtils;
+add_setup(async () => {
+ ({ FormAutofillNameUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/FormAutofillNameUtils.sys.mjs"
+ ));
+});
+
+// Test cases is initially copied from
+// https://cs.chromium.org/chromium/src/components/autofill/core/browser/autofill_data_util_unittest.cc
+const TESTCASES = [
+ {
+ // Non-CJK language with only ASCII characters.
+ fullName: "Homer Jay Simpson",
+ expectedResult: false,
+ },
+ {
+ // Non-CJK language with some ASCII characters.
+ fullName: "Éloïse Paré",
+ expectedResult: false,
+ },
+ {
+ // Non-CJK language with no ASCII characters.
+ fullName: "Σωκράτης",
+ expectedResult: false,
+ },
+ {
+ // (Simplified) Chinese name, Unihan.
+ fullName: "刘翔",
+ expectedResult: true,
+ },
+ {
+ // (Simplified) Chinese name, Unihan, with an ASCII space.
+ fullName: "成 龙",
+ expectedResult: true,
+ },
+ {
+ // Korean name, Hangul.
+ fullName: "송지효",
+ expectedResult: true,
+ },
+ {
+ // Korean name, Hangul, with an 'IDEOGRAPHIC SPACE' (U+3000).
+ fullName: "김 종국",
+ expectedResult: true,
+ },
+ {
+ // Japanese name, Unihan.
+ fullName: "山田貴洋",
+ expectedResult: true,
+ },
+ {
+ // Japanese name, Katakana, with a 'KATAKANA MIDDLE DOT' (U+30FB).
+ fullName: "ビル・ゲイツ",
+ expectedResult: true,
+ },
+ {
+ // Japanese name, Katakana, with a 'MIDDLE DOT' (U+00B7) (likely a typo).
+ fullName: "ビル·ゲイツ",
+ expectedResult: true,
+ },
+ {
+ // CJK names don't have a middle name, so a 3-part name is bogus to us.
+ fullName: "반 기 문",
+ expectedResult: false,
+ },
+];
+
+add_task(async function test_isCJKName() {
+ TESTCASES.forEach(testcase => {
+ info("Starting testcase: " + testcase.fullName);
+ let result = FormAutofillNameUtils._isCJKName(testcase.fullName);
+ Assert.equal(result, testcase.expectedResult);
+ });
+});
diff --git a/browser/extensions/formautofill/test/unit/test_isCreditCardAutofillAvailable.js b/browser/extensions/formautofill/test/unit/test_isCreditCardAutofillAvailable.js
new file mode 100644
index 0000000000..5be5101ee6
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_isCreditCardAutofillAvailable.js
@@ -0,0 +1,84 @@
+/**
+ * Test enabling the feature in specific locales and regions.
+ */
+
+"use strict";
+
+const { FormAutofill } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofill.sys.mjs"
+);
+
+add_task(async function test_defaultTestEnvironment() {
+ Assert.ok(Services.prefs.getBoolPref("dom.forms.autocomplete.formautofill"));
+});
+
+add_task(async function test_detect_unsupportedRegion() {
+ Services.prefs.setCharPref(
+ "extensions.formautofill.creditCards.supported",
+ "detect"
+ );
+ Services.prefs.setCharPref(
+ "extensions.formautofill.creditCards.supportedCountries",
+ "US,CA"
+ );
+ Services.prefs.setCharPref("browser.search.region", "ZZ");
+ registerCleanupFunction(function cleanupRegion() {
+ Services.prefs.clearUserPref("browser.search.region");
+ Services.prefs.clearUserPref(
+ "extensions.formautofill.creditCards.supported"
+ );
+ Services.prefs.clearUserPref("extensions.formautofill.addresses.supported");
+ Services.prefs.clearUserPref(
+ "extensions.formautofill.creditCards.supportedCountries"
+ );
+ });
+
+ let addon = await AddonManager.getAddonByID(EXTENSION_ID);
+ await addon.reload();
+
+ Assert.equal(
+ FormAutofill.isAutofillCreditCardsAvailable,
+ false,
+ "Credit card autofill should not be available"
+ );
+ Assert.equal(
+ FormAutofill.isAutofillCreditCardsEnabled,
+ false,
+ "Credit card autofill should not be enabled"
+ );
+});
+
+add_task(async function test_detect_supportedRegion() {
+ Services.prefs.setCharPref(
+ "extensions.formautofill.creditCards.supported",
+ "detect"
+ );
+ Services.prefs.setCharPref(
+ "extensions.formautofill.creditCards.supportedCountries",
+ "US,CA"
+ );
+ Services.prefs.setCharPref("browser.search.region", "US");
+ registerCleanupFunction(function cleanupRegion() {
+ Services.prefs.clearUserPref("browser.search.region");
+ Services.prefs.clearUserPref(
+ "extensions.formautofill.creditCards.supported"
+ );
+ Services.prefs.clearUserPref(
+ "extensions.formautofill.creditCards.supportedCountries"
+ );
+ });
+
+ let addon = await AddonManager.getAddonByID(EXTENSION_ID);
+ await addon.reload();
+
+ Assert.equal(
+ FormAutofill.isAutofillCreditCardsAvailable,
+ true,
+ "Credit card autofill should be available"
+ );
+ Assert.equal(
+ FormAutofill.isAutofillCreditCardsEnabled,
+ true,
+ "Credit card autofill should be enabled"
+ );
+});
diff --git a/browser/extensions/formautofill/test/unit/test_isCreditCardOrAddressFieldType.js b/browser/extensions/formautofill/test/unit/test_isCreditCardOrAddressFieldType.js
new file mode 100644
index 0000000000..872e9cfcda
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_isCreditCardOrAddressFieldType.js
@@ -0,0 +1,103 @@
+"use strict";
+
+var FormAutofillUtils;
+add_setup(async () => {
+ ({ FormAutofillUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/FormAutofillUtils.sys.mjs"
+ ));
+});
+
+const TESTCASES = [
+ {
+ document: `<input id="targetElement" type="text">`,
+ fieldId: "targetElement",
+ expectedResult: true,
+ },
+ {
+ document: `<input id="targetElement" type="email">`,
+ fieldId: "targetElement",
+ expectedResult: true,
+ },
+ {
+ document: `<input id="targetElement" type="number">`,
+ fieldId: "targetElement",
+ expectedResult: true,
+ },
+ {
+ document: `<input id="targetElement" type="tel">`,
+ fieldId: "targetElement",
+ expectedResult: true,
+ },
+ {
+ document: `<input id="targetElement" type="radio">`,
+ fieldId: "targetElement",
+ expectedResult: false,
+ },
+ {
+ document: `<input id="targetElement" type="text" autocomplete="off">`,
+ fieldId: "targetElement",
+ expectedResult: true,
+ },
+ {
+ document: `<input id="targetElement">`,
+ fieldId: "targetElement",
+ expectedResult: true,
+ },
+ {
+ document: `<input id="targetElement" type="unknown">`,
+ fieldId: "targetElement",
+ expectedResult: true,
+ },
+ {
+ document: `<input id="targetElement" value="JOHN DOE">`,
+ fieldId: "targetElement",
+ expectedResult: true,
+ },
+ {
+ document: `<select id="targetElement" autocomplete="off"></select>`,
+ fieldId: "targetElement",
+ expectedResult: true,
+ },
+ {
+ document: `<select id="targetElement"></select>`,
+ fieldId: "targetElement",
+ expectedResult: true,
+ },
+ {
+ document: `<select id="targetElement" multiple></select>`,
+ fieldId: "targetElement",
+ expectedResult: true,
+ },
+ {
+ document: `<div id="targetElement"></div>`,
+ fieldId: "targetElement",
+ expectedResult: false,
+ },
+ {
+ document: `<input id="targetElement" type="text" readonly>`,
+ fieldId: "targetElement",
+ expectedResult: true,
+ },
+ {
+ document: `<input id="targetElement" type="text" disabled>`,
+ fieldId: "targetElement",
+ expectedResult: true,
+ },
+];
+
+TESTCASES.forEach(testcase => {
+ add_task(async function () {
+ info("Starting testcase: " + testcase.document);
+
+ let doc = MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ testcase.document
+ );
+
+ let field = doc.getElementById(testcase.fieldId);
+ Assert.equal(
+ FormAutofillUtils.isCreditCardOrAddressFieldType(field),
+ testcase.expectedResult
+ );
+ });
+});
diff --git a/browser/extensions/formautofill/test/unit/test_known_strings.js b/browser/extensions/formautofill/test/unit/test_known_strings.js
new file mode 100644
index 0000000000..b3e69dc776
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_known_strings.js
@@ -0,0 +1,148 @@
+"use strict";
+/* global FormAutofillHeuristics: true */
+
+const KNOWN_NAMES = {
+ "cc-name": ["cc-name", "card-name", "cardholder-name", "cardholder"],
+ "cc-number": [
+ "cc-number",
+ "cc-num",
+ "card-number",
+ "card-num",
+ "number",
+ "cc",
+ "cc-no",
+ "card-no",
+ "credit-card",
+ "numero-carte",
+ "carte",
+ "carte-credit",
+ "num-carte",
+ "cb-num",
+ ],
+ "cc-exp": [
+ "cc-exp",
+ "card-exp",
+ "cc-expiration",
+ "card-expiration",
+ "cc-ex",
+ "card-ex",
+ "card-expire",
+ "card-expiry",
+ "validite",
+ "expiration",
+ "expiry",
+ "mm-yy",
+ "mm-yyyy",
+ "yy-mm",
+ "yyyy-mm",
+ "expiration-date",
+ "payment-card-expiration",
+ "payment-cc-date",
+ ],
+ "cc-exp-month": [
+ "exp-month",
+ "cc-exp-month",
+ "cc-month",
+ "card-month",
+ "cc-mo",
+ "card-mo",
+ "exp-mo",
+ "card-exp-mo",
+ "cc-exp-mo",
+ "card-expiration-month",
+ "expiration-month",
+ "cc-mm",
+ "cc-m",
+ "card-mm",
+ "card-m",
+ "card-exp-mm",
+ "cc-exp-mm",
+ "exp-mm",
+ "exp-m",
+ "expire-month",
+ "expire-mo",
+ "expiry-month",
+ "expiry-mo",
+ "card-expire-month",
+ "card-expire-mo",
+ "card-expiry-month",
+ "card-expiry-mo",
+ "mois-validite",
+ "mois-expiration",
+ "m-validite",
+ "m-expiration",
+ "expiry-date-field-month",
+ "expiration-date-month",
+ "expiration-date-mm",
+ "exp-mon",
+ "validity-mo",
+ "exp-date-mo",
+ "cb-date-mois",
+ "date-m",
+ ],
+ "cc-exp-year": [
+ "exp-year",
+ "cc-exp-year",
+ "cc-year",
+ "card-year",
+ "cc-yr",
+ "card-yr",
+ "exp-yr",
+ "card-exp-yr",
+ "cc-exp-yr",
+ "card-expiration-year",
+ "expiration-year",
+ "cc-yy",
+ "cc-y",
+ "card-yy",
+ "card-y",
+ "card-exp-yy",
+ "cc-exp-yy",
+ "exp-yy",
+ "exp-y",
+ "cc-yyyy",
+ "card-yyyy",
+ "card-exp-yyyy",
+ "cc-exp-yyyy",
+ "expire-year",
+ "expire-yr",
+ "expiry-year",
+ "expiry-yr",
+ "card-expire-year",
+ "card-expire-yr",
+ "card-expiry-year",
+ "card-expiry-yr",
+ "an-validite",
+ "an-expiration",
+ "annee-validite",
+ "annee-expiration",
+ "expiry-date-field-year",
+ "expiration-date-year",
+ "cb-date-ann",
+ "expiration-date-yy",
+ "expiration-date-yyyy",
+ "validity-year",
+ "exp-date-year",
+ "date-y",
+ ],
+};
+
+add_setup(async () => {
+ ({ FormAutofillHeuristics } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/FormAutofillHeuristics.sys.mjs"
+ ));
+});
+
+for (let field in KNOWN_NAMES) {
+ KNOWN_NAMES[field].forEach(name => {
+ add_task(async () => {
+ ok(
+ FormAutofillHeuristics.testRegex(
+ FormAutofillHeuristics.RULES[field],
+ name
+ ),
+ `RegExp for ${field} matches string '${name}'`
+ );
+ });
+ });
+}
diff --git a/browser/extensions/formautofill/test/unit/test_markAsAutofillField.js b/browser/extensions/formautofill/test/unit/test_markAsAutofillField.js
new file mode 100644
index 0000000000..72960fcaf2
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_markAsAutofillField.js
@@ -0,0 +1,201 @@
+"use strict";
+
+const TESTCASES = [
+ {
+ description: "Form containing 8 fields with autocomplete attribute.",
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="additional-name" autocomplete="additional-name">
+ <input id="family-name" autocomplete="family-name">
+ <input id="street-addr" autocomplete="street-address">
+ <input id="city" autocomplete="address-level2">
+ <input id="country" autocomplete="country">
+ <input id="email" autocomplete="email">
+ <input id="tel" autocomplete="tel">
+ <input id="without-autocomplete-1">
+ <input id="without-autocomplete-2">
+ </form>`,
+ targetElementId: "given-name",
+ expectedResult: [
+ "given-name",
+ "additional-name",
+ "family-name",
+ "street-addr",
+ "city",
+ "country",
+ "email",
+ "tel",
+ ],
+ },
+ {
+ description: "Form containing only 2 fields with autocomplete attribute.",
+ document: `<form>
+ <input id="street-addr" autocomplete="street-address">
+ <input id="city" autocomplete="address-level2">
+ <input id="without-autocomplete-1">
+ <input id="without-autocomplete-2">
+ </form>`,
+ targetElementId: "street-addr",
+ expectedResult: [],
+ },
+ {
+ description: "Fields without form element.",
+ document: `<input id="street-addr" autocomplete="street-address">
+ <input id="city" autocomplete="address-level2">
+ <input id="country" autocomplete="country">
+ <input id="email" autocomplete="email">
+ <input id="tel" autocomplete="tel">
+ <input id="without-autocomplete-1">
+ <input id="without-autocomplete-2">`,
+ targetElementId: "street-addr",
+ expectedResult: ["street-addr", "city", "country", "email", "tel"],
+ },
+ {
+ description: "Form containing credit card autocomplete attributes.",
+ document: `<form>
+ <input id="cc-number" autocomplete="cc-number">
+ <input id="cc-name" autocomplete="cc-name">
+ <input id="cc-exp-month" autocomplete="cc-exp-month">
+ <input id="cc-exp-year" autocomplete="cc-exp-year">
+ </form>`,
+ targetElementId: "cc-number",
+ expectedResult: ["cc-number", "cc-name", "cc-exp-month", "cc-exp-year"],
+ },
+ {
+ description:
+ "Form containing multiple cc-number fields without autocomplete attributes.",
+ document: `<form>
+ <input id="cc-number1" maxlength="4">
+ <input id="cc-number2" maxlength="4">
+ <input id="cc-number3" maxlength="4">
+ <input id="cc-number4" maxlength="4">
+ <input id="cc-name">
+ <input id="cc-exp-month">
+ <input id="cc-exp-year">
+ </form>`,
+ targetElementId: "cc-number1",
+ expectedResult: [
+ "cc-number1",
+ "cc-number2",
+ "cc-number3",
+ "cc-number4",
+ "cc-name",
+ "cc-exp-month",
+ "cc-exp-year",
+ ],
+ },
+ {
+ description:
+ "Invalid form containing three consecutive cc-number fields without autocomplete attributes.",
+ document: `<form>
+ <input id="cc-number1" maxlength="4">
+ <input id="cc-number2" maxlength="4">
+ <input id="cc-number3" maxlength="4">
+ </form>`,
+ targetElementId: "cc-number1",
+ expectedResult: [],
+ prefs: [
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.testConfidence",
+ "1.0",
+ ],
+ ],
+ },
+ {
+ description:
+ "Invalid form containing five consecutive cc-number fields without autocomplete attributes.",
+ document: `<form>
+ <input id="cc-number1" maxlength="4">
+ <input id="cc-number2" maxlength="4">
+ <input id="cc-number3" maxlength="4">
+ <input id="cc-number4" maxlength="4">
+ <input id="cc-number5" maxlength="4">
+ </form>`,
+ targetElementId: "cc-number1",
+ expectedResult: [],
+ prefs: [
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.testConfidence",
+ "1.0",
+ ],
+ ],
+ },
+ {
+ description:
+ "Valid form containing three consecutive cc-number fields without autocomplete attributes.",
+ document: `<form>
+ <input id="cc-number1" maxlength="4">
+ <input id="cc-number2" maxlength="4">
+ <input id="cc-number3" maxlength="4">
+ <input id="cc-name">
+ <input id="cc-exp-month">
+ <input id="cc-exp-year">
+ </form>`,
+ targetElementId: "cc-number1",
+ expectedResult: ["cc-number3", "cc-name", "cc-exp-month", "cc-exp-year"],
+ prefs: [
+ [
+ "extensions.formautofill.creditCards.heuristics.fathom.testConfidence",
+ "1.0",
+ ],
+ ],
+ },
+ {
+ description:
+ "Valid form containing five consecutive cc-number fields without autocomplete attributes.",
+ document: `<form>
+ <input id="cc-number1" maxlength="4">
+ <input id="cc-number2" maxlength="4">
+ <input id="cc-number3" maxlength="4">
+ <input id="cc-number4" maxlength="4">
+ <input id="cc-number5" maxlength="4">
+ <input id="cc-name">
+ <input id="cc-exp-month">
+ <input id="cc-exp-year">
+ </form>`,
+ targetElementId: "cc-number1",
+ expectedResult: ["cc-number5", "cc-name", "cc-exp-month", "cc-exp-year"],
+ },
+];
+
+let markedFieldId = [];
+
+var FormAutofillContent;
+add_setup(async () => {
+ ({ FormAutofillContent } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillContent.sys.mjs"
+ ));
+
+ FormAutofillContent._markAsAutofillField = function (field) {
+ markedFieldId.push(field.id);
+ };
+});
+
+TESTCASES.forEach(testcase => {
+ add_task(async function () {
+ info("Starting testcase: " + testcase.description);
+
+ if (testcase.prefs) {
+ testcase.prefs.forEach(pref => SetPref(pref[0], pref[1]));
+ }
+
+ markedFieldId = [];
+
+ let doc = MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ testcase.document
+ );
+ let element = doc.getElementById(testcase.targetElementId);
+ FormAutofillContent.identifyAutofillFields(element);
+
+ Assert.deepEqual(
+ markedFieldId,
+ testcase.expectedResult,
+ "Check the fields were marked correctly."
+ );
+
+ if (testcase.prefs) {
+ testcase.prefs.forEach(pref => Services.prefs.clearUserPref(pref[0]));
+ }
+ });
+});
diff --git a/browser/extensions/formautofill/test/unit/test_migrateRecords.js b/browser/extensions/formautofill/test/unit/test_migrateRecords.js
new file mode 100644
index 0000000000..24b13b4322
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_migrateRecords.js
@@ -0,0 +1,382 @@
+/**
+ * Tests the migration algorithm in profileStorage.
+ */
+
+"use strict";
+
+let FormAutofillStorage;
+add_setup(async () => {
+ ({ FormAutofillStorage } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillStorage.sys.mjs"
+ ));
+});
+
+const TEST_STORE_FILE_NAME = "test-profile.json";
+
+const { ADDRESS_SCHEMA_VERSION } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillStorageBase.sys.mjs"
+);
+const { CREDIT_CARD_SCHEMA_VERSION } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillStorageBase.sys.mjs"
+);
+
+const ADDRESS_TESTCASES = [
+ {
+ description:
+ "The record version is equal to the current version. The migration shouldn't be invoked.",
+ record: {
+ guid: "test-guid",
+ version: ADDRESS_SCHEMA_VERSION,
+ "given-name": "Timothy",
+ name: "John", // The cached name field doesn't align "given-name" but it
+ // won't be recomputed because the migration isn't invoked.
+ },
+ expectedResult: {
+ guid: "test-guid",
+ version: ADDRESS_SCHEMA_VERSION,
+ "given-name": "Timothy",
+ name: "John",
+ },
+ },
+ {
+ description:
+ "The record version is greater than the current version. The migration shouldn't be invoked.",
+ record: {
+ guid: "test-guid",
+ version: 99,
+ "given-name": "Timothy",
+ name: "John",
+ },
+ expectedResult: {
+ guid: "test-guid",
+ version: 99,
+ "given-name": "Timothy",
+ name: "John",
+ },
+ },
+ {
+ description:
+ "The record version is less than the current version. The migration should be invoked.",
+ record: {
+ guid: "test-guid",
+ version: 0,
+ "given-name": "Timothy",
+ name: "John",
+ },
+ expectedResult: {
+ guid: "test-guid",
+ version: ADDRESS_SCHEMA_VERSION,
+ "given-name": "Timothy",
+ name: "Timothy",
+ },
+ },
+ {
+ description:
+ "The record version is omitted. The migration should be invoked.",
+ record: {
+ guid: "test-guid",
+ "given-name": "Timothy",
+ name: "John",
+ "unknown-1": "an unknown field from another client",
+ },
+ expectedResult: {
+ guid: "test-guid",
+ version: ADDRESS_SCHEMA_VERSION,
+ "given-name": "Timothy",
+ name: "Timothy",
+ "unknown-1": "an unknown field from another client",
+ },
+ },
+ {
+ description:
+ "The record version is an invalid value. The migration should be invoked.",
+ record: {
+ guid: "test-guid",
+ version: "ABCDE",
+ "given-name": "Timothy",
+ name: "John",
+ "unknown-1": "an unknown field from another client",
+ },
+ expectedResult: {
+ guid: "test-guid",
+ version: ADDRESS_SCHEMA_VERSION,
+ "given-name": "Timothy",
+ name: "Timothy",
+ "unknown-1": "an unknown field from another client",
+ },
+ },
+ {
+ description:
+ "The omitted computed fields should be always recomputed even the record version is up-to-date.",
+ record: {
+ guid: "test-guid",
+ version: ADDRESS_SCHEMA_VERSION,
+ "given-name": "Timothy",
+ },
+ expectedResult: {
+ guid: "test-guid",
+ version: ADDRESS_SCHEMA_VERSION,
+ "given-name": "Timothy",
+ name: "Timothy",
+ },
+ },
+ {
+ description: "The migration shouldn't be invoked on tombstones.",
+ record: {
+ guid: "test-guid",
+ timeLastModified: 12345,
+ deleted: true,
+ },
+ expectedResult: {
+ guid: "test-guid",
+ timeLastModified: 12345,
+ deleted: true,
+
+ // Make sure no new fields are appended.
+ version: undefined,
+ name: undefined,
+ },
+ },
+];
+
+const CREDIT_CARD_TESTCASES = [
+ {
+ description:
+ "The record version is equal to the current version. The migration shouldn't be invoked.",
+ record: {
+ guid: "test-guid",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "Timothy",
+ "cc-given-name": "John", // The cached "cc-given-name" field doesn't align
+ // "cc-name" but it won't be recomputed because
+ // the migration isn't invoked.
+ },
+ expectedResult: {
+ guid: "test-guid",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "Timothy",
+ "cc-given-name": "John",
+ },
+ },
+ {
+ description:
+ "The record version is greater than the current version. The migration shouldn't be invoked.",
+ record: {
+ guid: "test-guid",
+ version: 99,
+ "cc-name": "Timothy",
+ "cc-given-name": "John",
+ },
+ expectedResult: {
+ guid: "test-guid",
+ version: 99,
+ "cc-name": "Timothy",
+ "cc-given-name": "John",
+ },
+ },
+ {
+ description:
+ "The record version is less than the current version. The migration should be invoked.",
+ record: {
+ guid: "test-guid",
+ version: 0,
+ "cc-name": "Timothy",
+ "cc-given-name": "John",
+ },
+ expectedResult: {
+ guid: "test-guid",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "Timothy",
+ "cc-given-name": "Timothy",
+ },
+ },
+ {
+ description:
+ "The record version is omitted. The migration should be invoked.",
+ record: {
+ guid: "test-guid",
+ "cc-name": "Timothy",
+ "cc-given-name": "John",
+ "unknown-1": "an unknown field from another client",
+ },
+ expectedResult: {
+ guid: "test-guid",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "Timothy",
+ "cc-given-name": "Timothy",
+ "unknown-1": "an unknown field from another client",
+ },
+ },
+ {
+ description:
+ "The record version is an invalid value. The migration should be invoked.",
+ record: {
+ guid: "test-guid",
+ version: "ABCDE",
+ "cc-name": "Timothy",
+ "cc-given-name": "John",
+ "unknown-1": "an unknown field from another client",
+ },
+ expectedResult: {
+ guid: "test-guid",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "Timothy",
+ "cc-given-name": "Timothy",
+ "unknown-1": "an unknown field from another client",
+ },
+ },
+ {
+ description:
+ "The omitted computed fields should be always recomputed even the record version is up-to-date.",
+ record: {
+ guid: "test-guid",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "Timothy",
+ },
+ expectedResult: {
+ guid: "test-guid",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "Timothy",
+ "cc-given-name": "Timothy",
+ },
+ },
+ {
+ description: "The migration shouldn't be invoked on tombstones.",
+ record: {
+ guid: "test-guid",
+ timeLastModified: 12345,
+ deleted: true,
+ },
+ expectedResult: {
+ guid: "test-guid",
+ timeLastModified: 12345,
+ deleted: true,
+
+ // Make sure no new fields are appended.
+ version: undefined,
+ "cc-given-name": undefined,
+ },
+ },
+];
+
+let do_check_record_matches = (expectedRecord, record) => {
+ for (let key in expectedRecord) {
+ Assert.equal(expectedRecord[key], record[key]);
+ }
+};
+
+add_task(async function test_migrateAddressRecords() {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+
+ let profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ for (let testcase of ADDRESS_TESTCASES) {
+ info(testcase.description);
+ profileStorage._store.data.addresses = [testcase.record];
+ await profileStorage.addresses._migrateRecord(testcase.record, 0);
+ do_check_record_matches(
+ testcase.expectedResult,
+ profileStorage.addresses._data[0]
+ );
+ }
+});
+
+add_task(async function test_migrateCreditCardRecords() {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+
+ let profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ for (let testcase of CREDIT_CARD_TESTCASES) {
+ info(testcase.description);
+ profileStorage._store.data.creditCards = [testcase.record];
+ await profileStorage.creditCards._migrateRecord(testcase.record, 0);
+ do_check_record_matches(
+ testcase.expectedResult,
+ profileStorage.creditCards._data[0]
+ );
+ }
+});
+
+add_task(async function test_migrateEncryptedCreditCardNumber() {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+
+ let profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ info("v1 and v2 schema cards should be abandoned.");
+
+ let v1record = {
+ guid: "test-guid1",
+ version: 1,
+ "cc-name": "Timothy",
+ "cc-number-encrypted": "aaaa",
+ };
+
+ let v2record = {
+ guid: "test-guid2",
+ version: 2,
+ "cc-name": "Bob",
+ "cc-number-encrypted": "bbbb",
+ };
+
+ profileStorage._store.data.creditCards = [v1record, v2record];
+ await profileStorage.creditCards._migrateRecord(v1record, 0);
+ await profileStorage.creditCards._migrateRecord(v2record, 1);
+ v1record = profileStorage.creditCards._data[0];
+ v2record = profileStorage.creditCards._data[1];
+
+ Assert.ok(v1record.deleted);
+ Assert.ok(v2record.deleted);
+});
+
+add_task(async function test_migrateDeprecatedCreditCardV4() {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+
+ let profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ let records = [
+ {
+ guid: "test-guid1",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "Alice",
+ _sync: {
+ changeCounter: 0,
+ lastSyncedFields: {},
+ },
+ },
+ {
+ guid: "test-guid2",
+ version: 4,
+ "cc-name": "Timothy",
+ _sync: {
+ changeCounter: 0,
+ lastSyncedFields: {},
+ },
+ },
+ {
+ guid: "test-guid3",
+ version: 4,
+ "cc-name": "Bob",
+ },
+ ];
+
+ profileStorage._store.data.creditCards = records;
+ for (let idx = 0; idx < records.length; idx++) {
+ await profileStorage.creditCards._migrateRecord(records[idx], idx);
+ }
+
+ profileStorage.creditCards.pullSyncChanges();
+
+ // Record that has already synced before, do not sync again
+ equal(getSyncChangeCounter(profileStorage.creditCards, records[0].guid), 0);
+
+ // alaways force sync v4 record
+ equal(records[1].version, CREDIT_CARD_SCHEMA_VERSION);
+ equal(getSyncChangeCounter(profileStorage.creditCards, records[1].guid), 1);
+
+ equal(records[2].version, CREDIT_CARD_SCHEMA_VERSION);
+ equal(getSyncChangeCounter(profileStorage.creditCards, records[2].guid), 1);
+});
diff --git a/browser/extensions/formautofill/test/unit/test_nameUtils.js b/browser/extensions/formautofill/test/unit/test_nameUtils.js
new file mode 100644
index 0000000000..1b8bdb6d49
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_nameUtils.js
@@ -0,0 +1,289 @@
+/**
+ * Tests FormAutofillNameUtils object.
+ */
+
+"use strict";
+
+var FormAutofillNameUtils;
+add_task(async function () {
+ ({ FormAutofillNameUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/FormAutofillNameUtils.sys.mjs"
+ ));
+});
+
+// Test cases initially copied from
+// https://cs.chromium.org/chromium/src/components/autofill/core/browser/autofill_data_util_unittest.cc
+const TESTCASES = [
+ {
+ description: "Full name including given, middle and family names",
+ fullName: "Homer Jay Simpson",
+ nameParts: {
+ given: "Homer",
+ middle: "Jay",
+ family: "Simpson",
+ },
+ },
+ {
+ description: "No middle name",
+ fullName: "Moe Szyslak",
+ nameParts: {
+ given: "Moe",
+ middle: "",
+ family: "Szyslak",
+ },
+ },
+ {
+ description: "Common name prefixes removed",
+ fullName: "Reverend Timothy Lovejoy",
+ nameParts: {
+ given: "Timothy",
+ middle: "",
+ family: "Lovejoy",
+ },
+ expectedFullName: "Timothy Lovejoy",
+ },
+ {
+ description: "Common name suffixes removed",
+ fullName: "John Frink Phd",
+ nameParts: {
+ given: "John",
+ middle: "",
+ family: "Frink",
+ },
+ expectedFullName: "John Frink",
+ },
+ {
+ description: "Exception to the name suffix removal",
+ fullName: "John Ma",
+ nameParts: {
+ given: "John",
+ middle: "",
+ family: "Ma",
+ },
+ },
+ {
+ description: "Common family name prefixes not considered a middle name",
+ fullName: "Milhouse Van Houten",
+ nameParts: {
+ given: "Milhouse",
+ middle: "",
+ family: "Van Houten",
+ },
+ },
+
+ // CJK names have reverse order (surname goes first, given name goes second).
+ {
+ description: "Chinese name, Unihan",
+ fullName: "孫 德明",
+ nameParts: {
+ given: "德明",
+ middle: "",
+ family: "孫",
+ },
+ expectedFullName: "孫德明",
+ },
+ {
+ description: 'Chinese name, Unihan, "IDEOGRAPHIC SPACE"',
+ fullName: "孫 德明",
+ nameParts: {
+ given: "德明",
+ middle: "",
+ family: "孫",
+ },
+ expectedFullName: "孫德明",
+ },
+ {
+ description: "Korean name, Hangul",
+ fullName: "홍 길동",
+ nameParts: {
+ given: "길동",
+ middle: "",
+ family: "홍",
+ },
+ expectedFullName: "홍길동",
+ },
+ {
+ description: "Japanese name, Unihan",
+ fullName: "山田 貴洋",
+ nameParts: {
+ given: "貴洋",
+ middle: "",
+ family: "山田",
+ },
+ expectedFullName: "山田貴洋",
+ },
+
+ // In Japanese, foreign names use 'KATAKANA MIDDLE DOT' (U+30FB) as a
+ // separator. There is no consensus for the ordering. For now, we use the same
+ // ordering as regular Japanese names ("last・first").
+ {
+ description: "Foreign name in Japanese, Katakana",
+ fullName: "ゲイツ・ビル",
+ nameParts: {
+ given: "ビル",
+ middle: "",
+ family: "ゲイツ",
+ },
+ expectedFullName: "ゲイツビル",
+ },
+
+ // 'KATAKANA MIDDLE DOT' is occasionally typoed as 'MIDDLE DOT' (U+00B7).
+ {
+ description: "Foreign name in Japanese, Katakana",
+ fullName: "ゲイツ·ビル",
+ nameParts: {
+ given: "ビル",
+ middle: "",
+ family: "ゲイツ",
+ },
+ expectedFullName: "ゲイツビル",
+ },
+
+ // CJK names don't usually have a space in the middle, but most of the time,
+ // the surname is only one character (in Chinese & Korean).
+ {
+ description: "Korean name, Hangul",
+ fullName: "최성훈",
+ nameParts: {
+ given: "성훈",
+ middle: "",
+ family: "최",
+ },
+ },
+ {
+ description: "(Simplified) Chinese name, Unihan",
+ fullName: "刘翔",
+ nameParts: {
+ given: "翔",
+ middle: "",
+ family: "刘",
+ },
+ },
+ {
+ description: "(Traditional) Chinese name, Unihan",
+ fullName: "劉翔",
+ nameParts: {
+ given: "翔",
+ middle: "",
+ family: "劉",
+ },
+ },
+
+ // There are a few exceptions. Occasionally, the surname has two characters.
+ {
+ description: "Korean name, Hangul",
+ fullName: "남궁도",
+ nameParts: {
+ given: "도",
+ middle: "",
+ family: "남궁",
+ },
+ },
+ {
+ description: "Korean name, Hangul",
+ fullName: "황보혜정",
+ nameParts: {
+ given: "혜정",
+ middle: "",
+ family: "황보",
+ },
+ },
+ {
+ description: "(Traditional) Chinese name, Unihan",
+ fullName: "歐陽靖",
+ nameParts: {
+ given: "靖",
+ middle: "",
+ family: "歐陽",
+ },
+ },
+
+ // In Korean, some 2-character surnames are rare/ambiguous, like "강전": "강"
+ // is a common surname, and "전" can be part of a given name. In those cases,
+ // we assume it's 1/2 for 3-character names, or 2/2 for 4-character names.
+ {
+ description: "Korean name, Hangul",
+ fullName: "강전희",
+ nameParts: {
+ given: "전희",
+ middle: "",
+ family: "강",
+ },
+ },
+ {
+ description: "Korean name, Hangul",
+ fullName: "황목치승",
+ nameParts: {
+ given: "치승",
+ middle: "",
+ family: "황목",
+ },
+ },
+
+ // It occasionally happens that a full name is 2 characters, 1/1.
+ {
+ description: "Korean name, Hangul",
+ fullName: "이도",
+ nameParts: {
+ given: "도",
+ middle: "",
+ family: "이",
+ },
+ },
+ {
+ description: "Korean name, Hangul",
+ fullName: "孫文",
+ nameParts: {
+ given: "文",
+ middle: "",
+ family: "孫",
+ },
+ },
+
+ // These are no CJK names for us, they're just bogus.
+ {
+ description: "Bogus",
+ fullName: "Homer シンプソン",
+ nameParts: {
+ given: "Homer",
+ middle: "",
+ family: "シンプソン",
+ },
+ },
+ {
+ description: "Bogus",
+ fullName: "ホーマー Simpson",
+ nameParts: {
+ given: "ホーマー",
+ middle: "",
+ family: "Simpson",
+ },
+ },
+ {
+ description: "CJK has a middle-name, too unusual",
+ fullName: "반 기 문",
+ nameParts: {
+ given: "반",
+ middle: "기",
+ family: "문",
+ },
+ },
+];
+
+add_task(async function test_splitName() {
+ TESTCASES.forEach(testcase => {
+ if (testcase.fullName) {
+ info("Starting testcase: " + testcase.description);
+ let nameParts = FormAutofillNameUtils.splitName(testcase.fullName);
+ Assert.deepEqual(nameParts, testcase.nameParts);
+ }
+ });
+});
+
+add_task(async function test_joinName() {
+ TESTCASES.forEach(testcase => {
+ info("Starting testcase: " + testcase.description);
+ let name = FormAutofillNameUtils.joinNameParts(testcase.nameParts);
+ Assert.equal(name, testcase.expectedFullName || testcase.fullName);
+ });
+});
diff --git a/browser/extensions/formautofill/test/unit/test_onFormSubmitted.js b/browser/extensions/formautofill/test/unit/test_onFormSubmitted.js
new file mode 100644
index 0000000000..1c252e04bb
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_onFormSubmitted.js
@@ -0,0 +1,805 @@
+"use strict";
+
+var FormAutofillContent;
+add_setup(async () => {
+ ({ FormAutofillContent } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillContent.sys.mjs"
+ ));
+});
+
+const DEFAULT_TEST_DOC = `<form id="form1">
+ <input id="street-addr" autocomplete="street-address">
+ <select id="address-level1" autocomplete="address-level1">
+ <option value=""></option>
+ <option value="AL">Alabama</option>
+ <option value="AK">Alaska</option>
+ <option value="AP">Armed Forces Pacific</option>
+
+ <option value="ca">california</option>
+ <option value="AR">US-Arkansas</option>
+ <option value="US-CA">California</option>
+ <option value="CA">California</option>
+ <option value="US-AZ">US_Arizona</option>
+ <option value="Ariz">Arizonac</option>
+ </select>
+ <input id="city" autocomplete="address-level2">
+ <input id="country" autocomplete="country">
+ <input id="email" autocomplete="email">
+ <input id="tel" autocomplete="tel">
+ <input id="cc-name" autocomplete="cc-name">
+ <input id="cc-number" autocomplete="cc-number">
+ <input id="cc-exp-month" autocomplete="cc-exp-month">
+ <input id="cc-exp-year" autocomplete="cc-exp-year">
+ <select id="cc-type">
+ <option value="">Select</option>
+ <option value="visa">Visa</option>
+ <option value="mastercard">Master Card</option>
+ <option value="amex">American Express</option>
+ </select>
+ <input id="submit" type="submit">
+ </form>`;
+const TARGET_ELEMENT_ID = "street-addr";
+
+const TESTCASES = [
+ {
+ description:
+ "Should not trigger address saving if the number of fields is less than 3",
+ document: DEFAULT_TEST_DOC,
+ targetElementId: TARGET_ELEMENT_ID,
+ formValue: {
+ "street-addr": "331 E. Evelyn Avenue",
+ tel: "1-650-903-0800",
+ },
+ expectedResult: {
+ formSubmission: false,
+ },
+ },
+ {
+ description: "Should not trigger credit card saving if number is empty",
+ document: DEFAULT_TEST_DOC,
+ targetElementId: TARGET_ELEMENT_ID,
+ formValue: {
+ "cc-name": "John Doe",
+ "cc-exp-month": 12,
+ "cc-exp-year": 2000,
+ },
+ expectedResult: {
+ formSubmission: false,
+ },
+ },
+ {
+ description:
+ "Should not trigger credit card saving if there is more than one cc-number field but less than four fields",
+ document: `<form id="form1">
+ <input id="cc-type" autocomplete="cc-type">
+ <input id="cc-name" autocomplete="cc-name">
+ <input id="cc-number1" maxlength="4">
+ <input id="cc-number2" maxlength="4">
+ <input id="cc-number3" maxlength="4">
+ <input id="cc-exp-month" autocomplete="cc-exp-month">
+ <input id="cc-exp-year" autocomplete="cc-exp-year">
+ <input id="submit" type="submit">
+ </form>
+ `,
+ targetElementId: "cc-name",
+ formValue: {
+ "cc-name": "John Doe",
+ "cc-number1": "3714",
+ "cc-number2": "4963",
+ "cc-number3": "5398",
+ "cc-exp-month": 12,
+ "cc-exp-year": 2000,
+ "cc-type": "amex",
+ },
+ expectedResult: {
+ formSubmission: false,
+ },
+ },
+ {
+ description: "Trigger address saving",
+ document: DEFAULT_TEST_DOC,
+ targetElementId: TARGET_ELEMENT_ID,
+ formValue: {
+ "street-addr": "331 E. Evelyn Avenue",
+ country: "US",
+ tel: "1-650-903-0800",
+ },
+ expectedResult: {
+ formSubmission: true,
+ records: {
+ address: [
+ {
+ guid: null,
+ record: {
+ "street-address": "331 E. Evelyn Avenue",
+ "address-level1": "",
+ "address-level2": "",
+ country: "US",
+ email: "",
+ tel: "1-650-903-0800",
+ },
+ untouchedFields: [],
+ },
+ ],
+ creditCard: [],
+ },
+ },
+ },
+ {
+ description: "Trigger credit card saving",
+ document: DEFAULT_TEST_DOC,
+ targetElementId: "cc-type",
+ formValue: {
+ "cc-name": "John Doe",
+ "cc-number": "5105105105105100",
+ "cc-exp-month": 12,
+ "cc-exp-year": 2000,
+ "cc-type": "amex",
+ },
+ expectedResult: {
+ formSubmission: true,
+ records: {
+ address: [],
+ creditCard: [
+ {
+ guid: null,
+ record: {
+ "cc-name": "John Doe",
+ "cc-number": "5105105105105100",
+ "cc-exp-month": 12,
+ "cc-exp-year": 2000,
+ "cc-type": "amex",
+ },
+ untouchedFields: [],
+ },
+ ],
+ },
+ },
+ },
+ {
+ description: "Trigger credit card saving using multiple cc-number fields",
+ document: `<form id="form1">
+ <input id="cc-type" autocomplete="cc-type">
+ <input id="cc-name" autocomplete="cc-name">
+ <input id="cc-number1" maxlength="4">
+ <input id="cc-number2" maxlength="4">
+ <input id="cc-number3" maxlength="4">
+ <input id="cc-number4" maxlength="4">
+ <input id="cc-exp-month" autocomplete="cc-exp-month">
+ <input id="cc-exp-year" autocomplete="cc-exp-year">
+ <input id="submit" type="submit">
+ </form>`,
+ targetElementId: "cc-type",
+ formValue: {
+ "cc-name": "John Doe",
+ "cc-number1": "3714",
+ "cc-number2": "4963",
+ "cc-number3": "5398",
+ "cc-number4": "431",
+ "cc-exp-month": 12,
+ "cc-exp-year": 2000,
+ "cc-type": "amex",
+ },
+ expectedResult: {
+ formSubmission: true,
+ records: {
+ address: [],
+ creditCard: [
+ {
+ guid: null,
+ record: {
+ "cc-name": "John Doe",
+ "cc-number": "371449635398431",
+ "cc-exp-month": 12,
+ "cc-exp-year": 2000,
+ "cc-type": "amex",
+ },
+ untouchedFields: [],
+ },
+ ],
+ },
+ },
+ },
+ {
+ description: "Trigger address and credit card saving",
+ document: DEFAULT_TEST_DOC,
+ targetElementId: TARGET_ELEMENT_ID,
+ formValue: {
+ "street-addr": "331 E. Evelyn Avenue",
+ country: "US",
+ tel: "1-650-903-0800",
+ "cc-name": "John Doe",
+ "cc-number": "5105105105105100",
+ "cc-exp-month": 12,
+ "cc-exp-year": 2000,
+ "cc-type": "visa",
+ },
+ expectedResult: {
+ formSubmission: true,
+ records: {
+ address: [
+ {
+ guid: null,
+ record: {
+ "street-address": "331 E. Evelyn Avenue",
+ "address-level1": "",
+ "address-level2": "",
+ country: "US",
+ email: "",
+ tel: "1-650-903-0800",
+ },
+ untouchedFields: [],
+ },
+ ],
+ creditCard: [
+ {
+ guid: null,
+ record: {
+ "cc-name": "John Doe",
+ "cc-number": "5105105105105100",
+ "cc-exp-month": 12,
+ "cc-exp-year": 2000,
+ "cc-type": "visa",
+ },
+ untouchedFields: [],
+ },
+ ],
+ },
+ },
+ },
+ {
+ description: "Profile saved with trimmed string",
+ document: DEFAULT_TEST_DOC,
+ targetElementId: TARGET_ELEMENT_ID,
+ formValue: {
+ "street-addr": "331 E. Evelyn Avenue ",
+ country: "US",
+ tel: " 1-650-903-0800",
+ },
+ expectedResult: {
+ formSubmission: true,
+ records: {
+ address: [
+ {
+ guid: null,
+ record: {
+ "street-address": "331 E. Evelyn Avenue",
+ "address-level1": "",
+ "address-level2": "",
+ country: "US",
+ email: "",
+ tel: "1-650-903-0800",
+ },
+ untouchedFields: [],
+ },
+ ],
+ creditCard: [],
+ },
+ },
+ },
+ {
+ description: "Eliminate the field that is empty after trimmed",
+ document: DEFAULT_TEST_DOC,
+ targetElementId: TARGET_ELEMENT_ID,
+ formValue: {
+ "street-addr": "331 E. Evelyn Avenue",
+ country: "US",
+ email: " ",
+ tel: "1-650-903-0800",
+ },
+ expectedResult: {
+ formSubmission: true,
+ records: {
+ address: [
+ {
+ guid: null,
+ record: {
+ "street-address": "331 E. Evelyn Avenue",
+ "address-level1": "",
+ "address-level2": "",
+ country: "US",
+ email: "",
+ tel: "1-650-903-0800",
+ },
+ untouchedFields: [],
+ },
+ ],
+ creditCard: [],
+ },
+ },
+ },
+ {
+ description: "Save state with regular select option",
+ document: DEFAULT_TEST_DOC,
+ targetElementId: TARGET_ELEMENT_ID,
+ formValue: {
+ "address-level1": "CA",
+ "street-addr": "331 E. Evelyn Avenue",
+ country: "US",
+ },
+ expectedResult: {
+ formSubmission: true,
+ records: {
+ address: [
+ {
+ guid: null,
+ record: {
+ "address-level1": "CA",
+ "address-level2": "",
+ "street-address": "331 E. Evelyn Avenue",
+ country: "US",
+ email: "",
+ tel: "",
+ },
+ untouchedFields: [],
+ },
+ ],
+ creditCard: [],
+ },
+ },
+ },
+ {
+ description: "Save state with lowercase value",
+ document: DEFAULT_TEST_DOC,
+ targetElementId: TARGET_ELEMENT_ID,
+ formValue: {
+ "address-level1": "ca",
+ "street-addr": "331 E. Evelyn Avenue",
+ country: "US",
+ },
+ expectedResult: {
+ formSubmission: true,
+ records: {
+ address: [
+ {
+ guid: null,
+ record: {
+ "address-level1": "CA",
+ "address-level2": "",
+ "street-address": "331 E. Evelyn Avenue",
+ country: "US",
+ email: "",
+ tel: "",
+ },
+ untouchedFields: [],
+ },
+ ],
+ creditCard: [],
+ },
+ },
+ },
+ {
+ description: "Save state with a country code prefixed to the label",
+ document: DEFAULT_TEST_DOC,
+ targetElementId: TARGET_ELEMENT_ID,
+ formValue: {
+ "address-level1": "AR",
+ "street-addr": "331 E. Evelyn Avenue",
+ country: "US",
+ },
+ expectedResult: {
+ formSubmission: true,
+ records: {
+ address: [
+ {
+ guid: null,
+ record: {
+ "address-level1": "AR",
+ "address-level2": "",
+ "street-address": "331 E. Evelyn Avenue",
+ country: "US",
+ email: "",
+ tel: "",
+ },
+ untouchedFields: [],
+ },
+ ],
+ creditCard: [],
+ },
+ },
+ },
+ {
+ description: "Save state with a country code prefixed to the value",
+ document: DEFAULT_TEST_DOC,
+ targetElementId: TARGET_ELEMENT_ID,
+ formValue: {
+ "address-level1": "US-CA",
+ "street-addr": "331 E. Evelyn Avenue",
+ country: "US",
+ },
+ expectedResult: {
+ formSubmission: true,
+ records: {
+ address: [
+ {
+ guid: null,
+ record: {
+ "address-level1": "CA",
+ "address-level2": "",
+ "street-address": "331 E. Evelyn Avenue",
+ country: "US",
+ email: "",
+ tel: "",
+ },
+ untouchedFields: [],
+ },
+ ],
+ creditCard: [],
+ },
+ },
+ },
+ {
+ description:
+ "Save state with a country code prefixed to the value and label",
+ document: DEFAULT_TEST_DOC,
+ targetElementId: TARGET_ELEMENT_ID,
+ formValue: {
+ "address-level1": "US-AZ",
+ "street-addr": "331 E. Evelyn Avenue",
+ country: "US",
+ },
+ expectedResult: {
+ formSubmission: true,
+ records: {
+ address: [
+ {
+ guid: null,
+ record: {
+ "address-level1": "AZ",
+ "address-level2": "",
+ "street-address": "331 E. Evelyn Avenue",
+ country: "US",
+ email: "",
+ tel: "",
+ },
+ untouchedFields: [],
+ },
+ ],
+ creditCard: [],
+ },
+ },
+ },
+ {
+ description:
+ "Should save select label instead when failed to abbreviate the value",
+ document: DEFAULT_TEST_DOC,
+ targetElementId: TARGET_ELEMENT_ID,
+ formValue: {
+ "address-level1": "Ariz",
+ "street-addr": "331 E. Evelyn Avenue",
+ country: "US",
+ },
+ expectedResult: {
+ formSubmission: true,
+ records: {
+ address: [
+ {
+ guid: null,
+ record: {
+ "address-level1": "Arizonac",
+ "address-level2": "",
+ "street-address": "331 E. Evelyn Avenue",
+ country: "US",
+ email: "",
+ tel: "",
+ },
+ untouchedFields: [],
+ },
+ ],
+ creditCard: [],
+ },
+ },
+ },
+ {
+ description: "Shouldn't save select with multiple selections",
+ document: DEFAULT_TEST_DOC,
+ targetElementId: TARGET_ELEMENT_ID,
+ formValue: {
+ "address-level1": ["AL", "AK", "AP"],
+ "street-addr": "331 E. Evelyn Avenue",
+ country: "US",
+ tel: "1-650-903-0800",
+ },
+ expectedResult: {
+ formSubmission: true,
+ records: {
+ address: [
+ {
+ guid: null,
+ record: {
+ "street-address": "331 E. Evelyn Avenue",
+ "address-level1": "",
+ "address-level2": "",
+ country: "US",
+ tel: "1-650-903-0800",
+ email: "",
+ },
+ untouchedFields: [],
+ },
+ ],
+ creditCard: [],
+ },
+ },
+ },
+ {
+ description: "Shouldn't save select with empty value",
+ document: DEFAULT_TEST_DOC,
+ targetElementId: TARGET_ELEMENT_ID,
+ formValue: {
+ "address-level1": "",
+ "street-addr": "331 E. Evelyn Avenue",
+ country: "US",
+ tel: "1-650-903-0800",
+ },
+ expectedResult: {
+ formSubmission: true,
+ records: {
+ address: [
+ {
+ guid: null,
+ record: {
+ "street-address": "331 E. Evelyn Avenue",
+ "address-level1": "",
+ "address-level2": "",
+ country: "US",
+ tel: "1-650-903-0800",
+ email: "",
+ },
+ untouchedFields: [],
+ },
+ ],
+ creditCard: [],
+ },
+ },
+ },
+ {
+ description: "Shouldn't save tel whose length is too short",
+ document: DEFAULT_TEST_DOC,
+ targetElementId: TARGET_ELEMENT_ID,
+ formValue: {
+ "street-addr": "331 E. Evelyn Avenue",
+ "address-level1": "CA",
+ country: "US",
+ tel: "1234",
+ },
+ expectedResult: {
+ formSubmission: true,
+ records: {
+ address: [
+ {
+ guid: null,
+ record: {
+ "street-address": "331 E. Evelyn Avenue",
+ "address-level1": "CA",
+ "address-level2": "",
+ country: "US",
+ tel: "",
+ email: "",
+ },
+ untouchedFields: [],
+ },
+ ],
+ creditCard: [],
+ },
+ },
+ },
+ {
+ description: "Shouldn't save tel whose length is too long",
+ document: DEFAULT_TEST_DOC,
+ targetElementId: TARGET_ELEMENT_ID,
+ formValue: {
+ "street-addr": "331 E. Evelyn Avenue",
+ "address-level1": "CA",
+ country: "US",
+ tel: "1234567890123456",
+ },
+ expectedResult: {
+ formSubmission: true,
+ records: {
+ address: [
+ {
+ guid: null,
+ record: {
+ "street-address": "331 E. Evelyn Avenue",
+ "address-level1": "CA",
+ "address-level2": "",
+ country: "US",
+ tel: "",
+ email: "",
+ },
+ untouchedFields: [],
+ },
+ ],
+ creditCard: [],
+ },
+ },
+ },
+ {
+ description: "Shouldn't save tel which contains invalid characters",
+ document: DEFAULT_TEST_DOC,
+ targetElementId: TARGET_ELEMENT_ID,
+ formValue: {
+ "street-addr": "331 E. Evelyn Avenue",
+ "address-level1": "CA",
+ country: "US",
+ tel: "12345###!!",
+ },
+ expectedResult: {
+ formSubmission: true,
+ records: {
+ address: [
+ {
+ guid: null,
+ record: {
+ "street-address": "331 E. Evelyn Avenue",
+ "address-level1": "CA",
+ "address-level2": "",
+ country: "US",
+ tel: "",
+ email: "",
+ },
+ untouchedFields: [],
+ },
+ ],
+ creditCard: [],
+ },
+ },
+ },
+];
+
+add_task(async function handle_invalid_form() {
+ info("Starting testcase: Test an invalid form element");
+ let doc = MockDocument.createTestDocument(
+ "http://localhost:8080/test",
+ DEFAULT_TEST_DOC
+ );
+ let fakeForm = doc.createElement("form");
+ sinon.spy(FormAutofillContent, "_onFormSubmit");
+
+ FormAutofillContent.formSubmitted(fakeForm, null);
+ Assert.equal(FormAutofillContent._onFormSubmit.called, false);
+ FormAutofillContent._onFormSubmit.restore();
+});
+
+add_task(async function autofill_disabled() {
+ let doc = MockDocument.createTestDocument(
+ "http://localhost:8080/test",
+ DEFAULT_TEST_DOC
+ );
+ let form = doc.getElementById("form1");
+ form.reset();
+
+ let testcase = {
+ "street-addr": "331 E. Evelyn Avenue",
+ country: "US",
+ tel: "+16509030800",
+ "cc-number": "1111222233334444",
+ };
+ for (let key in testcase) {
+ let input = doc.getElementById(key);
+ input.value = testcase[key];
+ }
+
+ let element = doc.getElementById(TARGET_ELEMENT_ID);
+ FormAutofillContent.identifyAutofillFields(element);
+
+ sinon.stub(FormAutofillContent, "_onFormSubmit");
+
+ // "_onFormSubmit" shouldn't be called if both "addresses" and "creditCards"
+ // are disabled.
+ Services.prefs.setBoolPref(
+ "extensions.formautofill.addresses.enabled",
+ false
+ );
+ Services.prefs.setBoolPref(
+ "extensions.formautofill.creditCards.enabled",
+ false
+ );
+ FormAutofillContent.formSubmitted(form, null);
+ Assert.equal(FormAutofillContent._onFormSubmit.called, false);
+ FormAutofillContent._onFormSubmit.resetHistory();
+
+ // "_onFormSubmit" should be called as usual.
+ Services.prefs.clearUserPref("extensions.formautofill.addresses.enabled");
+ Services.prefs.clearUserPref("extensions.formautofill.creditCards.enabled");
+
+ Services.prefs.setBoolPref(
+ "extensions.formautofill.creditCards.enabled",
+ true
+ );
+
+ FormAutofillContent.formSubmitted(form, null);
+ Assert.equal(FormAutofillContent._onFormSubmit.called, true);
+ Assert.notDeepEqual(FormAutofillContent._onFormSubmit.args[0][0].address, []);
+ Assert.notDeepEqual(
+ FormAutofillContent._onFormSubmit.args[0][0].creditCard,
+ []
+ );
+ FormAutofillContent._onFormSubmit.resetHistory();
+
+ // "address" should be empty if "addresses" pref is disabled.
+ Services.prefs.setBoolPref(
+ "extensions.formautofill.addresses.enabled",
+ false
+ );
+ FormAutofillContent.formSubmitted(form, null);
+ Assert.equal(FormAutofillContent._onFormSubmit.called, true);
+ Assert.deepEqual(FormAutofillContent._onFormSubmit.args[0][0].address, []);
+ Assert.notDeepEqual(
+ FormAutofillContent._onFormSubmit.args[0][0].creditCard,
+ []
+ );
+ FormAutofillContent._onFormSubmit.resetHistory();
+ Services.prefs.clearUserPref("extensions.formautofill.addresses.enabled");
+
+ // "creditCard" should be empty if "creditCards" pref is disabled.
+ Services.prefs.setBoolPref(
+ "extensions.formautofill.creditCards.enabled",
+ false
+ );
+ FormAutofillContent.formSubmitted(form, null);
+ Assert.deepEqual(FormAutofillContent._onFormSubmit.called, true);
+ Assert.notDeepEqual(FormAutofillContent._onFormSubmit.args[0][0].address, []);
+ Assert.deepEqual(FormAutofillContent._onFormSubmit.args[0][0].creditCard, []);
+ FormAutofillContent._onFormSubmit.resetHistory();
+ Services.prefs.clearUserPref("extensions.formautofill.creditCards.enabled");
+
+ FormAutofillContent._onFormSubmit.restore();
+});
+
+TESTCASES.forEach(testcase => {
+ add_task(async function check_records_saving_is_called_correctly() {
+ info("Starting testcase: " + testcase.description);
+
+ Services.prefs.setBoolPref(
+ "extensions.formautofill.creditCards.enabled",
+ true
+ );
+ let doc = MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ testcase.document
+ );
+ let form = doc.getElementById("form1");
+ form.reset();
+ for (let key in testcase.formValue) {
+ let input = doc.getElementById(key);
+ let value = testcase.formValue[key];
+ if (ChromeUtils.getClassName(input) === "HTMLSelectElement" && value) {
+ input.multiple = Array.isArray(value);
+ [...input.options].forEach(option => {
+ option.selected = value.includes(option.value);
+ });
+ } else {
+ input.value = testcase.formValue[key];
+ }
+ }
+ sinon.stub(FormAutofillContent, "_onFormSubmit");
+
+ let element = doc.getElementById(testcase.targetElementId);
+ FormAutofillContent.identifyAutofillFields(element);
+ FormAutofillContent.formSubmitted(form, null);
+
+ Assert.equal(
+ FormAutofillContent._onFormSubmit.called,
+ testcase.expectedResult.formSubmission,
+ "Check expected onFormSubmit.called"
+ );
+ if (FormAutofillContent._onFormSubmit.called) {
+ for (let ccRecord of FormAutofillContent._onFormSubmit.args[0][0]
+ .creditCard) {
+ delete ccRecord.flowId;
+ }
+ for (let addrRecord of FormAutofillContent._onFormSubmit.args[0][0]
+ .address) {
+ delete addrRecord.flowId;
+ }
+
+ Assert.deepEqual(
+ FormAutofillContent._onFormSubmit.args[0][0],
+ testcase.expectedResult.records
+ );
+ }
+ FormAutofillContent._onFormSubmit.restore();
+ Services.prefs.clearUserPref("extensions.formautofill.creditCards.enabled");
+ });
+});
diff --git a/browser/extensions/formautofill/test/unit/test_parseAddressFormat.js b/browser/extensions/formautofill/test/unit/test_parseAddressFormat.js
new file mode 100644
index 0000000000..df6c3e3069
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_parseAddressFormat.js
@@ -0,0 +1,66 @@
+"use strict";
+
+var FormAutofillUtils;
+add_setup(async () => {
+ ({ FormAutofillUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/FormAutofillUtils.sys.mjs"
+ ));
+});
+
+add_task(async function test_parseAddressFormat() {
+ const TEST_CASES = [
+ {
+ fmt: "%N%n%O%n%A%n%C, %S %Z", // US
+ parsed: [
+ { fieldId: "name", newLine: true },
+ { fieldId: "organization", newLine: true },
+ { fieldId: "street-address", newLine: true },
+ { fieldId: "address-level2" },
+ { fieldId: "address-level1" },
+ { fieldId: "postal-code" },
+ ],
+ },
+ {
+ fmt: "%N%n%O%n%A%n%C %S %Z", // CA
+ parsed: [
+ { fieldId: "name", newLine: true },
+ { fieldId: "organization", newLine: true },
+ { fieldId: "street-address", newLine: true },
+ { fieldId: "address-level2" },
+ { fieldId: "address-level1" },
+ { fieldId: "postal-code" },
+ ],
+ },
+ {
+ fmt: "%N%n%O%n%A%n%Z %C", // DE
+ parsed: [
+ { fieldId: "name", newLine: true },
+ { fieldId: "organization", newLine: true },
+ { fieldId: "street-address", newLine: true },
+ { fieldId: "postal-code" },
+ { fieldId: "address-level2" },
+ ],
+ },
+ {
+ fmt: "%N%n%O%n%A%n%D%n%C%n%S %Z", // IE
+ parsed: [
+ { fieldId: "name", newLine: true },
+ { fieldId: "organization", newLine: true },
+ { fieldId: "street-address", newLine: true },
+ { fieldId: "address-level3", newLine: true },
+ { fieldId: "address-level2", newLine: true },
+ { fieldId: "address-level1" },
+ { fieldId: "postal-code" },
+ ],
+ },
+ ];
+
+ Assert.throws(
+ () => FormAutofillUtils.parseAddressFormat(),
+ /fmt string is missing./,
+ "Should throw if fmt is empty"
+ );
+ for (let tc of TEST_CASES) {
+ Assert.deepEqual(FormAutofillUtils.parseAddressFormat(tc.fmt), tc.parsed);
+ }
+});
diff --git a/browser/extensions/formautofill/test/unit/test_parseStreetAddress.js b/browser/extensions/formautofill/test/unit/test_parseStreetAddress.js
new file mode 100644
index 0000000000..dc924d2ce8
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_parseStreetAddress.js
@@ -0,0 +1,74 @@
+"use strict";
+
+const { AddressParser } = ChromeUtils.import(
+ "resource://gre/modules/shared/AddressParser.jsm"
+);
+
+// To add a new test entry to a "TESTCASES" variable,
+// you would need to create a new array containing two elements.
+// - The first element is a string representing a street address to be parsed.
+// - The second element is an array containing the expected output after parsing the address,
+// which should follow the format of
+// [street number, street name, apartment number (if applicable), floor number (if applicable)].
+//
+// Note. If we expect the passed street address cannot be parsed, set the second element to null.
+const TESTCASES = [
+ ["123 Main St. Apt 4, Floor 2", [123, "Main St.", "4", 2]],
+ ["32 Vassar Street MIT Room 4", [32, "Vassar Street MIT", "4", null]],
+ ["32 Vassar Street MIT", [32, "Vassar Street MIT", null, null]],
+ [
+ "32 Vassar Street MIT Room 32-G524",
+ [32, "Vassar Street MIT", "32-G524", null],
+ ],
+ ["163 W Hastings\nSuite 209", [163, "W Hastings", "209", null]],
+ ["1234 Elm St. Apt 4, Floor 2", [1234, "Elm St.", "4", 2]],
+ ["456 Oak Drive, Unit 2A", [456, "Oak Drive", "2A", null]],
+ ["789 Maple Ave, Suite 300", [789, "Maple Ave", "300", null]],
+ ["321 Willow Lane, #5", [321, "Willow Lane", "5", null]],
+ ["654 Pine Circle, Apt B", [654, "Pine Circle", "B", null]],
+ ["987 Birch Court, 3rd Floor", [987, "Birch Court", null, 3]],
+ ["234 Cedar Way, Unit 6-2", [234, "Cedar Way", "6-2", null]],
+ ["345 Cherry St, Ste 12", [345, "Cherry St", "12", null]],
+ ["234 Palm St, Bldg 1, Apt 12", null],
+];
+
+add_task(async function test_parseStreetAddress() {
+ for (const TEST of TESTCASES) {
+ let [address, expected] = TEST;
+ const result = AddressParser.parseStreetAddress(address);
+ if (!expected) {
+ Assert.equal(result, null, "Expect failure to parse this street address");
+ continue;
+ }
+
+ const options = {
+ trim: true,
+ ignore_case: true,
+ };
+
+ const expectedSN = AddressParser.normalizeString(expected[0], options);
+ Assert.equal(
+ result.street_number,
+ expectedSN,
+ `expect street number to be ${expectedSN}, but got ${result.street_number}`
+ );
+ const expectedSNA = AddressParser.normalizeString(expected[1], options);
+ Assert.equal(
+ result.street_name,
+ expectedSNA,
+ `expect street name to be ${expectedSNA}, but got ${result.street_name}`
+ );
+ const expectedAN = AddressParser.normalizeString(expected[2], options);
+ Assert.equal(
+ result.apartment_number,
+ expectedAN,
+ `expect apartment number to be ${expectedAN}, but got ${result.apartment_number}`
+ );
+ const expectedFN = AddressParser.normalizeString(expected[3], options);
+ Assert.equal(
+ result.floor_number,
+ expectedFN,
+ `expect floor number to be ${expectedFN}, but got ${result.floor_number}`
+ );
+ }
+});
diff --git a/browser/extensions/formautofill/test/unit/test_phoneNumber.js b/browser/extensions/formautofill/test/unit/test_phoneNumber.js
new file mode 100644
index 0000000000..1c1d67e166
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_phoneNumber.js
@@ -0,0 +1,399 @@
+/**
+ * Tests PhoneNumber.jsm and PhoneNumberNormalizer.jsm.
+ */
+
+"use strict";
+
+var PhoneNumber, PhoneNumberNormalizer;
+add_setup(async () => {
+ ({ PhoneNumber } = ChromeUtils.importESModule(
+ "resource://autofill/phonenumberutils/PhoneNumber.sys.mjs"
+ ));
+ ({ PhoneNumberNormalizer } = ChromeUtils.importESModule(
+ "resource://autofill/phonenumberutils/PhoneNumberNormalizer.sys.mjs"
+ ));
+});
+
+function IsPlain(dial, expected) {
+ let result = PhoneNumber.IsPlain(dial);
+ Assert.equal(result, expected);
+}
+
+function Normalize(dial, expected) {
+ let result = PhoneNumberNormalizer.Normalize(dial);
+ Assert.equal(result, expected);
+}
+
+function CantParse(dial, currentRegion) {
+ let result = PhoneNumber.Parse(dial, currentRegion);
+ Assert.equal(null, result);
+}
+
+function Parse(dial, currentRegion) {
+ let result = PhoneNumber.Parse(dial, currentRegion);
+ Assert.notEqual(result, null);
+ return result;
+}
+
+function Test(dial, currentRegion, nationalNumber, region) {
+ let result = Parse(dial, currentRegion);
+ Assert.equal(result.nationalNumber, nationalNumber);
+ Assert.equal(result.region, region);
+ return result;
+}
+
+function TestProperties(dial, currentRegion) {
+ let result = Parse(dial, currentRegion);
+ Assert.ok(result.internationalFormat);
+ Assert.ok(result.internationalNumber);
+ Assert.ok(result.nationalFormat);
+ Assert.ok(result.nationalNumber);
+ Assert.ok(result.countryName);
+ Assert.ok(result.countryCode);
+}
+
+function Format(
+ dial,
+ currentRegion,
+ nationalNumber,
+ region,
+ nationalFormat,
+ internationalFormat
+) {
+ let result = Test(dial, currentRegion, nationalNumber, region);
+ Assert.equal(result.nationalFormat, nationalFormat);
+ Assert.equal(result.internationalFormat, internationalFormat);
+ return result;
+}
+
+function AllEqual(list, currentRegion) {
+ let parsedList = list.map(item => Parse(item, currentRegion));
+ let firstItem = parsedList.shift();
+ for (let item of parsedList) {
+ Assert.deepEqual(item, firstItem);
+ }
+}
+
+add_task(async function test_phoneNumber() {
+ // Test whether could a string be a phone number.
+ IsPlain(null, false);
+ IsPlain("", false);
+ IsPlain("1", true);
+ IsPlain("*2", true); // Real number used in Venezuela
+ IsPlain("*8", true); // Real number used in Venezuela
+ IsPlain("12", true);
+ IsPlain("123", true);
+ IsPlain("1a2", false);
+ IsPlain("12a", false);
+ IsPlain("1234", true);
+ IsPlain("123a", false);
+ IsPlain("+", true);
+ IsPlain("+1", true);
+ IsPlain("+12", true);
+ IsPlain("+123", true);
+ IsPlain("()123", false);
+ IsPlain("(1)23", false);
+ IsPlain("(12)3", false);
+ IsPlain("(123)", false);
+ IsPlain("(123)4", false);
+ IsPlain("(123)4", false);
+ IsPlain("123;ext=", false);
+ IsPlain("123;ext=1", false);
+ IsPlain("123;ext=1234567", false);
+ IsPlain("123;ext=12345678", false);
+ IsPlain("123 ext:1", false);
+ IsPlain("123 ext:1#", false);
+ IsPlain("123-1#", false);
+ IsPlain("123 1#", false);
+ IsPlain("123 12345#", false);
+ IsPlain("123 +123456#", false);
+
+ // Getting international number back from intl number.
+ TestProperties("+19497262896");
+
+ // Test parsing national numbers.
+ Parse("033316005", "NZ");
+ Parse("03-331 6005", "NZ");
+ Parse("03 331 6005", "NZ");
+ // Testing international prefixes.
+ // Should strip country code.
+ Parse("0064 3 331 6005", "NZ");
+
+ // Test CA before US because CA has to import meta-information for US.
+ Parse("4031234567", "CA");
+ Parse("(416) 585-4319", "CA");
+ Parse("647-967-4357", "CA");
+ Parse("416-716-8768", "CA");
+ Parse("18002684646", "CA");
+ Parse("416-445-9119", "CA");
+ Parse("1-800-668-6866", "CA");
+ Parse("(416) 453-6486", "CA");
+ Parse("(647) 268-4778", "CA");
+ Parse("647-218-1313", "CA");
+ Parse("+1 647-209-4642", "CA");
+ Parse("416-559-0133", "CA");
+ Parse("+1 647-639-4118", "CA");
+ Parse("+12898803664", "CA");
+ Parse("780-901-4687", "CA");
+ Parse("+14167070550", "CA");
+ Parse("+1-647-522-6487", "CA");
+ Parse("(416) 877-0880", "CA");
+
+ // Try again, but this time we have an international number with region rode US. It should
+ // recognize the country code and parse accordingly.
+ Parse("01164 3 331 6005", "US");
+ Parse("+64 3 331 6005", "US");
+ Parse("64(0)64123456", "NZ");
+ // Check that using a "/" is fine in a phone number.
+ Parse("123/45678", "DE");
+ Parse("123-456-7890", "US");
+
+ // Test parsing international numbers.
+ Parse("+1 (650) 333-6000", "NZ");
+ Parse("1-650-333-6000", "US");
+ // Calling the US number from Singapore by using different service providers
+ // 1st test: calling using SingTel IDD service (IDD is 001)
+ Parse("0011-650-333-6000", "SG");
+ // 2nd test: calling using StarHub IDD service (IDD is 008)
+ Parse("0081-650-333-6000", "SG");
+ // 3rd test: calling using SingTel V019 service (IDD is 019)
+ Parse("0191-650-333-6000", "SG");
+ // Calling the US number from Poland
+ Parse("0~01-650-333-6000", "PL");
+ // Using "++" at the start.
+ Parse("++1 (650) 333-6000", "PL");
+ // Using a full-width plus sign.
+ Parse("\uFF0B1 (650) 333-6000", "SG");
+ // The whole number, including punctuation, is here represented in full-width form.
+ Parse(
+ "\uFF0B\uFF11\u3000\uFF08\uFF16\uFF15\uFF10\uFF09" +
+ "\u3000\uFF13\uFF13\uFF13\uFF0D\uFF16\uFF10\uFF10\uFF10",
+ "SG"
+ );
+
+ // Test parsing with leading zeros.
+ Parse("+39 02-36618 300", "NZ");
+ Parse("02-36618 300", "IT");
+ Parse("312 345 678", "IT");
+
+ // Test parsing numbers in Argentina.
+ Parse("+54 9 343 555 1212", "AR");
+ Parse("0343 15 555 1212", "AR");
+ Parse("+54 9 3715 65 4320", "AR");
+ Parse("03715 15 65 4320", "AR");
+ Parse("+54 11 3797 0000", "AR");
+ Parse("011 3797 0000", "AR");
+ Parse("+54 3715 65 4321", "AR");
+ Parse("03715 65 4321", "AR");
+ Parse("+54 23 1234 0000", "AR");
+ Parse("023 1234 0000", "AR");
+
+ // Test numbers in Mexico
+ Parse("+52 (449)978-0001", "MX");
+ Parse("01 (449)978-0001", "MX");
+ Parse("(449)978-0001", "MX");
+ Parse("+52 1 33 1234-5678", "MX");
+ Parse("044 (33) 1234-5678", "MX");
+ Parse("045 33 1234-5678", "MX");
+
+ // Test that lots of spaces are ok.
+ Parse("0 3 3 3 1 6 0 0 5", "NZ");
+
+ // Test omitting the current region. This is only valid when the number starts
+ // with a '+'.
+ Parse("+64 3 331 6005");
+ Parse("+64 3 331 6005", null);
+
+ // US numbers
+ Format(
+ "19497261234",
+ "US",
+ "9497261234",
+ "US",
+ "(949) 726-1234",
+ "+1 949-726-1234"
+ );
+
+ // Try a couple german numbers from the US with various access codes.
+ Format(
+ "49451491934",
+ "US",
+ "0451491934",
+ "DE",
+ "0451 491934",
+ "+49 451 491934"
+ );
+ Format(
+ "+49451491934",
+ "US",
+ "0451491934",
+ "DE",
+ "0451 491934",
+ "+49 451 491934"
+ );
+ Format(
+ "01149451491934",
+ "US",
+ "0451491934",
+ "DE",
+ "0451 491934",
+ "+49 451 491934"
+ );
+
+ // Now try dialing the same number from within the German region.
+ Format(
+ "451491934",
+ "DE",
+ "0451491934",
+ "DE",
+ "0451 491934",
+ "+49 451 491934"
+ );
+ Format(
+ "0451491934",
+ "DE",
+ "0451491934",
+ "DE",
+ "0451 491934",
+ "+49 451 491934"
+ );
+
+ // Numbers in italy keep the leading 0 in the city code when dialing internationally.
+ Format(
+ "0577-555-555",
+ "IT",
+ "0577555555",
+ "IT",
+ "05 7755 5555",
+ "+39 05 7755 5555"
+ );
+
+ // Colombian international number without the leading "+"
+ Format("5712234567", "CO", "12234567", "CO", "(1) 2234567", "+57 1 2234567");
+
+ // Telefonica tests
+ Format(
+ "612123123",
+ "ES",
+ "612123123",
+ "ES",
+ "612 12 31 23",
+ "+34 612 12 31 23"
+ );
+
+ // Chile mobile number from a landline
+ Format(
+ "0997654321",
+ "CL",
+ "997654321",
+ "CL",
+ "(99) 765 4321",
+ "+56 99 765 4321"
+ );
+
+ // Chile mobile number from another mobile number
+ Format(
+ "997654321",
+ "CL",
+ "997654321",
+ "CL",
+ "(99) 765 4321",
+ "+56 99 765 4321"
+ );
+
+ // Dialing 911 in the US. This is not a national number.
+ CantParse("911", "US");
+
+ // China mobile number with a 0 in it
+ Format(
+ "15955042864",
+ "CN",
+ "015955042864",
+ "CN",
+ "0159 5504 2864",
+ "+86 159 5504 2864"
+ );
+
+ // Testing international region numbers.
+ CantParse("883510000000091", "001");
+ Format(
+ "+883510000000092",
+ "001",
+ "510000000092",
+ "001",
+ "510 000 000 092",
+ "+883 510 000 000 092"
+ );
+ Format(
+ "883510000000093",
+ "FR",
+ "510000000093",
+ "001",
+ "510 000 000 093",
+ "+883 510 000 000 093"
+ );
+ Format(
+ "+883510000000094",
+ "FR",
+ "510000000094",
+ "001",
+ "510 000 000 094",
+ "+883 510 000 000 094"
+ );
+ Format(
+ "883510000000095",
+ "US",
+ "510000000095",
+ "001",
+ "510 000 000 095",
+ "+883 510 000 000 095"
+ );
+ Format(
+ "+883510000000096",
+ "US",
+ "510000000096",
+ "001",
+ "510 000 000 096",
+ "+883 510 000 000 096"
+ );
+ CantParse("979510000012", "001");
+ Format(
+ "+979510000012",
+ "001",
+ "510000012",
+ "001",
+ "5 1000 0012",
+ "+979 5 1000 0012"
+ );
+
+ // Test normalizing numbers. Only 0-9,#* are valid in a phone number.
+ Normalize("+ABC # * , 9 _ 1 _0", "+222#*,910");
+ Normalize("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "22233344455566677778889999");
+ Normalize("abcdefghijklmnopqrstuvwxyz", "22233344455566677778889999");
+
+ // 8 and 9 digit numbers with area code in Brazil with collect call prefix (90)
+ AllEqual(
+ [
+ "01187654321",
+ "0411187654321",
+ "551187654321",
+ "90411187654321",
+ "+551187654321",
+ ],
+ "BR"
+ );
+ AllEqual(
+ [
+ "011987654321",
+ "04111987654321",
+ "5511987654321",
+ "904111987654321",
+ "+5511987654321",
+ ],
+ "BR"
+ );
+
+ Assert.equal(PhoneNumberNormalizer.Normalize("123abc", true), "123");
+ Assert.equal(PhoneNumberNormalizer.Normalize("12345", true), "12345");
+ Assert.equal(PhoneNumberNormalizer.Normalize("1abcd", false), "12223");
+});
diff --git a/browser/extensions/formautofill/test/unit/test_previewFormFields.js b/browser/extensions/formautofill/test/unit/test_previewFormFields.js
new file mode 100644
index 0000000000..a75f24de20
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_previewFormFields.js
@@ -0,0 +1,199 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Bug 1762063 - we need to fix this pattern of having to wrap destructuring calls in parentheses.
+// We can't do a standard destructuring call because FormAutofillUtils is already declared as a var in head.js
+({ FormAutofillUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/FormAutofillUtils.sys.mjs"
+));
+const { FIELD_STATES } = FormAutofillUtils;
+const PREVIEW = FIELD_STATES.PREVIEW;
+const NORMAL = FIELD_STATES.NORMAL;
+
+const { OSKeyStore } = ChromeUtils.importESModule(
+ "resource://gre/modules/OSKeyStore.sys.mjs"
+);
+
+const TESTCASES = [
+ {
+ description: "Preview best case address form",
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="family-name" autocomplete="family-name">
+ <input id="street-addr" autocomplete="street-address">
+ <input id="city" autocomplete="address-level2">
+ </form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ "given-name": "John",
+ "family-name": "Doe",
+ "street-address": "100 Main Street",
+ "address-level2": "Hamilton",
+ },
+ expectedResultState: {
+ "given-name": PREVIEW,
+ "family-name": PREVIEW,
+ "street-address": PREVIEW,
+ "address-level2": PREVIEW,
+ },
+ },
+ {
+ description: "Preview form with a readonly input and non-readonly inputs",
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="family-name" autocomplete="family-name">
+ <input id="street-addr" autocomplete="street-address">
+ <input id="city" autocomplete="address-level2" readonly value="TEST CITY">
+ </form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ "given-name": "John",
+ "family-name": "Doe",
+ "street-address": "100 Main Street",
+ city: "Hamilton",
+ },
+ expectedResultState: {
+ "given-name": PREVIEW,
+ "family-name": PREVIEW,
+ "street-address": PREVIEW,
+ "address-level2": undefined,
+ },
+ },
+ {
+ description: "Preview form with a disabled input and non-disabled inputs",
+ document: `<form>
+ <input id="given-name" autocomplete="given-name">
+ <input id="family-name" autocomplete="family-name">
+ <input id="street-addr" autocomplete="street-address">
+ <input id="country" autocomplete="country" disabled value="US">
+ </form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ "given-name": "John",
+ "family-name": "Doe",
+ "street-address": "100 Main Street",
+ country: "CA",
+ },
+ expectedResultState: {
+ "given-name": PREVIEW,
+ "family-name": PREVIEW,
+ "street-address": PREVIEW,
+ country: undefined,
+ },
+ },
+ {
+ description:
+ "Preview form with autocomplete select elements and matching option values",
+ document: `<form>
+ <input id="given-name" autocomplete="shipping given-name">
+ <select id="country" autocomplete="shipping country">
+ <option value=""></option>
+ <option value="US">United States</option>
+ </select>
+ <select id="state" autocomplete="shipping address-level1">
+ <option value=""></option>
+ <option value="CA">California</option>
+ <option value="WA">Washington</option>
+ </select>
+ </form>`,
+ focusedInputId: "given-name",
+ profileData: {
+ country: "US",
+ "address-level1": "CA",
+ },
+ expectedResultState: {
+ "given-name": NORMAL,
+ country: PREVIEW,
+ "address-level1": PREVIEW,
+ },
+ },
+ {
+ description: "Preview best case credit card form",
+ document: `<form>
+ <input id="cc-number" autocomplete="cc-number">
+ <input id="cc-name" autocomplete="cc-name">
+ <input id="cc-exp-month" autocomplete="cc-exp-month">
+ <input id="cc-exp-year" autocomplete="cc-exp-year">
+ <input id="cc-csc" autocomplete="cc-csc">
+ </form>
+ `,
+ focusedInputId: "cc-number",
+ profileData: {
+ guid: "123",
+ "cc-number": "4111111111111111",
+ "cc-name": "test name",
+ "cc-exp-month": 6,
+ "cc-exp-year": 25,
+ },
+ expectedResultState: {
+ "cc-number": PREVIEW,
+ "cc-name": PREVIEW,
+ "cc-exp-month": PREVIEW,
+ "cc-exp-year": PREVIEW,
+ "cc-csc": NORMAL,
+ },
+ },
+];
+
+function run_tests(testcases) {
+ for (let testcase of testcases) {
+ add_task(async function () {
+ info("Starting testcase: " + testcase.description);
+ let doc = MockDocument.createTestDocument(
+ "http://localhost:8080/test/",
+ testcase.document
+ );
+ let form = doc.querySelector("form");
+ let formLike = FormLikeFactory.createFromForm(form);
+ let handler = new FormAutofillHandler(formLike);
+
+ // Replace the internal decrypt method with OSKeyStore API,
+ // but don't pass the reauth parameter to avoid triggering
+ // reauth login dialog in these tests.
+ let decryptHelper = async (cipherText, reauth) => {
+ return OSKeyStore.decrypt(cipherText, false);
+ };
+ handler.collectFormFields();
+
+ let focusedInput = doc.getElementById(testcase.focusedInputId);
+ try {
+ handler.focusedInput = focusedInput;
+ } catch (e) {
+ if (e.message.includes("WeakMap key must be an object")) {
+ throw new Error(
+ `Couldn't find the focusedInputId in the current form! Make sure focusedInputId exists in your test form! testcase description:${testcase.description}`
+ );
+ } else {
+ throw e;
+ }
+ }
+
+ for (let section of handler.sections) {
+ section._decrypt = decryptHelper;
+ }
+
+ let [adaptedProfile] = handler.activeSection.getAdaptedProfiles([
+ testcase.profileData,
+ ]);
+
+ await handler.activeSection.previewFormFields(adaptedProfile);
+
+ for (let field of handler.fieldDetails) {
+ let actual = handler.getFilledStateByElement(
+ field.elementWeakRef.get()
+ );
+ let expected = testcase.expectedResultState[field.fieldName];
+ info(`Checking ${field.fieldName} state`);
+ Assert.equal(
+ actual,
+ expected,
+ "Check if preview state is set correctly"
+ );
+ }
+ });
+ }
+}
+
+run_tests(TESTCASES);
diff --git a/browser/extensions/formautofill/test/unit/test_profileAutocompleteResult.js b/browser/extensions/formautofill/test/unit/test_profileAutocompleteResult.js
new file mode 100644
index 0000000000..7a28f64634
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_profileAutocompleteResult.js
@@ -0,0 +1,450 @@
+"use strict";
+
+var AddressResult, CreditCardResult;
+add_setup(async () => {
+ ({ AddressResult, CreditCardResult } = ChromeUtils.importESModule(
+ "resource://autofill/ProfileAutoCompleteResult.sys.mjs"
+ ));
+});
+
+let matchingProfiles = [
+ {
+ guid: "test-guid-1",
+ "given-name": "Timothy",
+ "family-name": "Berners-Lee",
+ name: "Timothy Berners-Lee",
+ organization: "Sesame Street",
+ "street-address": "123 Sesame Street.",
+ "address-line1": "123 Sesame Street.",
+ tel: "1-345-345-3456.",
+ },
+ {
+ guid: "test-guid-2",
+ "given-name": "John",
+ "family-name": "Doe",
+ name: "John Doe",
+ organization: "Mozilla",
+ "street-address": "331 E. Evelyn Avenue",
+ "address-line1": "331 E. Evelyn Avenue",
+ tel: "1-650-903-0800",
+ },
+ {
+ guid: "test-guid-3",
+ organization: "",
+ "street-address": "321, No Name St. 2nd line 3rd line",
+ "-moz-street-address-one-line": "321, No Name St. 2nd line 3rd line",
+ "address-line1": "321, No Name St.",
+ "address-line2": "2nd line",
+ "address-line3": "3rd line",
+ tel: "1-000-000-0000",
+ },
+];
+
+let allFieldNames = [
+ "given-name",
+ "family-name",
+ "street-address",
+ "address-line1",
+ "address-line2",
+ "address-line3",
+ "organization",
+ "tel",
+];
+
+let addressTestCases = [
+ {
+ description: "Focus on an `organization` field",
+ options: {},
+ matchingProfiles,
+ allFieldNames,
+ searchString: "",
+ fieldName: "organization",
+ expected: {
+ searchResult: Ci.nsIAutoCompleteResult.RESULT_SUCCESS,
+ defaultIndex: 0,
+ items: [
+ {
+ value: "",
+ style: "autofill-profile",
+ comment: JSON.stringify(matchingProfiles[0]),
+ label: JSON.stringify({
+ primary: "Sesame Street",
+ secondary: "123 Sesame Street.",
+ }),
+ image: "",
+ },
+ {
+ value: "",
+ style: "autofill-profile",
+ comment: JSON.stringify(matchingProfiles[1]),
+ label: JSON.stringify({
+ primary: "Mozilla",
+ secondary: "331 E. Evelyn Avenue",
+ }),
+ image: "",
+ },
+ ],
+ },
+ },
+ {
+ description: "Focus on an `tel` field",
+ options: {},
+ matchingProfiles,
+ allFieldNames,
+ searchString: "",
+ fieldName: "tel",
+ expected: {
+ searchResult: Ci.nsIAutoCompleteResult.RESULT_SUCCESS,
+ defaultIndex: 0,
+ items: [
+ {
+ value: "",
+ style: "autofill-profile",
+ comment: JSON.stringify(matchingProfiles[0]),
+ label: JSON.stringify({
+ primary: "1-345-345-3456.",
+ secondary: "123 Sesame Street.",
+ }),
+ image: "",
+ },
+ {
+ value: "",
+ style: "autofill-profile",
+ comment: JSON.stringify(matchingProfiles[1]),
+ label: JSON.stringify({
+ primary: "1-650-903-0800",
+ secondary: "331 E. Evelyn Avenue",
+ }),
+ image: "",
+ },
+ {
+ value: "",
+ style: "autofill-profile",
+ comment: JSON.stringify(matchingProfiles[2]),
+ label: JSON.stringify({
+ primary: "1-000-000-0000",
+ secondary: "321, No Name St. 2nd line 3rd line",
+ }),
+ image: "",
+ },
+ ],
+ },
+ },
+ {
+ description: "Focus on an `street-address` field",
+ options: {},
+ matchingProfiles,
+ allFieldNames,
+ searchString: "",
+ fieldName: "street-address",
+ expected: {
+ searchResult: Ci.nsIAutoCompleteResult.RESULT_SUCCESS,
+ defaultIndex: 0,
+ items: [
+ {
+ value: "",
+ style: "autofill-profile",
+ comment: JSON.stringify(matchingProfiles[0]),
+ label: JSON.stringify({
+ primary: "123 Sesame Street.",
+ secondary: "Timothy Berners-Lee",
+ }),
+ image: "",
+ },
+ {
+ value: "",
+ style: "autofill-profile",
+ comment: JSON.stringify(matchingProfiles[1]),
+ label: JSON.stringify({
+ primary: "331 E. Evelyn Avenue",
+ secondary: "John Doe",
+ }),
+ image: "",
+ },
+ {
+ value: "",
+ style: "autofill-profile",
+ comment: JSON.stringify(matchingProfiles[2]),
+ label: JSON.stringify({
+ primary: "321, No Name St. 2nd line 3rd line",
+ secondary: "1-000-000-0000",
+ }),
+ image: "",
+ },
+ ],
+ },
+ },
+ {
+ description: "Focus on an `address-line1` field",
+ options: {},
+ matchingProfiles,
+ allFieldNames,
+ searchString: "",
+ fieldName: "address-line1",
+ expected: {
+ searchResult: Ci.nsIAutoCompleteResult.RESULT_SUCCESS,
+ defaultIndex: 0,
+ items: [
+ {
+ value: "",
+ style: "autofill-profile",
+ comment: JSON.stringify(matchingProfiles[0]),
+ label: JSON.stringify({
+ primary: "123 Sesame Street.",
+ secondary: "Timothy Berners-Lee",
+ }),
+ image: "",
+ },
+ {
+ value: "",
+ style: "autofill-profile",
+ comment: JSON.stringify(matchingProfiles[1]),
+ label: JSON.stringify({
+ primary: "331 E. Evelyn Avenue",
+ secondary: "John Doe",
+ }),
+ image: "",
+ },
+ {
+ value: "",
+ style: "autofill-profile",
+ comment: JSON.stringify(matchingProfiles[2]),
+ label: JSON.stringify({
+ primary: "321, No Name St.",
+ secondary: "1-000-000-0000",
+ }),
+ image: "",
+ },
+ ],
+ },
+ },
+ {
+ description: "No matching profiles",
+ options: {},
+ matchingProfiles: [],
+ allFieldNames,
+ searchString: "",
+ fieldName: "",
+ expected: {
+ searchResult: Ci.nsIAutoCompleteResult.RESULT_NOMATCH,
+ defaultIndex: 0,
+ items: [],
+ },
+ },
+ {
+ description: "Search with failure",
+ options: { resultCode: Ci.nsIAutoCompleteResult.RESULT_FAILURE },
+ matchingProfiles: [],
+ allFieldNames,
+ searchString: "",
+ fieldName: "",
+ expected: {
+ searchResult: Ci.nsIAutoCompleteResult.RESULT_FAILURE,
+ defaultIndex: 0,
+ items: [],
+ },
+ },
+];
+
+matchingProfiles = [
+ {
+ guid: "test-guid-1",
+ "cc-name": "Timothy Berners-Lee",
+ "cc-number": "************6785",
+ "cc-exp-month": 12,
+ "cc-exp-year": 2014,
+ "cc-type": "visa",
+ },
+ {
+ guid: "test-guid-2",
+ "cc-name": "John Doe",
+ "cc-number": "************1234",
+ "cc-exp-month": 4,
+ "cc-exp-year": 2014,
+ "cc-type": "amex",
+ },
+ {
+ guid: "test-guid-3",
+ "cc-number": "************5678",
+ "cc-exp-month": 8,
+ "cc-exp-year": 2018,
+ },
+];
+
+allFieldNames = ["cc-name", "cc-number", "cc-exp-month", "cc-exp-year"];
+
+let creditCardTestCases = [
+ {
+ description: "Focus on a `cc-name` field",
+ options: {},
+ matchingProfiles,
+ allFieldNames,
+ searchString: "",
+ fieldName: "cc-name",
+ expected: {
+ searchResult: Ci.nsIAutoCompleteResult.RESULT_SUCCESS,
+ defaultIndex: 0,
+ items: [
+ {
+ value: "",
+ style: "autofill-profile",
+ comment: JSON.stringify(matchingProfiles[0]),
+ label: JSON.stringify({
+ primary: "Timothy Berners-Lee",
+ secondary: "****6785",
+ ariaLabel: "Visa Timothy Berners-Lee ****6785",
+ }),
+ image: "chrome://formautofill/content/third-party/cc-logo-visa.svg",
+ },
+ {
+ value: "",
+ style: "autofill-profile",
+ comment: JSON.stringify(matchingProfiles[1]),
+ label: JSON.stringify({
+ primary: "John Doe",
+ secondary: "****1234",
+ ariaLabel: "American Express John Doe ****1234",
+ }),
+ image: "chrome://formautofill/content/third-party/cc-logo-amex.png",
+ },
+ ],
+ },
+ },
+ {
+ description: "Focus on a `cc-number` field",
+ options: {},
+ matchingProfiles,
+ allFieldNames,
+ searchString: "",
+ fieldName: "cc-number",
+ expected: {
+ searchResult: Ci.nsIAutoCompleteResult.RESULT_SUCCESS,
+ defaultIndex: 0,
+ items: [
+ {
+ value: "",
+ style: "autofill-profile",
+ comment: JSON.stringify(matchingProfiles[0]),
+ label: JSON.stringify({
+ primaryAffix: "****",
+ primary: "6785",
+ secondary: "Timothy Berners-Lee",
+ ariaLabel: "Visa **** 6785 Timothy Berners-Lee",
+ }),
+ image: "chrome://formautofill/content/third-party/cc-logo-visa.svg",
+ },
+ {
+ value: "",
+ style: "autofill-profile",
+ comment: JSON.stringify(matchingProfiles[1]),
+ label: JSON.stringify({
+ primaryAffix: "****",
+ primary: "1234",
+ secondary: "John Doe",
+ ariaLabel: "American Express **** 1234 John Doe",
+ }),
+ image: "chrome://formautofill/content/third-party/cc-logo-amex.png",
+ },
+ {
+ value: "",
+ style: "autofill-profile",
+ comment: JSON.stringify(matchingProfiles[2]),
+ label: JSON.stringify({
+ primaryAffix: "****",
+ primary: "5678",
+ secondary: "",
+ ariaLabel: "**** 5678",
+ }),
+ image: "chrome://formautofill/content/icon-credit-card-generic.svg",
+ },
+ ],
+ },
+ },
+ {
+ description: "No matching profiles",
+ options: {},
+ matchingProfiles: [],
+ allFieldNames,
+ searchString: "",
+ fieldName: "",
+ expected: {
+ searchResult: Ci.nsIAutoCompleteResult.RESULT_NOMATCH,
+ defaultIndex: 0,
+ items: [],
+ },
+ },
+ {
+ description: "Search with failure",
+ options: { resultCode: Ci.nsIAutoCompleteResult.RESULT_FAILURE },
+ matchingProfiles: [],
+ allFieldNames,
+ searchString: "",
+ fieldName: "",
+ expected: {
+ searchResult: Ci.nsIAutoCompleteResult.RESULT_FAILURE,
+ defaultIndex: 0,
+ items: [],
+ },
+ },
+];
+
+add_task(async function test_all_patterns() {
+ let testSets = [
+ {
+ collectionConstructor: AddressResult,
+ testCases: addressTestCases,
+ },
+ {
+ collectionConstructor: CreditCardResult,
+ testCases: creditCardTestCases,
+ },
+ ];
+
+ testSets.forEach(({ collectionConstructor, testCases }) => {
+ testCases.forEach(testCase => {
+ info("Starting testcase: " + testCase.description);
+ let actual = new collectionConstructor(
+ testCase.searchString,
+ testCase.fieldName,
+ testCase.allFieldNames,
+ testCase.matchingProfiles,
+ testCase.options
+ );
+ let expectedValue = testCase.expected;
+ let expectedItemLength = expectedValue.items.length;
+ // If the last item shows up as a footer, we expect one more item
+ // than expected.
+ if (actual.getStyleAt(actual.matchCount - 1) == "autofill-footer") {
+ expectedItemLength++;
+ }
+
+ equal(actual.searchResult, expectedValue.searchResult);
+ equal(actual.defaultIndex, expectedValue.defaultIndex);
+ equal(actual.matchCount, expectedItemLength);
+ expectedValue.items.forEach((item, index) => {
+ equal(actual.getValueAt(index), item.value);
+ equal(actual.getCommentAt(index), item.comment);
+ equal(actual.getLabelAt(index), item.label);
+ equal(actual.getStyleAt(index), item.style);
+ equal(actual.getImageAt(index), item.image);
+ });
+
+ if (expectedValue.items.length) {
+ Assert.throws(
+ () => actual.getValueAt(expectedItemLength),
+ /Index out of range\./
+ );
+
+ Assert.throws(
+ () => actual.getLabelAt(expectedItemLength),
+ /Index out of range\./
+ );
+
+ Assert.throws(
+ () => actual.getCommentAt(expectedItemLength),
+ /Index out of range\./
+ );
+ }
+ });
+ });
+});
diff --git a/browser/extensions/formautofill/test/unit/test_reconcile.js b/browser/extensions/formautofill/test/unit/test_reconcile.js
new file mode 100644
index 0000000000..1700f89fe3
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_reconcile.js
@@ -0,0 +1,1173 @@
+"use strict";
+
+const TEST_STORE_FILE_NAME = "test-profile.json";
+const { CREDIT_CARD_SCHEMA_VERSION } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillStorageBase.sys.mjs"
+);
+
+// NOTE: a guide to reading these test-cases:
+// parent: What the local record looked like the last time we wrote the
+// record to the Sync server.
+// local: What the local record looks like now. IOW, the differences between
+// 'parent' and 'local' are changes recently made which we wish to sync.
+// remote: An incoming record we need to apply (ie, a record that was possibly
+// changed on a remote device)
+//
+// To further help understanding this, a few of the testcases are annotated.
+const ADDRESS_RECONCILE_TESTCASES = [
+ {
+ description: "Local change",
+ parent: {
+ // So when we last wrote the record to the server, it had these values.
+ guid: "2bbd2d8fbc6b",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ },
+ local: [
+ {
+ // The current local record - by comparing against parent we can see that
+ // only the given-name has changed locally.
+ "given-name": "Skip",
+ "family-name": "Hammond",
+ },
+ ],
+ remote: {
+ // This is the incoming record. It has the same values as "parent", so
+ // we can deduce the record hasn't actually been changed remotely so we
+ // can safely ignore the incoming record and write our local changes.
+ guid: "2bbd2d8fbc6b",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ },
+ reconciled: {
+ guid: "2bbd2d8fbc6b",
+ "given-name": "Skip",
+ "family-name": "Hammond",
+ },
+ },
+ {
+ description: "Remote change",
+ parent: {
+ guid: "e3680e9f890d",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ },
+ local: [
+ {
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ },
+ ],
+ remote: {
+ guid: "e3680e9f890d",
+ version: 1,
+ "given-name": "Skip",
+ "family-name": "Hammond",
+ },
+ reconciled: {
+ guid: "e3680e9f890d",
+ "given-name": "Skip",
+ "family-name": "Hammond",
+ },
+ },
+ {
+ description: "New local field",
+ parent: {
+ guid: "0cba738b1be0",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ },
+ local: [
+ {
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ tel: "123456",
+ },
+ ],
+ remote: {
+ guid: "0cba738b1be0",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ },
+ reconciled: {
+ guid: "0cba738b1be0",
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ tel: "123456",
+ },
+ },
+ {
+ description: "New remote field",
+ parent: {
+ guid: "be3ef97f8285",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ },
+ local: [
+ {
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ },
+ ],
+ remote: {
+ guid: "be3ef97f8285",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ tel: "123456",
+ },
+ reconciled: {
+ guid: "be3ef97f8285",
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ tel: "123456",
+ },
+ },
+ {
+ description: "Deleted field locally",
+ parent: {
+ guid: "9627322248ec",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ tel: "123456",
+ },
+ local: [
+ {
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ },
+ ],
+ remote: {
+ guid: "9627322248ec",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ tel: "123456",
+ },
+ reconciled: {
+ guid: "9627322248ec",
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ },
+ },
+ {
+ description: "Deleted field remotely",
+ parent: {
+ guid: "7d7509f3eeb2",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ tel: "123456",
+ },
+ local: [
+ {
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ tel: "123456",
+ },
+ ],
+ remote: {
+ guid: "7d7509f3eeb2",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ },
+ reconciled: {
+ guid: "7d7509f3eeb2",
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ },
+ },
+ {
+ description: "Local and remote changes to unrelated fields",
+ parent: {
+ // The last time we wrote this to the server, country was NZ.
+ guid: "e087a06dfc57",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ country: "NZ",
+ // We also had an unknown field we round-tripped
+ foo: "bar",
+ },
+ local: [
+ {
+ // The current local record - so locally we've changed given-name to Skip.
+ "given-name": "Skip",
+ "family-name": "Hammond",
+ country: "NZ",
+ },
+ ],
+ remote: {
+ // Remotely, we've changed the country to AU.
+ guid: "e087a06dfc57",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ country: "AU",
+ // This is a new unknown field that should send instead!
+ "unknown-1": "an unknown field from another client",
+ },
+ reconciled: {
+ guid: "e087a06dfc57",
+ "given-name": "Skip",
+ "family-name": "Hammond",
+ country: "AU",
+ // This is a new unknown field that should send instead!
+ "unknown-1": "an unknown field from another client",
+ },
+ },
+ {
+ description: "Multiple local changes",
+ parent: {
+ guid: "340a078c596f",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ tel: "123456",
+ },
+ local: [
+ {
+ "given-name": "Skip",
+ "family-name": "Hammond",
+ },
+ {
+ "given-name": "Skip",
+ "family-name": "Hammond",
+ organization: "Mozilla",
+ },
+ ],
+ remote: {
+ guid: "340a078c596f",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ tel: "123456",
+ country: "AU",
+ },
+ reconciled: {
+ guid: "340a078c596f",
+ "given-name": "Skip",
+ "family-name": "Hammond",
+ organization: "Mozilla",
+ country: "AU",
+ },
+ },
+ {
+ // Local and remote diverged from the shared parent, but the values are the
+ // same, so we shouldn't fork.
+ description: "Same change to local and remote",
+ parent: {
+ guid: "0b3a72a1bea2",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ // unknown fields we previously roundtripped
+ foo: "bar",
+ },
+ local: [
+ {
+ "given-name": "Skip",
+ "family-name": "Hammond",
+ },
+ ],
+ remote: {
+ guid: "0b3a72a1bea2",
+ version: 1,
+ "given-name": "Skip",
+ "family-name": "Hammond",
+ // New unknown field that should be the new round trip
+ "unknown-1": "an unknown field from another client",
+ },
+ reconciled: {
+ guid: "0b3a72a1bea2",
+ "given-name": "Skip",
+ "family-name": "Hammond",
+ },
+ },
+ {
+ description: "Conflicting changes to single field",
+ parent: {
+ // This is what we last wrote to the sync server.
+ guid: "62068784d089",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ "unknown-1": "an unknown field from another client",
+ },
+ local: [
+ {
+ // The current version of the local record - the given-name has changed locally.
+ "given-name": "Skip",
+ "family-name": "Hammond",
+ },
+ ],
+ remote: {
+ // An incoming record has a different given-name than any of the above!
+ guid: "62068784d089",
+ version: 1,
+ "given-name": "Kip",
+ "family-name": "Hammond",
+ "unknown-1": "an unknown field from another client",
+ },
+ forked: {
+ // So we've forked the local record to a new GUID (and the next sync is
+ // going to write this as a new record)
+ "given-name": "Skip",
+ "family-name": "Hammond",
+ "unknown-1": "an unknown field from another client",
+ },
+ reconciled: {
+ // And we've updated the local version of the record to be the remote version.
+ guid: "62068784d089",
+ "given-name": "Kip",
+ "family-name": "Hammond",
+ "unknown-1": "an unknown field from another client",
+ },
+ },
+ {
+ description: "Conflicting changes to multiple fields",
+ parent: {
+ guid: "244dbb692e94",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ country: "NZ",
+ },
+ local: [
+ {
+ "given-name": "Skip",
+ "family-name": "Hammond",
+ country: "AU",
+ },
+ ],
+ remote: {
+ guid: "244dbb692e94",
+ version: 1,
+ "given-name": "Kip",
+ "family-name": "Hammond",
+ country: "CA",
+ },
+ forked: {
+ "given-name": "Skip",
+ "family-name": "Hammond",
+ country: "AU",
+ },
+ reconciled: {
+ guid: "244dbb692e94",
+ "given-name": "Kip",
+ "family-name": "Hammond",
+ country: "CA",
+ },
+ },
+ {
+ description: "Field deleted locally, changed remotely",
+ parent: {
+ guid: "6fc45e03d19a",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ country: "AU",
+ },
+ local: [
+ {
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ },
+ ],
+ remote: {
+ guid: "6fc45e03d19a",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ country: "NZ",
+ },
+ forked: {
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ },
+ reconciled: {
+ guid: "6fc45e03d19a",
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ country: "NZ",
+ },
+ },
+ {
+ description: "Field changed locally, deleted remotely",
+ parent: {
+ guid: "fff9fa27fa18",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ country: "AU",
+ },
+ local: [
+ {
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ country: "NZ",
+ },
+ ],
+ remote: {
+ guid: "fff9fa27fa18",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ },
+ forked: {
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ country: "NZ",
+ },
+ reconciled: {
+ guid: "fff9fa27fa18",
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ },
+ },
+ {
+ // Created, last modified should be synced; last used and times used should
+ // be local. Remote created time older than local, remote modified time
+ // newer than local.
+ description:
+ "Created, last modified time reconciliation without local changes",
+ parent: {
+ guid: "5113f329c42f",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ timeCreated: 1234,
+ timeLastModified: 5678,
+ timeLastUsed: 5678,
+ timesUsed: 6,
+ },
+ local: [],
+ remote: {
+ guid: "5113f329c42f",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ timeCreated: 1200,
+ timeLastModified: 5700,
+ timeLastUsed: 5700,
+ timesUsed: 3,
+ },
+ reconciled: {
+ guid: "5113f329c42f",
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ timeCreated: 1200,
+ timeLastModified: 5700,
+ timeLastUsed: 5678,
+ timesUsed: 6,
+ },
+ },
+ {
+ // Local changes, remote created time newer than local, remote modified time
+ // older than local.
+ description:
+ "Created, last modified time reconciliation with local changes",
+ parent: {
+ guid: "791e5608b80a",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ timeCreated: 1234,
+ timeLastModified: 5678,
+ timeLastUsed: 5678,
+ timesUsed: 6,
+ },
+ local: [
+ {
+ "given-name": "Skip",
+ "family-name": "Hammond",
+ },
+ ],
+ remote: {
+ guid: "791e5608b80a",
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ timeCreated: 1300,
+ timeLastModified: 5000,
+ timeLastUsed: 5000,
+ timesUsed: 3,
+ },
+ reconciled: {
+ guid: "791e5608b80a",
+ "given-name": "Skip",
+ "family-name": "Hammond",
+ timeCreated: 1234,
+ timeLastUsed: 5678,
+ timesUsed: 6,
+ },
+ },
+];
+
+const CREDIT_CARD_RECONCILE_TESTCASES = [
+ {
+ description: "Local change",
+ parent: {
+ // So when we last wrote the record to the server, it had these values.
+ guid: "2bbd2d8fbc6b",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "unknown-1": "an unknown field from another client",
+ },
+ local: [
+ {
+ // The current local record - by comparing against parent we can see that
+ // only the cc-number has changed locally.
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ },
+ ],
+ remote: {
+ // This is the incoming record. It has the same values as "parent", so
+ // we can deduce the record hasn't actually been changed remotely so we
+ // can safely ignore the incoming record and write our local changes.
+ guid: "2bbd2d8fbc6b",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "unknown-2": "a newer unknown field",
+ },
+ reconciled: {
+ guid: "2bbd2d8fbc6b",
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ "unknown-2": "a newer unknown field",
+ },
+ },
+ {
+ description: "Remote change",
+ parent: {
+ guid: "e3680e9f890d",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "unknown-1": "unknown field",
+ },
+ local: [
+ {
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ },
+ ],
+ remote: {
+ guid: "e3680e9f890d",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ "unknown-1": "unknown field",
+ },
+ reconciled: {
+ guid: "e3680e9f890d",
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ "unknown-1": "unknown field",
+ },
+ },
+
+ {
+ description: "New local field",
+ parent: {
+ guid: "0cba738b1be0",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ },
+ local: [
+ {
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 12,
+ },
+ ],
+ remote: {
+ guid: "0cba738b1be0",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ },
+ reconciled: {
+ guid: "0cba738b1be0",
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 12,
+ },
+ },
+ {
+ description: "New remote field",
+ parent: {
+ guid: "be3ef97f8285",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ },
+ local: [
+ {
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ },
+ ],
+ remote: {
+ guid: "be3ef97f8285",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 12,
+ },
+ reconciled: {
+ guid: "be3ef97f8285",
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 12,
+ },
+ },
+ {
+ description: "Deleted field locally",
+ parent: {
+ guid: "9627322248ec",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 12,
+ },
+ local: [
+ {
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ },
+ ],
+ remote: {
+ guid: "9627322248ec",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 12,
+ },
+ reconciled: {
+ guid: "9627322248ec",
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ },
+ },
+ {
+ description: "Deleted field remotely",
+ parent: {
+ guid: "7d7509f3eeb2",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 12,
+ },
+ local: [
+ {
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 12,
+ },
+ ],
+ remote: {
+ guid: "7d7509f3eeb2",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ },
+ reconciled: {
+ guid: "7d7509f3eeb2",
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ },
+ },
+ {
+ description: "Local and remote changes to unrelated fields",
+ parent: {
+ // The last time we wrote this to the server, "cc-exp-month" was 12.
+ guid: "e087a06dfc57",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 12,
+ "unknown-1": "unknown field",
+ },
+ local: [
+ {
+ // The current local record - so locally we've changed "cc-number".
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ "cc-exp-month": 12,
+ },
+ ],
+ remote: {
+ // Remotely, we've changed "cc-exp-month" to 1.
+ guid: "e087a06dfc57",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 1,
+ "unknown-2": "a newer unknown field",
+ },
+ reconciled: {
+ guid: "e087a06dfc57",
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ "cc-exp-month": 1,
+ "unknown-2": "a newer unknown field",
+ },
+ },
+ {
+ description: "Multiple local changes",
+ parent: {
+ guid: "340a078c596f",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "unknown-1": "unknown field",
+ },
+ local: [
+ {
+ "cc-name": "Skip",
+ "cc-number": "4111111111111111",
+ },
+ {
+ "cc-name": "Skip",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 12,
+ },
+ ],
+ remote: {
+ guid: "340a078c596f",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-year": 2000,
+ "unknown-1": "unknown field",
+ },
+ reconciled: {
+ guid: "340a078c596f",
+ "cc-name": "Skip",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 12,
+ "cc-exp-year": 2000,
+ "unknown-1": "unknown field",
+ },
+ },
+ {
+ // Local and remote diverged from the shared parent, but the values are the
+ // same, so we shouldn't fork.
+ description: "Same change to local and remote",
+ parent: {
+ guid: "0b3a72a1bea2",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ },
+ local: [
+ {
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ },
+ ],
+ remote: {
+ guid: "0b3a72a1bea2",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ },
+ reconciled: {
+ guid: "0b3a72a1bea2",
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ },
+ },
+ {
+ description: "Conflicting changes to single field",
+ parent: {
+ // This is what we last wrote to the sync server.
+ guid: "62068784d089",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "unknown-1": "unknown field",
+ },
+ local: [
+ {
+ // The current version of the local record - the cc-number has changed locally.
+ "cc-name": "John Doe",
+ "cc-number": "5103059495477870",
+ },
+ ],
+ remote: {
+ // An incoming record has a different cc-number than any of the above!
+ guid: "62068784d089",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ "unknown-1": "unknown field",
+ },
+ forked: {
+ // So we've forked the local record to a new GUID (and the next sync is
+ // going to write this as a new record)
+ "cc-name": "John Doe",
+ "cc-number": "5103059495477870",
+ "unknown-1": "unknown field",
+ },
+ reconciled: {
+ // And we've updated the local version of the record to be the remote version.
+ guid: "62068784d089",
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ "unknown-1": "unknown field",
+ },
+ },
+ {
+ description: "Conflicting changes to multiple fields",
+ parent: {
+ guid: "244dbb692e94",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 12,
+ },
+ local: [
+ {
+ "cc-name": "John Doe",
+ "cc-number": "5103059495477870",
+ "cc-exp-month": 1,
+ },
+ ],
+ remote: {
+ guid: "244dbb692e94",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ "cc-exp-month": 3,
+ },
+ forked: {
+ "cc-name": "John Doe",
+ "cc-number": "5103059495477870",
+ "cc-exp-month": 1,
+ },
+ reconciled: {
+ guid: "244dbb692e94",
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ "cc-exp-month": 3,
+ },
+ },
+ {
+ description: "Field deleted locally, changed remotely",
+ parent: {
+ guid: "6fc45e03d19a",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 12,
+ },
+ local: [
+ {
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ },
+ ],
+ remote: {
+ guid: "6fc45e03d19a",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 3,
+ },
+ forked: {
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ },
+ reconciled: {
+ guid: "6fc45e03d19a",
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 3,
+ },
+ },
+ {
+ description: "Field changed locally, deleted remotely",
+ parent: {
+ guid: "fff9fa27fa18",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 12,
+ },
+ local: [
+ {
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 3,
+ },
+ ],
+ remote: {
+ guid: "fff9fa27fa18",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ },
+ forked: {
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 3,
+ },
+ reconciled: {
+ guid: "fff9fa27fa18",
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ },
+ },
+ {
+ // Created, last modified should be synced; last used and times used should
+ // be local. Remote created time older than local, remote modified time
+ // newer than local.
+ description:
+ "Created, last modified time reconciliation without local changes",
+ parent: {
+ guid: "5113f329c42f",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ timeCreated: 1234,
+ timeLastModified: 5678,
+ timeLastUsed: 5678,
+ timesUsed: 6,
+ },
+ local: [],
+ remote: {
+ guid: "5113f329c42f",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ timeCreated: 1200,
+ timeLastModified: 5700,
+ timeLastUsed: 5700,
+ timesUsed: 3,
+ },
+ reconciled: {
+ guid: "5113f329c42f",
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ timeCreated: 1200,
+ timeLastModified: 5700,
+ timeLastUsed: 5678,
+ timesUsed: 6,
+ },
+ },
+ {
+ // Local changes, remote created time newer than local, remote modified time
+ // older than local.
+ description:
+ "Created, last modified time reconciliation with local changes",
+ parent: {
+ guid: "791e5608b80a",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ timeCreated: 1234,
+ timeLastModified: 5678,
+ timeLastUsed: 5678,
+ timesUsed: 6,
+ },
+ local: [
+ {
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ },
+ ],
+ remote: {
+ guid: "791e5608b80a",
+ version: CREDIT_CARD_SCHEMA_VERSION,
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ timeCreated: 1300,
+ timeLastModified: 5000,
+ timeLastUsed: 5000,
+ timesUsed: 3,
+ },
+ reconciled: {
+ guid: "791e5608b80a",
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ timeCreated: 1234,
+ timeLastUsed: 5678,
+ timesUsed: 6,
+ },
+ },
+];
+
+add_task(async function test_reconcile_unknown_version() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME);
+
+ // Cross-version reconciliation isn't supported yet. See bug 1377204.
+ await Assert.rejects(
+ profileStorage.addresses.reconcile({
+ guid: "31d83d2725ec",
+ version: 3,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ }),
+ /Got unknown record version/
+ );
+});
+
+add_task(async function test_reconcile_idempotent() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME);
+
+ let guid = "de1ba7b094fe";
+ await profileStorage.addresses.add(
+ {
+ guid,
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ // an unknown field from a previous sync
+ foo: "bar",
+ },
+ { sourceSync: true }
+ );
+ await profileStorage.addresses.update(guid, {
+ "given-name": "Skip",
+ "family-name": "Hammond",
+ organization: "Mozilla",
+ });
+
+ let remote = {
+ guid,
+ version: 1,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ tel: "123456",
+ "unknown-1": "an unknown field from another client",
+ };
+
+ {
+ let { forkedGUID } = await profileStorage.addresses.reconcile(remote);
+ let updatedRecord = await profileStorage.addresses.get(guid, {
+ rawData: true,
+ });
+
+ ok(!forkedGUID, "First merge should not fork record");
+ ok(
+ objectMatches(updatedRecord, {
+ guid: "de1ba7b094fe",
+ "given-name": "Skip",
+ "family-name": "Hammond",
+ organization: "Mozilla",
+ tel: "123456",
+ "unknown-1": "an unknown field from another client",
+ }),
+ "First merge should merge local and remote changes"
+ );
+ }
+
+ {
+ let { forkedGUID } = await profileStorage.addresses.reconcile(remote);
+ let updatedRecord = await profileStorage.addresses.get(guid, {
+ rawData: true,
+ });
+
+ ok(!forkedGUID, "Second merge should not fork record");
+ ok(
+ objectMatches(updatedRecord, {
+ guid: "de1ba7b094fe",
+ "given-name": "Skip",
+ "family-name": "Hammond",
+ organization: "Mozilla",
+ tel: "123456",
+ "unknown-1": "an unknown field from another client",
+ }),
+ "Second merge should not change record"
+ );
+ }
+});
+
+add_task(async function test_reconcile_three_way_merge() {
+ let TESTCASES = {
+ addresses: ADDRESS_RECONCILE_TESTCASES,
+ creditCards: CREDIT_CARD_RECONCILE_TESTCASES,
+ };
+
+ for (let collectionName in TESTCASES) {
+ info(`Start to test reconcile on ${collectionName}`);
+
+ let profileStorage = await initProfileStorage(
+ TEST_STORE_FILE_NAME,
+ null,
+ collectionName
+ );
+
+ for (let test of TESTCASES[collectionName]) {
+ info(test.description);
+
+ await profileStorage[collectionName].add(test.parent, {
+ sourceSync: true,
+ });
+
+ for (let updatedRecord of test.local) {
+ await profileStorage[collectionName].update(
+ test.parent.guid,
+ updatedRecord
+ );
+ }
+
+ let localRecord = await profileStorage[collectionName].get(
+ test.parent.guid,
+ {
+ rawData: true,
+ }
+ );
+
+ let onReconciled = TestUtils.topicObserved(
+ "formautofill-storage-changed",
+ (subject, data) =>
+ data == "reconcile" &&
+ subject.wrappedJSObject.collectionName == collectionName
+ );
+ let { forkedGUID } = await profileStorage[collectionName].reconcile(
+ test.remote
+ );
+ await onReconciled;
+ let reconciledRecord = await profileStorage[collectionName].get(
+ test.parent.guid,
+ {
+ rawData: true,
+ }
+ );
+ if (forkedGUID) {
+ let forkedRecord = await profileStorage[collectionName].get(
+ forkedGUID,
+ {
+ rawData: true,
+ }
+ );
+
+ notEqual(forkedRecord.guid, reconciledRecord.guid);
+ equal(forkedRecord.timeLastModified, localRecord.timeLastModified);
+ ok(
+ objectMatches(forkedRecord, test.forked),
+ `${test.description} should fork record`
+ );
+ } else {
+ ok(!test.forked, `${test.description} should not fork record`);
+ }
+
+ ok(objectMatches(reconciledRecord, test.reconciled));
+ }
+ }
+});
diff --git a/browser/extensions/formautofill/test/unit/test_savedFieldNames.js b/browser/extensions/formautofill/test/unit/test_savedFieldNames.js
new file mode 100644
index 0000000000..6e3474c06d
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_savedFieldNames.js
@@ -0,0 +1,106 @@
+/*
+ * Test for keeping the valid fields information in sharedData.
+ */
+
+"use strict";
+
+let FormAutofillStatus;
+
+add_setup(async () => {
+ ({ FormAutofillStatus } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillParent.sys.mjs"
+ ));
+});
+
+add_task(async function test_profileSavedFieldNames_init() {
+ FormAutofillStatus.init();
+ sinon.stub(FormAutofillStatus, "updateSavedFieldNames");
+
+ await FormAutofillStatus.formAutofillStorage.initialize();
+ Assert.equal(FormAutofillStatus.updateSavedFieldNames.called, true);
+
+ FormAutofillStatus.uninit();
+});
+
+add_task(async function test_profileSavedFieldNames_observe() {
+ FormAutofillStatus.init();
+
+ // profile changed => Need to trigger updateValidFields
+ ["add", "update", "remove", "reconcile", "removeAll"].forEach(event => {
+ FormAutofillStatus.observe(null, "formautofill-storage-changed", event);
+ Assert.equal(FormAutofillStatus.updateSavedFieldNames.called, true);
+ });
+
+ // profile metadata updated => no need to trigger updateValidFields
+ FormAutofillStatus.updateSavedFieldNames.resetHistory();
+ FormAutofillStatus.observe(
+ null,
+ "formautofill-storage-changed",
+ "notifyUsed"
+ );
+ Assert.equal(FormAutofillStatus.updateSavedFieldNames.called, false);
+ FormAutofillStatus.updateSavedFieldNames.restore();
+});
+
+add_task(async function test_profileSavedFieldNames_update() {
+ registerCleanupFunction(function cleanup() {
+ Services.prefs.clearUserPref("extensions.formautofill.addresses.enabled");
+ });
+
+ Object.defineProperty(
+ FormAutofillStatus.formAutofillStorage.addresses,
+ "_data",
+ { writable: true }
+ );
+
+ FormAutofillStatus.formAutofillStorage.addresses._data = [];
+
+ // The set is empty if there's no profile in the store.
+ await FormAutofillStatus.updateSavedFieldNames();
+ Assert.equal(
+ Services.ppmm.sharedData.get("FormAutofill:savedFieldNames").size,
+ 0
+ );
+
+ // 2 profiles with 4 valid fields.
+ FormAutofillStatus.formAutofillStorage.addresses._data = [
+ {
+ guid: "test-guid-1",
+ organization: "Sesame Street",
+ "street-address": "123 Sesame Street.",
+ tel: "1-345-345-3456",
+ email: "",
+ timeCreated: 0,
+ timeLastUsed: 0,
+ timeLastModified: 0,
+ timesUsed: 0,
+ },
+ {
+ guid: "test-guid-2",
+ organization: "Mozilla",
+ "street-address": "331 E. Evelyn Avenue",
+ tel: "1-650-903-0800",
+ country: "US",
+ timeCreated: 0,
+ timeLastUsed: 0,
+ timeLastModified: 0,
+ timesUsed: 0,
+ },
+ ];
+
+ await FormAutofillStatus.updateSavedFieldNames();
+
+ let autofillSavedFieldNames = Services.ppmm.sharedData.get(
+ "FormAutofill:savedFieldNames"
+ );
+ Assert.equal(autofillSavedFieldNames.size, 4);
+ Assert.equal(autofillSavedFieldNames.has("organization"), true);
+ Assert.equal(autofillSavedFieldNames.has("street-address"), true);
+ Assert.equal(autofillSavedFieldNames.has("tel"), true);
+ Assert.equal(autofillSavedFieldNames.has("email"), false);
+ Assert.equal(autofillSavedFieldNames.has("guid"), false);
+ Assert.equal(autofillSavedFieldNames.has("timeCreated"), false);
+ Assert.equal(autofillSavedFieldNames.has("timeLastUsed"), false);
+ Assert.equal(autofillSavedFieldNames.has("timeLastModified"), false);
+ Assert.equal(autofillSavedFieldNames.has("timesUsed"), false);
+});
diff --git a/browser/extensions/formautofill/test/unit/test_storage_remove.js b/browser/extensions/formautofill/test/unit/test_storage_remove.js
new file mode 100644
index 0000000000..0c33440ea9
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_storage_remove.js
@@ -0,0 +1,88 @@
+/**
+ * Tests removing all address/creditcard records.
+ */
+
+"use strict";
+
+let FormAutofillStorage;
+add_setup(async () => {
+ ({ FormAutofillStorage } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillStorage.sys.mjs"
+ ));
+});
+
+const TEST_STORE_FILE_NAME = "test-tombstones.json";
+
+const TEST_ADDRESS_1 = {
+ "given-name": "Timothy",
+ "additional-name": "John",
+ "family-name": "Berners-Lee",
+ organization: "World Wide Web Consortium",
+ "street-address": "32 Vassar Street\nMIT Room 32-G524",
+ "address-level2": "Cambridge",
+ "address-level1": "MA",
+ "postal-code": "02139",
+ country: "US",
+ tel: "+1 617 253 5702",
+ email: "timbl@w3.org",
+};
+
+const TEST_ADDRESS_2 = {
+ "street-address": "Some Address",
+ country: "US",
+};
+
+const TEST_CREDIT_CARD_1 = {
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 4,
+ "cc-exp-year": 2017,
+};
+
+const TEST_CREDIT_CARD_2 = {
+ "cc-name": "Timothy Berners-Lee",
+ "cc-number": "4929001587121045",
+ "cc-exp-month": 12,
+ "cc-exp-year": 2022,
+};
+
+// Like add_task, but actually adds 2 - one for addresses and one for cards.
+function add_storage_task(test_function) {
+ add_task(async function () {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+ let profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+ let address_records = [TEST_ADDRESS_1, TEST_ADDRESS_2];
+ let cc_records = [TEST_CREDIT_CARD_1, TEST_CREDIT_CARD_2];
+
+ for (let [storage, record] of [
+ [profileStorage.addresses, address_records],
+ [profileStorage.creditCards, cc_records],
+ ]) {
+ await test_function(storage, record);
+ }
+ });
+}
+
+add_storage_task(async function test_remove_everything(storage, records) {
+ info("check simple tombstone semantics");
+
+ let guid = await storage.add(records[0]);
+ Assert.equal((await storage.getAll()).length, 1);
+
+ storage.pullSyncChanges(); // force sync metadata, which triggers tombstone behaviour.
+
+ storage.remove(guid);
+
+ await storage.add(records[1]);
+ // getAll() is still 1 as we deleted the first.
+ Assert.equal((await storage.getAll()).length, 1);
+
+ // check we have the tombstone.
+ Assert.equal((await storage.getAll({ includeDeleted: true })).length, 2);
+
+ storage.removeAll();
+
+ // should have deleted both the existing and deleted records.
+ Assert.equal((await storage.getAll({ includeDeleted: true })).length, 0);
+});
diff --git a/browser/extensions/formautofill/test/unit/test_storage_syncfields.js b/browser/extensions/formautofill/test/unit/test_storage_syncfields.js
new file mode 100644
index 0000000000..e304aa4df0
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_storage_syncfields.js
@@ -0,0 +1,498 @@
+/**
+ * Tests FormAutofillStorage objects support for sync related fields.
+ */
+
+"use strict";
+
+// The duplication of some of these fixtures between tests is unfortunate.
+const TEST_STORE_FILE_NAME = "test-profile.json";
+
+const TEST_ADDRESS_1 = {
+ "given-name": "Timothy",
+ "additional-name": "John",
+ "family-name": "Berners-Lee",
+ organization: "World Wide Web Consortium",
+ "street-address": "32 Vassar Street\nMIT Room 32-G524",
+ "address-level2": "Cambridge",
+ "address-level1": "MA",
+ "postal-code": "02139",
+ country: "US",
+ tel: "+1 617 253 5702",
+ email: "timbl@w3.org",
+ "unknown-1": "an unknown field we roundtrip",
+};
+
+const TEST_ADDRESS_2 = {
+ "street-address": "Some Address",
+ country: "US",
+};
+
+const TEST_ADDRESS_3 = {
+ "street-address": "Other Address",
+ "postal-code": "12345",
+};
+
+// storage.get() doesn't support getting deleted items. However, this test
+// wants to do that, so rather than making .get() support that just for this
+// test, we use this helper.
+async function findGUID(storage, guid, options) {
+ let all = await storage.getAll(options);
+ let records = all.filter(r => r.guid == guid);
+ equal(records.length, 1);
+ return records[0];
+}
+
+add_task(async function test_changeCounter() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [
+ TEST_ADDRESS_1,
+ ]);
+
+ let [address] = await profileStorage.addresses.getAll();
+ // new records don't get the sync metadata.
+ equal(getSyncChangeCounter(profileStorage.addresses, address.guid), -1);
+ // But we can force one.
+ profileStorage.addresses.pullSyncChanges();
+ equal(getSyncChangeCounter(profileStorage.addresses, address.guid), 1);
+});
+
+add_task(async function test_pushChanges() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [
+ TEST_ADDRESS_1,
+ TEST_ADDRESS_2,
+ ]);
+
+ profileStorage.addresses.pullSyncChanges(); // force sync metadata for all items
+
+ let [, address] = await profileStorage.addresses.getAll();
+ let guid = address.guid;
+ let changeCounter = getSyncChangeCounter(profileStorage.addresses, guid);
+
+ // Pretend we're doing a sync now, and an update occured mid-sync.
+ let changes = {
+ [guid]: {
+ profile: address,
+ counter: changeCounter,
+ modified: address.timeLastModified,
+ synced: true,
+ },
+ };
+
+ let onChanged = TestUtils.topicObserved(
+ "formautofill-storage-changed",
+ (subject, data) => data == "update"
+ );
+ await profileStorage.addresses.update(guid, TEST_ADDRESS_3);
+ await onChanged;
+
+ changeCounter = getSyncChangeCounter(profileStorage.addresses, guid);
+ Assert.equal(changeCounter, 2);
+
+ profileStorage.addresses.pushSyncChanges(changes);
+ address = await profileStorage.addresses.get(guid);
+ changeCounter = getSyncChangeCounter(profileStorage.addresses, guid);
+
+ // Counter should still be 1, since our sync didn't record the mid-sync change
+ Assert.equal(
+ changeCounter,
+ 1,
+ "Counter shouldn't be zero because it didn't record update"
+ );
+
+ // now, push a new set of changes, which should make the changeCounter 0
+ profileStorage.addresses.pushSyncChanges({
+ [guid]: {
+ profile: address,
+ counter: changeCounter,
+ modified: address.timeLastModified,
+ synced: true,
+ },
+ });
+
+ changeCounter = getSyncChangeCounter(profileStorage.addresses, guid);
+ Assert.equal(changeCounter, 0);
+});
+
+async function checkingSyncChange(action, callback) {
+ let onChanged = TestUtils.topicObserved(
+ "formautofill-storage-changed",
+ (subject, data) => data == action
+ );
+ await callback();
+ let [subject] = await onChanged;
+ ok(
+ subject.wrappedJSObject.sourceSync,
+ "change notification should have source sync"
+ );
+}
+
+add_task(async function test_add_sourceSync() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, []);
+
+ // Hardcode a guid so that we don't need to generate a dynamic regex
+ let guid = "aaaaaaaaaaaa";
+ let testAddr = Object.assign({ guid, version: 1 }, TEST_ADDRESS_1);
+
+ await checkingSyncChange("add", async () =>
+ profileStorage.addresses.add(testAddr, { sourceSync: true })
+ );
+
+ let changeCounter = getSyncChangeCounter(profileStorage.addresses, guid);
+ equal(changeCounter, 0);
+
+ await Assert.rejects(
+ profileStorage.addresses.add({ guid, deleted: true }, { sourceSync: true }),
+ /Record aaaaaaaaaaaa already exists/
+ );
+});
+
+add_task(async function test_add_tombstone_sourceSync() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, []);
+
+ let guid = profileStorage.addresses._generateGUID();
+ let testAddr = { guid, deleted: true };
+ await checkingSyncChange("add", async () =>
+ profileStorage.addresses.add(testAddr, { sourceSync: true })
+ );
+
+ let added = await findGUID(profileStorage.addresses, guid, {
+ includeDeleted: true,
+ });
+ ok(added);
+ equal(getSyncChangeCounter(profileStorage.addresses, guid), 0);
+ ok(added.deleted);
+
+ // Adding same record again shouldn't throw (or change anything)
+ await checkingSyncChange("add", async () =>
+ profileStorage.addresses.add(testAddr, { sourceSync: true })
+ );
+
+ added = await findGUID(profileStorage.addresses, guid, {
+ includeDeleted: true,
+ });
+ equal(getSyncChangeCounter(profileStorage.addresses, guid), 0);
+ ok(added.deleted);
+});
+
+add_task(async function test_add_resurrects_tombstones() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, []);
+
+ let guid = profileStorage.addresses._generateGUID();
+
+ // Add a tombstone.
+ await profileStorage.addresses.add({ guid, deleted: true });
+
+ // You can't re-add an item with an explicit GUID.
+ let resurrected = Object.assign({}, TEST_ADDRESS_1, { guid, version: 1 });
+ await Assert.rejects(
+ profileStorage.addresses.add(resurrected),
+ /"(guid|version)" is not a valid field/
+ );
+
+ // But Sync can!
+ let guid3 = await profileStorage.addresses.add(resurrected, {
+ sourceSync: true,
+ });
+ equal(guid, guid3);
+
+ let got = await profileStorage.addresses.get(guid);
+ equal(got["given-name"], TEST_ADDRESS_1["given-name"]);
+});
+
+add_task(async function test_remove_sourceSync_localChanges() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [
+ TEST_ADDRESS_1,
+ ]);
+ profileStorage.addresses.pullSyncChanges(); // force sync metadata
+
+ let [{ guid }] = await profileStorage.addresses.getAll();
+
+ equal(getSyncChangeCounter(profileStorage.addresses, guid), 1);
+ // try and remove a record stored locally with local changes
+ await checkingSyncChange("remove", async () =>
+ profileStorage.addresses.remove(guid, { sourceSync: true })
+ );
+
+ let record = await profileStorage.addresses.get(guid);
+ ok(record);
+ equal(getSyncChangeCounter(profileStorage.addresses, guid), 1);
+});
+
+add_task(async function test_remove_sourceSync_unknown() {
+ // remove a record not stored locally
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, []);
+
+ let guid = profileStorage.addresses._generateGUID();
+ await checkingSyncChange("remove", async () =>
+ profileStorage.addresses.remove(guid, { sourceSync: true })
+ );
+
+ let tombstone = await findGUID(profileStorage.addresses, guid, {
+ includeDeleted: true,
+ });
+ ok(tombstone.deleted);
+ equal(getSyncChangeCounter(profileStorage.addresses, guid), 0);
+});
+
+add_task(async function test_remove_sourceSync_unchanged() {
+ // Remove a local record without a change counter.
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, []);
+
+ let guid = profileStorage.addresses._generateGUID();
+ let addr = Object.assign({ guid, version: 1 }, TEST_ADDRESS_1);
+ // add a record with sourceSync to guarantee changeCounter == 0
+ await checkingSyncChange("add", async () =>
+ profileStorage.addresses.add(addr, { sourceSync: true })
+ );
+
+ equal(getSyncChangeCounter(profileStorage.addresses, guid), 0);
+
+ await checkingSyncChange("remove", async () =>
+ profileStorage.addresses.remove(guid, { sourceSync: true })
+ );
+
+ let tombstone = await findGUID(profileStorage.addresses, guid, {
+ includeDeleted: true,
+ });
+ ok(tombstone.deleted);
+ equal(getSyncChangeCounter(profileStorage.addresses, guid), 0);
+});
+
+add_task(async function test_pullSyncChanges() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [
+ TEST_ADDRESS_1,
+ TEST_ADDRESS_2,
+ ]);
+
+ let startAddresses = await profileStorage.addresses.getAll();
+ equal(startAddresses.length, 2);
+ // All should start without sync metadata
+ for (let { guid } of profileStorage.addresses._store.data.addresses) {
+ let changeCounter = getSyncChangeCounter(profileStorage.addresses, guid);
+ equal(changeCounter, -1);
+ }
+ profileStorage.addresses.pullSyncChanges(); // force sync metadata
+
+ let addedDirectGUID = profileStorage.addresses._generateGUID();
+ let testAddr = Object.assign(
+ { guid: addedDirectGUID, version: 1 },
+ TEST_ADDRESS_1,
+ TEST_ADDRESS_3
+ );
+
+ await checkingSyncChange("add", async () =>
+ profileStorage.addresses.add(testAddr, { sourceSync: true })
+ );
+
+ let tombstoneGUID = profileStorage.addresses._generateGUID();
+ await checkingSyncChange("add", async () =>
+ profileStorage.addresses.add(
+ { guid: tombstoneGUID, deleted: true },
+ { sourceSync: true }
+ )
+ );
+
+ let onChanged = TestUtils.topicObserved(
+ "formautofill-storage-changed",
+ (subject, data) => data == "remove"
+ );
+
+ profileStorage.addresses.remove(startAddresses[0].guid);
+ await onChanged;
+
+ let addresses = await profileStorage.addresses.getAll({
+ includeDeleted: true,
+ });
+
+ // Should contain changes with a change counter
+ let changes = profileStorage.addresses.pullSyncChanges();
+ equal(Object.keys(changes).length, 2);
+
+ ok(changes[startAddresses[0].guid].profile.deleted);
+ equal(changes[startAddresses[0].guid].counter, 2);
+
+ ok(!changes[startAddresses[1].guid].profile.deleted);
+ equal(changes[startAddresses[1].guid].counter, 1);
+
+ ok(
+ !changes[tombstoneGUID],
+ "Missing because it's a tombstone from sourceSync"
+ );
+ ok(!changes[addedDirectGUID], "Missing because it was added with sourceSync");
+
+ for (let address of addresses) {
+ let change = changes[address.guid];
+ if (!change) {
+ continue;
+ }
+ equal(change.profile.guid, address.guid);
+ let changeCounter = getSyncChangeCounter(
+ profileStorage.addresses,
+ change.profile.guid
+ );
+ equal(change.counter, changeCounter);
+ ok(!change.synced);
+ }
+});
+
+add_task(async function test_pullPushChanges() {
+ // round-trip changes between pull and push
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, []);
+ let psa = profileStorage.addresses;
+
+ let guid1 = await psa.add(TEST_ADDRESS_1);
+ let guid2 = await psa.add(TEST_ADDRESS_2);
+ let guid3 = await psa.add(TEST_ADDRESS_3);
+
+ let changes = psa.pullSyncChanges();
+
+ equal(getSyncChangeCounter(psa, guid1), 1);
+ equal(getSyncChangeCounter(psa, guid2), 1);
+ equal(getSyncChangeCounter(psa, guid3), 1);
+
+ // between the pull and the push we change the second.
+ await psa.update(guid2, Object.assign({}, TEST_ADDRESS_2, { country: "AU" }));
+ equal(getSyncChangeCounter(psa, guid2), 2);
+ // and update the changeset to indicated we did update the first 2, but failed
+ // to update the 3rd for some reason.
+ changes[guid1].synced = true;
+ changes[guid2].synced = true;
+
+ psa.pushSyncChanges(changes);
+
+ // first was synced correctly.
+ equal(getSyncChangeCounter(psa, guid1), 0);
+ // second was synced correctly, but it had a change while syncing.
+ equal(getSyncChangeCounter(psa, guid2), 1);
+ // 3rd wasn't marked as having synced.
+ equal(getSyncChangeCounter(psa, guid3), 1);
+});
+
+add_task(async function test_changeGUID() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, []);
+
+ let newguid = () => profileStorage.addresses._generateGUID();
+
+ let guid_synced = await profileStorage.addresses.add(TEST_ADDRESS_1);
+
+ // pullSyncChanges so guid_synced is flagged as syncing.
+ profileStorage.addresses.pullSyncChanges();
+
+ // and 2 items that haven't been synced.
+ let guid_u1 = await profileStorage.addresses.add(TEST_ADDRESS_2);
+ let guid_u2 = await profileStorage.addresses.add(TEST_ADDRESS_3);
+
+ // Change a non-existing guid
+ Assert.throws(
+ () => profileStorage.addresses.changeGUID(newguid(), newguid()),
+ /changeGUID: no source record/
+ );
+ // Change to a guid that already exists.
+ Assert.throws(
+ () => profileStorage.addresses.changeGUID(guid_u1, guid_u2),
+ /changeGUID: record with destination id exists already/
+ );
+ // Try and change a guid that's already been synced.
+ Assert.throws(
+ () => profileStorage.addresses.changeGUID(guid_synced, newguid()),
+ /changeGUID: existing record has already been synced/
+ );
+
+ // Change an item to itself makes no sense.
+ Assert.throws(
+ () => profileStorage.addresses.changeGUID(guid_u1, guid_u1),
+ /changeGUID: old and new IDs are the same/
+ );
+
+ // and one that works.
+ equal(
+ (await profileStorage.addresses.getAll({ includeDeleted: true })).length,
+ 3
+ );
+ let targetguid = newguid();
+ profileStorage.addresses.changeGUID(guid_u1, targetguid);
+ equal(
+ (await profileStorage.addresses.getAll({ includeDeleted: true })).length,
+ 3
+ );
+
+ ok(
+ await profileStorage.addresses.get(guid_synced),
+ "synced item still exists."
+ );
+ ok(
+ await profileStorage.addresses.get(guid_u2),
+ "guid we didn't touch still exists."
+ );
+ ok(await profileStorage.addresses.get(targetguid), "target guid exists.");
+ ok(
+ !(await profileStorage.addresses.get(guid_u1)),
+ "old guid no longer exists."
+ );
+});
+
+add_task(async function test_findDuplicateGUID() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [
+ TEST_ADDRESS_1,
+ ]);
+
+ let [record] = await profileStorage.addresses.getAll({ rawData: true });
+ await Assert.rejects(
+ profileStorage.addresses.findDuplicateGUID(record),
+ /Record \w+ already exists/,
+ "Should throw if the GUID already exists"
+ );
+
+ // Add a malformed record, passing `sourceSync` to work around the record
+ // normalization logic that would prevent this.
+ let timeLastModified = Date.now();
+ let timeCreated = timeLastModified - 60 * 1000;
+
+ await profileStorage.addresses.add(
+ {
+ guid: profileStorage.addresses._generateGUID(),
+ version: 1,
+ timeCreated,
+ timeLastModified,
+ },
+ { sourceSync: true }
+ );
+
+ strictEqual(
+ await profileStorage.addresses.findDuplicateGUID({
+ guid: profileStorage.addresses._generateGUID(),
+ version: 1,
+ timeCreated,
+ timeLastModified,
+ }),
+ null,
+ "Should ignore internal fields and malformed records"
+ );
+});
+
+add_task(async function test_reset() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME, [
+ TEST_ADDRESS_1,
+ TEST_ADDRESS_2,
+ ]);
+
+ let addresses = await profileStorage.addresses.getAll();
+ // All should start without sync metadata
+ for (let { guid } of addresses) {
+ let changeCounter = getSyncChangeCounter(profileStorage.addresses, guid);
+ equal(changeCounter, -1);
+ }
+ // pullSyncChanges should create the metadata.
+ profileStorage.addresses.pullSyncChanges();
+ addresses = await profileStorage.addresses.getAll();
+ for (let { guid } of addresses) {
+ let changeCounter = getSyncChangeCounter(profileStorage.addresses, guid);
+ equal(changeCounter, 1);
+ }
+ // and resetSync should wipe it.
+ profileStorage.addresses.resetSync();
+ addresses = await profileStorage.addresses.getAll();
+ for (let { guid } of addresses) {
+ let changeCounter = getSyncChangeCounter(profileStorage.addresses, guid);
+ equal(changeCounter, -1);
+ }
+});
diff --git a/browser/extensions/formautofill/test/unit/test_storage_tombstones.js b/browser/extensions/formautofill/test/unit/test_storage_tombstones.js
new file mode 100644
index 0000000000..584dac8043
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_storage_tombstones.js
@@ -0,0 +1,190 @@
+/**
+ * Tests tombstones in address/creditcard records.
+ */
+
+"use strict";
+
+let FormAutofillStorage;
+add_setup(async () => {
+ ({ FormAutofillStorage } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillStorage.sys.mjs"
+ ));
+});
+
+const TEST_STORE_FILE_NAME = "test-tombstones.json";
+
+const TEST_ADDRESS_1 = {
+ "given-name": "Timothy",
+ "additional-name": "John",
+ "family-name": "Berners-Lee",
+ organization: "World Wide Web Consortium",
+ "street-address": "32 Vassar Street\nMIT Room 32-G524",
+ "address-level2": "Cambridge",
+ "address-level1": "MA",
+ "postal-code": "02139",
+ country: "US",
+ tel: "+1 617 253 5702",
+ email: "timbl@w3.org",
+};
+
+const TEST_CC_1 = {
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 4,
+ "cc-exp-year": 2017,
+};
+
+let do_check_tombstone_record = profile => {
+ Assert.ok(profile.deleted);
+ Assert.deepEqual(
+ Object.keys(profile).sort(),
+ ["guid", "timeLastModified", "deleted"].sort()
+ );
+};
+
+// Like add_task, but actually adds 2 - one for addresses and one for cards.
+function add_storage_task(test_function) {
+ add_task(async function () {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+ let profileStorage = new FormAutofillStorage(path);
+ let testCC1 = Object.assign({}, TEST_CC_1);
+ await profileStorage.initialize();
+
+ for (let [storage, record] of [
+ [profileStorage.addresses, TEST_ADDRESS_1],
+ [profileStorage.creditCards, testCC1],
+ ]) {
+ await test_function(storage, record);
+ }
+ });
+}
+
+add_storage_task(async function test_simple_tombstone(storage, record) {
+ info("check simple tombstone semantics");
+
+ let guid = await storage.add(record);
+ Assert.equal((await storage.getAll()).length, 1);
+
+ storage.remove(guid);
+
+ // should be unable to get it normally.
+ Assert.equal(await storage.get(guid), null);
+ // and getAll should also not return it.
+ Assert.equal((await storage.getAll()).length, 0);
+
+ // but getAll allows us to access deleted items - but we didn't create
+ // a tombstone here, so even that will not get it.
+ let all = await storage.getAll({ includeDeleted: true });
+ Assert.equal(all.length, 0);
+});
+
+add_storage_task(async function test_simple_synctombstone(storage, record) {
+ info("check simple tombstone semantics for synced records");
+
+ let guid = await storage.add(record);
+ Assert.equal((await storage.getAll()).length, 1);
+
+ storage.pullSyncChanges(); // force sync metadata, which triggers tombstone behaviour.
+
+ storage.remove(guid);
+
+ // should be unable to get it normally.
+ Assert.equal(await storage.get(guid), null);
+ // and getAll should also not return it.
+ Assert.equal((await storage.getAll()).length, 0);
+
+ // but getAll allows us to access deleted items.
+ let all = await storage.getAll({ includeDeleted: true });
+ Assert.equal(all.length, 1);
+
+ do_check_tombstone_record(all[0]);
+
+ // a tombstone got from API should look exactly the same as it got from the
+ // disk (besides "_sync").
+ let tombstoneInDisk = Object.assign(
+ {},
+ storage._store.data[storage._collectionName][0]
+ );
+ delete tombstoneInDisk._sync;
+ do_check_tombstone_record(tombstoneInDisk);
+});
+
+add_storage_task(async function test_add_tombstone(storage, record) {
+ info("Should be able to add a new tombstone");
+ let guid = await storage.add({ guid: "test-guid-1", deleted: true });
+
+ // should be unable to get it normally.
+ Assert.equal(await storage.get(guid), null);
+ // and getAll should also not return it.
+ Assert.equal((await storage.getAll()).length, 0);
+
+ // but getAll allows us to access deleted items.
+ let all = await storage.getAll({ rawData: true, includeDeleted: true });
+ Assert.equal(all.length, 1);
+
+ do_check_tombstone_record(all[0]);
+
+ // a tombstone got from API should look exactly the same as it got from the
+ // disk (besides "_sync").
+ let tombstoneInDisk = Object.assign(
+ {},
+ storage._store.data[storage._collectionName][0]
+ );
+ delete tombstoneInDisk._sync;
+ do_check_tombstone_record(tombstoneInDisk);
+});
+
+add_storage_task(async function test_add_tombstone_without_guid(
+ storage,
+ record
+) {
+ info("Should not be able to add a new tombstone without specifying the guid");
+ await Assert.rejects(storage.add({ deleted: true }), /Record missing GUID/);
+ Assert.equal((await storage.getAll({ includeDeleted: true })).length, 0);
+});
+
+add_storage_task(async function test_add_tombstone_existing_guid(
+ storage,
+ record
+) {
+ info(
+ "Should not be able to add a new tombstone when a record with that ID exists"
+ );
+ let guid = await storage.add(record);
+ await Assert.rejects(
+ storage.add({ guid, deleted: true }),
+ /a record with this GUID already exists/
+ );
+
+ // same if the existing item is already a tombstone.
+ await storage.add({ guid: "test-guid-1", deleted: true });
+ await Assert.rejects(
+ storage.add({ guid: "test-guid-1", deleted: true }),
+ /a record with this GUID already exists/
+ );
+});
+
+add_storage_task(async function test_update_tombstone(storage, record) {
+ info("Updating a tombstone should fail");
+ let guid = await storage.add({ guid: "test-guid-1", deleted: true });
+ await Assert.rejects(storage.update(guid, {}), /No matching record./);
+});
+
+add_storage_task(async function test_remove_existing_tombstone(
+ storage,
+ record
+) {
+ info("Removing a record that's already a tombstone should be a no-op");
+ let guid = await storage.add({
+ guid: "test-guid-1",
+ deleted: true,
+ timeLastModified: 1234,
+ });
+
+ storage.remove(guid);
+ let all = await storage.getAll({ rawData: true, includeDeleted: true });
+ Assert.equal(all.length, 1);
+
+ do_check_tombstone_record(all[0]);
+ equal(all[0].timeLastModified, 1234); // should not be updated to now().
+});
diff --git a/browser/extensions/formautofill/test/unit/test_sync.js b/browser/extensions/formautofill/test/unit/test_sync.js
new file mode 100644
index 0000000000..bc9467d7c9
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_sync.js
@@ -0,0 +1,1017 @@
+/**
+ * Tests sync functionality.
+ */
+
+/* import-globals-from ../../../../../services/sync/tests/unit/head_appinfo.js */
+/* import-globals-from ../../../../../services/common/tests/unit/head_helpers.js */
+/* import-globals-from ../../../../../services/sync/tests/unit/head_helpers.js */
+/* import-globals-from ../../../../../services/sync/tests/unit/head_http_server.js */
+
+"use strict";
+
+const { Service } = ChromeUtils.importESModule(
+ "resource://services-sync/service.sys.mjs"
+);
+const { SCORE_INCREMENT_XLARGE } = ChromeUtils.importESModule(
+ "resource://services-sync/constants.sys.mjs"
+);
+
+const { sanitizeStorageObject, AutofillRecord, AddressesEngine } =
+ ChromeUtils.importESModule("resource://autofill/FormAutofillSync.sys.mjs");
+
+Services.prefs.setCharPref("extensions.formautofill.loglevel", "Trace");
+initTestLogging("Trace");
+
+const TEST_STORE_FILE_NAME = "test-profile.json";
+
+const TEST_PROFILE_1 = {
+ "given-name": "Timothy",
+ "additional-name": "John",
+ "family-name": "Berners-Lee",
+ organization: "World Wide Web Consortium",
+ "street-address": "32 Vassar Street\nMIT Room 32-G524",
+ "address-level2": "Cambridge",
+ "address-level1": "MA",
+ "postal-code": "02139",
+ country: "US",
+ tel: "+16172535702",
+ email: "timbl@w3.org",
+ // A field this client doesn't "understand" from another client
+ "unknown-1": "some unknown data from another client",
+};
+
+const TEST_PROFILE_2 = {
+ "street-address": "Some Address",
+ country: "US",
+};
+
+async function expectLocalProfiles(profileStorage, expected) {
+ let profiles = await profileStorage.addresses.getAll({
+ rawData: true,
+ includeDeleted: true,
+ });
+ expected.sort((a, b) => a.guid.localeCompare(b.guid));
+ profiles.sort((a, b) => a.guid.localeCompare(b.guid));
+ try {
+ deepEqual(
+ profiles.map(p => p.guid),
+ expected.map(p => p.guid)
+ );
+ for (let i = 0; i < expected.length; i++) {
+ let thisExpected = expected[i];
+ let thisGot = profiles[i];
+ // always check "deleted".
+ equal(thisExpected.deleted, thisGot.deleted);
+ ok(objectMatches(thisGot, thisExpected));
+ }
+ } catch (ex) {
+ info("Comparing expected profiles:");
+ info(JSON.stringify(expected, undefined, 2));
+ info("against actual profiles:");
+ info(JSON.stringify(profiles, undefined, 2));
+ throw ex;
+ }
+}
+
+async function setup() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME);
+ // should always start with no profiles.
+ Assert.equal(
+ (await profileStorage.addresses.getAll({ includeDeleted: true })).length,
+ 0
+ );
+
+ Services.prefs.setCharPref(
+ "services.sync.log.logger.engine.addresses",
+ "Trace"
+ );
+ let engine = new AddressesEngine(Service);
+ await engine.initialize();
+ // Avoid accidental automatic sync due to our own changes
+ Service.scheduler.syncThreshold = 10000000;
+ let syncID = await engine.resetLocalSyncID();
+ let server = serverForUsers(
+ { foo: "password" },
+ {
+ meta: {
+ global: { engines: { addresses: { version: engine.version, syncID } } },
+ },
+ addresses: {},
+ }
+ );
+
+ Service.engineManager._engines.addresses = engine;
+ engine.enabled = true;
+ engine._store._storage = profileStorage.addresses;
+
+ generateNewKeys(Service.collectionKeys);
+
+ await SyncTestingInfrastructure(server);
+
+ let collection = server.user("foo").collection("addresses");
+
+ return { profileStorage, server, collection, engine };
+}
+
+async function cleanup(server) {
+ let promiseStartOver = promiseOneObserver("weave:service:start-over:finish");
+ await Service.startOver();
+ await promiseStartOver;
+ await promiseStopServer(server);
+}
+
+add_task(async function test_log_sanitization() {
+ let sanitized = sanitizeStorageObject(TEST_PROFILE_1);
+ // all strings have been mangled.
+ for (let key of Object.keys(TEST_PROFILE_1)) {
+ let val = TEST_PROFILE_1[key];
+ if (typeof val == "string") {
+ notEqual(sanitized[key], val);
+ }
+ }
+ // And check that stringifying a sync record is sanitized.
+ let record = new AutofillRecord("collection", "some-id");
+ record.entry = TEST_PROFILE_1;
+ let serialized = record.toString();
+ // None of the string values should appear in the output.
+ for (let key of Object.keys(TEST_PROFILE_1)) {
+ let val = TEST_PROFILE_1[key];
+ if (typeof val == "string") {
+ ok(!serialized.includes(val), `"${val}" shouldn't be in: ${serialized}`);
+ }
+ }
+});
+
+add_task(async function test_outgoing() {
+ let { profileStorage, server, collection, engine } = await setup();
+ try {
+ equal(engine._tracker.score, 0);
+ let existingGUID = await profileStorage.addresses.add(TEST_PROFILE_1);
+ // And a deleted item.
+ let deletedGUID = profileStorage.addresses._generateGUID();
+ await profileStorage.addresses.add({ guid: deletedGUID, deleted: true });
+
+ await expectLocalProfiles(profileStorage, [
+ {
+ guid: existingGUID,
+ },
+ {
+ guid: deletedGUID,
+ deleted: true,
+ },
+ ]);
+
+ await engine._tracker.asyncObserver.promiseObserversComplete();
+ // The tracker should have a score recorded for the 2 additions we had.
+ equal(engine._tracker.score, SCORE_INCREMENT_XLARGE * 2);
+
+ await engine.setLastSync(0);
+ await engine.sync();
+
+ Assert.equal(collection.count(), 2);
+ Assert.ok(collection.wbo(existingGUID));
+ Assert.ok(collection.wbo(deletedGUID));
+
+ await expectLocalProfiles(profileStorage, [
+ {
+ guid: existingGUID,
+ },
+ {
+ guid: deletedGUID,
+ deleted: true,
+ },
+ ]);
+
+ strictEqual(
+ getSyncChangeCounter(profileStorage.addresses, existingGUID),
+ 0
+ );
+ strictEqual(getSyncChangeCounter(profileStorage.addresses, deletedGUID), 0);
+ } finally {
+ await cleanup(server);
+ }
+});
+
+add_task(async function test_incoming_new() {
+ let { profileStorage, server, engine } = await setup();
+ try {
+ let profileID = Utils.makeGUID();
+ let deletedID = Utils.makeGUID();
+
+ server.insertWBO(
+ "foo",
+ "addresses",
+ new ServerWBO(
+ profileID,
+ encryptPayload({
+ id: profileID,
+ entry: Object.assign(
+ {
+ version: 1,
+ },
+ TEST_PROFILE_1
+ ),
+ }),
+ getDateForSync()
+ )
+ );
+ server.insertWBO(
+ "foo",
+ "addresses",
+ new ServerWBO(
+ deletedID,
+ encryptPayload({
+ id: deletedID,
+ deleted: true,
+ }),
+ getDateForSync()
+ )
+ );
+
+ // The tracker should start with no score.
+ equal(engine._tracker.score, 0);
+
+ await engine.setLastSync(0);
+ await engine.sync();
+
+ await expectLocalProfiles(profileStorage, [
+ {
+ guid: profileID,
+ },
+ {
+ guid: deletedID,
+ deleted: true,
+ },
+ ]);
+
+ strictEqual(getSyncChangeCounter(profileStorage.addresses, profileID), 0);
+ strictEqual(getSyncChangeCounter(profileStorage.addresses, deletedID), 0);
+
+ // Validate incoming records with unknown fields get stored
+ let localRecord = await profileStorage.addresses.get(profileID);
+ equal(localRecord["unknown-1"], TEST_PROFILE_1["unknown-1"]);
+
+ // The sync applied new records - ensure our tracker knew it came from
+ // sync and didn't bump the score.
+ equal(engine._tracker.score, 0);
+ } finally {
+ await cleanup(server);
+ }
+});
+
+add_task(async function test_incoming_existing() {
+ let { profileStorage, server, engine } = await setup();
+ try {
+ let guid1 = await profileStorage.addresses.add(TEST_PROFILE_1);
+ let guid2 = await profileStorage.addresses.add(TEST_PROFILE_2);
+
+ // an initial sync so we don't think they are locally modified.
+ await engine.setLastSync(0);
+ await engine.sync();
+
+ // now server records that modify the existing items.
+ let modifiedEntry1 = Object.assign({}, TEST_PROFILE_1, {
+ version: 1,
+ "given-name": "NewName",
+ });
+
+ let lastSync = await engine.getLastSync();
+ server.insertWBO(
+ "foo",
+ "addresses",
+ new ServerWBO(
+ guid1,
+ encryptPayload({
+ id: guid1,
+ entry: modifiedEntry1,
+ }),
+ lastSync + 10
+ )
+ );
+ server.insertWBO(
+ "foo",
+ "addresses",
+ new ServerWBO(
+ guid2,
+ encryptPayload({
+ id: guid2,
+ deleted: true,
+ }),
+ lastSync + 10
+ )
+ );
+
+ await engine.sync();
+
+ await expectLocalProfiles(profileStorage, [
+ Object.assign({}, modifiedEntry1, { guid: guid1 }),
+ { guid: guid2, deleted: true },
+ ]);
+ } finally {
+ await cleanup(server);
+ }
+});
+
+add_task(async function test_tombstones() {
+ let { profileStorage, server, collection, engine } = await setup();
+ try {
+ let existingGUID = await profileStorage.addresses.add(TEST_PROFILE_1);
+
+ await engine.setLastSync(0);
+ await engine.sync();
+
+ Assert.equal(collection.count(), 1);
+ let payload = collection.payloads()[0];
+ equal(payload.id, existingGUID);
+ equal(payload.deleted, undefined);
+
+ profileStorage.addresses.remove(existingGUID);
+ await engine.sync();
+
+ // should still exist, but now be a tombstone.
+ Assert.equal(collection.count(), 1);
+ payload = collection.payloads()[0];
+ equal(payload.id, existingGUID);
+ equal(payload.deleted, true);
+ } finally {
+ await cleanup(server);
+ }
+});
+
+add_task(async function test_applyIncoming_both_deleted() {
+ let { profileStorage, server, engine } = await setup();
+ try {
+ let guid = await profileStorage.addresses.add(TEST_PROFILE_1);
+
+ await engine.setLastSync(0);
+ await engine.sync();
+
+ // Delete synced record locally.
+ profileStorage.addresses.remove(guid);
+
+ // Delete same record remotely.
+ let lastSync = await engine.getLastSync();
+ let collection = server.user("foo").collection("addresses");
+ collection.insert(
+ guid,
+ encryptPayload({
+ id: guid,
+ deleted: true,
+ }),
+ lastSync + 10
+ );
+
+ await engine.sync();
+
+ ok(
+ !(await await profileStorage.addresses.get(guid)),
+ "Should not return record for locally deleted item"
+ );
+
+ let localRecords = await profileStorage.addresses.getAll({
+ includeDeleted: true,
+ });
+ equal(localRecords.length, 1, "Only tombstone should exist locally");
+
+ equal(collection.count(), 1, "Only tombstone should exist on server");
+ } finally {
+ await cleanup(server);
+ }
+});
+
+add_task(async function test_applyIncoming_nonexistent_tombstone() {
+ let { profileStorage, server, engine } = await setup();
+ try {
+ let guid = profileStorage.addresses._generateGUID();
+ let collection = server.user("foo").collection("addresses");
+ collection.insert(
+ guid,
+ encryptPayload({
+ id: guid,
+ deleted: true,
+ }),
+ getDateForSync()
+ );
+
+ await engine.setLastSync(0);
+ await engine.sync();
+
+ ok(
+ !(await profileStorage.addresses.get(guid)),
+ "Should not return record for uknown deleted item"
+ );
+ let localTombstone = (
+ await profileStorage.addresses.getAll({
+ includeDeleted: true,
+ })
+ ).find(record => record.guid == guid);
+ ok(localTombstone, "Should store tombstone for unknown item");
+ } finally {
+ await cleanup(server);
+ }
+});
+
+add_task(async function test_applyIncoming_incoming_deleted() {
+ let { profileStorage, server, engine } = await setup();
+ try {
+ let guid = await profileStorage.addresses.add(TEST_PROFILE_1);
+
+ await engine.setLastSync(0);
+ await engine.sync();
+
+ // Delete the record remotely.
+ let lastSync = await engine.getLastSync();
+ let collection = server.user("foo").collection("addresses");
+ collection.insert(
+ guid,
+ encryptPayload({
+ id: guid,
+ deleted: true,
+ }),
+ lastSync + 10
+ );
+
+ await engine.sync();
+
+ ok(
+ !(await profileStorage.addresses.get(guid)),
+ "Should delete unmodified item locally"
+ );
+
+ let localTombstone = (
+ await profileStorage.addresses.getAll({
+ includeDeleted: true,
+ })
+ ).find(record => record.guid == guid);
+ ok(localTombstone, "Should keep local tombstone for remotely deleted item");
+ strictEqual(
+ getSyncChangeCounter(profileStorage.addresses, guid),
+ 0,
+ "Local tombstone should be marked as syncing"
+ );
+ } finally {
+ await cleanup(server);
+ }
+});
+
+add_task(async function test_applyIncoming_incoming_restored() {
+ let { profileStorage, server, engine } = await setup();
+ try {
+ let guid = await profileStorage.addresses.add(TEST_PROFILE_1);
+
+ // Upload the record to the server.
+ await engine.setLastSync(0);
+ await engine.sync();
+
+ // Removing a synced record should write a tombstone.
+ profileStorage.addresses.remove(guid);
+
+ // Modify the deleted record remotely.
+ let collection = server.user("foo").collection("addresses");
+ let serverPayload = JSON.parse(
+ JSON.parse(collection.payload(guid)).ciphertext
+ );
+ serverPayload.entry["street-address"] = "I moved!";
+ let lastSync = await engine.getLastSync();
+ collection.insert(guid, encryptPayload(serverPayload), lastSync + 10);
+
+ // Sync again.
+ await engine.sync();
+
+ // We should replace our tombstone with the server's version.
+ let localRecord = await profileStorage.addresses.get(guid);
+ ok(
+ objectMatches(localRecord, {
+ "given-name": "Timothy",
+ "family-name": "Berners-Lee",
+ "street-address": "I moved!",
+ })
+ );
+
+ let maybeNewServerPayload = JSON.parse(
+ JSON.parse(collection.payload(guid)).ciphertext
+ );
+ deepEqual(
+ maybeNewServerPayload,
+ serverPayload,
+ "Should not change record on server"
+ );
+ } finally {
+ await cleanup(server);
+ }
+});
+
+add_task(async function test_applyIncoming_outgoing_restored() {
+ let { profileStorage, server, engine } = await setup();
+ try {
+ let guid = await profileStorage.addresses.add(TEST_PROFILE_1);
+
+ // Upload the record to the server.
+ await engine.setLastSync(0);
+ await engine.sync();
+
+ // Modify the local record.
+ let localCopy = Object.assign({}, TEST_PROFILE_1);
+ localCopy["street-address"] = "I moved!";
+ await profileStorage.addresses.update(guid, localCopy);
+
+ // Replace the record with a tombstone on the server.
+ let lastSync = await engine.getLastSync();
+ let collection = server.user("foo").collection("addresses");
+ collection.insert(
+ guid,
+ encryptPayload({
+ id: guid,
+ deleted: true,
+ }),
+ lastSync + 10
+ );
+
+ // Sync again.
+ await engine.sync();
+
+ // We should resurrect the record on the server.
+ let serverPayload = JSON.parse(
+ JSON.parse(collection.payload(guid)).ciphertext
+ );
+ ok(!serverPayload.deleted, "Should resurrect record on server");
+ ok(
+ objectMatches(serverPayload.entry, {
+ "given-name": "Timothy",
+ "family-name": "Berners-Lee",
+ "street-address": "I moved!",
+ // resurrection also beings back any unknown fields we had
+ "unknown-1": "some unknown data from another client",
+ })
+ );
+
+ let localRecord = await profileStorage.addresses.get(guid);
+ ok(localRecord, "Modified record should not be deleted locally");
+ } finally {
+ await cleanup(server);
+ }
+});
+
+// Unlike most sync engines, we want "both modified" to inspect the records,
+// and if materially different, create a duplicate.
+add_task(async function test_reconcile_both_modified_identical() {
+ let { profileStorage, server, engine } = await setup();
+ try {
+ // create a record locally.
+ let guid = await profileStorage.addresses.add(TEST_PROFILE_1);
+
+ // and an identical record on the server.
+ server.insertWBO(
+ "foo",
+ "addresses",
+ new ServerWBO(
+ guid,
+ encryptPayload({
+ id: guid,
+ entry: TEST_PROFILE_1,
+ }),
+ getDateForSync()
+ )
+ );
+
+ await engine.setLastSync(0);
+ await engine.sync();
+
+ await expectLocalProfiles(profileStorage, [{ guid }]);
+ } finally {
+ await cleanup(server);
+ }
+});
+
+add_task(async function test_incoming_dupes() {
+ let { profileStorage, server, engine } = await setup();
+ try {
+ // Create a profile locally, then sync to upload the new profile to the
+ // server.
+ let guid1 = await profileStorage.addresses.add(TEST_PROFILE_1);
+
+ await engine.setLastSync(0);
+ await engine.sync();
+
+ // Create another profile locally, but don't sync it yet.
+ await profileStorage.addresses.add(TEST_PROFILE_2);
+
+ // Now create two records on the server with the same contents as our local
+ // profiles, but different GUIDs.
+ let lastSync = await engine.getLastSync();
+ let guid1_dupe = Utils.makeGUID();
+ server.insertWBO(
+ "foo",
+ "addresses",
+ new ServerWBO(
+ guid1_dupe,
+ encryptPayload({
+ id: guid1_dupe,
+ entry: Object.assign(
+ {
+ version: 1,
+ },
+ TEST_PROFILE_1
+ ),
+ }),
+ lastSync + 10
+ )
+ );
+ let guid2_dupe = Utils.makeGUID();
+ server.insertWBO(
+ "foo",
+ "addresses",
+ new ServerWBO(
+ guid2_dupe,
+ encryptPayload({
+ id: guid2_dupe,
+ entry: Object.assign(
+ {
+ version: 1,
+ },
+ TEST_PROFILE_2
+ ),
+ }),
+ lastSync + 10
+ )
+ );
+
+ // Sync again. We should download `guid1_dupe` and `guid2_dupe`, then
+ // reconcile changes.
+ await engine.sync();
+
+ await expectLocalProfiles(profileStorage, [
+ // We uploaded `guid1` during the first sync. Even though its contents
+ // are the same as `guid1_dupe`, we keep both.
+ Object.assign({}, TEST_PROFILE_1, { guid: guid1 }),
+ Object.assign({}, TEST_PROFILE_1, { guid: guid1_dupe }),
+ // However, we didn't upload `guid2` before downloading `guid2_dupe`, so
+ // we *should* dedupe `guid2` to `guid2_dupe`.
+ Object.assign({}, TEST_PROFILE_2, { guid: guid2_dupe }),
+ ]);
+ } finally {
+ await cleanup(server);
+ }
+});
+
+add_task(async function test_dedupe_identical_unsynced() {
+ let { profileStorage, server, engine } = await setup();
+ try {
+ // create a record locally.
+ let localGuid = await profileStorage.addresses.add(TEST_PROFILE_1);
+
+ // and an identical record on the server but different GUID.
+ let remoteGuid = Utils.makeGUID();
+ notEqual(localGuid, remoteGuid);
+ server.insertWBO(
+ "foo",
+ "addresses",
+ new ServerWBO(
+ remoteGuid,
+ encryptPayload({
+ id: remoteGuid,
+ entry: Object.assign(
+ {
+ version: 1,
+ },
+ TEST_PROFILE_1
+ ),
+ }),
+ getDateForSync()
+ )
+ );
+
+ await engine.setLastSync(0);
+ await engine.sync();
+
+ // Should have 1 item locally with GUID changed to the remote one.
+ // There's no tombstone as the original was unsynced.
+ await expectLocalProfiles(profileStorage, [
+ {
+ guid: remoteGuid,
+ },
+ ]);
+ } finally {
+ await cleanup(server);
+ }
+});
+
+add_task(async function test_dedupe_identical_synced() {
+ let { profileStorage, server, engine } = await setup();
+ try {
+ // create a record locally.
+ let localGuid = await profileStorage.addresses.add(TEST_PROFILE_1);
+
+ // sync it - it will no longer be a candidate for de-duping.
+ await engine.setLastSync(0);
+ await engine.sync();
+
+ // and an identical record on the server but different GUID.
+ let lastSync = await engine.getLastSync();
+ let remoteGuid = Utils.makeGUID();
+ server.insertWBO(
+ "foo",
+ "addresses",
+ new ServerWBO(
+ remoteGuid,
+ encryptPayload({
+ id: remoteGuid,
+ entry: Object.assign(
+ {
+ version: 1,
+ },
+ TEST_PROFILE_1
+ ),
+ }),
+ lastSync + 10
+ )
+ );
+
+ await engine.sync();
+
+ // Should have 2 items locally, since the first was synced.
+ await expectLocalProfiles(profileStorage, [
+ { guid: localGuid },
+ { guid: remoteGuid },
+ ]);
+ } finally {
+ await cleanup(server);
+ }
+});
+
+add_task(async function test_dedupe_multiple_candidates() {
+ let { profileStorage, server, engine } = await setup();
+ try {
+ // It's possible to have duplicate local profiles, with the same fields but
+ // different GUIDs. After a node reassignment, or after disconnecting and
+ // reconnecting to Sync, we might dedupe a local record A to a remote record
+ // B, if we see B before we download and apply A. Since A and B are dupes,
+ // that's OK. We'll write a tombstone for A when we dedupe A to B, and
+ // overwrite that tombstone when we see A.
+
+ let localRecord = {
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ organization: "Mozilla",
+ country: "AU",
+ tel: "+12345678910",
+ };
+ let serverRecord = Object.assign(
+ {
+ version: 1,
+ },
+ localRecord
+ );
+
+ // We don't pass `sourceSync` so that the records are marked as NEW.
+ let aGuid = await profileStorage.addresses.add(localRecord);
+ let bGuid = await profileStorage.addresses.add(localRecord);
+
+ // Insert B before A.
+ server.insertWBO(
+ "foo",
+ "addresses",
+ new ServerWBO(
+ bGuid,
+ encryptPayload({
+ id: bGuid,
+ entry: serverRecord,
+ }),
+ getDateForSync()
+ )
+ );
+ server.insertWBO(
+ "foo",
+ "addresses",
+ new ServerWBO(
+ aGuid,
+ encryptPayload({
+ id: aGuid,
+ entry: serverRecord,
+ }),
+ getDateForSync()
+ )
+ );
+
+ await engine.setLastSync(0);
+ await engine.sync();
+
+ await expectLocalProfiles(profileStorage, [
+ {
+ guid: aGuid,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ organization: "Mozilla",
+ country: "AU",
+ tel: "+12345678910",
+ },
+ {
+ guid: bGuid,
+ "given-name": "Mark",
+ "family-name": "Hammond",
+ organization: "Mozilla",
+ country: "AU",
+ tel: "+12345678910",
+ },
+ ]);
+ // Make sure these are both syncing.
+ strictEqual(
+ getSyncChangeCounter(profileStorage.addresses, aGuid),
+ 0,
+ "A should be marked as syncing"
+ );
+ strictEqual(
+ getSyncChangeCounter(profileStorage.addresses, bGuid),
+ 0,
+ "B should be marked as syncing"
+ );
+ } finally {
+ await cleanup(server);
+ }
+});
+
+// Unlike most sync engines, we want "both modified" to inspect the records,
+// and if materially different, create a duplicate.
+add_task(async function test_reconcile_both_modified_conflict() {
+ let { profileStorage, server, engine } = await setup();
+ try {
+ // create a record locally.
+ let guid = await profileStorage.addresses.add(TEST_PROFILE_1);
+
+ // Upload the record to the server.
+ await engine.setLastSync(0);
+ await engine.sync();
+
+ strictEqual(
+ getSyncChangeCounter(profileStorage.addresses, guid),
+ 0,
+ "Original record should be marked as syncing"
+ );
+
+ // Change the same field locally and on the server.
+ let localCopy = Object.assign({}, TEST_PROFILE_1);
+ localCopy["street-address"] = "I moved!";
+ await profileStorage.addresses.update(guid, localCopy);
+
+ let lastSync = await engine.getLastSync();
+ let collection = server.user("foo").collection("addresses");
+ let serverPayload = JSON.parse(
+ JSON.parse(collection.payload(guid)).ciphertext
+ );
+ serverPayload.entry["street-address"] = "I moved, too!";
+ collection.insert(guid, encryptPayload(serverPayload), lastSync + 10);
+
+ // Sync again.
+ await engine.sync();
+
+ // Since we wait to pull changes until we're ready to upload, both records
+ // should now exist on the server; we don't need a follow-up sync.
+ let serverPayloads = collection.payloads();
+ equal(serverPayloads.length, 2, "Both records should exist on server");
+
+ let forkedPayload = serverPayloads.find(payload => payload.id != guid);
+ ok(forkedPayload, "Forked record should exist on server");
+
+ await expectLocalProfiles(profileStorage, [
+ {
+ guid,
+ "given-name": "Timothy",
+ "family-name": "Berners-Lee",
+ "street-address": "I moved, too!",
+ },
+ {
+ guid: forkedPayload.id,
+ "given-name": "Timothy",
+ "family-name": "Berners-Lee",
+ "street-address": "I moved!",
+ },
+ ]);
+
+ let changeCounter = getSyncChangeCounter(
+ profileStorage.addresses,
+ forkedPayload.id
+ );
+ strictEqual(changeCounter, 0, "Forked record should be marked as syncing");
+ } finally {
+ await cleanup(server);
+ }
+});
+
+add_task(async function test_wipe() {
+ let { profileStorage, server, engine } = await setup();
+ try {
+ let guid = await profileStorage.addresses.add(TEST_PROFILE_1);
+
+ await expectLocalProfiles(profileStorage, [{ guid }]);
+
+ let promiseObserved = promiseOneObserver("formautofill-storage-changed");
+
+ await engine._wipeClient();
+
+ let { subject, data } = await promiseObserved;
+ Assert.equal(
+ subject.wrappedJSObject.sourceSync,
+ true,
+ "it should be noted this came from sync"
+ );
+ Assert.equal(
+ subject.wrappedJSObject.collectionName,
+ "addresses",
+ "got the correct collection"
+ );
+ Assert.equal(data, "removeAll", "a removeAll should be noted");
+
+ await expectLocalProfiles(profileStorage, []);
+ } finally {
+ await cleanup(server);
+ }
+});
+
+// Other clients might have data that we aren't able to process/understand yet
+// We should keep that data and ensure when we sync we don't lose that data
+add_task(async function test_full_roundtrip_unknown_data() {
+ let { profileStorage, server, engine } = await setup();
+ try {
+ let profileID = Utils.makeGUID();
+
+ info("Incoming records with unknown fields are properly stored");
+ // Insert a record onto the server
+ server.insertWBO(
+ "foo",
+ "addresses",
+ new ServerWBO(
+ profileID,
+ encryptPayload({
+ id: profileID,
+ entry: Object.assign(
+ {
+ version: 1,
+ },
+ TEST_PROFILE_1
+ ),
+ }),
+ getDateForSync()
+ )
+ );
+
+ // The tracker should start with no score.
+ equal(engine._tracker.score, 0);
+
+ await engine.setLastSync(0);
+ await engine.sync();
+
+ await expectLocalProfiles(profileStorage, [
+ {
+ guid: profileID,
+ },
+ ]);
+
+ strictEqual(getSyncChangeCounter(profileStorage.addresses, profileID), 0);
+
+ // The sync applied new records - ensure our tracker knew it came from
+ // sync and didn't bump the score.
+ equal(engine._tracker.score, 0);
+
+ // Validate incoming records with unknown fields are correctly stored
+ let localRecord = await profileStorage.addresses.get(profileID);
+ equal(localRecord["unknown-1"], TEST_PROFILE_1["unknown-1"]);
+
+ let onChanged = TestUtils.topicObserved(
+ "formautofill-storage-changed",
+ (subject, data) => data == "update"
+ );
+
+ // Validate we can update the records locally and not drop any unknown fields
+ info("Unknown fields are sent back up to the server");
+
+ // Modify the local copy
+ let localCopy = Object.assign({}, TEST_PROFILE_1);
+ localCopy["street-address"] = "I moved!";
+ await profileStorage.addresses.update(profileID, localCopy);
+ await onChanged;
+ await profileStorage._saveImmediately();
+
+ let updatedCopy = await profileStorage.addresses.get(profileID);
+ equal(updatedCopy["street-address"], "I moved!");
+
+ // Sync our changes to the server
+ await engine.setLastSync(0);
+ await engine.sync();
+
+ let collection = server.user("foo").collection("addresses");
+
+ Assert.ok(collection.wbo(profileID));
+ let serverPayload = JSON.parse(
+ JSON.parse(collection.payload(profileID)).ciphertext
+ );
+
+ // The server has the updated field as well as any unknown fields
+ equal(
+ serverPayload.entry["unknown-1"],
+ "some unknown data from another client"
+ );
+ equal(serverPayload.entry["street-address"], "I moved!");
+ } finally {
+ await cleanup(server);
+ }
+});
diff --git a/browser/extensions/formautofill/test/unit/test_sync_deprecate_credit_card_v4.js b/browser/extensions/formautofill/test/unit/test_sync_deprecate_credit_card_v4.js
new file mode 100644
index 0000000000..2362796b0c
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_sync_deprecate_credit_card_v4.js
@@ -0,0 +1,248 @@
+/**
+ * Tests sync functionality.
+ */
+
+/* import-globals-from ../../../../../services/sync/tests/unit/head_appinfo.js */
+/* import-globals-from ../../../../../services/common/tests/unit/head_helpers.js */
+/* import-globals-from ../../../../../services/sync/tests/unit/head_helpers.js */
+/* import-globals-from ../../../../../services/sync/tests/unit/head_http_server.js */
+
+"use strict";
+
+const { Service } = ChromeUtils.importESModule(
+ "resource://services-sync/service.sys.mjs"
+);
+
+const { CreditCardsEngine } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillSync.sys.mjs"
+);
+
+Services.prefs.setCharPref("extensions.formautofill.loglevel", "Trace");
+initTestLogging("Trace");
+
+const TEST_STORE_FILE_NAME = "test-profile.json";
+
+const TEST_CREDIT_CARD_1 = {
+ guid: "86d961c7717a",
+ "cc-name": "John Doe",
+ "cc-number": "4111111111111111",
+ "cc-exp-month": 4,
+ "cc-exp-year": new Date().getFullYear(),
+};
+
+const TEST_CREDIT_CARD_2 = {
+ guid: "cf57a7ac3539",
+ "cc-name": "Timothy Berners-Lee",
+ "cc-number": "4929001587121045",
+ "cc-exp-month": 12,
+ "cc-exp-year": new Date().getFullYear() + 10,
+};
+
+function expectProfiles(profiles, expected) {
+ expected.sort((a, b) => a.guid.localeCompare(b.guid));
+ profiles.sort((a, b) => a.guid.localeCompare(b.guid));
+ try {
+ deepEqual(
+ profiles.map(p => p.guid),
+ expected.map(p => p.guid)
+ );
+ for (let i = 0; i < expected.length; i++) {
+ let thisExpected = expected[i];
+ let thisGot = profiles[i];
+ // always check "deleted".
+ equal(thisExpected.deleted, thisGot.deleted);
+ ok(objectMatches(thisGot, thisExpected));
+ }
+ } catch (ex) {
+ info("Comparing expected profiles:");
+ info(JSON.stringify(expected, undefined, 2));
+ info("against actual profiles:");
+ info(JSON.stringify(profiles, undefined, 2));
+ throw ex;
+ }
+}
+
+async function expectServerProfiles(collection, expected) {
+ const profiles = collection
+ .payloads()
+ .map(payload => Object.assign({ guid: payload.id }, payload.entry));
+ expectProfiles(profiles, expected);
+}
+
+async function expectLocalProfiles(profileStorage, expected) {
+ const profiles = await profileStorage.creditCards.getAll({
+ rawData: true,
+ includeDeleted: true,
+ });
+ expectProfiles(profiles, expected);
+}
+
+async function setup() {
+ let profileStorage = await initProfileStorage(TEST_STORE_FILE_NAME);
+ // should always start with no profiles.
+ Assert.equal(
+ (await profileStorage.creditCards.getAll({ includeDeleted: true })).length,
+ 0
+ );
+
+ Services.prefs.setCharPref(
+ "services.sync.log.logger.engine.CreditCards",
+ "Trace"
+ );
+ let engine = new CreditCardsEngine(Service);
+ await engine.initialize();
+ // Avoid accidental automatic sync due to our own changes
+ Service.scheduler.syncThreshold = 10000000;
+ let syncID = await engine.resetLocalSyncID();
+ let server = serverForUsers(
+ { foo: "password" },
+ {
+ meta: {
+ global: {
+ engines: { creditcards: { version: engine.version, syncID } },
+ },
+ },
+ creditcards: {},
+ }
+ );
+
+ Service.engineManager._engines.creditcards = engine;
+ engine.enabled = true;
+ engine._store._storage = profileStorage.creditCards;
+
+ generateNewKeys(Service.collectionKeys);
+
+ await SyncTestingInfrastructure(server);
+
+ let collection = server.user("foo").collection("creditcards");
+
+ return { profileStorage, server, collection, engine };
+}
+
+async function cleanup(server) {
+ let promiseStartOver = promiseOneObserver("weave:service:start-over:finish");
+ await Service.startOver();
+ await promiseStartOver;
+ await promiseStopServer(server);
+}
+
+function getTestRecords(profileStorage, version) {
+ return [
+ Object.assign({ version }, TEST_CREDIT_CARD_1),
+ Object.assign({ version }, TEST_CREDIT_CARD_2),
+ ];
+}
+
+function setupServerRecords(server, records) {
+ for (const record of records) {
+ server.insertWBO(
+ "foo",
+ "creditcards",
+ new ServerWBO(
+ record.guid,
+ encryptPayload({
+ id: record.guid,
+ entry: Object.assign({}, record),
+ }),
+ getDateForSync()
+ )
+ );
+ }
+}
+
+/**
+ * We want to setup old records and run init() to migrate records.
+ * However, We don't have an easy way to setup an older version record with
+ * init() function now.
+ * So as a workaround, we simulate the behavior by directly setting data and then
+ * run migration.
+ */
+async function setupLocalProfilesAndRunMigration(profileStorage, records) {
+ for (const record of records) {
+ profileStorage._store.data.creditCards.push(Object.assign({}, record));
+ }
+ await Promise.all(
+ profileStorage.creditCards._data.map(async (record, index) =>
+ profileStorage.creditCards._migrateRecord(record, index)
+ )
+ );
+}
+
+// local v3, server v4
+add_task(async function test_local_v3_server_v4() {
+ let { collection, profileStorage, server, engine } = await setup();
+
+ const V3_RECORDS = getTestRecords(profileStorage, 3);
+ const V4_RECORDS = getTestRecords(profileStorage, 4);
+
+ await setupLocalProfilesAndRunMigration(profileStorage, V3_RECORDS);
+ setupServerRecords(server, V4_RECORDS);
+
+ await engine.setLastSync(0);
+ await engine.sync();
+
+ await expectServerProfiles(collection, V3_RECORDS);
+ await expectLocalProfiles(profileStorage, V3_RECORDS);
+
+ await cleanup(server);
+});
+
+// local v4, server empty
+add_task(async function test_local_v4_server_empty() {
+ let { collection, profileStorage, server, engine } = await setup();
+ const V3_RECORDS = getTestRecords(profileStorage, 3);
+ const V4_RECORDS = getTestRecords(profileStorage, 4);
+
+ await setupLocalProfilesAndRunMigration(profileStorage, V4_RECORDS);
+
+ await engine.setLastSync(0);
+ await engine.sync();
+
+ await expectServerProfiles(collection, V3_RECORDS);
+ await expectLocalProfiles(profileStorage, V3_RECORDS);
+
+ await cleanup(server);
+});
+
+// local v4, server v3
+add_task(async function test_local_v4_server_v3() {
+ let { collection, profileStorage, server, engine } = await setup();
+ const V3_RECORDS = getTestRecords(profileStorage, 3);
+ const V4_RECORDS = getTestRecords(profileStorage, 4);
+
+ await setupLocalProfilesAndRunMigration(profileStorage, V4_RECORDS);
+ setupServerRecords(server, V3_RECORDS);
+
+ // local should be v3 before syncing.
+ await expectLocalProfiles(profileStorage, V3_RECORDS);
+
+ await engine.setLastSync(0);
+ await engine.sync();
+
+ await expectServerProfiles(collection, V3_RECORDS);
+ await expectLocalProfiles(profileStorage, V3_RECORDS);
+
+ await cleanup(server);
+});
+
+// local v4, server v4
+add_task(async function test_local_v4_server_v4() {
+ let { collection, profileStorage, server, engine } = await setup();
+ const V3_RECORDS = getTestRecords(profileStorage, 3);
+ const V4_RECORDS = getTestRecords(profileStorage, 4);
+
+ await setupLocalProfilesAndRunMigration(profileStorage, V4_RECORDS);
+ setupServerRecords(server, V4_RECORDS);
+
+ // local should be v3 before syncing and then we ignore
+ // incoming v4 from server
+ await expectLocalProfiles(profileStorage, V3_RECORDS);
+
+ await engine.setLastSync(0);
+ await engine.sync();
+
+ await expectServerProfiles(collection, V3_RECORDS);
+ await expectLocalProfiles(profileStorage, V3_RECORDS);
+
+ await cleanup(server);
+});
diff --git a/browser/extensions/formautofill/test/unit/test_toOneLineAddress.js b/browser/extensions/formautofill/test/unit/test_toOneLineAddress.js
new file mode 100644
index 0000000000..ee04c8d1d5
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_toOneLineAddress.js
@@ -0,0 +1,64 @@
+"use strict";
+
+var FormAutofillUtils;
+add_setup(async () => {
+ ({ FormAutofillUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/shared/FormAutofillUtils.sys.mjs"
+ ));
+});
+
+add_task(async function test_getCategoriesFromFieldNames() {
+ const TEST_CASES = [
+ {
+ strings: ["A", "B", "C", "D"],
+ expectedValue: "A B C D",
+ },
+ {
+ strings: ["A", "B", "", "D"],
+ expectedValue: "A B D",
+ },
+ {
+ strings: ["", "B", "", "D"],
+ expectedValue: "B D",
+ },
+ {
+ strings: [null, "B", " ", "D"],
+ expectedValue: "B D",
+ },
+ {
+ strings: "A B C",
+ expectedValue: "A B C",
+ },
+ {
+ strings: "A\nB\n\n\nC",
+ expectedValue: "A B C",
+ },
+ {
+ strings: "A B \nC",
+ expectedValue: "A B C",
+ },
+ {
+ strings: "A-B-C",
+ expectedValue: "A B C",
+ delimiter: "-",
+ },
+ {
+ strings: "A B\n \nC",
+ expectedValue: "A B C",
+ },
+ {
+ strings: null,
+ expectedValue: "",
+ },
+ ];
+
+ for (let tc of TEST_CASES) {
+ let result;
+ if (tc.delimiter) {
+ result = FormAutofillUtils.toOneLineAddress(tc.strings, tc.delimiter);
+ } else {
+ result = FormAutofillUtils.toOneLineAddress(tc.strings);
+ }
+ Assert.equal(result, tc.expectedValue);
+ }
+});
diff --git a/browser/extensions/formautofill/test/unit/test_transformFields.js b/browser/extensions/formautofill/test/unit/test_transformFields.js
new file mode 100644
index 0000000000..47ba396e06
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/test_transformFields.js
@@ -0,0 +1,972 @@
+/**
+ * Tests the transform algorithm in profileStorage.
+ */
+
+"use strict";
+
+let FormAutofillStorage;
+add_setup(async () => {
+ ({ FormAutofillStorage } = ChromeUtils.importESModule(
+ "resource://autofill/FormAutofillStorage.sys.mjs"
+ ));
+});
+
+const TEST_STORE_FILE_NAME = "test-profile.json";
+
+const ADDRESS_COMPUTE_TESTCASES = [
+ // Name
+ {
+ description: "Has split names",
+ address: {
+ "given-name": "Timothy",
+ "additional-name": "John",
+ "family-name": "Berners-Lee",
+ },
+ expectedResult: {
+ "given-name": "Timothy",
+ "additional-name": "John",
+ "family-name": "Berners-Lee",
+ name: "Timothy John Berners-Lee",
+ },
+ },
+ {
+ description: "Has split CJK names",
+ address: {
+ "given-name": "德明",
+ "family-name": "孫",
+ },
+ expectedResult: {
+ "given-name": "德明",
+ "family-name": "孫",
+ name: "孫德明",
+ },
+ },
+
+ // Address
+ {
+ description: '"street-address" with single line',
+ address: {
+ "street-address": "single line",
+ },
+ expectedResult: {
+ "street-address": "single line",
+ "address-line1": "single line",
+ },
+ },
+ {
+ description: '"street-address" with multiple lines',
+ address: {
+ "street-address": "line1\nline2\nline3",
+ },
+ expectedResult: {
+ "street-address": "line1\nline2\nline3",
+ "address-line1": "line1",
+ "address-line2": "line2",
+ "address-line3": "line3",
+ },
+ },
+ {
+ description: '"street-address" with multiple lines but line2 is omitted',
+ address: {
+ "street-address": "line1\n\nline3",
+ },
+ expectedResult: {
+ "street-address": "line1\n\nline3",
+ "address-line1": "line1",
+ "address-line2": undefined,
+ "address-line3": "line3",
+ },
+ },
+ {
+ description: '"street-address" with 4 lines',
+ address: {
+ "street-address": "line1\nline2\nline3\nline4",
+ },
+ expectedResult: {
+ "street-address": "line1\nline2\nline3\nline4",
+ "address-line1": "line1",
+ "address-line2": "line2",
+ "address-line3": "line3 line4",
+ },
+ },
+ {
+ description: '"street-address" with blank lines',
+ address: {
+ "street-address": "line1\n \nline3\n \nline5",
+ },
+ expectedResult: {
+ "street-address": "line1\n \nline3\n \nline5",
+ "address-line1": "line1",
+ "address-line2": undefined,
+ "address-line3": "line3 line5",
+ },
+ },
+
+ // Country
+ {
+ description: 'Has "country"',
+ address: {
+ country: "US",
+ },
+ expectedResult: {
+ country: "US",
+ "country-name": "United States",
+ },
+ },
+
+ // Tel
+ {
+ description: '"tel" with US country code',
+ address: {
+ tel: "+16172535702",
+ },
+ expectedResult: {
+ tel: "+16172535702",
+ "tel-country-code": "+1",
+ "tel-national": "6172535702",
+ "tel-area-code": "617",
+ "tel-local": "2535702",
+ "tel-local-prefix": "253",
+ "tel-local-suffix": "5702",
+ },
+ },
+ {
+ description: '"tel" with TW country code (the components won\'t be parsed)',
+ address: {
+ tel: "+886212345678",
+ },
+ expectedResult: {
+ tel: "+886212345678",
+ "tel-country-code": "+886",
+ "tel-national": "0212345678",
+ "tel-area-code": undefined,
+ "tel-local": undefined,
+ "tel-local-prefix": undefined,
+ "tel-local-suffix": undefined,
+ },
+ },
+ {
+ description: '"tel" without country code so use "US" as default resion',
+ address: {
+ tel: "6172535702",
+ },
+ expectedResult: {
+ tel: "+16172535702",
+ "tel-country-code": "+1",
+ "tel-national": "6172535702",
+ "tel-area-code": "617",
+ "tel-local": "2535702",
+ "tel-local-prefix": "253",
+ "tel-local-suffix": "5702",
+ },
+ },
+ {
+ description: '"tel" without country code but "country" is "TW"',
+ address: {
+ tel: "0212345678",
+ country: "TW",
+ },
+ expectedResult: {
+ tel: "+886212345678",
+ "tel-country-code": "+886",
+ "tel-national": "0212345678",
+ "tel-area-code": undefined,
+ "tel-local": undefined,
+ "tel-local-prefix": undefined,
+ "tel-local-suffix": undefined,
+ },
+ },
+ {
+ description: '"tel" can\'t be parsed so leave it as-is',
+ address: {
+ tel: "12345",
+ },
+ expectedResult: {
+ tel: "12345",
+ "tel-country-code": undefined,
+ "tel-national": "12345",
+ "tel-area-code": undefined,
+ "tel-local": undefined,
+ "tel-local-prefix": undefined,
+ "tel-local-suffix": undefined,
+ },
+ },
+];
+
+const ADDRESS_NORMALIZE_TESTCASES = [
+ // Name
+ {
+ description: 'Has "name", and the split names are omitted',
+ address: {
+ name: "Timothy John Berners-Lee",
+ },
+ expectedResult: {
+ "given-name": "Timothy",
+ "additional-name": "John",
+ "family-name": "Berners-Lee",
+ },
+ },
+ {
+ description: 'Has both "name" and split names',
+ address: {
+ name: "John Doe",
+ "given-name": "Timothy",
+ "additional-name": "John",
+ "family-name": "Berners-Lee",
+ },
+ expectedResult: {
+ "given-name": "Timothy",
+ "additional-name": "John",
+ "family-name": "Berners-Lee",
+ },
+ },
+ {
+ description: 'Has "name", and some of split names are omitted',
+ address: {
+ name: "John Doe",
+ "given-name": "Timothy",
+ },
+ expectedResult: {
+ "given-name": "Timothy",
+ "family-name": "Doe",
+ },
+ },
+
+ // Address
+ {
+ description: 'Has "address-line1~3" and "street-address" is omitted',
+ address: {
+ "address-line1": "line1",
+ "address-line2": "line2",
+ "address-line3": "line3",
+ },
+ expectedResult: {
+ "street-address": "line1\nline2\nline3",
+ },
+ },
+ {
+ description: 'Has both "address-line1~3" and "street-address"',
+ address: {
+ "street-address": "street address",
+ "address-line1": "line1",
+ "address-line2": "line2",
+ "address-line3": "line3",
+ },
+ expectedResult: {
+ "street-address": "street address",
+ },
+ },
+ {
+ description: 'Has "address-line2~3" and single-line "street-address"',
+ address: {
+ "street-address": "street address",
+ "address-line2": "line2",
+ "address-line3": "line3",
+ },
+ expectedResult: {
+ "street-address": "street address\nline2\nline3",
+ },
+ },
+ {
+ description: 'Has "address-line2~3" and multiple-line "street-address"',
+ address: {
+ "street-address": "street address\nstreet address line 2",
+ "address-line2": "line2",
+ "address-line3": "line3",
+ },
+ expectedResult: {
+ "street-address": "street address\nstreet address line 2",
+ },
+ },
+ {
+ description: 'Has only "address-line1~2"',
+ address: {
+ "address-line1": "line1",
+ "address-line2": "line2",
+ },
+ expectedResult: {
+ "street-address": "line1\nline2",
+ },
+ },
+ {
+ description: 'Has only "address-line1"',
+ address: {
+ "address-line1": "line1",
+ },
+ expectedResult: {
+ "street-address": "line1",
+ },
+ },
+ {
+ description: 'Has only "address-line2~3"',
+ address: {
+ "address-line2": "line2",
+ "address-line3": "line3",
+ },
+ expectedResult: {
+ "street-address": "\nline2\nline3",
+ },
+ },
+ {
+ description: 'Has only "address-line2"',
+ address: {
+ "address-line2": "line2",
+ },
+ expectedResult: {
+ "street-address": "\nline2",
+ },
+ },
+
+ // Country
+ {
+ description: 'Has "country" in lowercase',
+ address: {
+ country: "us",
+ },
+ expectedResult: {
+ country: "US",
+ },
+ },
+ {
+ description: 'Has unknown "country"',
+ address: {
+ "given-name": "John", // Make sure it won't be an empty record.
+ country: "AA",
+ },
+ expectedResult: {
+ country: undefined,
+ },
+ },
+ {
+ description: 'Has "country-name"',
+ address: {
+ "country-name": "united states",
+ },
+ expectedResult: {
+ country: "US",
+ "country-name": "United States",
+ },
+ },
+ {
+ description: 'Has alternative "country-name"',
+ address: {
+ "country-name": "america",
+ },
+ expectedResult: {
+ country: "US",
+ "country-name": "United States",
+ },
+ },
+ {
+ description: 'Has "country-name" as a substring',
+ address: {
+ "country-name": "test america test",
+ },
+ expectedResult: {
+ country: "US",
+ "country-name": "United States",
+ },
+ },
+ {
+ description: 'Has "country-name" as part of a word',
+ address: {
+ "given-name": "John", // Make sure it won't be an empty record.
+ "country-name": "TRUST",
+ },
+ expectedResult: {
+ country: undefined,
+ "country-name": undefined,
+ },
+ },
+ {
+ description: 'Has unknown "country-name"',
+ address: {
+ "given-name": "John", // Make sure it won't be an empty record.
+ "country-name": "unknown country name",
+ },
+ expectedResult: {
+ country: undefined,
+ "country-name": undefined,
+ },
+ },
+ {
+ description: 'Has "country" and unknown "country-name"',
+ address: {
+ country: "us",
+ "country-name": "unknown country name",
+ },
+ expectedResult: {
+ country: "US",
+ "country-name": "United States",
+ },
+ },
+ {
+ description: 'Has "country-name" and unknown "country"',
+ address: {
+ "given-name": "John", // Make sure it won't be an empty record.
+ country: "AA",
+ "country-name": "united states",
+ },
+ expectedResult: {
+ country: undefined,
+ "country-name": undefined,
+ },
+ },
+ {
+ description: 'Has unsupported "country"',
+ address: {
+ "given-name": "John", // Make sure it won't be an empty record.
+ country: "XX",
+ },
+ expectedResult: {
+ country: undefined,
+ "country-name": undefined,
+ },
+ },
+
+ // Tel
+ {
+ description: 'Has "tel" with country code',
+ address: {
+ tel: "+16172535702",
+ },
+ expectedResult: {
+ tel: "+16172535702",
+ },
+ },
+ {
+ description: 'Has "tel" without country code but "country" is set',
+ address: {
+ tel: "0212345678",
+ country: "TW",
+ },
+ expectedResult: {
+ tel: "+886212345678",
+ },
+ },
+ {
+ description:
+ 'Has "tel" without country code and "country" so use "US" as default region',
+ address: {
+ tel: "6172535702",
+ },
+ expectedResult: {
+ tel: "+16172535702",
+ },
+ },
+ {
+ description: '"tel" can\'t be parsed so leave it as-is',
+ address: {
+ tel: "12345",
+ },
+ expectedResult: {
+ tel: "12345",
+ },
+ },
+ {
+ description: 'Has a valid tel-local format "tel"',
+ address: {
+ tel: "1234567",
+ },
+ expectedResult: {
+ tel: "1234567",
+ },
+ },
+ {
+ description: 'Has "tel-national" and "tel-country-code"',
+ address: {
+ "tel-national": "0212345678",
+ "tel-country-code": "+886",
+ },
+ expectedResult: {
+ tel: "+886212345678",
+ },
+ },
+ {
+ description: 'Has "tel-national" and "country"',
+ address: {
+ "tel-national": "0212345678",
+ country: "TW",
+ },
+ expectedResult: {
+ tel: "+886212345678",
+ },
+ },
+ {
+ description: 'Has "tel-national", "tel-country-code" and "country"',
+ address: {
+ "tel-national": "0212345678",
+ "tel-country-code": "+886",
+ country: "US",
+ },
+ expectedResult: {
+ tel: "+886212345678",
+ },
+ },
+ {
+ description: 'Has "tel-area-code" and "tel-local"',
+ address: {
+ "tel-area-code": "617",
+ "tel-local": "2535702",
+ },
+ expectedResult: {
+ tel: "+16172535702",
+ },
+ },
+ {
+ description:
+ 'Has "tel-area-code", "tel-local-prefix" and "tel-local-suffix"',
+ address: {
+ "tel-area-code": "617",
+ "tel-local-prefix": "253",
+ "tel-local-suffix": "5702",
+ },
+ expectedResult: {
+ tel: "+16172535702",
+ },
+ },
+];
+
+const CREDIT_CARD_COMPUTE_TESTCASES = [
+ // Name
+ {
+ description: 'Has "cc-name"',
+ creditCard: {
+ "cc-name": "Timothy John Berners-Lee",
+ "cc-number": "4929001587121045",
+ },
+ expectedResult: {
+ "cc-name": "Timothy John Berners-Lee",
+ "cc-number": "************1045",
+ "cc-given-name": "Timothy",
+ "cc-additional-name": "John",
+ "cc-family-name": "Berners-Lee",
+ },
+ },
+
+ // Card Number
+ {
+ description: "Number should be encrypted and masked",
+ creditCard: {
+ "cc-number": "4929001587121045",
+ },
+ expectedResult: {
+ "cc-number": "************1045",
+ },
+ },
+
+ // Expiration Date
+ {
+ description: 'Has "cc-exp-year" and "cc-exp-month"',
+ creditCard: {
+ "cc-exp-month": 12,
+ "cc-exp-year": 2022,
+ "cc-number": "4929001587121045",
+ },
+ expectedResult: {
+ "cc-exp-month": 12,
+ "cc-exp-year": 2022,
+ "cc-exp": "2022-12",
+ "cc-number": "************1045",
+ },
+ },
+ {
+ description: 'Has only "cc-exp-month"',
+ creditCard: {
+ "cc-exp-month": 12,
+ "cc-number": "4929001587121045",
+ },
+ expectedResult: {
+ "cc-exp-month": 12,
+ "cc-exp": undefined,
+ "cc-number": "************1045",
+ },
+ },
+ {
+ description: 'Has only "cc-exp-year"',
+ creditCard: {
+ "cc-exp-year": 2022,
+ "cc-number": "4929001587121045",
+ },
+ expectedResult: {
+ "cc-exp-year": 2022,
+ "cc-exp": undefined,
+ "cc-number": "************1045",
+ },
+ },
+];
+
+const CREDIT_CARD_NORMALIZE_TESTCASES = [
+ // Name
+ {
+ description: 'Has both "cc-name" and the split name fields',
+ creditCard: {
+ "cc-name": "Timothy John Berners-Lee",
+ "cc-given-name": "John",
+ "cc-family-name": "Doe",
+ "cc-number": "4929001587121045",
+ },
+ expectedResult: {
+ "cc-name": "Timothy John Berners-Lee",
+ "cc-number": "4929001587121045",
+ },
+ },
+ {
+ description: "Has only the split name fields",
+ creditCard: {
+ "cc-given-name": "John",
+ "cc-family-name": "Doe",
+ "cc-number": "4929001587121045",
+ },
+ expectedResult: {
+ "cc-name": "John Doe",
+ "cc-number": "4929001587121045",
+ },
+ },
+
+ // Card Number
+ {
+ description: "Regular number",
+ creditCard: {
+ "cc-number": "4929001587121045",
+ },
+ expectedResult: {
+ "cc-number": "4929001587121045",
+ },
+ },
+ {
+ description: "Number with spaces",
+ creditCard: {
+ "cc-number": "4111 1111 1111 1111",
+ },
+ expectedResult: {
+ "cc-number": "4111111111111111",
+ },
+ },
+ {
+ description: "Number with hyphens",
+ creditCard: {
+ "cc-number": "4111-1111-1111-1111",
+ },
+ expectedResult: {
+ "cc-number": "4111111111111111",
+ },
+ },
+
+ // Expiration Date
+ {
+ description: 'Has "cc-exp" formatted "yyyy-mm"',
+ creditCard: {
+ "cc-number": "4929001587121045",
+ "cc-exp": "2022-12",
+ },
+ expectedResult: {
+ "cc-exp-month": 12,
+ "cc-exp-year": 2022,
+ "cc-number": "4929001587121045",
+ },
+ },
+ {
+ description: 'Has "cc-exp" formatted "yyyy/mm"',
+ creditCard: {
+ "cc-number": "4929001587121045",
+ "cc-exp": "2022/12",
+ },
+ expectedResult: {
+ "cc-exp-month": 12,
+ "cc-exp-year": 2022,
+ "cc-number": "4929001587121045",
+ },
+ },
+ {
+ description: 'Has "cc-exp" formatted "yyyy-m"',
+ creditCard: {
+ "cc-number": "4929001587121045",
+ "cc-exp": "2022-3",
+ },
+ expectedResult: {
+ "cc-exp-month": 3,
+ "cc-exp-year": 2022,
+ "cc-number": "4929001587121045",
+ },
+ },
+ {
+ description: 'Has "cc-exp" formatted "yyyy/m"',
+ creditCard: {
+ "cc-number": "4929001587121045",
+ "cc-exp": "2022/3",
+ },
+ expectedResult: {
+ "cc-exp-month": 3,
+ "cc-exp-year": 2022,
+ "cc-number": "4929001587121045",
+ },
+ },
+ {
+ description: 'Has "cc-exp" formatted "mm-yyyy"',
+ creditCard: {
+ "cc-number": "4929001587121045",
+ "cc-exp": "12-2022",
+ },
+ expectedResult: {
+ "cc-exp-month": 12,
+ "cc-exp-year": 2022,
+ "cc-number": "4929001587121045",
+ },
+ },
+ {
+ description: 'Has "cc-exp" formatted "mm/yyyy"',
+ creditCard: {
+ "cc-number": "4929001587121045",
+ "cc-exp": "12/2022",
+ },
+ expectedResult: {
+ "cc-exp-month": 12,
+ "cc-exp-year": 2022,
+ "cc-number": "4929001587121045",
+ },
+ },
+ {
+ description: 'Has "cc-exp" formatted "m-yyyy"',
+ creditCard: {
+ "cc-number": "4929001587121045",
+ "cc-exp": "3-2022",
+ },
+ expectedResult: {
+ "cc-exp-month": 3,
+ "cc-exp-year": 2022,
+ "cc-number": "4929001587121045",
+ },
+ },
+ {
+ description: 'Has "cc-exp" formatted "m/yyyy"',
+ creditCard: {
+ "cc-number": "4929001587121045",
+ "cc-exp": "3/2022",
+ },
+ expectedResult: {
+ "cc-exp-month": 3,
+ "cc-exp-year": 2022,
+ "cc-number": "4929001587121045",
+ },
+ },
+ {
+ description: 'Has "cc-exp" formatted "mm-yy"',
+ creditCard: {
+ "cc-number": "4929001587121045",
+ "cc-exp": "12-22",
+ },
+ expectedResult: {
+ "cc-exp-month": 12,
+ "cc-exp-year": 2022,
+ "cc-number": "4929001587121045",
+ },
+ },
+ {
+ description: 'Has "cc-exp" formatted "mm/yy"',
+ creditCard: {
+ "cc-number": "4929001587121045",
+ "cc-exp": "12/22",
+ },
+ expectedResult: {
+ "cc-exp-month": 12,
+ "cc-exp-year": 2022,
+ "cc-number": "4929001587121045",
+ },
+ },
+ {
+ description: 'Has "cc-exp" formatted "yy-mm"',
+ creditCard: {
+ "cc-number": "4929001587121045",
+ "cc-exp": "22-12",
+ },
+ expectedResult: {
+ "cc-exp-month": 12,
+ "cc-exp-year": 2022,
+ "cc-number": "4929001587121045",
+ },
+ },
+ {
+ description: 'Has "cc-exp" formatted "yy/mm"',
+ creditCard: {
+ "cc-exp": "22/12",
+ "cc-number": "4929001587121045",
+ },
+ expectedResult: {
+ "cc-exp-month": 12,
+ "cc-exp-year": 2022,
+ "cc-number": "4929001587121045",
+ },
+ },
+ {
+ description: 'Has "cc-exp" formatted "mmyy"',
+ creditCard: {
+ "cc-exp": "1222",
+ "cc-number": "4929001587121045",
+ },
+ expectedResult: {
+ "cc-exp-month": 12,
+ "cc-exp-year": 2022,
+ "cc-number": "4929001587121045",
+ },
+ },
+ {
+ description: 'Has "cc-exp" formatted "yymm"',
+ creditCard: {
+ "cc-exp": "2212",
+ "cc-number": "4929001587121045",
+ },
+ expectedResult: {
+ "cc-exp-month": 12,
+ "cc-exp-year": 2022,
+ "cc-number": "4929001587121045",
+ },
+ },
+ {
+ description: 'Has "cc-exp" with spaces',
+ creditCard: {
+ "cc-exp": " 2033-11 ",
+ "cc-number": "4929001587121045",
+ },
+ expectedResult: {
+ "cc-exp-month": 11,
+ "cc-exp-year": 2033,
+ "cc-number": "4929001587121045",
+ },
+ },
+ {
+ description: 'Has invalid "cc-exp"',
+ creditCard: {
+ "cc-number": "4111111111111111", // Make sure it won't be an empty record.
+ "cc-exp": "99-9999",
+ },
+ expectedResult: {
+ "cc-exp-month": undefined,
+ "cc-exp-year": undefined,
+ },
+ },
+ {
+ description: 'Has both "cc-exp-*" and "cc-exp"',
+ creditCard: {
+ "cc-exp": "2022-12",
+ "cc-exp-month": 3,
+ "cc-exp-year": 2030,
+ "cc-number": "4929001587121045",
+ },
+ expectedResult: {
+ "cc-exp-month": 3,
+ "cc-exp-year": 2030,
+ "cc-number": "4929001587121045",
+ },
+ },
+ {
+ description: 'Has only "cc-exp-year" and "cc-exp"',
+ creditCard: {
+ "cc-exp": "2022-12",
+ "cc-exp-year": 2030,
+ "cc-number": "4929001587121045",
+ },
+ expectedResult: {
+ "cc-exp-month": 12,
+ "cc-exp-year": 2022,
+ "cc-number": "4929001587121045",
+ },
+ },
+ {
+ description: 'Has only "cc-exp-month" and "cc-exp"',
+ creditCard: {
+ "cc-exp": "2022-12",
+ "cc-exp-month": 3,
+ "cc-number": "4929001587121045",
+ },
+ expectedResult: {
+ "cc-exp-month": 12,
+ "cc-exp-year": 2022,
+ "cc-number": "4929001587121045",
+ },
+ },
+];
+
+let do_check_record_matches = (expectedRecord, record) => {
+ for (let key in expectedRecord) {
+ Assert.equal(expectedRecord[key], record[key]);
+ }
+};
+
+add_task(async function test_computeAddressFields() {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+
+ let profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ for (let testcase of ADDRESS_COMPUTE_TESTCASES) {
+ info("Verify testcase: " + testcase.description);
+
+ let guid = await profileStorage.addresses.add(testcase.address);
+ let address = await profileStorage.addresses.get(guid);
+ do_check_record_matches(testcase.expectedResult, address);
+
+ profileStorage.addresses.remove(guid);
+ }
+
+ await profileStorage._finalize();
+});
+
+add_task(async function test_normalizeAddressFields() {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+
+ let profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ for (let testcase of ADDRESS_NORMALIZE_TESTCASES) {
+ info("Verify testcase: " + testcase.description);
+
+ let guid = await profileStorage.addresses.add(testcase.address);
+ let address = await profileStorage.addresses.get(guid);
+ do_check_record_matches(testcase.expectedResult, address);
+
+ profileStorage.addresses.remove(guid);
+ }
+
+ await profileStorage._finalize();
+});
+
+add_task(async function test_computeCreditCardFields() {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+
+ let profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ for (let testcase of CREDIT_CARD_COMPUTE_TESTCASES) {
+ info("Verify testcase: " + testcase.description);
+
+ let guid = await profileStorage.creditCards.add(testcase.creditCard);
+ let creditCard = await profileStorage.creditCards.get(guid);
+ do_check_record_matches(testcase.expectedResult, creditCard);
+
+ profileStorage.creditCards.remove(guid);
+ }
+
+ await profileStorage._finalize();
+});
+
+add_task(async function test_normalizeCreditCardFields() {
+ let path = getTempFile(TEST_STORE_FILE_NAME).path;
+
+ let profileStorage = new FormAutofillStorage(path);
+ await profileStorage.initialize();
+
+ for (let testcase of CREDIT_CARD_NORMALIZE_TESTCASES) {
+ info("Verify testcase: " + testcase.description);
+
+ let guid = await profileStorage.creditCards.add(testcase.creditCard);
+ let creditCard = await profileStorage.creditCards.get(guid, {
+ rawData: true,
+ });
+ do_check_record_matches(testcase.expectedResult, creditCard);
+
+ profileStorage.creditCards.remove(guid);
+ }
+
+ await profileStorage._finalize();
+});
diff --git a/browser/extensions/formautofill/test/unit/xpcshell.ini b/browser/extensions/formautofill/test/unit/xpcshell.ini
new file mode 100644
index 0000000000..bb0a71729c
--- /dev/null
+++ b/browser/extensions/formautofill/test/unit/xpcshell.ini
@@ -0,0 +1,100 @@
+[DEFAULT]
+skip-if =
+ (os == "linux") && ccov # bug 1821945
+ toolkit == 'android' # bug 1730213
+firefox-appdir = browser
+head = head.js
+support-files =
+ ../fixtures/**
+prefs =
+ extensions.formautofill.heuristics.visibilityCheckThreshold=0
+
+[test_activeStatus.js]
+[test_addressComponent_city.js]
+head = head_addressComponent.js
+[test_addressComponent_country.js]
+head = head_addressComponent.js
+[test_addressComponent_email.js]
+head = head_addressComponent.js
+[test_addressComponent_name.js]
+head = head_addressComponent.js
+[test_addressComponent_organization.js]
+head = head_addressComponent.js
+[test_addressComponent_postal_code.js]
+head = head_addressComponent.js
+[test_addressComponent_state.js]
+head = head_addressComponent.js
+[test_addressComponent_street_address.js]
+head = head_addressComponent.js
+[test_addressComponent_tel.js]
+head = head_addressComponent.js
+[test_addressDataLoader.js]
+[test_addressRecords.js]
+skip-if =
+ apple_silicon # bug 1729554
+[test_autofillFormFields.js]
+skip-if =
+ tsan # Times out, bug 1612707
+ apple_silicon # bug 1729554
+[test_clearPopulatedForm.js]
+[test_collectFormFields.js]
+[test_createRecords.js]
+[test_creditCardRecords.js]
+skip-if =
+ tsan # Times out, bug 1612707
+ apple_silicon # bug 1729554
+[test_extractLabelStrings.js]
+[test_findLabelElements.js]
+[test_getAdaptedProfiles.js]
+[test_getAdaptedProfiles_locales.js]
+[test_getCategoriesFromFieldNames.js]
+[test_getCreditCardLogo.js]
+[test_getFormInputDetails.js]
+[test_getInfo.js]
+[test_getRecords.js]
+skip-if =
+ tsan # Times out, bug 1612707
+ apple_silicon # bug 1729554
+[test_isAddressAutofillAvailable.js]
+[test_isCJKName.js]
+[test_isCreditCardAutofillAvailable.js]
+[test_isCreditCardOrAddressFieldType.js]
+[test_known_strings.js]
+[test_markAsAutofillField.js]
+[test_migrateRecords.js]
+skip-if = tsan # Times out, bug 1612707
+[test_nameUtils.js]
+[test_onFormSubmitted.js]
+skip-if = tsan # Times out, bug 1612707
+[test_parseStreetAddress.js]
+[test_parseAddressFormat.js]
+[test_previewFormFields.js]
+[test_profileAutocompleteResult.js]
+[test_phoneNumber.js]
+[test_reconcile.js]
+skip-if =
+ tsan # Times out, bug 1612707
+ apple_silicon # bug 1729554
+[test_savedFieldNames.js]
+[test_toOneLineAddress.js]
+[test_storage_tombstones.js]
+skip-if =
+ tsan # Times out, bug 1612707
+ apple_silicon # bug 1729554
+[test_storage_remove.js]
+skip-if =
+ tsan # Times out, bug 1612707
+ apple_silicon # bug 1729554
+[test_storage_syncfields.js]
+[test_transformFields.js]
+skip-if =
+ tsan # Times out, bug 1612707
+ apple_silicon # bug 1729554
+[test_sync.js]
+head = head.js ../../../../../services/sync/tests/unit/head_appinfo.js ../../../../../services/common/tests/unit/head_helpers.js ../../../../../services/sync/tests/unit/head_helpers.js ../../../../../services/sync/tests/unit/head_http_server.js
+skip-if = tsan # Times out, bug 1612707
+[test_sync_deprecate_credit_card_v4.js]
+head = head.js ../../../../../services/sync/tests/unit/head_appinfo.js ../../../../../services/common/tests/unit/head_helpers.js ../../../../../services/sync/tests/unit/head_helpers.js ../../../../../services/sync/tests/unit/head_http_server.js
+skip-if =
+ tsan # Times out, bug 1612707
+ apple_silicon # bug 1729554
diff --git a/browser/extensions/moz.build b/browser/extensions/moz.build
new file mode 100644
index 0000000000..3c6e7eb886
--- /dev/null
+++ b/browser/extensions/moz.build
@@ -0,0 +1,14 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DIRS += [
+ "formautofill",
+ "screenshots",
+ "webcompat",
+ "report-site-issue",
+ "pictureinpicture",
+ "search-detection",
+]
diff --git a/browser/extensions/pictureinpicture/data/picture_in_picture_overrides.js b/browser/extensions/pictureinpicture/data/picture_in_picture_overrides.js
new file mode 100644
index 0000000000..4f3186036c
--- /dev/null
+++ b/browser/extensions/pictureinpicture/data/picture_in_picture_overrides.js
@@ -0,0 +1,304 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* globals browser */
+
+let AVAILABLE_PIP_OVERRIDES;
+
+{
+ // See PictureInPictureControls.sys.mjs for these values.
+ // eslint-disable-next-line no-unused-vars
+ const TOGGLE_POLICIES = browser.pictureInPictureChild.getPolicies();
+ const KEYBOARD_CONTROLS = browser.pictureInPictureChild.getKeyboardControls();
+
+ AVAILABLE_PIP_OVERRIDES = {
+ // The keys of this object are match patterns for URLs, as documented in
+ // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns
+ //
+ // Example:
+ // const KEYBOARD_CONTROLS = browser.pictureInPictureChild.getKeyboardControls();
+ //
+ //
+ // "https://*.youtube.com/*": {
+ // policy: TOGGLE_POLICIES.THREE_QUARTERS,
+ // disabledKeyboardControls: KEYBOARD_CONTROLS.PLAY_PAUSE | KEYBOARD_CONTROLS.VOLUME,
+ // },
+ // "https://*.twitch.tv/mikeconley_dot_ca/*": {
+ // policy: TOGGLE_POLICIES.TOP,
+ // disabledKeyboardControls: KEYBOARD_CONTROLS.ALL,
+ // },
+
+ tests: {
+ // FOR TESTS ONLY!
+ "https://mochitest.youtube.com/*browser/browser/extensions/pictureinpicture/tests/browser/test-mock-wrapper.html":
+ {
+ videoWrapperScriptPath: "video-wrappers/mock-wrapper.js",
+ },
+ "https://mochitest.youtube.com/*browser/browser/extensions/pictureinpicture/tests/browser/test-toggle-visibility.html":
+ {
+ videoWrapperScriptPath: "video-wrappers/mock-wrapper.js",
+ },
+ },
+
+ abcnews: {
+ "https://*.abcnews.go.com/*": {
+ videoWrapperScriptPath: "video-wrappers/videojsWrapper.js",
+ },
+ },
+
+ airmozilla: {
+ "https://*.mozilla.hosted.panopto.com/*": {
+ videoWrapperScriptPath: "video-wrappers/airmozilla.js",
+ },
+ },
+
+ aol: {
+ "https://*.aol.com/*": {
+ videoWrapperScriptPath: "video-wrappers/yahoo.js",
+ },
+ },
+
+ bbc: {
+ "https://*.bbc.com/*": {
+ videoWrapperScriptPath: "video-wrappers/bbc.js",
+ },
+ "https://*.bbc.co.uk/*": {
+ videoWrapperScriptPath: "video-wrappers/bbc.js",
+ },
+ },
+
+ brightcove: {
+ "https://*.brightcove.com/*": {
+ videoWrapperScriptPath: "video-wrappers/videojsWrapper.js",
+ },
+ },
+ cbc: {
+ "https://*.cbc.ca/*": {
+ videoWrapperScriptPath: "video-wrappers/cbc.js",
+ },
+ },
+
+ dailymotion: {
+ "https://*.dailymotion.com/*": {
+ videoWrapperScriptPath: "video-wrappers/dailymotion.js",
+ },
+ },
+
+ disneyplus: {
+ "https://*.disneyplus.com/*": {
+ videoWrapperScriptPath: "video-wrappers/disneyplus.js",
+ },
+ },
+
+ edx: {
+ "https://*.edx.org/*": {
+ videoWrapperScriptPath: "video-wrappers/edx.js",
+ },
+ },
+
+ frontendMasters: {
+ "https://*.frontendmasters.com/*": {
+ videoWrapperScriptPath: "video-wrappers/videojsWrapper.js",
+ },
+ },
+
+ funimation: {
+ "https://*.funimation.com/*": {
+ videoWrapperScriptPath: "video-wrappers/videojsWrapper.js",
+ },
+ },
+
+ hbomax: {
+ "https://play.hbomax.com/page/*": { policy: TOGGLE_POLICIES.HIDDEN },
+ "https://play.hbomax.com/player/*": {
+ videoWrapperScriptPath: "video-wrappers/hbomax.js",
+ },
+ },
+
+ hotstar: {
+ "https://*.hotstar.com/*": {
+ videoWrapperScriptPath: "video-wrappers/hotstar.js",
+ },
+ },
+
+ hulu: {
+ "https://www.hulu.com/watch/*": {
+ videoWrapperScriptPath: "video-wrappers/hulu.js",
+ },
+ },
+
+ instagram: {
+ "https://www.instagram.com/*": { policy: TOGGLE_POLICIES.ONE_QUARTER },
+ },
+
+ laracasts: {
+ "https://*.laracasts.com/*": { policy: TOGGLE_POLICIES.ONE_QUARTER },
+ },
+
+ msn: {
+ "https://*.msn.com/*": {
+ visibilityThreshold: 0.7,
+ },
+ },
+ mxplayer: {
+ "https://*.mxplayer.in/*": {
+ videoWrapperScriptPath: "video-wrappers/videojsWrapper.js",
+ },
+ },
+
+ nebula: {
+ "https://*.nebula.app/*": {
+ videoWrapperScriptPath: "video-wrappers/videojsWrapper.js",
+ },
+ },
+
+ netflix: {
+ "https://*.netflix.com/*": {
+ videoWrapperScriptPath: "video-wrappers/netflix.js",
+ },
+ "https://*.netflix.com/browse*": { policy: TOGGLE_POLICIES.HIDDEN },
+ "https://*.netflix.com/latest*": { policy: TOGGLE_POLICIES.HIDDEN },
+ "https://*.netflix.com/Kids*": { policy: TOGGLE_POLICIES.HIDDEN },
+ "https://*.netflix.com/title*": { policy: TOGGLE_POLICIES.HIDDEN },
+ "https://*.netflix.com/notification*": { policy: TOGGLE_POLICIES.HIDDEN },
+ "https://*.netflix.com/search*": { policy: TOGGLE_POLICIES.HIDDEN },
+ },
+
+ nytimes: {
+ "https://*.nytimes.com/*": {
+ videoWrapperScriptPath: "video-wrappers/nytimes.js",
+ },
+ },
+
+ pbs: {
+ "https://*.pbs.org/*": {
+ videoWrapperScriptPath: "video-wrappers/videojsWrapper.js",
+ },
+ "https://*.pbskids.org/*": {
+ videoWrapperScriptPath: "video-wrappers/videojsWrapper.js",
+ },
+ },
+
+ piped: {
+ "https://*.piped.kavin.rocks/*": {
+ videoWrapperScriptPath: "video-wrappers/piped.js",
+ },
+ "https://*.piped.silkky.cloud/*": {
+ videoWrapperScriptPath: "video-wrappers/piped.js",
+ },
+ },
+
+ radiocanada: {
+ "https://*.ici.radio-canada.ca/*": {
+ videoWrapperScriptPath: "video-wrappers/radiocanada.js",
+ },
+ },
+
+ reddit: {
+ "https://*.reddit.com/*": { policy: TOGGLE_POLICIES.ONE_QUARTER },
+ },
+
+ sonyliv: {
+ "https://*.sonyliv.com/*": {
+ videoWrapperScriptPath: "video-wrappers/sonyliv.js",
+ },
+ },
+
+ ted: {
+ "https://*.ted.com/*": {
+ showHiddenTextTracks: true,
+ },
+ },
+
+ tubi: {
+ "https://*.tubitv.com/live*": {
+ videoWrapperScriptPath: "video-wrappers/tubilive.js",
+ },
+ "https://*.tubitv.com/movies*": {
+ videoWrapperScriptPath: "video-wrappers/tubi.js",
+ },
+ "https://*.tubitv.com/tv-shows*": {
+ videoWrapperScriptPath: "video-wrappers/tubi.js",
+ },
+ },
+
+ twitch: {
+ "https://*.twitch.tv/*": {
+ videoWrapperScriptPath: "video-wrappers/twitch.js",
+ policy: TOGGLE_POLICIES.ONE_QUARTER,
+ disabledKeyboardControls: KEYBOARD_CONTROLS.LIVE_SEEK,
+ },
+ "https://*.twitch.tech/*": {
+ videoWrapperScriptPath: "video-wrappers/twitch.js",
+ policy: TOGGLE_POLICIES.ONE_QUARTER,
+ disabledKeyboardControls: KEYBOARD_CONTROLS.LIVE_SEEK,
+ },
+ "https://*.twitch.a2z.com/*": {
+ videoWrapperScriptPath: "video-wrappers/twitch.js",
+ policy: TOGGLE_POLICIES.ONE_QUARTER,
+ disabledKeyboardControls: KEYBOARD_CONTROLS.LIVE_SEEK,
+ },
+ },
+
+ udemy: {
+ "https://*.udemy.com/*": {
+ videoWrapperScriptPath: "video-wrappers/udemy.js",
+ policy: TOGGLE_POLICIES.ONE_QUARTER,
+ },
+ },
+
+ voot: {
+ "https://*.voot.com/*": {
+ videoWrapperScriptPath: "video-wrappers/voot.js",
+ },
+ },
+
+ wired: {
+ "https://*.wired.com/*": {
+ videoWrapperScriptPath: "video-wrappers/videojsWrapper.js",
+ },
+ },
+
+ yahoofinance: {
+ "https://*.finance.yahoo.com/*": {
+ videoWrapperScriptPath: "video-wrappers/yahoo.js",
+ },
+ },
+
+ youtube: {
+ /**
+ * The threshold of 0.7 is so that users can click on the "Skip Ads"
+ * button on the YouTube site player without accidentally triggering
+ * PiP.
+ */
+ "https://*.youtube.com/*": {
+ visibilityThreshold: 0.7,
+ videoWrapperScriptPath: "video-wrappers/youtube.js",
+ },
+ "https://*.youtube-nocookie.com/*": {
+ visibilityThreshold: 0.9,
+ videoWrapperScriptPath: "video-wrappers/youtube.js",
+ },
+ },
+
+ washingtonpost: {
+ "https://*.washingtonpost.com/*": {
+ videoWrapperScriptPath: "video-wrappers/washingtonpost.js",
+ },
+ },
+
+ primeVideo: {
+ "https://*.primevideo.com/*": {
+ visibilityThreshold: 0.9,
+ videoWrapperScriptPath: "video-wrappers/primeVideo.js",
+ },
+ "https://*.amazon.com/*": {
+ visibilityThreshold: 0.9,
+ videoWrapperScriptPath: "video-wrappers/primeVideo.js",
+ },
+ },
+ };
+}
diff --git a/browser/extensions/pictureinpicture/experiment-apis/aboutConfigPipPrefs.js b/browser/extensions/pictureinpicture/experiment-apis/aboutConfigPipPrefs.js
new file mode 100644
index 0000000000..0d7e15de05
--- /dev/null
+++ b/browser/extensions/pictureinpicture/experiment-apis/aboutConfigPipPrefs.js
@@ -0,0 +1,65 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* global ExtensionAPI, ExtensionCommon, Services, XPCOMUtils */
+
+/**
+ * Class extending the ExtensionAPI, ensures we can set/get preferences
+ */
+this.aboutConfigPipPrefs = class extends ExtensionAPI {
+ /**
+ * Override ExtensionAPI with PiP override's specific preference API, prefixed by `disabled_picture_in_picture_overrides`
+ * @param {ExtensionContext} context the context of an extension
+ * @returns {Object} returns the necessary API structure required to manage prefs within this extension
+ */
+ getAPI(context) {
+ const EventManager = ExtensionCommon.EventManager;
+ const extensionIDBase = context.extension.id.split("@")[0];
+ const extensionPrefNameBase = `extensions.${extensionIDBase}.`;
+
+ return {
+ aboutConfigPipPrefs: {
+ onPrefChange: new EventManager({
+ context,
+ name: "aboutConfigPipPrefs.onSiteOverridesPrefChange",
+ register: (fire, name) => {
+ const prefName = `${extensionPrefNameBase}${name}`;
+ const callback = () => {
+ fire.async(name).catch(() => {}); // ignore Message Manager disconnects
+ };
+ Services.prefs.addObserver(prefName, callback);
+ return () => {
+ Services.prefs.removeObserver(prefName, callback);
+ };
+ },
+ }).api(),
+ /**
+ * Calls `Services.prefs.getBoolPref` to get a preference
+ * @param {String} name The name of the preference to get; will be prefixed with this extension's branch
+ * @returns the preference, or undefined
+ */
+ async getPref(name) {
+ try {
+ return Services.prefs.getBoolPref(
+ `${extensionPrefNameBase}${name}`
+ );
+ } catch (_) {
+ return undefined;
+ }
+ },
+
+ /**
+ * Calls `Services.prefs.setBoolPref` to set a preference
+ * @param {String} name the name of the preference to set; will be prefixed with this extension's branch
+ * @param {String} value the bool value to save in the pref
+ */
+ async setPref(name, value) {
+ Services.prefs.setBoolPref(`${extensionPrefNameBase}${name}`, value);
+ },
+ },
+ };
+ }
+};
diff --git a/browser/extensions/pictureinpicture/experiment-apis/aboutConfigPipPrefs.json b/browser/extensions/pictureinpicture/experiment-apis/aboutConfigPipPrefs.json
new file mode 100644
index 0000000000..8b2b352667
--- /dev/null
+++ b/browser/extensions/pictureinpicture/experiment-apis/aboutConfigPipPrefs.json
@@ -0,0 +1,59 @@
+[
+ {
+ "namespace": "aboutConfigPipPrefs",
+ "description": "experimental API extension to allow access to about:config preferences",
+ "events": [
+ {
+ "name": "onPrefChange",
+ "type": "function",
+ "parameters": [
+ {
+ "name": "name",
+ "type": "string",
+ "description": "The preference which changed"
+ }
+ ],
+ "extraParameters": [
+ {
+ "name": "name",
+ "type": "string",
+ "description": "The preference to monitor"
+ }
+ ]
+ }
+ ],
+ "functions": [
+ {
+ "name": "getPref",
+ "type": "function",
+ "description": "Get a preference's value",
+ "parameters": [
+ {
+ "name": "name",
+ "type": "string",
+ "description": "The preference name"
+ }
+ ],
+ "async": true
+ },
+ {
+ "name": "setPref",
+ "type": "function",
+ "description": "Set a preference's value",
+ "parameters": [
+ {
+ "name": "name",
+ "type": "string",
+ "description": "The preference name"
+ },
+ {
+ "name": "value",
+ "type": "boolean",
+ "description": "The new value"
+ }
+ ],
+ "async": true
+ }
+ ]
+ }
+]
diff --git a/browser/extensions/pictureinpicture/experiment-apis/pictureInPicture.js b/browser/extensions/pictureinpicture/experiment-apis/pictureInPicture.js
new file mode 100644
index 0000000000..45323f5280
--- /dev/null
+++ b/browser/extensions/pictureinpicture/experiment-apis/pictureInPicture.js
@@ -0,0 +1,84 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* global AppConstants, ChromeUtils, ExtensionAPI, Services */
+
+ChromeUtils.defineESModuleGetters(this, {
+ KEYBOARD_CONTROLS: "resource://gre/modules/PictureInPictureControls.sys.mjs",
+ TOGGLE_POLICIES: "resource://gre/modules/PictureInPictureControls.sys.mjs",
+});
+
+const TOGGLE_ENABLED_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.enabled";
+
+/**
+ * This API is expected to be running in the parent process.
+ */
+this.pictureInPictureParent = class extends ExtensionAPI {
+ /**
+ * Override ExtensionAPI with PiP override's specific API
+ * Relays the site overrides to this extension's child process
+ * @param {ExtensionContext} context the context of our extension
+ * @returns {Object} returns the necessary API structure required to manage sharedData in PictureInPictureParent
+ */
+ getAPI(context) {
+ return {
+ pictureInPictureParent: {
+ setOverrides(overrides) {
+ // The Picture-in-Picture toggle is only implemented for Desktop, so make
+ // this a no-op for non-Desktop builds.
+ if (AppConstants.platform == "android") {
+ return;
+ }
+
+ Services.ppmm.sharedData.set(
+ "PictureInPicture:SiteOverrides",
+ overrides
+ );
+ },
+ },
+ };
+ }
+};
+
+/**
+ * This API is expected to be running in a content process - specifically,
+ * the WebExtension content process that the background scripts run in. We
+ * split these out so that they can return values synchronously to the
+ * background scripts.
+ */
+this.pictureInPictureChild = class extends ExtensionAPI {
+ /**
+ * Override ExtensionAPI with PiP override's specific API
+ * Clone constants into the Picture-in-Picture child process
+ * @param {ExtensionContext} context the context of our extension
+ * @returns returns the necessary API structure required to get data from PictureInPictureChild
+ */
+ getAPI(context) {
+ return {
+ pictureInPictureChild: {
+ getKeyboardControls() {
+ // The Picture-in-Picture toggle is only implemented for Desktop, so make
+ // this return nothing for non-Desktop builds.
+ if (AppConstants.platform == "android") {
+ return Cu.cloneInto({}, context.cloneScope);
+ }
+
+ return Cu.cloneInto(KEYBOARD_CONTROLS, context.cloneScope);
+ },
+ getPolicies() {
+ // The Picture-in-Picture toggle is only implemented for Desktop, so make
+ // this return nothing for non-Desktop builds.
+ if (AppConstants.platform == "android") {
+ return Cu.cloneInto({}, context.cloneScope);
+ }
+
+ return Cu.cloneInto(TOGGLE_POLICIES, context.cloneScope);
+ },
+ },
+ };
+ }
+};
diff --git a/browser/extensions/pictureinpicture/experiment-apis/pictureInPicture.json b/browser/extensions/pictureinpicture/experiment-apis/pictureInPicture.json
new file mode 100644
index 0000000000..5f34616b6e
--- /dev/null
+++ b/browser/extensions/pictureinpicture/experiment-apis/pictureInPicture.json
@@ -0,0 +1,51 @@
+[
+ {
+ "namespace": "pictureInPictureParent",
+ "description": "Parent process methods for controlling the Picture-in-Picture feature.",
+ "functions": [
+ {
+ "name": "setOverrides",
+ "type": "function",
+ "description": "Set Picture-in-Picture toggle position overrides",
+ "parameters": [
+ {
+ "name": "overrides",
+ "type": "object",
+ "additionalProperties": { "type": "any" },
+ "description": "The Picture-in-Picture toggle position overrides to set"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "namespace": "pictureInPictureChild",
+ "description": "WebExtension process methods for querying the Picture-in-Picture feature.",
+ "functions": [
+ {
+ "name": "getKeyboardControls",
+ "type": "function",
+ "description": "Get the Picture-in-Picture keyboard control override constants",
+ "parameters": [],
+ "returns": {
+ "type": "object",
+ "properties": {},
+ "additionalProperties": { "type": "any" },
+ "description": "The Picture-in-Picture keyboard control override constants"
+ }
+ },
+ {
+ "name": "getPolicies",
+ "type": "function",
+ "description": "Get the Picture-in-Picture toggle position override constants",
+ "parameters": [],
+ "returns": {
+ "type": "object",
+ "properties": {},
+ "additionalProperties": { "type": "any" },
+ "description": "The Picture-in-Picture toggle position override constants"
+ }
+ }
+ ]
+ }
+]
diff --git a/browser/extensions/pictureinpicture/lib/picture_in_picture_overrides.js b/browser/extensions/pictureinpicture/lib/picture_in_picture_overrides.js
new file mode 100644
index 0000000000..829f33e15b
--- /dev/null
+++ b/browser/extensions/pictureinpicture/lib/picture_in_picture_overrides.js
@@ -0,0 +1,98 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* globals browser, module */
+
+/**
+ * Picture-in-Picture Overrides
+ */
+class PictureInPictureOverrides {
+ /**
+ * Class constructor
+ * @param {Object} availableOverrides Contains all overrides provided in data/picture_in_picture_overrides.js
+ */
+ constructor(availableOverrides) {
+ this.pref = "enable_picture_in_picture_overrides";
+ this._prefEnabledOverrides = new Set();
+ this._availableOverrides = availableOverrides;
+ this.policies = browser.pictureInPictureChild.getPolicies();
+ }
+
+ /**
+ * Ensures the "enable_picture_in_picture_overrides" pref is set; if it is undefined, sets the pref to true
+ */
+ async _checkGlobalPref() {
+ await browser.aboutConfigPipPrefs.getPref(this.pref).then(value => {
+ if (value === false) {
+ this._enabled = false;
+ } else {
+ if (value === undefined) {
+ browser.aboutConfigPipPrefs.setPref(this.pref, true);
+ }
+ this._enabled = true;
+ }
+ });
+ }
+
+ /**
+ * Checks the status of a specified override, and updates the set, `this._prefEnabledOverrides`, accordingly
+ * @param {String} id the id of the specific override contained in `this._availableOverrides`
+ * @param {String} pref the specific preference to check, in the form `disabled_picture_in_picture_overrides.${id}`
+ */
+ async _checkSpecificOverridePref(id, pref) {
+ const isDisabled = await browser.aboutConfigPipPrefs.getPref(pref);
+ if (isDisabled === true) {
+ this._prefEnabledOverrides.delete(id);
+ } else {
+ this._prefEnabledOverrides.add(id);
+ }
+ }
+
+ /**
+ * The function that `run.js` calls to begin checking for changes to the PiP overrides
+ */
+ bootup() {
+ const checkGlobal = async () => {
+ await this._checkGlobalPref();
+ this._onAvailableOverridesChanged();
+ };
+ browser.aboutConfigPipPrefs.onPrefChange.addListener(
+ checkGlobal,
+ this.pref
+ );
+
+ const bootupPrefCheckPromises = [this._checkGlobalPref()];
+
+ for (const id of Object.keys(this._availableOverrides)) {
+ const pref = `disabled_picture_in_picture_overrides.${id}`;
+ const checkSingle = async () => {
+ await this._checkSpecificOverridePref(id, pref);
+ this._onAvailableOverridesChanged();
+ };
+ browser.aboutConfigPipPrefs.onPrefChange.addListener(checkSingle, pref);
+ bootupPrefCheckPromises.push(this._checkSpecificOverridePref(id, pref));
+ }
+
+ Promise.all(bootupPrefCheckPromises).then(() => {
+ this._onAvailableOverridesChanged();
+ });
+ }
+
+ /**
+ * Sets pictureInPictureParent's overrides
+ */
+ async _onAvailableOverridesChanged() {
+ const policies = await this.policies;
+ let enabledOverrides = {};
+ for (const [id, override] of Object.entries(this._availableOverrides)) {
+ const enabled = this._enabled && this._prefEnabledOverrides.has(id);
+ for (const [url, policy] of Object.entries(override)) {
+ enabledOverrides[url] = enabled ? policy : policies.DEFAULT;
+ }
+ }
+ browser.pictureInPictureParent.setOverrides(enabledOverrides);
+ }
+}
diff --git a/browser/extensions/pictureinpicture/manifest.json b/browser/extensions/pictureinpicture/manifest.json
new file mode 100644
index 0000000000..c193c446be
--- /dev/null
+++ b/browser/extensions/pictureinpicture/manifest.json
@@ -0,0 +1,48 @@
+{
+ "manifest_version": 2,
+ "name": "Picture-In-Picture",
+ "description": "Fixes for web compatibility with Picture-in-Picture",
+ "version": "1.0.0",
+
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "pictureinpicture@mozilla.org",
+ "strict_min_version": "88.0a1"
+ }
+ },
+
+ "experiment_apis": {
+ "aboutConfigPipPrefs": {
+ "schema": "experiment-apis/aboutConfigPipPrefs.json",
+ "parent": {
+ "scopes": ["addon_parent"],
+ "script": "experiment-apis/aboutConfigPipPrefs.js",
+ "paths": [["aboutConfigPipPrefs"]]
+ }
+ },
+ "pictureInPictureChild": {
+ "schema": "experiment-apis/pictureInPicture.json",
+ "child": {
+ "scopes": ["addon_child"],
+ "script": "experiment-apis/pictureInPicture.js",
+ "paths": [["pictureInPictureChild"]]
+ }
+ },
+ "pictureInPictureParent": {
+ "schema": "experiment-apis/pictureInPicture.json",
+ "parent": {
+ "scopes": ["addon_parent"],
+ "script": "experiment-apis/pictureInPicture.js",
+ "paths": [["pictureInPictureParent"]]
+ }
+ }
+ },
+
+ "background": {
+ "scripts": [
+ "data/picture_in_picture_overrides.js",
+ "lib/picture_in_picture_overrides.js",
+ "run.js"
+ ]
+ }
+}
diff --git a/browser/extensions/pictureinpicture/moz.build b/browser/extensions/pictureinpicture/moz.build
new file mode 100644
index 0000000000..9ab6a1a37d
--- /dev/null
+++ b/browser/extensions/pictureinpicture/moz.build
@@ -0,0 +1,61 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DEFINES["MOZ_APP_VERSION"] = CONFIG["MOZ_APP_VERSION"]
+DEFINES["MOZ_APP_MAXVERSION"] = CONFIG["MOZ_APP_MAXVERSION"]
+
+FINAL_TARGET_FILES.features["pictureinpicture@mozilla.org"] += [
+ "manifest.json",
+ "run.js",
+]
+
+FINAL_TARGET_FILES.features["pictureinpicture@mozilla.org"]["data"] += [
+ "data/picture_in_picture_overrides.js",
+]
+
+FINAL_TARGET_FILES.features["pictureinpicture@mozilla.org"]["experiment-apis"] += [
+ "experiment-apis/aboutConfigPipPrefs.js",
+ "experiment-apis/aboutConfigPipPrefs.json",
+ "experiment-apis/pictureInPicture.js",
+ "experiment-apis/pictureInPicture.json",
+]
+
+FINAL_TARGET_FILES.features["pictureinpicture@mozilla.org"]["lib"] += [
+ "lib/picture_in_picture_overrides.js",
+]
+
+FINAL_TARGET_FILES.features["pictureinpicture@mozilla.org"]["video-wrappers"] += [
+ "video-wrappers/airmozilla.js",
+ "video-wrappers/bbc.js",
+ "video-wrappers/cbc.js",
+ "video-wrappers/dailymotion.js",
+ "video-wrappers/disneyplus.js",
+ "video-wrappers/edx.js",
+ "video-wrappers/hbomax.js",
+ "video-wrappers/hotstar.js",
+ "video-wrappers/hulu.js",
+ "video-wrappers/mock-wrapper.js",
+ "video-wrappers/netflix.js",
+ "video-wrappers/nytimes.js",
+ "video-wrappers/piped.js",
+ "video-wrappers/primeVideo.js",
+ "video-wrappers/radiocanada.js",
+ "video-wrappers/sonyliv.js",
+ "video-wrappers/tubi.js",
+ "video-wrappers/tubilive.js",
+ "video-wrappers/twitch.js",
+ "video-wrappers/udemy.js",
+ "video-wrappers/videojsWrapper.js",
+ "video-wrappers/voot.js",
+ "video-wrappers/washingtonpost.js",
+ "video-wrappers/yahoo.js",
+ "video-wrappers/youtube.js",
+]
+
+BROWSER_CHROME_MANIFESTS += ["tests/browser/browser.ini"]
+
+with Files("**"):
+ BUG_COMPONENT = ("Toolkit", "Picture-in-Picture")
diff --git a/browser/extensions/pictureinpicture/run.js b/browser/extensions/pictureinpicture/run.js
new file mode 100644
index 0000000000..cb2982f83f
--- /dev/null
+++ b/browser/extensions/pictureinpicture/run.js
@@ -0,0 +1,9 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* globals AVAILABLE_PIP_OVERRIDES, PictureInPictureOverrides */
+const pipOverrides = new PictureInPictureOverrides(AVAILABLE_PIP_OVERRIDES);
+pipOverrides.bootup();
diff --git a/browser/extensions/pictureinpicture/tests/browser/.eslintrc.js b/browser/extensions/pictureinpicture/tests/browser/.eslintrc.js
new file mode 100644
index 0000000000..9bf153d21a
--- /dev/null
+++ b/browser/extensions/pictureinpicture/tests/browser/.eslintrc.js
@@ -0,0 +1,14 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+module.exports = {
+ globals: {
+ ensureVideosReady: "readonly",
+ triggerPictureInPicture: "readonly",
+ isVideoMuted: "readonly",
+ isVideoPaused: "readonly",
+ },
+};
diff --git a/browser/extensions/pictureinpicture/tests/browser/browser.ini b/browser/extensions/pictureinpicture/tests/browser/browser.ini
new file mode 100644
index 0000000000..3194c87ca5
--- /dev/null
+++ b/browser/extensions/pictureinpicture/tests/browser/browser.ini
@@ -0,0 +1,19 @@
+[DEFAULT]
+support-files =
+ test-mock-wrapper.html
+ test-mock-wrapper.js
+ test-toggle-visibility.html
+ ../../../../../toolkit/components/pictureinpicture/tests/click-event-helper.js
+ ../../../../../toolkit/components/pictureinpicture/tests/head.js
+ ../../../../../toolkit/components/pictureinpicture/tests/test-video.mp4
+
+prefs =
+ media.videocontrols.picture-in-picture.enabled=true
+ media.videocontrols.picture-in-picture.video-toggle.enabled=true
+ media.videocontrols.picture-in-picture.video-toggle.testing=true
+ media.videocontrols.picture-in-picture.video-toggle.always-show=true
+ media.videocontrols.picture-in-picture.video-toggle.has-used=true
+ media.videocontrols.picture-in-picture.video-toggle.position="right"
+
+[browser_mock_wrapper.js]
+skip-if = !nightly_build # Bug 1751793
diff --git a/browser/extensions/pictureinpicture/tests/browser/browser_mock_wrapper.js b/browser/extensions/pictureinpicture/tests/browser/browser_mock_wrapper.js
new file mode 100644
index 0000000000..6dcdd7536e
--- /dev/null
+++ b/browser/extensions/pictureinpicture/tests/browser/browser_mock_wrapper.js
@@ -0,0 +1,205 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/* import-globals-from ../../../../../toolkit/components/pictureinpicture/tests/head.js */
+
+ChromeUtils.defineESModuleGetters(this, {
+ TOGGLE_POLICIES: "resource://gre/modules/PictureInPictureControls.sys.mjs",
+});
+
+const TEST_URL =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://mochitest.youtube.com:443"
+ ) + "test-mock-wrapper.html";
+const TEST_URL_TOGGLE_VISIBILITY =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://mochitest.youtube.com:443"
+ ) + "test-toggle-visibility.html";
+
+/**
+ * Tests the mock-wrapper.js video wrapper script selects the expected element
+ * responsible for toggling the video player's mute status.
+ */
+add_task(async function test_mock_mute_button() {
+ await BrowserTestUtils.withNewTab(TEST_URL, async browser => {
+ await ensureVideosReady(browser);
+
+ // Open the video in PiP
+ let videoID = "mock-video-controls";
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ // Mute audio
+ await toggleMute(browser, pipWin);
+ ok(await isVideoMuted(browser, videoID), "The audio is muted.");
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ let muteButton = content.document.querySelector(".mute-button");
+ ok(
+ muteButton.getAttribute("isMuted"),
+ "muteButton has isMuted attribute."
+ );
+ });
+
+ // Unmute audio
+ await toggleMute(browser, pipWin);
+ ok(!(await isVideoMuted(browser, videoID)), "The audio is playing.");
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ let muteButton = content.document.querySelector(".mute-button");
+ ok(
+ !muteButton.getAttribute("isMuted"),
+ "muteButton does not have isMuted attribute"
+ );
+ });
+
+ // Close PiP window
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ let closeButton = pipWin.document.getElementById("close");
+ EventUtils.synthesizeMouseAtCenter(closeButton, {}, pipWin);
+ await pipClosed;
+ });
+});
+
+/**
+ * Tests the mock-wrapper.js video wrapper script selects the expected element
+ * responsible for toggling the video player's play/pause status.
+ */
+add_task(async function test_mock_play_pause_button() {
+ await BrowserTestUtils.withNewTab(TEST_URL, async browser => {
+ await ensureVideosReady(browser);
+ await setupVideoListeners(browser);
+
+ // Open the video in PiP
+ let videoID = "mock-video-controls";
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ info("Test a wrapper method with a correct selector");
+ // Play video
+ let playbackPromise = waitForVideoEvent(browser, "playing");
+ let playPause = pipWin.document.getElementById("playpause");
+ EventUtils.synthesizeMouseAtCenter(playPause, {}, pipWin);
+ await playbackPromise;
+ ok(!(await isVideoPaused(browser, videoID)), "The video is playing.");
+
+ info("Test a wrapper method with an incorrect selector");
+ // Pause the video.
+ let pausePromise = waitForVideoEvent(browser, "pause");
+ EventUtils.synthesizeMouseAtCenter(playPause, {}, pipWin);
+ await pausePromise;
+ ok(await isVideoPaused(browser, videoID), "The video is paused.");
+
+ // Close PiP window
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ let closeButton = pipWin.document.getElementById("close");
+ EventUtils.synthesizeMouseAtCenter(closeButton, {}, pipWin);
+ await pipClosed;
+ });
+});
+
+/**
+ * Tests the mock-wrapper.js video wrapper script does not toggle mute/umute
+ * state when increasing/decreasing the volume using the arrow keys.
+ */
+add_task(async function test_volume_change_with_keyboard() {
+ await BrowserTestUtils.withNewTab(TEST_URL, async browser => {
+ await ensureVideosReady(browser);
+ await setupVideoListeners(browser);
+
+ // Open the video in PiP
+ let videoID = "mock-video-controls";
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ // Initially set video to be muted
+ await toggleMute(browser, pipWin);
+ ok(await isVideoMuted(browser, videoID), "The audio is not playing.");
+
+ // Decrease volume with arrow down
+ EventUtils.synthesizeKey("KEY_ArrowDown", {}, pipWin);
+ ok(!(await isVideoMuted(browser, videoID)), "The audio is playing.");
+
+ // Increase volume with arrow up
+ EventUtils.synthesizeKey("KEY_ArrowUp", {}, pipWin);
+ ok(!(await isVideoMuted(browser, videoID)), "The audio is still playing.");
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ let video = content.document.querySelector("video");
+ ok(!video.muted, "Video should be unmuted.");
+ });
+
+ // Close PiP window
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ let closeButton = pipWin.document.getElementById("close");
+ EventUtils.synthesizeMouseAtCenter(closeButton, {}, pipWin);
+ await pipClosed;
+ });
+});
+
+function waitForVideoEvent(browser, eventType) {
+ return BrowserTestUtils.waitForContentEvent(browser, eventType, true);
+}
+
+async function toggleMute(browser, pipWin) {
+ let mutedPromise = waitForVideoEvent(browser, "volumechange");
+ let audioButton = pipWin.document.getElementById("audio");
+ EventUtils.synthesizeMouseAtCenter(audioButton, {}, pipWin);
+ await mutedPromise;
+}
+
+async function setupVideoListeners(browser) {
+ await SpecialPowers.spawn(browser, [], async function () {
+ let video = content.document.querySelector("video");
+
+ // Set a listener for "playing" event
+ video.addEventListener("playing", async () => {
+ info("Got playing event!");
+ let playPauseButton =
+ content.document.querySelector(".play-pause-button");
+ ok(
+ !playPauseButton.getAttribute("isPaused"),
+ "playPauseButton does not have isPaused attribute."
+ );
+ });
+
+ // Set a listener for "pause" event
+ video.addEventListener("pause", async () => {
+ info("Got pause event!");
+ let playPauseButton =
+ content.document.querySelector(".play-pause-button");
+ // mock-wrapper's pause() method uses an invalid selector and should throw
+ // an error. Test that the PiP wrapper uses the fallback pause() method.
+ // This is to ensure PiP can handle cases where a site wrapper script is
+ // incorrect, but does not break functionality altogether.
+ ok(
+ !playPauseButton.getAttribute("isPaused"),
+ "playPauseButton still doesn't have isPaused attribute."
+ );
+ });
+ });
+}
+
+/**
+ * Tests that the mock-wrapper.js video wrapper hides the pip toggle when shouldHideToggle()
+ * returns true.
+ */
+add_task(async function test_mock_should_hide_toggle() {
+ await testToggle(TEST_URL_TOGGLE_VISIBILITY, {
+ "mock-video-controls": { canToggle: false, policy: TOGGLE_POLICIES.HIDDEN },
+ });
+});
+
+/**
+ * Tests that the mock-wrapper.js video wrapper does not hide the pip toggle when shouldHideToggle()
+ * returns false.
+ */
+add_task(async function test_mock_should_not_hide_toggle() {
+ await testToggle(TEST_URL, {
+ "mock-video-controls": { canToggle: true },
+ });
+});
diff --git a/browser/extensions/pictureinpicture/tests/browser/test-mock-wrapper.html b/browser/extensions/pictureinpicture/tests/browser/test-mock-wrapper.html
new file mode 100644
index 0000000000..f53e7a1b28
--- /dev/null
+++ b/browser/extensions/pictureinpicture/tests/browser/test-mock-wrapper.html
@@ -0,0 +1,22 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Mock Wrapper Test</title>
+ <script type="text/javascript" src="test-mock-wrapper.js"></script>
+ <script type="text/javascript" src="click-event-helper.js"></script>
+</head>
+<style>
+ video {
+ display: block;
+ border: 1px solid black;
+ }
+</style>
+<body>
+ <div id="player">
+ <video id="mock-video-controls" src="test-video.mp4" controls loop="true" width="400" height="225"></video>
+ <button class="play-pause-button" onclick="playPause()">play/pause button</button>
+ <button class="mute-button" onclick="toggleMute()">mute/unmute button</button>
+ </div>
+</body>
+</html>
diff --git a/browser/extensions/pictureinpicture/tests/browser/test-mock-wrapper.js b/browser/extensions/pictureinpicture/tests/browser/test-mock-wrapper.js
new file mode 100644
index 0000000000..2119122428
--- /dev/null
+++ b/browser/extensions/pictureinpicture/tests/browser/test-mock-wrapper.js
@@ -0,0 +1,31 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+function toggleMute() {
+ let video = document.getElementById("mock-video-controls");
+ let muteButton = document.querySelector(".mute-button");
+ let isMuted = video.muted;
+ video.muted = !isMuted;
+
+ if (video.muted) {
+ muteButton.setAttribute("isMuted", true);
+ } else {
+ muteButton.removeAttribute("isMuted");
+ }
+}
+
+function playPause() {
+ let video = document.getElementById("mock-video-controls");
+ let playPauseButton = document.querySelector(".play-pause-button");
+
+ if (video.paused) {
+ video.play();
+ playPauseButton.removeAttribute("isPaused");
+ } else {
+ video.setAttribute("isPaused", true);
+ video.pause();
+ }
+}
diff --git a/browser/extensions/pictureinpicture/tests/browser/test-toggle-visibility.html b/browser/extensions/pictureinpicture/tests/browser/test-toggle-visibility.html
new file mode 100644
index 0000000000..622ea6c568
--- /dev/null
+++ b/browser/extensions/pictureinpicture/tests/browser/test-toggle-visibility.html
@@ -0,0 +1,22 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Mock Wrapper Test</title>
+ <script type="text/javascript" src="test-mock-wrapper.js"></script>
+ <script type="text/javascript" src="click-event-helper.js"></script>
+</head>
+<style>
+ video {
+ display: block;
+ border: 1px solid black;
+ }
+</style>
+<body>
+ <div id="player">
+ <video id="mock-video-controls" class="mock-preview-video" src="test-video.mp4" controls loop="true" width="400" height="225"></video>
+ <button class="play-pause-button" onclick="playPause()">play/pause button</button>
+ <button class="mute-button" onclick="toggleMute()">mute/unmute button</button>
+ </div>
+</body>
+</html>
diff --git a/browser/extensions/pictureinpicture/video-wrappers/airmozilla.js b/browser/extensions/pictureinpicture/video-wrappers/airmozilla.js
new file mode 100644
index 0000000000..d2e98cbe48
--- /dev/null
+++ b/browser/extensions/pictureinpicture/video-wrappers/airmozilla.js
@@ -0,0 +1,63 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+class PictureInPictureVideoWrapper {
+ play(video) {
+ let playPauseButton = document.querySelector(
+ "#transportControls #playButton"
+ );
+ if (video.paused) {
+ playPauseButton?.click();
+ }
+ }
+
+ pause(video) {
+ let playPauseButton = document.querySelector(
+ "#transportControls #playButton"
+ );
+ if (!video.paused) {
+ playPauseButton?.click();
+ }
+ }
+
+ setMuted(video, shouldMute) {
+ let muteButton = document.querySelector("#transportControls #muteButton");
+ if (video.muted !== shouldMute && muteButton) {
+ muteButton.click();
+ }
+ }
+
+ setCaptionContainerObserver(video, updateCaptionsFunction) {
+ let container = document.querySelector("#absoluteControls");
+
+ if (container) {
+ updateCaptionsFunction("");
+ const callback = function (mutationsList, observer) {
+ let text = container?.querySelector("#overlayCaption").innerText;
+
+ if (!text) {
+ updateCaptionsFunction("");
+ return;
+ }
+
+ updateCaptionsFunction(text);
+ };
+
+ // immediately invoke the callback function to add subtitles to the PiP window
+ callback([1], null);
+
+ let captionsObserver = new MutationObserver(callback);
+
+ captionsObserver.observe(container, {
+ attributes: false,
+ childList: true,
+ subtree: true,
+ });
+ }
+ }
+}
+
+this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper;
diff --git a/browser/extensions/pictureinpicture/video-wrappers/bbc.js b/browser/extensions/pictureinpicture/video-wrappers/bbc.js
new file mode 100644
index 0000000000..a5adcbc534
--- /dev/null
+++ b/browser/extensions/pictureinpicture/video-wrappers/bbc.js
@@ -0,0 +1,31 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+class PictureInPictureVideoWrapper {
+ setCaptionContainerObserver(video, updateCaptionsFunction) {
+ let container = document.querySelector(".p_subtitlesContainer");
+
+ if (container) {
+ updateCaptionsFunction("");
+ const callback = function (mutationsList, observer) {
+ let text = container.querySelector(".p_cueDirUniWrapper")?.innerText;
+ updateCaptionsFunction(text);
+ };
+
+ callback([1], null);
+
+ let captionsObserver = new MutationObserver(callback);
+
+ captionsObserver.observe(container, {
+ attributes: false,
+ childList: true,
+ subtree: true,
+ });
+ }
+ }
+}
+
+this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper;
diff --git a/browser/extensions/pictureinpicture/video-wrappers/cbc.js b/browser/extensions/pictureinpicture/video-wrappers/cbc.js
new file mode 100644
index 0000000000..595d23594b
--- /dev/null
+++ b/browser/extensions/pictureinpicture/video-wrappers/cbc.js
@@ -0,0 +1,30 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+class PictureInPictureVideoWrapper {
+ play(video) {
+ let playPauseButton = document.querySelector(".video-ui .play-button");
+ if (video.paused) {
+ playPauseButton?.click();
+ }
+ }
+
+ pause(video) {
+ let playPauseButton = document.querySelector(".video-ui .pause-button");
+ if (!video.paused) {
+ playPauseButton?.click();
+ }
+ }
+
+ setMuted(video, shouldMute) {
+ let muteButton = document.querySelector(".video-ui .muted-btn");
+ if (video.muted !== shouldMute && muteButton) {
+ muteButton.click();
+ }
+ }
+}
+
+this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper;
diff --git a/browser/extensions/pictureinpicture/video-wrappers/dailymotion.js b/browser/extensions/pictureinpicture/video-wrappers/dailymotion.js
new file mode 100644
index 0000000000..161f1ec516
--- /dev/null
+++ b/browser/extensions/pictureinpicture/video-wrappers/dailymotion.js
@@ -0,0 +1,42 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+class PictureInPictureVideoWrapper {
+ setCaptionContainerObserver(video, updateCaptionsFunction) {
+ let container = document.querySelector("#player");
+
+ if (container) {
+ updateCaptionsFunction("");
+ const callback = function (mutationsList, observer) {
+ let textNodeList = container
+ ?.querySelector(".subtitles")
+ ?.querySelectorAll("div");
+
+ if (!textNodeList) {
+ updateCaptionsFunction("");
+ return;
+ }
+
+ updateCaptionsFunction(
+ Array.from(textNodeList, x => x.innerText).join("\n")
+ );
+ };
+
+ // immediately invoke the callback function to add subtitles to the PiP window
+ callback([1], null);
+
+ let captionsObserver = new MutationObserver(callback);
+
+ captionsObserver.observe(container, {
+ attributes: false,
+ childList: true,
+ subtree: true,
+ });
+ }
+ }
+}
+
+this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper;
diff --git a/browser/extensions/pictureinpicture/video-wrappers/disneyplus.js b/browser/extensions/pictureinpicture/video-wrappers/disneyplus.js
new file mode 100644
index 0000000000..816dc0d5e0
--- /dev/null
+++ b/browser/extensions/pictureinpicture/video-wrappers/disneyplus.js
@@ -0,0 +1,40 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+class PictureInPictureVideoWrapper {
+ setCaptionContainerObserver(video, updateCaptionsFunction) {
+ let container = document.querySelector(".dss-hls-subtitle-overlay");
+
+ if (container) {
+ const callback = () => {
+ let textNodeList = container.querySelectorAll(
+ ".dss-subtitle-renderer-line"
+ );
+
+ if (!textNodeList.length) {
+ updateCaptionsFunction("");
+ return;
+ }
+
+ updateCaptionsFunction(
+ Array.from(textNodeList, x => x.textContent).join("\n")
+ );
+ };
+
+ // immediately invoke the callback function to add subtitles to the PiP window
+ callback();
+
+ let captionsObserver = new MutationObserver(callback);
+ captionsObserver.observe(container, {
+ attributes: false,
+ childList: true,
+ subtree: true,
+ });
+ }
+ }
+}
+
+this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper;
diff --git a/browser/extensions/pictureinpicture/video-wrappers/edx.js b/browser/extensions/pictureinpicture/video-wrappers/edx.js
new file mode 100644
index 0000000000..07a3d9f302
--- /dev/null
+++ b/browser/extensions/pictureinpicture/video-wrappers/edx.js
@@ -0,0 +1,33 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+class PictureInPictureVideoWrapper {
+ setCaptionContainerObserver(video, updateCaptionsFunction) {
+ let container = document.querySelector(".video-wrapper");
+
+ if (container) {
+ updateCaptionsFunction("");
+ const callback = function (mutationsList, observer) {
+ let text = container.querySelector(
+ ".closed-captions.is-visible"
+ )?.innerText;
+ updateCaptionsFunction(text);
+ };
+
+ callback([1], null);
+
+ let captionsObserver = new MutationObserver(callback);
+
+ captionsObserver.observe(container, {
+ attributes: true,
+ childList: true,
+ subtree: true,
+ });
+ }
+ }
+}
+
+this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper;
diff --git a/browser/extensions/pictureinpicture/video-wrappers/hbomax.js b/browser/extensions/pictureinpicture/video-wrappers/hbomax.js
new file mode 100644
index 0000000000..8aff3e0077
--- /dev/null
+++ b/browser/extensions/pictureinpicture/video-wrappers/hbomax.js
@@ -0,0 +1,48 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+class PictureInPictureVideoWrapper {
+ setVolume(video, volume) {
+ video.volume = volume;
+ }
+ isMuted(video) {
+ return video.volume === 0;
+ }
+ setMuted(video, shouldMute) {
+ if (shouldMute) {
+ this.setVolume(video, 0);
+ } else {
+ this.setVolume(video, 1);
+ }
+ }
+ setCaptionContainerObserver(video, updateCaptionsFunction) {
+ let container = document.querySelector(
+ '[data-testid="CueBoxContainer"]'
+ ).parentElement;
+
+ if (container) {
+ updateCaptionsFunction("");
+ const callback = function (mutationsList, observer) {
+ let text = container.querySelector(
+ '[data-testid="CueBoxContainer"]'
+ )?.innerText;
+ updateCaptionsFunction(text);
+ };
+
+ callback([1], null);
+
+ let captionsObserver = new MutationObserver(callback);
+
+ captionsObserver.observe(container, {
+ attributes: false,
+ childList: true,
+ subtree: true,
+ });
+ }
+ }
+}
+
+this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper;
diff --git a/browser/extensions/pictureinpicture/video-wrappers/hotstar.js b/browser/extensions/pictureinpicture/video-wrappers/hotstar.js
new file mode 100644
index 0000000000..d64c0dabb2
--- /dev/null
+++ b/browser/extensions/pictureinpicture/video-wrappers/hotstar.js
@@ -0,0 +1,41 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+class PictureInPictureVideoWrapper {
+ setCaptionContainerObserver(video, updateCaptionsFunction) {
+ let container = document.querySelector(".subtitle-container");
+
+ if (container) {
+ updateCaptionsFunction("");
+ const callback = function (mutationsList, observer) {
+ let textNodeList = container
+ .querySelector(".shaka-text-container")
+ ?.querySelectorAll("span");
+ if (!textNodeList) {
+ updateCaptionsFunction("");
+ return;
+ }
+
+ updateCaptionsFunction(
+ Array.from(textNodeList, x => x.textContent).join("\n")
+ );
+ };
+
+ // immediately invoke the callback function to add subtitles to the PiP window
+ callback([1], null);
+
+ let captionsObserver = new MutationObserver(callback);
+
+ captionsObserver.observe(container, {
+ attributes: false,
+ childList: true,
+ subtree: true,
+ });
+ }
+ }
+}
+
+this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper;
diff --git a/browser/extensions/pictureinpicture/video-wrappers/hulu.js b/browser/extensions/pictureinpicture/video-wrappers/hulu.js
new file mode 100644
index 0000000000..fdaf6d7c18
--- /dev/null
+++ b/browser/extensions/pictureinpicture/video-wrappers/hulu.js
@@ -0,0 +1,71 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+class PictureInPictureVideoWrapper {
+ constructor(video) {
+ this.player = video.wrappedJSObject.__HuluDashPlayer__;
+ }
+ play() {
+ this.player.play();
+ }
+ pause() {
+ this.player.pause();
+ }
+ isMuted(video) {
+ return video.volume === 0;
+ }
+ setMuted(video, shouldMute) {
+ let muteButton = document.querySelector(".VolumeControl > div");
+
+ if (this.isMuted(video) !== shouldMute) {
+ muteButton.click();
+ }
+ }
+ setCurrentTime(video, position) {
+ this.player.currentTime = position;
+ }
+ setCaptionContainerObserver(video, updateCaptionsFunction) {
+ let container = document.querySelector(".ClosedCaption");
+
+ if (container) {
+ updateCaptionsFunction("");
+ const callback = function (mutationsList, observer) {
+ // This will get the subtitles for both live and regular playback videos
+ // and combine them to display. liveVideoText should be an empty string
+ // when the video is regular playback and vice versa. If both
+ // liveVideoText and regularVideoText are non empty strings, which
+ // doesn't seem to be the case, they will both show.
+ let liveVideoText = Array.from(
+ container.querySelectorAll(
+ "#inband-closed-caption > div > div > div"
+ ),
+ x => x.textContent.trim()
+ )
+ .filter(String)
+ .join("\n");
+ let regularVideoText = container.querySelector(".CaptionBox").innerText;
+
+ updateCaptionsFunction(liveVideoText + regularVideoText);
+ };
+
+ // immediately invoke the callback function to add subtitles to the PiP window
+ callback([1], null);
+
+ let captionsObserver = new MutationObserver(callback);
+
+ captionsObserver.observe(container, {
+ attributes: false,
+ childList: true,
+ subtree: true,
+ });
+ }
+ }
+ getDuration(video) {
+ return this.player.duration;
+ }
+}
+
+this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper;
diff --git a/browser/extensions/pictureinpicture/video-wrappers/mock-wrapper.js b/browser/extensions/pictureinpicture/video-wrappers/mock-wrapper.js
new file mode 100644
index 0000000000..b68ce3fa9b
--- /dev/null
+++ b/browser/extensions/pictureinpicture/video-wrappers/mock-wrapper.js
@@ -0,0 +1,34 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+class PictureInPictureVideoWrapper {
+ play(video) {
+ let playPauseButton = document.querySelector("#player .play-pause-button");
+ playPauseButton.click();
+ }
+
+ pause(video) {
+ let invalidSelector = "#player .pause-button";
+ let playPauseButton = document.querySelector(invalidSelector);
+ playPauseButton.click();
+ }
+
+ setMuted(video, shouldMute) {
+ let muteButton = document.querySelector("#player .mute-button");
+ if (video.muted !== shouldMute && muteButton) {
+ muteButton.click();
+ } else {
+ video.muted = shouldMute;
+ }
+ }
+
+ shouldHideToggle() {
+ let video = document.getElementById("mock-video-controls");
+ return !!video.classList.contains("mock-preview-video");
+ }
+}
+
+this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper;
diff --git a/browser/extensions/pictureinpicture/video-wrappers/netflix.js b/browser/extensions/pictureinpicture/video-wrappers/netflix.js
new file mode 100644
index 0000000000..bda91796b6
--- /dev/null
+++ b/browser/extensions/pictureinpicture/video-wrappers/netflix.js
@@ -0,0 +1,79 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+class PictureInPictureVideoWrapper {
+ constructor() {
+ let netflixPlayerAPI =
+ window.wrappedJSObject.netflix.appContext.state.playerApp.getAPI()
+ .videoPlayer;
+ let sessionId = null;
+ for (let id of netflixPlayerAPI.getAllPlayerSessionIds()) {
+ if (id.startsWith("watch-")) {
+ sessionId = id;
+ break;
+ }
+ }
+ this.player = netflixPlayerAPI.getVideoPlayerBySessionId(sessionId);
+ }
+ /**
+ * The Netflix player returns the current time in milliseconds so we convert
+ * to seconds before returning.
+ * @param {HTMLVideoElement} video The original video element
+ * @returns {Number} The current time in seconds
+ */
+ getCurrentTime(video) {
+ return this.player.getCurrentTime() / 1000;
+ }
+ /**
+ * The Netflix player returns the duration in milliseconds so we convert to
+ * seconds before returning.
+ * @param {HTMLVideoElement} video The original video element
+ * @returns {Number} The duration in seconds
+ */
+ getDuration(video) {
+ return this.player.getDuration() / 1000;
+ }
+ play() {
+ this.player.play();
+ }
+ pause() {
+ this.player.pause();
+ }
+
+ setCaptionContainerObserver(video, updateCaptionsFunction) {
+ let container = document.querySelector(".watch-video");
+
+ if (container) {
+ updateCaptionsFunction("");
+ const callback = function (mutationsList, observer) {
+ let text = container.querySelector(".player-timedtext").innerText;
+ updateCaptionsFunction(text);
+ };
+
+ // immediately invoke the callback function to add subtitles to the PiP window
+ callback([1], null);
+
+ let captionsObserver = new MutationObserver(callback);
+
+ captionsObserver.observe(container, {
+ attributes: false,
+ childList: true,
+ subtree: true,
+ });
+ }
+ }
+
+ /**
+ * Set the current time of the video in milliseconds.
+ * @param {HTMLVideoElement} video The original video element
+ * @param {Number} position The new time in seconds
+ */
+ setCurrentTime(video, position) {
+ this.player.seek(position * 1000);
+ }
+}
+
+this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper;
diff --git a/browser/extensions/pictureinpicture/video-wrappers/nytimes.js b/browser/extensions/pictureinpicture/video-wrappers/nytimes.js
new file mode 100644
index 0000000000..4f6d8cbe44
--- /dev/null
+++ b/browser/extensions/pictureinpicture/video-wrappers/nytimes.js
@@ -0,0 +1,37 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+class PictureInPictureVideoWrapper {
+ setCaptionContainerObserver(video, updateCaptionsFunction) {
+ let container = document.querySelector(".react-vhs-player");
+
+ if (container) {
+ updateCaptionsFunction("");
+ const callback = function (mutationsList, observer) {
+ let text = container.querySelector(".cueWrap-2P4Ue4VQ")?.innerText;
+ if (!text) {
+ updateCaptionsFunction("");
+ return;
+ }
+
+ updateCaptionsFunction(text);
+ };
+
+ // immediately invoke the callback function to add subtitles to the PiP window
+ callback([1], null);
+
+ let captionsObserver = new MutationObserver(callback);
+
+ captionsObserver.observe(container, {
+ attributes: false,
+ childList: true,
+ subtree: true,
+ });
+ }
+ }
+}
+
+this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper;
diff --git a/browser/extensions/pictureinpicture/video-wrappers/piped.js b/browser/extensions/pictureinpicture/video-wrappers/piped.js
new file mode 100644
index 0000000000..1cc1c32eb2
--- /dev/null
+++ b/browser/extensions/pictureinpicture/video-wrappers/piped.js
@@ -0,0 +1,41 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+class PictureInPictureVideoWrapper {
+ setCaptionContainerObserver(video, updateCaptionsFunction) {
+ let container = document.querySelector(".player-container");
+
+ if (container) {
+ updateCaptionsFunction("");
+ const callback = function (mutationsList, observer) {
+ let textNodeList = container
+ .querySelector(".shaka-text-wrapper")
+ ?.querySelectorAll('span[style="background-color: black;"]');
+ if (!textNodeList) {
+ updateCaptionsFunction("");
+ return;
+ }
+
+ updateCaptionsFunction(
+ Array.from(textNodeList, x => x.textContent).join("\n")
+ );
+ };
+
+ // immediately invoke the callback function to add subtitles to the PiP window
+ callback([1], null);
+
+ let captionsObserver = new MutationObserver(callback);
+
+ captionsObserver.observe(container, {
+ attributes: false,
+ childList: true,
+ subtree: true,
+ });
+ }
+ }
+}
+
+this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper;
diff --git a/browser/extensions/pictureinpicture/video-wrappers/primeVideo.js b/browser/extensions/pictureinpicture/video-wrappers/primeVideo.js
new file mode 100644
index 0000000000..1425846598
--- /dev/null
+++ b/browser/extensions/pictureinpicture/video-wrappers/primeVideo.js
@@ -0,0 +1,101 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+class PictureInPictureVideoWrapper {
+ /**
+ * Playing the video when the readyState is HAVE_METADATA (1) can cause play
+ * to fail but it will load the video and trying to play again allows enough
+ * time for the second play to successfully play the video.
+ * @param {HTMLVideoElement} video
+ * The original video element
+ */
+ play(video) {
+ video.play().catch(() => {
+ video.play();
+ });
+ }
+ /**
+ * Seeking large amounts of time can cause the video readyState to
+ * HAVE_METADATA (1) and it will throw an error when trying to play the video.
+ * To combat this, after seeking we check if the readyState changed and if so,
+ * we will play to video to "load" the video at the new time and then play or
+ * pause the video depending on if the video was playing before we seeked.
+ * @param {HTMLVideoElement} video
+ * The original video element
+ * @param {Number} position
+ * The new time to set the video to
+ * @param {Boolean} wasPlaying
+ * True if the video was playing before seeking else false
+ */
+ setCurrentTime(video, position, wasPlaying) {
+ if (wasPlaying === undefined) {
+ this.wasPlaying = !video.paused;
+ }
+ video.currentTime = position;
+ if (video.readyState < video.HAVE_CURRENT_DATA) {
+ video
+ .play()
+ .then(() => {
+ if (!wasPlaying) {
+ video.pause();
+ }
+ })
+ .catch(() => {
+ if (wasPlaying) {
+ this.play(video);
+ }
+ });
+ }
+ }
+ setCaptionContainerObserver(video, updateCaptionsFunction) {
+ let container = document?.querySelector("#dv-web-player");
+
+ if (container) {
+ updateCaptionsFunction("");
+ const callback = function (mutationsList, observer) {
+ // eslint-disable-next-line no-unused-vars
+ for (const mutation of mutationsList) {
+ let text;
+ // windows, mac
+ if (container?.querySelector(".atvwebplayersdk-player-container")) {
+ text = container
+ ?.querySelector(".f35bt6a")
+ ?.querySelector(".atvwebplayersdk-captions-text")?.innerText;
+ } else {
+ // linux
+ text = container
+ ?.querySelector(".persistentPanel")
+ ?.querySelector("span")?.innerText;
+ }
+
+ if (!text) {
+ updateCaptionsFunction("");
+ return;
+ }
+
+ updateCaptionsFunction(text);
+ }
+ };
+
+ // immediately invoke the callback function to add subtitles to the PiP window
+ callback([1], null);
+
+ let captionsObserver = new MutationObserver(callback);
+
+ captionsObserver.observe(container, {
+ attributes: true,
+ childList: true,
+ subtree: true,
+ });
+ }
+ }
+
+ shouldHideToggle(video) {
+ return !!video.classList.contains("tst-video-overlay-player-html5");
+ }
+}
+
+this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper;
diff --git a/browser/extensions/pictureinpicture/video-wrappers/radiocanada.js b/browser/extensions/pictureinpicture/video-wrappers/radiocanada.js
new file mode 100644
index 0000000000..1a55377493
--- /dev/null
+++ b/browser/extensions/pictureinpicture/video-wrappers/radiocanada.js
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+class PictureInPictureVideoWrapper {
+ play(video) {
+ let playPauseButton = document.querySelector(
+ ".rcplayer-btn.rcplayer-smallPlayPauseBtn"
+ );
+ if (video.paused) {
+ playPauseButton.click();
+ }
+ }
+
+ pause(video) {
+ let playPauseButton = document.querySelector(
+ ".rcplayer-btn.rcplayer-smallPlayPauseBtn"
+ );
+ if (!video.paused) {
+ playPauseButton.click();
+ }
+ }
+
+ setMuted(video, shouldMute) {
+ let muteButton = document.querySelector(
+ ".rcplayer-bouton-with-panel--volume > button"
+ );
+ if (video.muted !== shouldMute && muteButton) {
+ muteButton.click();
+ }
+ }
+}
+
+this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper;
diff --git a/browser/extensions/pictureinpicture/video-wrappers/sonyliv.js b/browser/extensions/pictureinpicture/video-wrappers/sonyliv.js
new file mode 100644
index 0000000000..b703aaec2c
--- /dev/null
+++ b/browser/extensions/pictureinpicture/video-wrappers/sonyliv.js
@@ -0,0 +1,39 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+class PictureInPictureVideoWrapper {
+ setCaptionContainerObserver(video, updateCaptionsFunction) {
+ let container = document.querySelector(".player-ui-main-wrapper");
+
+ if (container) {
+ updateCaptionsFunction("");
+ const callback = function (mutationsList, observer) {
+ let text = container.querySelector(
+ `.text-track-wrapper:not([style*="display: none"])`
+ )?.innerText;
+ if (!text) {
+ updateCaptionsFunction("");
+ return;
+ }
+
+ updateCaptionsFunction(text);
+ };
+
+ // immediately invoke the callback function to add subtitles to the PiP window
+ callback([1], null);
+
+ let captionsObserver = new MutationObserver(callback);
+
+ captionsObserver.observe(container, {
+ attributes: false,
+ childList: true,
+ subtree: true,
+ });
+ }
+ }
+}
+
+this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper;
diff --git a/browser/extensions/pictureinpicture/video-wrappers/tubi.js b/browser/extensions/pictureinpicture/video-wrappers/tubi.js
new file mode 100644
index 0000000000..291dbfddeb
--- /dev/null
+++ b/browser/extensions/pictureinpicture/video-wrappers/tubi.js
@@ -0,0 +1,35 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+class PictureInPictureVideoWrapper {
+ setCaptionContainerObserver(video, updateCaptionsFunction) {
+ let container = document.querySelector(`[data-id="hls"]`);
+
+ if (container) {
+ updateCaptionsFunction("");
+ const callback = function (mutationsList, observer) {
+ let text = container?.querySelector(
+ `[data-id="captionsComponent"]:not([style="display: none;"])`
+ )?.innerText;
+
+ updateCaptionsFunction(text);
+ };
+
+ // immediately invoke the callback function to add subtitles to the PiP window
+ callback([1], null);
+
+ let captionsObserver = new MutationObserver(callback);
+
+ captionsObserver.observe(container, {
+ attributes: true,
+ childList: true,
+ subtree: true,
+ });
+ }
+ }
+}
+
+this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper;
diff --git a/browser/extensions/pictureinpicture/video-wrappers/tubilive.js b/browser/extensions/pictureinpicture/video-wrappers/tubilive.js
new file mode 100644
index 0000000000..0de748e717
--- /dev/null
+++ b/browser/extensions/pictureinpicture/video-wrappers/tubilive.js
@@ -0,0 +1,35 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+class PictureInPictureVideoWrapper {
+ setCaptionContainerObserver(video, updateCaptionsFunction) {
+ let container = video.parentElement;
+
+ if (container) {
+ updateCaptionsFunction("");
+ const callback = function (mutationsList, observer) {
+ let text =
+ container.querySelector(`.tubi-text-track-container`)?.innerText ||
+ container.querySelector(`.subtitleWindow`)?.innerText;
+
+ updateCaptionsFunction(text);
+ };
+
+ // immediately invoke the callback function to add subtitles to the PiP window
+ callback([1], null);
+
+ let captionsObserver = new MutationObserver(callback);
+
+ captionsObserver.observe(container, {
+ attributes: true,
+ childList: true,
+ subtree: true,
+ });
+ }
+ }
+}
+
+this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper;
diff --git a/browser/extensions/pictureinpicture/video-wrappers/twitch.js b/browser/extensions/pictureinpicture/video-wrappers/twitch.js
new file mode 100644
index 0000000000..1dd7567c24
--- /dev/null
+++ b/browser/extensions/pictureinpicture/video-wrappers/twitch.js
@@ -0,0 +1,19 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+class PictureInPictureVideoWrapper {
+ isLive(video) {
+ return !document.querySelector(".seekbar-bar");
+ }
+ getDuration(video) {
+ if (this.isLive(video)) {
+ return Infinity;
+ }
+ return video.duration;
+ }
+}
+
+this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper;
diff --git a/browser/extensions/pictureinpicture/video-wrappers/udemy.js b/browser/extensions/pictureinpicture/video-wrappers/udemy.js
new file mode 100644
index 0000000000..302a8d1eaf
--- /dev/null
+++ b/browser/extensions/pictureinpicture/video-wrappers/udemy.js
@@ -0,0 +1,41 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+class PictureInPictureVideoWrapper {
+ setCaptionContainerObserver(video, updateCaptionsFunction) {
+ let container = document.querySelector(
+ ".video-player--video-player--1sfof"
+ );
+
+ if (container) {
+ updateCaptionsFunction("");
+ const callback = function (mutationsList, observer) {
+ let text = container.querySelector(
+ ".captions-display--captions-container--1-aQJ"
+ )?.innerText;
+ if (!text) {
+ updateCaptionsFunction("");
+ return;
+ }
+
+ updateCaptionsFunction(text);
+ };
+
+ // immediately invoke the callback function to add subtitles to the PiP window
+ callback([1], null);
+
+ let captionsObserver = new MutationObserver(callback);
+
+ captionsObserver.observe(container, {
+ attributes: true,
+ childList: true,
+ subtree: true,
+ });
+ }
+ }
+}
+
+this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper;
diff --git a/browser/extensions/pictureinpicture/video-wrappers/videojsWrapper.js b/browser/extensions/pictureinpicture/video-wrappers/videojsWrapper.js
new file mode 100644
index 0000000000..ca3145af4a
--- /dev/null
+++ b/browser/extensions/pictureinpicture/video-wrappers/videojsWrapper.js
@@ -0,0 +1,38 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// This wrapper supports multiple sites that use video.js player
+class PictureInPictureVideoWrapper {
+ setCaptionContainerObserver(video, updateCaptionsFunction) {
+ let container = document.querySelector(".vjs-text-track-display");
+
+ if (container) {
+ updateCaptionsFunction("");
+ const callback = function (mutationsList, observer) {
+ let text = container.querySelector("div").innerText;
+ if (!text) {
+ updateCaptionsFunction("");
+ return;
+ }
+
+ updateCaptionsFunction(text);
+ };
+
+ // immediately invoke the callback function to add subtitles to the PiP window
+ callback([1], null);
+
+ let captionsObserver = new MutationObserver(callback);
+
+ captionsObserver.observe(container, {
+ attributes: false,
+ childList: true,
+ subtree: true,
+ });
+ }
+ }
+}
+
+this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper;
diff --git a/browser/extensions/pictureinpicture/video-wrappers/voot.js b/browser/extensions/pictureinpicture/video-wrappers/voot.js
new file mode 100644
index 0000000000..57d903a2e8
--- /dev/null
+++ b/browser/extensions/pictureinpicture/video-wrappers/voot.js
@@ -0,0 +1,37 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+class PictureInPictureVideoWrapper {
+ setCaptionContainerObserver(video, updateCaptionsFunction) {
+ let container = document.querySelector(".playkit-container");
+
+ if (container) {
+ updateCaptionsFunction("");
+ const callback = function (mutationsList, observer) {
+ let text = container.querySelector(".playkit-subtitles").innerText;
+ if (!text) {
+ updateCaptionsFunction("");
+ return;
+ }
+
+ updateCaptionsFunction(text);
+ };
+
+ // immediately invoke the callback function to add subtitles to the PiP window
+ callback([1], null);
+
+ let captionsObserver = new MutationObserver(callback);
+
+ captionsObserver.observe(container, {
+ attributes: false,
+ childList: true,
+ subtree: true,
+ });
+ }
+ }
+}
+
+this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper;
diff --git a/browser/extensions/pictureinpicture/video-wrappers/washingtonpost.js b/browser/extensions/pictureinpicture/video-wrappers/washingtonpost.js
new file mode 100644
index 0000000000..6d0e57c96a
--- /dev/null
+++ b/browser/extensions/pictureinpicture/video-wrappers/washingtonpost.js
@@ -0,0 +1,42 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+class PictureInPictureVideoWrapper {
+ setCaptionContainerObserver(video, updateCaptionsFunction) {
+ let container = document.querySelector(".powa");
+
+ if (container) {
+ updateCaptionsFunction("");
+ const callback = function (mutationsList, observer) {
+ let subtitleElement = container.querySelector(".powa-sub-torpedo");
+ if (!subtitleElement?.innerText) {
+ updateCaptionsFunction("");
+ return;
+ }
+ let subtitleElementClone = subtitleElement.cloneNode(true);
+ let breaks = subtitleElementClone.getElementsByTagName("br");
+ for (const element of breaks) {
+ element.replaceWith("\n");
+ }
+ let text = subtitleElementClone.innerText;
+
+ updateCaptionsFunction(text);
+ };
+
+ callback([1], null);
+
+ let captionsObserver = new MutationObserver(callback);
+
+ captionsObserver.observe(container, {
+ attributes: false,
+ childList: true,
+ subtree: true,
+ });
+ }
+ }
+}
+
+this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper;
diff --git a/browser/extensions/pictureinpicture/video-wrappers/yahoo.js b/browser/extensions/pictureinpicture/video-wrappers/yahoo.js
new file mode 100644
index 0000000000..b7d8d3160f
--- /dev/null
+++ b/browser/extensions/pictureinpicture/video-wrappers/yahoo.js
@@ -0,0 +1,38 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+class PictureInPictureVideoWrapper {
+ setCaptionContainerObserver(video, updateCaptionsFunction) {
+ let container = document.querySelector(".vp-main");
+
+ if (container) {
+ updateCaptionsFunction("");
+ const callback = function (mutationsList, observer) {
+ let text = container.querySelector(".vp-cc-element.vp-show")?.innerText;
+
+ if (!text) {
+ updateCaptionsFunction("");
+ return;
+ }
+
+ updateCaptionsFunction(text);
+ };
+
+ // immediately invoke the callback function to add subtitles to the PiP window
+ callback([1], null);
+
+ let captionsObserver = new MutationObserver(callback);
+
+ captionsObserver.observe(container, {
+ attributes: false,
+ childList: true,
+ subtree: true,
+ });
+ }
+ }
+}
+
+this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper;
diff --git a/browser/extensions/pictureinpicture/video-wrappers/youtube.js b/browser/extensions/pictureinpicture/video-wrappers/youtube.js
new file mode 100644
index 0000000000..b017328982
--- /dev/null
+++ b/browser/extensions/pictureinpicture/video-wrappers/youtube.js
@@ -0,0 +1,65 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+class PictureInPictureVideoWrapper {
+ isLive(video) {
+ return !!document.querySelector(".ytp-live");
+ }
+ setMuted(video, shouldMute) {
+ let muteButton = document.querySelector("#player .ytp-mute-button");
+
+ if (video.muted !== shouldMute && muteButton) {
+ muteButton.click();
+ } else {
+ video.muted = shouldMute;
+ }
+ }
+ getDuration(video) {
+ if (this.isLive(video)) {
+ return Infinity;
+ }
+ return video.duration;
+ }
+ setCaptionContainerObserver(video, updateCaptionsFunction) {
+ let container = document.getElementById("ytp-caption-window-container");
+
+ if (container) {
+ updateCaptionsFunction("");
+ const callback = function (mutationsList, observer) {
+ // eslint-disable-next-line no-unused-vars
+ for (const mutation of mutationsList) {
+ let textNodeList = container
+ .querySelector(".captions-text")
+ ?.querySelectorAll(".caption-visual-line");
+ if (!textNodeList) {
+ updateCaptionsFunction("");
+ return;
+ }
+
+ updateCaptionsFunction(
+ Array.from(textNodeList, x => x.textContent).join("\n")
+ );
+ }
+ };
+
+ // immediately invoke the callback function to add subtitles to the PiP window
+ callback([1], null);
+
+ let captionsObserver = new MutationObserver(callback);
+
+ captionsObserver.observe(container, {
+ attributes: false,
+ childList: true,
+ subtree: true,
+ });
+ }
+ }
+ shouldHideToggle(video) {
+ return !!video.closest(".ytd-video-preview");
+ }
+}
+
+this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper;
diff --git a/browser/extensions/report-site-issue/.eslintrc.js b/browser/extensions/report-site-issue/.eslintrc.js
new file mode 100644
index 0000000000..13b953ef04
--- /dev/null
+++ b/browser/extensions/report-site-issue/.eslintrc.js
@@ -0,0 +1,58 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+module.exports = {
+ rules: {
+ // Rules from the mozilla plugin
+ "mozilla/balanced-listeners": "error",
+ "mozilla/no-aArgs": "error",
+ "mozilla/var-only-at-top-level": "error",
+
+ // No expressions where a statement is expected
+ "no-unused-expressions": "error",
+
+ // No declaring variables that are never used
+ "no-unused-vars": "error",
+
+ // Disallow using variables outside the blocks they are defined (especially
+ // since only let and const are used, see "no-var").
+ "block-scoped-var": "error",
+
+ // Warn about cyclomatic complexity in functions.
+ complexity: ["error", { max: 26 }],
+
+ // Maximum depth callbacks can be nested.
+ "max-nested-callbacks": ["error", 4],
+
+ // Allow the console API aside from console.log.
+ "no-console": ["error", { allow: ["error", "info", "trace", "warn"] }],
+
+ // Disallow fallthrough of case statements, except if there is a comment.
+ "no-fallthrough": "error",
+
+ // Disallow use of multiline strings (use template strings instead).
+ "no-multi-str": "error",
+
+ // Disallow usage of __proto__ property.
+ "no-proto": "error",
+
+ // Disallow use of assignment in return statement. It is preferable for a
+ // single line of code to have only one easily predictable effect.
+ "no-return-assign": "error",
+
+ // Require use of the second argument for parseInt().
+ radix: "error",
+
+ // Require "use strict" to be defined globally in the script.
+ strict: ["error", "global"],
+
+ // Disallow Yoda conditions (where literal value comes first).
+ yoda: "error",
+
+ // Disallow function or variable declarations in nested blocks
+ "no-inner-declarations": "error",
+ },
+};
diff --git a/browser/extensions/report-site-issue/background.js b/browser/extensions/report-site-issue/background.js
new file mode 100644
index 0000000000..9662f37846
--- /dev/null
+++ b/browser/extensions/report-site-issue/background.js
@@ -0,0 +1,217 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* globals browser */
+
+const Config = {
+ newIssueEndpoint: "https://webcompat.com/issues/new",
+ newIssueEndpointPref: "newIssueEndpoint",
+ screenshotFormat: {
+ format: "jpeg",
+ quality: 75,
+ },
+};
+
+const FRAMEWORK_KEYS = ["hasFastClick", "hasMobify", "hasMarfeel"];
+
+browser.helpMenu.onHelpMenuCommand.addListener(tab => {
+ return getWebCompatInfoForTab(tab).then(
+ info => {
+ return openWebCompatTab(info);
+ },
+ err => {
+ console.error("WebCompat Reporter: unexpected error", err);
+ }
+ );
+});
+
+browser.aboutConfigPrefs.onEndpointPrefChange.addListener(checkEndpointPref);
+
+checkEndpointPref();
+
+async function checkEndpointPref() {
+ const value = await browser.aboutConfigPrefs.getEndpointPref();
+ if (value === undefined) {
+ browser.aboutConfigPrefs.setEndpointPref(Config.newIssueEndpoint);
+ } else {
+ Config.newIssueEndpoint = value;
+ }
+}
+
+function hasFastClickPageScript() {
+ const win = window.wrappedJSObject;
+
+ if (win.FastClick) {
+ return true;
+ }
+
+ for (const property in win) {
+ try {
+ const proto = win[property].prototype;
+ if (proto && proto.needsClick) {
+ return true;
+ }
+ } catch (_) {}
+ }
+
+ return false;
+}
+
+function hasMobifyPageScript() {
+ const win = window.wrappedJSObject;
+ return !!(win.Mobify && win.Mobify.Tag);
+}
+
+function hasMarfeelPageScript() {
+ const win = window.wrappedJSObject;
+ return !!win.marfeel;
+}
+
+function checkForFrameworks(tabId) {
+ return browser.tabs
+ .executeScript(tabId, {
+ code: `
+ (function() {
+ ${hasFastClickPageScript};
+ ${hasMobifyPageScript};
+ ${hasMarfeelPageScript};
+
+ const result = {
+ hasFastClick: hasFastClickPageScript(),
+ hasMobify: hasMobifyPageScript(),
+ hasMarfeel: hasMarfeelPageScript(),
+ }
+
+ return result;
+ })();
+ `,
+ })
+ .then(([results]) => results)
+ .catch(() => false);
+}
+
+function getWebCompatInfoForTab(tab) {
+ const { id, url } = tab;
+ return Promise.all([
+ browser.browserInfo.getBlockList(),
+ browser.browserInfo.getBuildID(),
+ browser.browserInfo.getGraphicsPrefs(),
+ browser.browserInfo.getUpdateChannel(),
+ browser.browserInfo.hasTouchScreen(),
+ browser.tabExtras.getWebcompatInfo(id),
+ browser.browserInfo.getAdditionalData(),
+ checkForFrameworks(id),
+ browser.tabs.captureTab(id, Config.screenshotFormat).catch(e => {
+ console.error("WebCompat Reporter: getting a screenshot failed", e);
+ return Promise.resolve(undefined);
+ }),
+ ]).then(
+ ([
+ blockList,
+ buildID,
+ graphicsPrefs,
+ channel,
+ hasTouchScreen,
+ frameInfo,
+ additionalData,
+ frameworks,
+ screenshot,
+ ]) => {
+ if (channel !== "linux") {
+ delete graphicsPrefs["layers.acceleration.force-enabled"];
+ }
+
+ const consoleLog = frameInfo.log;
+ delete frameInfo.log;
+
+ additionalData.isPB = frameInfo.isPB;
+ additionalData.prefs = { ...additionalData.prefs, ...graphicsPrefs };
+ additionalData.hasMixedActiveContentBlocked =
+ frameInfo.hasMixedActiveContentBlocked;
+ additionalData.hasMixedDisplayContentBlocked =
+ frameInfo.hasMixedDisplayContentBlocked;
+ additionalData.hasTrackingContentBlocked =
+ !!frameInfo.hasTrackingContentBlocked;
+
+ return Object.assign(frameInfo, {
+ tabId: id,
+ blockList,
+ details: Object.assign(graphicsPrefs, {
+ buildID,
+ channel,
+ consoleLog,
+ frameworks,
+ additionalData,
+ hasTouchScreen,
+ "mixed active content blocked":
+ frameInfo.hasMixedActiveContentBlocked,
+ "mixed passive content blocked":
+ frameInfo.hasMixedDisplayContentBlocked,
+ "tracking content blocked": frameInfo.hasTrackingContentBlocked
+ ? `true (${blockList})`
+ : "false",
+ }),
+ screenshot,
+ url,
+ });
+ }
+ );
+}
+
+function stripNonASCIIChars(str) {
+ // eslint-disable-next-line no-control-regex
+ return str.replace(/[^\x00-\x7F]/g, "");
+}
+
+async function openWebCompatTab(compatInfo) {
+ const url = new URL(Config.newIssueEndpoint);
+ const { details } = compatInfo;
+ const params = {
+ url: `${compatInfo.url}`,
+ utm_source: "desktop-reporter",
+ utm_campaign: "report-site-issue-button",
+ src: "desktop-reporter",
+ details,
+ extra_labels: [],
+ };
+
+ for (let framework of FRAMEWORK_KEYS) {
+ if (details.frameworks[framework]) {
+ params.details[framework] = true;
+ params.extra_labels.push(
+ framework.replace(/^has/, "type-").toLowerCase()
+ );
+ }
+ }
+ delete details.frameworks;
+
+ if (details["gfx.webrender.all"] || details["gfx.webrender.enabled"]) {
+ params.extra_labels.push("type-webrender-enabled");
+ }
+ if (compatInfo.hasTrackingContentBlocked) {
+ params.extra_labels.push(
+ `type-tracking-protection-${compatInfo.blockList}`
+ );
+ }
+
+ const json = stripNonASCIIChars(JSON.stringify(params));
+ const tab = await browser.tabs.create({ url: url.href });
+ await browser.tabs.executeScript(tab.id, {
+ runAt: "document_end",
+ code: `(function() {
+ async function postMessageData(dataURI, metadata) {
+ const res = await fetch(dataURI);
+ const blob = await res.blob();
+ const data = {
+ screenshot: blob,
+ message: metadata
+ };
+ postMessage(data, "${url.origin}");
+ }
+ postMessageData("${compatInfo.screenshot}", ${json});
+ })()`,
+ });
+}
diff --git a/browser/extensions/report-site-issue/experimentalAPIs/aboutConfigPrefs.js b/browser/extensions/report-site-issue/experimentalAPIs/aboutConfigPrefs.js
new file mode 100644
index 0000000000..0e8b6346c4
--- /dev/null
+++ b/browser/extensions/report-site-issue/experimentalAPIs/aboutConfigPrefs.js
@@ -0,0 +1,39 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* global ExtensionAPI, ExtensionCommon, Services */
+
+this.aboutConfigPrefs = class extends ExtensionAPI {
+ getAPI(context) {
+ const EventManager = ExtensionCommon.EventManager;
+ const extensionIDBase = context.extension.id.split("@")[0];
+ const endpointPrefName = `extensions.${extensionIDBase}.newIssueEndpoint`;
+
+ return {
+ aboutConfigPrefs: {
+ onEndpointPrefChange: new EventManager({
+ context,
+ name: "aboutConfigPrefs.onEndpointPrefChange",
+ register: fire => {
+ const callback = () => {
+ fire.async().catch(() => {}); // ignore Message Manager disconnects
+ };
+ Services.prefs.addObserver(endpointPrefName, callback);
+ return () => {
+ Services.prefs.removeObserver(endpointPrefName, callback);
+ };
+ },
+ }).api(),
+ async getEndpointPref() {
+ return Services.prefs.getStringPref(endpointPrefName, undefined);
+ },
+ async setEndpointPref(value) {
+ Services.prefs.setStringPref(endpointPrefName, value);
+ },
+ },
+ };
+ }
+};
diff --git a/browser/extensions/report-site-issue/experimentalAPIs/aboutConfigPrefs.json b/browser/extensions/report-site-issue/experimentalAPIs/aboutConfigPrefs.json
new file mode 100644
index 0000000000..1fd313e392
--- /dev/null
+++ b/browser/extensions/report-site-issue/experimentalAPIs/aboutConfigPrefs.json
@@ -0,0 +1,35 @@
+[
+ {
+ "namespace": "aboutConfigPrefs",
+ "description": "experimental API extension to allow access to about:config preferences",
+ "events": [
+ {
+ "name": "onEndpointPrefChange",
+ "type": "function",
+ "parameters": []
+ }
+ ],
+ "functions": [
+ {
+ "name": "getEndpointPref",
+ "type": "function",
+ "description": "Get the endpoint preference's value",
+ "parameters": [],
+ "async": true
+ },
+ {
+ "name": "setEndpointPref",
+ "type": "function",
+ "description": "Set the endpoint preference's value",
+ "parameters": [
+ {
+ "name": "value",
+ "type": "string",
+ "description": "The new value"
+ }
+ ],
+ "async": true
+ }
+ ]
+ }
+]
diff --git a/browser/extensions/report-site-issue/experimentalAPIs/actors/tabExtrasActor.jsm b/browser/extensions/report-site-issue/experimentalAPIs/actors/tabExtrasActor.jsm
new file mode 100644
index 0000000000..52bc6c56c6
--- /dev/null
+++ b/browser/extensions/report-site-issue/experimentalAPIs/actors/tabExtrasActor.jsm
@@ -0,0 +1,163 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["ReportSiteIssueHelperChild"];
+
+const PREVIEW_MAX_ITEMS = 10;
+const LOG_LEVELS = ["debug", "info", "warn", "error"];
+
+function getPreview(value) {
+ switch (typeof value) {
+ case "symbol":
+ return value.toString();
+
+ case "function":
+ return "function ()";
+
+ case "object":
+ if (value === null) {
+ return null;
+ }
+
+ if (Array.isArray(value)) {
+ return `(${value.length})[...]`;
+ }
+
+ return "{...}";
+
+ case "undefined":
+ return "undefined";
+
+ default:
+ try {
+ structuredClone(value);
+ } catch (_) {
+ return `${value}` || "?";
+ }
+
+ return value;
+ }
+}
+
+function getArrayPreview(arr) {
+ const preview = [];
+ let count = 0;
+ for (const value of arr) {
+ if (++count > PREVIEW_MAX_ITEMS) {
+ break;
+ }
+ preview.push(getPreview(value));
+ }
+
+ return preview;
+}
+
+function getObjectPreview(obj) {
+ const preview = {};
+ let count = 0;
+ for (const key of Object.keys(obj)) {
+ if (++count > PREVIEW_MAX_ITEMS) {
+ break;
+ }
+ preview[key] = getPreview(obj[key]);
+ }
+
+ return preview;
+}
+
+function getArgs(value) {
+ if (typeof value === "object" && value !== null) {
+ if (Array.isArray(value)) {
+ return getArrayPreview(value);
+ }
+
+ return getObjectPreview(value);
+ }
+
+ return getPreview(value);
+}
+
+class ReportSiteIssueHelperChild extends JSWindowActorChild {
+ _getConsoleMessages(windowId) {
+ const ConsoleAPIStorage = Cc[
+ "@mozilla.org/consoleAPI-storage;1"
+ ].getService(Ci.nsIConsoleAPIStorage);
+ let messages = ConsoleAPIStorage.getEvents(windowId);
+ return messages.map(evt => {
+ const { columnNumber, filename, level, lineNumber, timeStamp } = evt;
+ const args = evt.arguments.map(getArgs);
+
+ const message = {
+ level,
+ log: args,
+ uri: filename,
+ pos: `${lineNumber}:${columnNumber}`,
+ };
+
+ return { timeStamp, message };
+ });
+ }
+
+ _getScriptErrors(windowId, includePrivate) {
+ const messages = Services.console.getMessageArray();
+ return messages
+ .filter(message => {
+ if (message instanceof Ci.nsIScriptError) {
+ if (!includePrivate && message.isFromPrivateWindow) {
+ return false;
+ }
+
+ if (windowId && windowId !== message.innerWindowID) {
+ return false;
+ }
+
+ return true;
+ }
+
+ // If this is not an nsIScriptError and we need to do window-based
+ // filtering we skip this message.
+ return false;
+ })
+ .map(error => {
+ const {
+ timeStamp,
+ errorMessage,
+ sourceName,
+ lineNumber,
+ columnNumber,
+ logLevel,
+ } = error;
+ const message = {
+ level: LOG_LEVELS[logLevel],
+ log: [errorMessage],
+ uri: sourceName,
+ pos: `${lineNumber}:${columnNumber}`,
+ };
+ return { timeStamp, message };
+ });
+ }
+
+ _getLoggedMessages(includePrivate = false) {
+ const windowId = this.contentWindow.windowGlobalChild.innerWindowId;
+ return this._getConsoleMessages(windowId).concat(
+ this._getScriptErrors(windowId, includePrivate)
+ );
+ }
+
+ receiveMessage(msg) {
+ switch (msg.name) {
+ case "GetLog":
+ return this._getLoggedMessages();
+ case "GetBlockingStatus":
+ const { docShell } = this;
+ return {
+ hasTrackingContentBlocked: docShell.hasTrackingContentBlocked,
+ };
+ }
+ return null;
+ }
+}
diff --git a/browser/extensions/report-site-issue/experimentalAPIs/browserInfo.js b/browser/extensions/report-site-issue/experimentalAPIs/browserInfo.js
new file mode 100644
index 0000000000..ce4466f1cf
--- /dev/null
+++ b/browser/extensions/report-site-issue/experimentalAPIs/browserInfo.js
@@ -0,0 +1,197 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* global AppConstants, ExtensionAPI, Services */
+
+function isTelemetryEnabled() {
+ return Services.prefs.getBoolPref(
+ "datareporting.healthreport.uploadEnabled",
+ false
+ );
+}
+
+function getSysinfoProperty(propertyName, defaultValue) {
+ try {
+ return Services.sysinfo.getProperty(propertyName);
+ } catch (e) {}
+
+ return defaultValue;
+}
+
+function getUserAgent() {
+ const { userAgent } = Cc[
+ "@mozilla.org/network/protocol;1?name=http"
+ ].getService(Ci.nsIHttpProtocolHandler);
+ return userAgent;
+}
+
+function getGfxData() {
+ const gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo);
+ const data = {};
+
+ try {
+ const {
+ compositor,
+ hwCompositing,
+ openglCompositing,
+ wrCompositor,
+ wrSoftware,
+ } = gfxInfo.getFeatures();
+
+ data.features = {
+ compositor,
+ hwCompositing,
+ openglCompositing,
+ wrCompositor,
+ wrSoftware,
+ };
+ } catch (e) {}
+
+ try {
+ if (AppConstants.platform !== "android") {
+ data.monitors = gfxInfo.getMonitors();
+ }
+ } catch (e) {}
+
+ return data;
+}
+
+function limitStringToLength(str, maxLength) {
+ if (typeof str !== "string") {
+ return null;
+ }
+ return str.substring(0, maxLength);
+}
+
+function getSecurityAppData() {
+ const maxStringLength = 256;
+
+ const keys = [
+ ["registeredAntiVirus", "antivirus"],
+ ["registeredAntiSpyware", "antispyware"],
+ ["registeredFirewall", "firewall"],
+ ];
+
+ let result = {};
+
+ for (let [inKey, outKey] of keys) {
+ let prop = getSysinfoProperty(inKey, null);
+ if (prop) {
+ prop = limitStringToLength(prop, maxStringLength).split(";");
+ }
+
+ result[outKey] = prop;
+ }
+
+ return result;
+}
+
+function getAdditionalPrefs() {
+ const prefs = {};
+ for (const [name, dflt] of Object.entries({
+ "browser.opaqueResponseBlocking": false,
+ "extensions.InstallTrigger.enabled": false,
+ "gfx.canvas.accelerated.force-enabled": false,
+ "gfx.webrender.compositor.force-enabled": false,
+ "privacy.resistFingerprinting": false,
+ })) {
+ prefs[name] = Services.prefs.getBoolPref(name, dflt);
+ }
+ const cookieBehavior = "network.cookie.cookieBehavior";
+ prefs[cookieBehavior] = Services.prefs.getIntPref(cookieBehavior);
+
+ return prefs;
+}
+
+function getMemoryMB() {
+ let memoryMB = getSysinfoProperty("memsize", null);
+ if (memoryMB) {
+ memoryMB = Math.round(memoryMB / 1024 / 1024);
+ }
+
+ return memoryMB;
+}
+
+this.browserInfo = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ browserInfo: {
+ async getGraphicsPrefs() {
+ const prefs = {};
+ for (const [name, dflt] of Object.entries({
+ "layers.acceleration.force-enabled": false,
+ "gfx.webrender.all": false,
+ "gfx.webrender.blob-images": true,
+ "gfx.webrender.enabled": false,
+ "image.mem.shared": true,
+ })) {
+ prefs[name] = Services.prefs.getBoolPref(name, dflt);
+ }
+ return prefs;
+ },
+ async getAppVersion() {
+ return AppConstants.MOZ_APP_VERSION;
+ },
+ async getBlockList() {
+ const trackingTable = Services.prefs.getCharPref(
+ "urlclassifier.trackingTable"
+ );
+ // If content-track-digest256 is in the tracking table,
+ // the user has enabled the strict list.
+ return trackingTable.includes("content") ? "strict" : "basic";
+ },
+ async getBuildID() {
+ return Services.appinfo.appBuildID;
+ },
+ async getUpdateChannel() {
+ return AppConstants.MOZ_UPDATE_CHANNEL;
+ },
+ async getPlatform() {
+ return AppConstants.platform;
+ },
+ async hasTouchScreen() {
+ const gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(
+ Ci.nsIGfxInfo
+ );
+ return gfxInfo.getInfo().ApzTouchInput == 1;
+ },
+ async getAdditionalData() {
+ const blockList = await this.getBlockList();
+ const userAgent = getUserAgent();
+ const gfxData = getGfxData();
+ const prefs = getAdditionalPrefs();
+ const memoryMb = getMemoryMB();
+
+ const data = {
+ applicationName: Services.appinfo.name,
+ version: Services.appinfo.version,
+ updateChannel: AppConstants.MOZ_UPDATE_CHANNEL,
+ osArchitecture: getSysinfoProperty("arch", null),
+ osName: getSysinfoProperty("name", null),
+ osVersion: getSysinfoProperty("version", null),
+ fissionEnabled: Services.appinfo.fissionAutostart,
+ userAgent,
+ gfxData,
+ blockList,
+ prefs,
+ memoryMb,
+ };
+
+ if (AppConstants.isPlatformAndVersionAtLeast("win", "6.2")) {
+ data.sec = getSecurityAppData();
+ }
+
+ if (AppConstants.platform === "android") {
+ data.device = getSysinfoProperty("device", null);
+ data.isTablet = getSysinfoProperty("tablet", false);
+ }
+
+ return data;
+ },
+ },
+ };
+ }
+};
diff --git a/browser/extensions/report-site-issue/experimentalAPIs/browserInfo.json b/browser/extensions/report-site-issue/experimentalAPIs/browserInfo.json
new file mode 100644
index 0000000000..c12f2ceb2e
--- /dev/null
+++ b/browser/extensions/report-site-issue/experimentalAPIs/browserInfo.json
@@ -0,0 +1,64 @@
+[
+ {
+ "namespace": "browserInfo",
+ "description": "experimental API extensions to get browser info not exposed via web APIs",
+ "functions": [
+ {
+ "name": "getAppVersion",
+ "type": "function",
+ "description": "Gets the app version",
+ "parameters": [],
+ "async": true
+ },
+ {
+ "name": "getBlockList",
+ "type": "function",
+ "description": "Gets the current blocklist",
+ "parameters": [],
+ "async": true
+ },
+ {
+ "name": "getBuildID",
+ "type": "function",
+ "description": "Gets the build ID",
+ "parameters": [],
+ "async": true
+ },
+ {
+ "name": "getGraphicsPrefs",
+ "type": "function",
+ "description": "Gets interesting about:config prefs for graphics",
+ "parameters": [],
+ "async": true
+ },
+ {
+ "name": "getPlatform",
+ "type": "function",
+ "description": "Gets the platform",
+ "parameters": [],
+ "async": true
+ },
+ {
+ "name": "getUpdateChannel",
+ "type": "function",
+ "description": "Gets the update channel",
+ "parameters": [],
+ "async": true
+ },
+ {
+ "name": "hasTouchScreen",
+ "type": "function",
+ "description": "Gets whether a touchscreen is present",
+ "parameters": [],
+ "async": true
+ },
+ {
+ "name": "getAdditionalData",
+ "type": "function",
+ "description": "Gets additional info for the new reporter experiment",
+ "parameters": [],
+ "async": true
+ }
+ ]
+ }
+]
diff --git a/browser/extensions/report-site-issue/experimentalAPIs/helpMenu.js b/browser/extensions/report-site-issue/experimentalAPIs/helpMenu.js
new file mode 100644
index 0000000000..804f4b08d5
--- /dev/null
+++ b/browser/extensions/report-site-issue/experimentalAPIs/helpMenu.js
@@ -0,0 +1,38 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* global ExtensionAPI, ExtensionCommon, Services */
+
+const TOPIC = "report-site-issue";
+
+this.helpMenu = class extends ExtensionAPI {
+ getAPI(context) {
+ const { tabManager } = context.extension;
+ let EventManager = ExtensionCommon.EventManager;
+
+ return {
+ helpMenu: {
+ onHelpMenuCommand: new EventManager({
+ context,
+ name: "helpMenu",
+ register: fire => {
+ let observer = (subject, topic, data) => {
+ let nativeTab = subject.wrappedJSObject;
+ let tab = tabManager.convert(nativeTab);
+ fire.async(tab);
+ };
+
+ Services.obs.addObserver(observer, TOPIC);
+
+ return () => {
+ Services.obs.removeObserver(observer, TOPIC);
+ };
+ },
+ }).api(),
+ },
+ };
+ }
+};
diff --git a/browser/extensions/report-site-issue/experimentalAPIs/helpMenu.json b/browser/extensions/report-site-issue/experimentalAPIs/helpMenu.json
new file mode 100644
index 0000000000..e7c3a8c405
--- /dev/null
+++ b/browser/extensions/report-site-issue/experimentalAPIs/helpMenu.json
@@ -0,0 +1,28 @@
+[
+ {
+ "namespace": "helpMenu",
+ "events": [
+ {
+ "name": "onHelpMenuCommand",
+ "type": "function",
+ "async": "callback",
+ "description": "Fired when the command event for the Report Site Issue menuitem in Help is fired.",
+ "parameters": [
+ {
+ "type": "function",
+ "name": "callback",
+ "optional": true,
+ "parameters": [
+ {
+ "name": "tab",
+ "$ref": "tabs.Tab",
+ "optional": true,
+ "description": "Details about the selected tab in the window where the menuitem command fired."
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/browser/extensions/report-site-issue/experimentalAPIs/l10n.js b/browser/extensions/report-site-issue/experimentalAPIs/l10n.js
new file mode 100644
index 0000000000..1c91e7a04d
--- /dev/null
+++ b/browser/extensions/report-site-issue/experimentalAPIs/l10n.js
@@ -0,0 +1,55 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* global ExtensionAPI, Services, XPCOMUtils */
+
+XPCOMUtils.defineLazyGetter(this, "l10nStrings", function () {
+ return Services.strings.createBundle(
+ "chrome://report-site-issue/locale/webcompat.properties"
+ );
+});
+
+let l10nManifest;
+
+this.l10n = class extends ExtensionAPI {
+ onShutdown(isAppShutdown) {
+ if (!isAppShutdown && l10nManifest) {
+ Components.manager.removeBootstrappedManifestLocation(l10nManifest);
+ }
+ }
+ getAPI(context) {
+ // Until we move to Fluent (bug 1446164), we're stuck with
+ // chrome.manifest for handling localization since its what the
+ // build system can handle for localized repacks.
+ if (context.extension.rootURI instanceof Ci.nsIJARURI) {
+ l10nManifest = context.extension.rootURI.JARFile.QueryInterface(
+ Ci.nsIFileURL
+ ).file;
+ } else if (context.extension.rootURI instanceof Ci.nsIFileURL) {
+ l10nManifest = context.extension.rootURI.file;
+ }
+
+ if (l10nManifest) {
+ Components.manager.addBootstrappedManifestLocation(l10nManifest);
+ } else {
+ console.error(
+ "Cannot find webcompat reporter chrome.manifest for registering translated strings"
+ );
+ }
+
+ return {
+ l10n: {
+ getMessage(name) {
+ try {
+ return Promise.resolve(l10nStrings.GetStringFromName(name));
+ } catch (e) {
+ return Promise.reject(e);
+ }
+ },
+ },
+ };
+ }
+};
diff --git a/browser/extensions/report-site-issue/experimentalAPIs/l10n.json b/browser/extensions/report-site-issue/experimentalAPIs/l10n.json
new file mode 100644
index 0000000000..60942e726c
--- /dev/null
+++ b/browser/extensions/report-site-issue/experimentalAPIs/l10n.json
@@ -0,0 +1,21 @@
+[
+ {
+ "namespace": "l10n",
+ "description": "A stop-gap L10N API only meant to be used until a Fluent-based API is added in bug 1425104",
+ "functions": [
+ {
+ "name": "getMessage",
+ "type": "function",
+ "description": "Gets the message with the given name",
+ "parameters": [
+ {
+ "name": "name",
+ "type": "string",
+ "description": "The name of the message"
+ }
+ ],
+ "async": true
+ }
+ ]
+ }
+]
diff --git a/browser/extensions/report-site-issue/experimentalAPIs/tabExtras.js b/browser/extensions/report-site-issue/experimentalAPIs/tabExtras.js
new file mode 100644
index 0000000000..12ccd91e05
--- /dev/null
+++ b/browser/extensions/report-site-issue/experimentalAPIs/tabExtras.js
@@ -0,0 +1,99 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* global ExtensionAPI, XPCOMUtils, Services */
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "resProto",
+ "@mozilla.org/network/protocol;1?name=resource",
+ "nsISubstitutingProtocolHandler"
+);
+
+this.tabExtras = class extends ExtensionAPI {
+ constructor(extension) {
+ super(extension);
+ this._registerActorModule();
+ }
+
+ getAPI(context) {
+ const { tabManager } = context.extension;
+ return {
+ tabExtras: {
+ async getWebcompatInfo(tabId) {
+ const {
+ browser: { browsingContext },
+ incognito,
+ } = tabManager.get(tabId);
+ const actors = gatherActors("ReportSiteIssueHelper", browsingContext);
+ const promises = actors.map(actor => actor.sendQuery("GetLog"));
+ const logs = await Promise.all(promises);
+ const info = await actors[0].sendQuery("GetBlockingStatus");
+ info.hasMixedActiveContentBlocked = !!(
+ browsingContext.secureBrowserUI.state &
+ Ci.nsIWebProgressListener.STATE_BLOCKED_MIXED_ACTIVE_CONTENT
+ );
+ info.hasMixedDisplayContentBlocked = !!(
+ browsingContext.secureBrowserUI.state &
+ Ci.nsIWebProgressListener.STATE_BLOCKED_MIXED_DISPLAY_CONTENT
+ );
+ info.isPB = incognito;
+ info.log = logs
+ .flat()
+ .sort((a, b) => a.timeStamp - b.timeStamp)
+ .map(m => m.message);
+ return info;
+ },
+ },
+ };
+ }
+
+ onShutdown(isAppShutdown) {
+ this._unregisterActorModule();
+ }
+
+ _registerActorModule() {
+ resProto.setSubstitution(
+ "report-site-issue",
+ Services.io.newURI(
+ "experimentalAPIs/actors/",
+ null,
+ this.extension.rootURI
+ )
+ );
+ ChromeUtils.registerWindowActor("ReportSiteIssueHelper", {
+ child: {
+ moduleURI: "resource://report-site-issue/tabExtrasActor.jsm",
+ },
+ allFrames: true,
+ });
+ }
+
+ _unregisterActorModule() {
+ ChromeUtils.unregisterWindowActor("ReportSiteIssueHelper");
+ resProto.setSubstitution("report-site-issue", null);
+ }
+};
+
+function getActorForBrowsingContext(name, browsingContext) {
+ const windowGlobal = browsingContext.currentWindowGlobal;
+ return windowGlobal ? windowGlobal.getActor(name) : null;
+}
+
+function gatherActors(name, browsingContext) {
+ const list = [];
+
+ const actor = getActorForBrowsingContext(name, browsingContext);
+ if (actor) {
+ list.push(actor);
+ }
+
+ for (const child of browsingContext.children) {
+ list.push(...gatherActors(name, child));
+ }
+
+ return list;
+}
diff --git a/browser/extensions/report-site-issue/experimentalAPIs/tabExtras.json b/browser/extensions/report-site-issue/experimentalAPIs/tabExtras.json
new file mode 100644
index 0000000000..7769049798
--- /dev/null
+++ b/browser/extensions/report-site-issue/experimentalAPIs/tabExtras.json
@@ -0,0 +1,21 @@
+[
+ {
+ "namespace": "tabExtras",
+ "description": "experimental tab API extensions",
+ "functions": [
+ {
+ "name": "getWebcompatInfo",
+ "type": "function",
+ "description": "Gets the content blocking status and script log for a given tab",
+ "parameters": [
+ {
+ "type": "integer",
+ "name": "tabId",
+ "minimum": 0
+ }
+ ],
+ "async": true
+ }
+ ]
+ }
+]
diff --git a/browser/extensions/report-site-issue/locales/en-US/webcompat.properties b/browser/extensions/report-site-issue/locales/en-US/webcompat.properties
new file mode 100644
index 0000000000..ee8cab2cf0
--- /dev/null
+++ b/browser/extensions/report-site-issue/locales/en-US/webcompat.properties
@@ -0,0 +1,10 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# LOCALIZATION NOTE(wc-reporter.label2): This string will be used in the
+# Firefox page actions menu. Localized length should be considered.
+wc-reporter.label2=Report Site Issue…
+# LOCALIZATION NOTE(wc-reporter.tooltip): A site compatibility issue is
+# a website bug that exists in one browser (Firefox), but not another.
+wc-reporter.tooltip=Report a site compatibility issue
diff --git a/browser/extensions/report-site-issue/locales/jar.mn b/browser/extensions/report-site-issue/locales/jar.mn
new file mode 100644
index 0000000000..3422f6248f
--- /dev/null
+++ b/browser/extensions/report-site-issue/locales/jar.mn
@@ -0,0 +1,8 @@
+#filter substitution
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+[features/webcompat-reporter@mozilla.org] @AB_CD@.jar:
+% locale report-site-issue @AB_CD@ %locale/@AB_CD@/
+ locale/@AB_CD@/webcompat.properties (%webcompat.properties)
diff --git a/browser/extensions/report-site-issue/locales/moz.build b/browser/extensions/report-site-issue/locales/moz.build
new file mode 100644
index 0000000000..d988c0ff9b
--- /dev/null
+++ b/browser/extensions/report-site-issue/locales/moz.build
@@ -0,0 +1,7 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+JAR_MANIFESTS += ["jar.mn"]
diff --git a/browser/extensions/report-site-issue/manifest.json b/browser/extensions/report-site-issue/manifest.json
new file mode 100644
index 0000000000..12cfd89988
--- /dev/null
+++ b/browser/extensions/report-site-issue/manifest.json
@@ -0,0 +1,66 @@
+{
+ "manifest_version": 2,
+ "name": "WebCompat Reporter",
+ "description": "Report site compatibility issues on webcompat.com",
+ "author": "Thomas Wisniewski <twisniewski@mozilla.com>",
+ "version": "1.5.1",
+ "homepage_url": "https://github.com/mozilla/webcompat-reporter",
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "webcompat-reporter@mozilla.org"
+ }
+ },
+ "experiment_apis": {
+ "aboutConfigPrefs": {
+ "schema": "experimentalAPIs/aboutConfigPrefs.json",
+ "parent": {
+ "scopes": ["addon_parent"],
+ "script": "experimentalAPIs/aboutConfigPrefs.js",
+ "paths": [["aboutConfigPrefs"]]
+ }
+ },
+ "browserInfo": {
+ "schema": "experimentalAPIs/browserInfo.json",
+ "parent": {
+ "scopes": ["addon_parent"],
+ "script": "experimentalAPIs/browserInfo.js",
+ "paths": [["browserInfo"]]
+ }
+ },
+ "helpMenu": {
+ "schema": "experimentalAPIs/helpMenu.json",
+ "parent": {
+ "scopes": ["addon_parent"],
+ "script": "experimentalAPIs/helpMenu.js",
+ "paths": [["helpMenu"]]
+ }
+ },
+ "l10n": {
+ "schema": "experimentalAPIs/l10n.json",
+ "parent": {
+ "scopes": ["addon_parent"],
+ "script": "experimentalAPIs/l10n.js",
+ "paths": [["l10n"]]
+ }
+ },
+ "tabExtras": {
+ "schema": "experimentalAPIs/tabExtras.json",
+ "parent": {
+ "scopes": ["addon_parent"],
+ "script": "experimentalAPIs/tabExtras.js",
+ "paths": [["tabExtras"]]
+ }
+ }
+ },
+ "icons": {
+ "16": "icons/lightbulb.svg",
+ "32": "icons/lightbulb.svg",
+ "48": "icons/lightbulb.svg",
+ "96": "icons/lightbulb.svg",
+ "128": "icons/lightbulb.svg"
+ },
+ "permissions": ["tabs", "<all_urls>"],
+ "background": {
+ "scripts": ["background.js"]
+ }
+}
diff --git a/browser/extensions/report-site-issue/moz.build b/browser/extensions/report-site-issue/moz.build
new file mode 100644
index 0000000000..b946897b0e
--- /dev/null
+++ b/browser/extensions/report-site-issue/moz.build
@@ -0,0 +1,41 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DEFINES["MOZ_APP_VERSION"] = CONFIG["MOZ_APP_VERSION"]
+DEFINES["MOZ_APP_MAXVERSION"] = CONFIG["MOZ_APP_MAXVERSION"]
+
+DIRS += ["locales"]
+
+FINAL_TARGET_FILES.features["webcompat-reporter@mozilla.org"] += [
+ "background.js",
+ "manifest.json",
+]
+
+FINAL_TARGET_FILES.features["webcompat-reporter@mozilla.org"].experimentalAPIs += [
+ "experimentalAPIs/aboutConfigPrefs.js",
+ "experimentalAPIs/aboutConfigPrefs.json",
+ "experimentalAPIs/browserInfo.js",
+ "experimentalAPIs/browserInfo.json",
+ "experimentalAPIs/helpMenu.js",
+ "experimentalAPIs/helpMenu.json",
+ "experimentalAPIs/l10n.js",
+ "experimentalAPIs/l10n.json",
+ "experimentalAPIs/tabExtras.js",
+ "experimentalAPIs/tabExtras.json",
+]
+
+FINAL_TARGET_FILES.features[
+ "webcompat-reporter@mozilla.org"
+].experimentalAPIs.actors += ["experimentalAPIs/actors/tabExtrasActor.jsm"]
+
+FINAL_TARGET_FILES.features["webcompat-reporter@mozilla.org"].icons += [
+ "../../../toolkit/themes/shared/icons/lightbulb.svg"
+]
+
+BROWSER_CHROME_MANIFESTS += ["test/browser/browser.ini"]
+
+with Files("**"):
+ BUG_COMPONENT = ("Web Compatibility", "Tooling & Investigations")
diff --git a/browser/extensions/report-site-issue/test/browser/browser.ini b/browser/extensions/report-site-issue/test/browser/browser.ini
new file mode 100644
index 0000000000..9c0b4c5968
--- /dev/null
+++ b/browser/extensions/report-site-issue/test/browser/browser.ini
@@ -0,0 +1,14 @@
+[DEFAULT]
+support-files =
+ frameworks.html
+ fastclick.html
+ head.js
+ test.html
+ webcompat.html
+
+[browser_button_state.js]
+skip-if = true # Disabled until we figure out why it is failing in bug 1775526
+[browser_disabled_cleanup.js]
+skip-if = true # Disabled until we figure out why it is failing in bug 1775526
+[browser_report_site_issue.js]
+skip-if = true # Disabled until we figure out why it is failing in bug 1775526
diff --git a/browser/extensions/report-site-issue/test/browser/browser_button_state.js b/browser/extensions/report-site-issue/test/browser/browser_button_state.js
new file mode 100644
index 0000000000..6111a26975
--- /dev/null
+++ b/browser/extensions/report-site-issue/test/browser/browser_button_state.js
@@ -0,0 +1,52 @@
+"use strict";
+
+const REPORTABLE_PAGE = "http://example.com/";
+const REPORTABLE_PAGE2 = "https://example.com/";
+const NONREPORTABLE_PAGE = "about:mozilla";
+
+/* Test that the Report Site Issue help menu item is enabled for http and https tabs,
+ on page load, or TabSelect, and disabled for everything else. */
+add_task(async function test_button_state_disabled() {
+ await SpecialPowers.pushPrefEnv({ set: [[PREF_WC_REPORTER_ENABLED, true]] });
+
+ let tab1 = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ REPORTABLE_PAGE
+ );
+ const menu = new HelpMenuHelper();
+ await menu.open();
+ is(
+ menu.isItemEnabled(),
+ true,
+ "Check that panel item is enabled for reportable schemes on tab load"
+ );
+ await menu.close();
+
+ let tab2 = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ NONREPORTABLE_PAGE
+ );
+ await menu.open();
+ is(
+ menu.isItemEnabled(),
+ false,
+ "Check that panel item is disabled for non-reportable schemes on tab load"
+ );
+ await menu.close();
+
+ let tab3 = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ REPORTABLE_PAGE2
+ );
+ await menu.open();
+ is(
+ menu.isItemEnabled(),
+ true,
+ "Check that panel item is enabled for reportable schemes on tab load"
+ );
+ await menu.close();
+
+ await BrowserTestUtils.removeTab(tab1);
+ await BrowserTestUtils.removeTab(tab2);
+ await BrowserTestUtils.removeTab(tab3);
+});
diff --git a/browser/extensions/report-site-issue/test/browser/browser_disabled_cleanup.js b/browser/extensions/report-site-issue/test/browser/browser_disabled_cleanup.js
new file mode 100644
index 0000000000..65b6ff7369
--- /dev/null
+++ b/browser/extensions/report-site-issue/test/browser/browser_disabled_cleanup.js
@@ -0,0 +1,41 @@
+"use strict";
+
+// Test the addon is cleaning up after itself when disabled.
+add_task(async function test_disabled() {
+ await promiseAddonEnabled();
+
+ SpecialPowers.Services.prefs.setBoolPref(PREF_WC_REPORTER_ENABLED, false);
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "http://example.com" },
+ async function () {
+ const menu = new HelpMenuHelper();
+ await menu.open();
+ is(
+ menu.isItemHidden(),
+ true,
+ "Report Site Issue help menu item is hidden."
+ );
+ await menu.close();
+ }
+ );
+
+ await promiseAddonEnabled();
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "http://example.com" },
+ async function () {
+ const menu = new HelpMenuHelper();
+ await menu.open();
+ is(
+ await menu.isItemHidden(),
+ false,
+ "Report Site Issue help menu item is visible."
+ );
+ await menu.close();
+ }
+ );
+
+ // Shut down the addon at the end,or the new instance started when we re-enabled it will "leak".
+ SpecialPowers.Services.prefs.setBoolPref(PREF_WC_REPORTER_ENABLED, false);
+});
diff --git a/browser/extensions/report-site-issue/test/browser/browser_report_site_issue.js b/browser/extensions/report-site-issue/test/browser/browser_report_site_issue.js
new file mode 100644
index 0000000000..62d3516c46
--- /dev/null
+++ b/browser/extensions/report-site-issue/test/browser/browser_report_site_issue.js
@@ -0,0 +1,300 @@
+"use strict";
+
+async function clickToReportAndAwaitReportTabLoad() {
+ const helpMenu = new HelpMenuHelper();
+ await helpMenu.open();
+
+ // click on "report site issue" and wait for the new tab to open
+ const tab = await new Promise(resolve => {
+ gBrowser.tabContainer.addEventListener(
+ "TabOpen",
+ event => {
+ resolve(event.target);
+ },
+ { once: true }
+ );
+ document.getElementById("help_reportSiteIssue").click();
+ });
+
+ // wait for the new tab to acknowledge that it received a screenshot
+ await BrowserTestUtils.waitForContentEvent(
+ gBrowser.selectedBrowser,
+ "ScreenshotReceived",
+ false,
+ null,
+ true
+ );
+
+ await helpMenu.close();
+
+ return tab;
+}
+
+add_task(async function start_issue_server() {
+ requestLongerTimeout(2);
+
+ const serverLanding = await startIssueServer();
+
+ // ./head.js sets the value for PREF_WC_REPORTER_ENDPOINT
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["datareporting.healthreport.uploadEnabled", true],
+ [PREF_WC_REPORTER_ENABLED, true],
+ [PREF_WC_REPORTER_ENDPOINT, serverLanding],
+ ],
+ });
+});
+
+/* Test that clicking on the Report Site Issue button opens a new tab
+ and sends a postMessaged blob to it. */
+add_task(async function test_opened_page() {
+ let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_PAGE);
+ let tab2 = await clickToReportAndAwaitReportTabLoad();
+
+ await SpecialPowers.spawn(
+ tab2.linkedBrowser,
+ [{ TEST_PAGE }],
+ async function (args) {
+ async function isGreen(dataUrl) {
+ const getPixel = await new Promise(resolve => {
+ const myCanvas = content.document.createElement("canvas");
+ const ctx = myCanvas.getContext("2d");
+ const img = new content.Image();
+ img.onload = () => {
+ ctx.drawImage(img, 0, 0);
+ resolve((x, y) => {
+ return ctx.getImageData(x, y, 1, 1).data;
+ });
+ };
+ img.src = dataUrl;
+ });
+ function isPixelGreenFuzzy(p) {
+ // jpeg, so it will be off slightly
+ const fuzz = 4;
+ return p[0] < fuzz && Math.abs(p[1] - 128) < fuzz && p[2] < fuzz;
+ }
+ ok(isPixelGreenFuzzy(getPixel(0, 0)), "The pixels were green");
+ }
+
+ let doc = content.document;
+ let urlParam = doc.getElementById("url").innerText;
+ let preview = doc.getElementById("screenshot-preview");
+ const URL =
+ "http://example.com/browser/browser/extensions/report-site-issue/test/browser/test.html";
+ is(
+ urlParam,
+ args.TEST_PAGE,
+ "Reported page is correctly added to the url param"
+ );
+
+ let docShell = content.docShell;
+ is(
+ typeof docShell.getHasTrackingContentBlocked,
+ "function",
+ "docShell.hasTrackingContentBlocked is available"
+ );
+
+ let detailsParam = doc.getElementById("details").innerText;
+ const details = JSON.parse(detailsParam);
+ ok(
+ typeof details == "object",
+ "Details param is a stringified JSON object."
+ );
+ ok(Array.isArray(details.consoleLog), "Details has a consoleLog array.");
+
+ const log1 = details.consoleLog[0];
+ is(log1.log[0], null, "Can handle degenerate console logs");
+ is(log1.level, "log", "Reports correct log level");
+ is(log1.uri, URL, "Reports correct url");
+ is(log1.pos, "7:13", "Reports correct line and column");
+
+ const log2 = details.consoleLog[1];
+ is(log2.log[0], "colored message", "Can handle fancy console logs");
+ is(log2.level, "error", "Reports correct log level");
+ is(log2.uri, URL, "Reports correct url");
+ is(log2.pos, "8:13", "Reports correct line and column");
+
+ const log3 = details.consoleLog[2];
+ const loggedObject = log3.log[0];
+ is(loggedObject.testobj, "{...}", "Reports object inside object");
+ is(
+ loggedObject.testSymbol,
+ "Symbol(sym)",
+ "Reports symbol inside object"
+ );
+ is(loggedObject.testnumber, 1, "Reports number inside object");
+ is(loggedObject.testArray, "(4)[...]", "Reports array inside object");
+ is(loggedObject.testUndf, "undefined", "Reports undefined inside object");
+ is(loggedObject.testNull, null, "Reports null inside object");
+ is(
+ loggedObject.testFunc,
+ undefined,
+ "Reports function inside object as undefined due to security reasons"
+ );
+ is(loggedObject.testString, "string", "Reports string inside object");
+ is(loggedObject.c, "{...}", "Reports circular reference inside object");
+ is(
+ Object.keys(loggedObject).length,
+ 10,
+ "Preview has 10 keys inside object"
+ );
+ is(log3.level, "log", "Reports correct log level");
+ is(log3.uri, URL, "Reports correct url");
+ is(log3.pos, "24:13", "Reports correct line and column");
+
+ const log4 = details.consoleLog[3];
+ const loggedArray = log4.log[0];
+ is(loggedArray[0], "string", "Reports string inside array");
+ is(loggedArray[1], "{...}", "Reports object inside array");
+ is(loggedArray[2], null, "Reports null inside array");
+ is(loggedArray[3], 90, "Reports number inside array");
+ is(loggedArray[4], "undefined", "Reports undefined inside array");
+ is(
+ loggedArray[5],
+ "undefined",
+ "Reports function inside array as undefined due to security reasons"
+ );
+ is(loggedArray[6], "(4)[...]", "Reports array inside array");
+ is(loggedArray[7], "(8)[...]", "Reports circular array inside array");
+
+ const log5 = details.consoleLog[4];
+ ok(
+ log5.log[0].match(/TypeError: .*document\.access is undefined/),
+ "Script errors are logged"
+ );
+ is(log5.level, "error", "Reports correct log level");
+ is(log5.uri, URL, "Reports correct url");
+ is(log5.pos, "36:5", "Reports correct line and column");
+
+ ok(typeof details.buildID == "string", "Details has a buildID string.");
+ ok(typeof details.channel == "string", "Details has a channel string.");
+ ok(
+ typeof details.hasTouchScreen == "boolean",
+ "Details has a hasTouchScreen flag."
+ );
+ ok(
+ typeof details.hasFastClick == "undefined",
+ "Details does not have FastClick if not found."
+ );
+ ok(
+ typeof details.hasMobify == "undefined",
+ "Details does not have Mobify if not found."
+ );
+ ok(
+ typeof details.hasMarfeel == "undefined",
+ "Details does not have Marfeel if not found."
+ );
+ ok(
+ typeof details["mixed active content blocked"] == "boolean",
+ "Details has a mixed active content blocked flag."
+ );
+ ok(
+ typeof details["mixed passive content blocked"] == "boolean",
+ "Details has a mixed passive content blocked flag."
+ );
+ ok(
+ typeof details["tracking content blocked"] == "string",
+ "Details has a tracking content blocked string."
+ );
+ ok(
+ typeof details["gfx.webrender.all"] == "boolean",
+ "Details has gfx.webrender.all."
+ );
+ ok(
+ typeof details["gfx.webrender.blob-images"] == "boolean",
+ "Details has gfx.webrender.blob-images."
+ );
+ ok(
+ typeof details["gfx.webrender.enabled"] == "boolean",
+ "Details has gfx.webrender.enabled."
+ );
+ ok(
+ typeof details["image.mem.shared"] == "boolean",
+ "Details has image.mem.shared."
+ );
+
+ is(
+ preview.innerText,
+ "Pass",
+ "A Blob object was successfully transferred to the test page."
+ );
+
+ const bgUrl = preview.style.backgroundImage.match(/url\(\"(.*)\"\)/)[1];
+ ok(
+ bgUrl.startsWith("data:image/jpeg;base64,"),
+ "A jpeg screenshot was successfully postMessaged"
+ );
+ await isGreen(bgUrl);
+ }
+ );
+
+ BrowserTestUtils.removeTab(tab2);
+ BrowserTestUtils.removeTab(tab1);
+});
+
+add_task(async function test_framework_detection() {
+ let tab1 = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ FRAMEWORKS_TEST_PAGE
+ );
+ let tab2 = await clickToReportAndAwaitReportTabLoad();
+
+ await SpecialPowers.spawn(tab2.linkedBrowser, [], async function (args) {
+ let doc = content.document;
+ let detailsParam = doc.getElementById("details").innerText;
+ const details = JSON.parse(detailsParam);
+ ok(
+ typeof details == "object",
+ "Details param is a stringified JSON object."
+ );
+ is(details.hasFastClick, true, "FastClick was found.");
+ is(details.hasMobify, true, "Mobify was found.");
+ is(details.hasMarfeel, true, "Marfeel was found.");
+ });
+
+ BrowserTestUtils.removeTab(tab2);
+ BrowserTestUtils.removeTab(tab1);
+});
+
+add_task(async function test_fastclick_detection() {
+ let tab1 = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ FASTCLICK_TEST_PAGE
+ );
+ let tab2 = await clickToReportAndAwaitReportTabLoad();
+
+ await SpecialPowers.spawn(tab2.linkedBrowser, [], async function (args) {
+ let doc = content.document;
+ let detailsParam = doc.getElementById("details").innerText;
+ const details = JSON.parse(detailsParam);
+ ok(
+ typeof details == "object",
+ "Details param is a stringified JSON object."
+ );
+ is(details.hasFastClick, true, "FastClick was found.");
+ });
+
+ BrowserTestUtils.removeTab(tab2);
+ BrowserTestUtils.removeTab(tab1);
+});
+
+add_task(async function test_framework_label() {
+ let tab1 = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ FRAMEWORKS_TEST_PAGE
+ );
+ let tab2 = await clickToReportAndAwaitReportTabLoad();
+
+ await SpecialPowers.spawn(tab2.linkedBrowser, [], async function (args) {
+ let doc = content.document;
+ let labelParam = doc.getElementById("label").innerText;
+ const label = JSON.parse(labelParam);
+ ok(typeof label == "object", "Label param is a stringified JSON object.");
+ is(label.includes("type-fastclick"), true, "FastClick was found.");
+ is(label.includes("type-mobify"), true, "Mobify was found.");
+ is(label.includes("type-marfeel"), true, "Marfeel was found.");
+ });
+
+ BrowserTestUtils.removeTab(tab2);
+ BrowserTestUtils.removeTab(tab1);
+});
diff --git a/browser/extensions/report-site-issue/test/browser/fastclick.html b/browser/extensions/report-site-issue/test/browser/fastclick.html
new file mode 100644
index 0000000000..e13329dfd7
--- /dev/null
+++ b/browser/extensions/report-site-issue/test/browser/fastclick.html
@@ -0,0 +1,11 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<script>
+ "use strict";
+ function ObscuredFastClick() {
+ }
+ ObscuredFastClick.prototype = {
+ needsClick: () => {},
+ };
+ window.someRandomVar = new ObscuredFastClick();
+</script>
diff --git a/browser/extensions/report-site-issue/test/browser/frameworks.html b/browser/extensions/report-site-issue/test/browser/frameworks.html
new file mode 100644
index 0000000000..14df387ec9
--- /dev/null
+++ b/browser/extensions/report-site-issue/test/browser/frameworks.html
@@ -0,0 +1,8 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<script>
+ "use strict";
+ function FastClick() {}
+ function marfeel() {}
+ var Mobify = {Tag: "something"};
+</script>
diff --git a/browser/extensions/report-site-issue/test/browser/head.js b/browser/extensions/report-site-issue/test/browser/head.js
new file mode 100644
index 0000000000..8fd4e91406
--- /dev/null
+++ b/browser/extensions/report-site-issue/test/browser/head.js
@@ -0,0 +1,119 @@
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+});
+
+const { Management } = ChromeUtils.importESModule(
+ "resource://gre/modules/Extension.sys.mjs"
+);
+
+const PREF_WC_REPORTER_ENABLED = "extensions.webcompat-reporter.enabled";
+const PREF_WC_REPORTER_ENDPOINT =
+ "extensions.webcompat-reporter.newIssueEndpoint";
+
+const TEST_ROOT = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "http://example.com"
+);
+const TEST_PAGE = TEST_ROOT + "test.html";
+const FRAMEWORKS_TEST_PAGE = TEST_ROOT + "frameworks.html";
+const FASTCLICK_TEST_PAGE = TEST_ROOT + "fastclick.html";
+const NEW_ISSUE_PAGE = TEST_ROOT + "webcompat.html";
+
+const WC_ADDON_ID = "webcompat-reporter@mozilla.org";
+
+async function promiseAddonEnabled() {
+ const addon = await AddonManager.getAddonByID(WC_ADDON_ID);
+ if (addon.isActive) {
+ return;
+ }
+ const pref = SpecialPowers.Services.prefs.getBoolPref(
+ PREF_WC_REPORTER_ENABLED,
+ false
+ );
+ if (!pref) {
+ SpecialPowers.Services.prefs.setBoolPref(PREF_WC_REPORTER_ENABLED, true);
+ }
+}
+
+class HelpMenuHelper {
+ #popup = null;
+
+ async open() {
+ this.popup = document.getElementById("menu_HelpPopup");
+ ok(this.popup, "Help menu should exist");
+
+ const menuOpen = BrowserTestUtils.waitForEvent(this.popup, "popupshown");
+
+ // This event-faking method was copied from browser_title_case_menus.js so
+ // this can be tested on MacOS (where the actual menus cannot be opened in
+ // tests, but we only need the help menu to populate itself).
+ this.popup.dispatchEvent(new MouseEvent("popupshowing", { bubbles: true }));
+ this.popup.dispatchEvent(new MouseEvent("popupshown", { bubbles: true }));
+
+ await menuOpen;
+ }
+
+ async close() {
+ if (this.popup) {
+ const menuClose = BrowserTestUtils.waitForEvent(
+ this.popup,
+ "popuphidden"
+ );
+
+ // (Also copied from browser_title_case_menus.js)
+ // Just for good measure, we'll fire the popuphiding/popuphidden events
+ // after we close the menupopups.
+ this.popup.dispatchEvent(
+ new MouseEvent("popuphiding", { bubbles: true })
+ );
+ this.popup.dispatchEvent(
+ new MouseEvent("popuphidden", { bubbles: true })
+ );
+
+ await menuClose;
+ this.popup = null;
+ }
+ }
+
+ isItemHidden() {
+ const item = document.getElementById("help_reportSiteIssue");
+ return item && item.hidden;
+ }
+
+ isItemEnabled() {
+ const item = document.getElementById("help_reportSiteIssue");
+ return item && !item.hidden && !item.disabled;
+ }
+}
+
+async function startIssueServer() {
+ const landingTemplate = await new Promise((resolve, reject) => {
+ const xhr = new XMLHttpRequest();
+ xhr.open("GET", NEW_ISSUE_PAGE);
+ xhr.onload = () => {
+ resolve(xhr.responseText);
+ };
+ xhr.onerror = reject;
+ xhr.send();
+ });
+
+ const { HttpServer } = ChromeUtils.import(
+ "resource://testing-common/httpd.js"
+ );
+ const server = new HttpServer();
+
+ registerCleanupFunction(async function cleanup() {
+ await new Promise(resolve => server.stop(resolve));
+ });
+
+ server.registerPathHandler("/new", function (request, response) {
+ response.setHeader("Content-Type", "text/html", false);
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.write(landingTemplate);
+ });
+
+ server.start(-1);
+ return `http://localhost:${server.identity.primaryPort}/new`;
+}
diff --git a/browser/extensions/report-site-issue/test/browser/test.html b/browser/extensions/report-site-issue/test/browser/test.html
new file mode 100644
index 0000000000..ed1844f530
--- /dev/null
+++ b/browser/extensions/report-site-issue/test/browser/test.html
@@ -0,0 +1,40 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<script>
+ /* eslint-disable no-console */
+ /* eslint-disable no-unused-expressions */
+ "use strict";
+ console.log(null);
+ console.error("%ccolored message", "background:green; color:white");
+ const obj = {
+ testSymbol: Symbol("sym"),
+ testobj: {},
+ testnumber: 1,
+ testArray: [1, {}, 2, 555],
+ testUndf: undefined,
+ testNull: null,
+ testFunc() {},
+ testString: 'string',
+ prop1: 'prop1',
+ prop2: 'prop2'
+ };
+ obj.c = obj;
+ obj.prop3 = 'prop3';
+ obj.prop4 = 'prop4';
+ console.log(obj);
+ const arr = [
+ 'string',
+ {test: 'obj'},
+ null,
+ 90,
+ undefined,
+ function() {},
+ [1, {}, 2, 555]
+ ];
+ arr.push(arr);
+ console.log(arr);
+ document.access.non.existent.property.to.trigger.error;
+</script>
+<style>
+ body {background: rgb(0, 128, 0);}
+</style>
diff --git a/browser/extensions/report-site-issue/test/browser/webcompat.html b/browser/extensions/report-site-issue/test/browser/webcompat.html
new file mode 100644
index 0000000000..872c8917b7
--- /dev/null
+++ b/browser/extensions/report-site-issue/test/browser/webcompat.html
@@ -0,0 +1,85 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<style>
+ #screenshot-preview {width: 200px; height: 200px;}
+</style>
+<div id="url">$$URL$$</div>
+<div id="details">$$DETAILS$$</div>
+<div id="label">$$LABEL$$</div>
+<div id="screenshot-preview">Fail</div>
+<script>
+"use strict";
+let preview = document.getElementById("screenshot-preview");
+const CONFIG = {
+ url: {
+ element: document.getElementById("url")
+ },
+ details: {
+ element: document.getElementById("details"),
+ toStringify: true
+ },
+ extra_labels: {
+ element: document.getElementById("label"),
+ toStringify: true
+ },
+};
+
+function getBlobAsDataURL(blob) {
+ return new Promise((resolve, reject) => {
+ let reader = new FileReader();
+
+ // eslint-disable-next-line mozilla/balanced-listeners
+ reader.addEventListener("error", (e) => {
+ reject(`There was an error reading the blob: ${e.type}`);
+ });
+
+ // eslint-disable-next-line mozilla/balanced-listeners
+ reader.addEventListener("load", (e) => {
+ resolve(e.target.result);
+ });
+
+ reader.readAsDataURL(blob);
+ });
+}
+
+function setPreviewBG(backgroundData) {
+ return new Promise((resolve) => {
+ preview.style.background = `url(${backgroundData})`;
+ resolve();
+ });
+}
+
+function sendReceivedEvent() {
+ window.dispatchEvent(new CustomEvent("ScreenshotReceived", {bubbles: true}));
+}
+
+function prepareContent(toStringify, content) {
+ if (toStringify) {
+ return JSON.stringify(content)
+ }
+
+ return content;
+}
+
+function appendMessage(message) {
+ for (const key in CONFIG) {
+ if (key in message) {
+ const field = CONFIG[key];
+ field.element.innerText = prepareContent(field.toStringify, message[key]);
+ }
+ }
+}
+
+// eslint-disable-next-line mozilla/balanced-listeners
+window.addEventListener("message", function(event) {
+ if (event.data.screenshot instanceof Blob) {
+ preview.innerText = "Pass";
+ }
+
+ if (event.data.message) {
+ appendMessage(event.data.message);
+ }
+
+ getBlobAsDataURL(event.data.screenshot).then(setPreviewBG).then(sendReceivedEvent);
+});
+</script>
diff --git a/browser/extensions/screenshots/assertIsBlankDocument.js b/browser/extensions/screenshots/assertIsBlankDocument.js
new file mode 100644
index 0000000000..be2371632a
--- /dev/null
+++ b/browser/extensions/screenshots/assertIsBlankDocument.js
@@ -0,0 +1,18 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals browser */
+
+/** For use inside an iframe onload function, throws an Error if iframe src is not blank.html
+
+ Should be applied *inside* catcher.watchFunction
+*/
+this.assertIsBlankDocument = function assertIsBlankDocument(doc) {
+ if (doc.documentURI !== browser.runtime.getURL("blank.html")) {
+ const exc = new Error("iframe URL does not match expected blank.html");
+ exc.foundURL = doc.documentURI;
+ throw exc;
+ }
+};
+null;
diff --git a/browser/extensions/screenshots/assertIsTrusted.js b/browser/extensions/screenshots/assertIsTrusted.js
new file mode 100644
index 0000000000..5e2a072f37
--- /dev/null
+++ b/browser/extensions/screenshots/assertIsTrusted.js
@@ -0,0 +1,24 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/** For use with addEventListener, assures that any events have event.isTrusted set to true
+ https://developer.mozilla.org/en-US/docs/Web/API/Event/isTrusted
+ Should be applied *inside* catcher.watchFunction
+*/
+this.assertIsTrusted = function assertIsTrusted(handlerFunction) {
+ return function (event) {
+ if (!event) {
+ const exc = new Error("assertIsTrusted did not get an event");
+ exc.noPopup = true;
+ throw exc;
+ }
+ if (!event.isTrusted) {
+ const exc = new Error(`Received untrusted event (type: ${event.type})`);
+ exc.noPopup = true;
+ throw exc;
+ }
+ return handlerFunction.call(this, event);
+ };
+};
+null;
diff --git a/browser/extensions/screenshots/background/analytics.js b/browser/extensions/screenshots/background/analytics.js
new file mode 100644
index 0000000000..20f2c99ffd
--- /dev/null
+++ b/browser/extensions/screenshots/background/analytics.js
@@ -0,0 +1,55 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals main, browser, catcher, log */
+
+"use strict";
+
+this.analytics = (function () {
+ const exports = {};
+
+ let telemetryEnabled;
+
+ exports.incrementCount = function (scalar) {
+ const allowedScalars = [
+ "download",
+ "upload",
+ "copy",
+ "visible",
+ "full_page",
+ "custom",
+ "element",
+ ];
+ if (!allowedScalars.includes(scalar)) {
+ const err = `incrementCount passed an unrecognized scalar ${scalar}`;
+ log.warn(err);
+ return Promise.resolve();
+ }
+ return browser.telemetry
+ .scalarAdd(`screenshots.${scalar}`, 1)
+ .catch(err => {
+ log.warn(`incrementCount failed with error: ${err}`);
+ });
+ };
+
+ exports.refreshTelemetryPref = function () {
+ return browser.telemetry.canUpload().then(
+ result => {
+ telemetryEnabled = result;
+ },
+ error => {
+ // If there's an error reading the pref, we should assume that we shouldn't send data
+ telemetryEnabled = false;
+ throw error;
+ }
+ );
+ };
+
+ exports.isTelemetryEnabled = function () {
+ catcher.watchPromise(exports.refreshTelemetryPref());
+ return telemetryEnabled;
+ };
+
+ return exports;
+})();
diff --git a/browser/extensions/screenshots/background/communication.js b/browser/extensions/screenshots/background/communication.js
new file mode 100644
index 0000000000..275b8ef7f8
--- /dev/null
+++ b/browser/extensions/screenshots/background/communication.js
@@ -0,0 +1,69 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals catcher, log */
+
+"use strict";
+
+this.communication = (function () {
+ const exports = {};
+
+ const registeredFunctions = {};
+
+ exports.onMessage = catcher.watchFunction((req, sender, sendResponse) => {
+ if (!(req.funcName in registeredFunctions)) {
+ log.error(`Received unknown internal message type ${req.funcName}`);
+ sendResponse({ type: "error", name: "Unknown message type" });
+ return;
+ }
+ if (!Array.isArray(req.args)) {
+ log.error("Received message with no .args list");
+ sendResponse({ type: "error", name: "No .args" });
+ return;
+ }
+ const func = registeredFunctions[req.funcName];
+ let result;
+ try {
+ req.args.unshift(sender);
+ result = func.apply(null, req.args);
+ } catch (e) {
+ log.error(`Error in ${req.funcName}:`, e, e.stack);
+ // FIXME: should consider using makeError from catcher here:
+ sendResponse({
+ type: "error",
+ message: e + "",
+ errorCode: e.errorCode,
+ popupMessage: e.popupMessage,
+ });
+ return;
+ }
+ if (result && result.then) {
+ result
+ .then(concreteResult => {
+ sendResponse({ type: "success", value: concreteResult });
+ })
+ .catch(errorResult => {
+ log.error(
+ `Promise error in ${req.funcName}:`,
+ errorResult,
+ errorResult && errorResult.stack
+ );
+ sendResponse({
+ type: "error",
+ message: errorResult + "",
+ errorCode: errorResult.errorCode,
+ popupMessage: errorResult.popupMessage,
+ });
+ });
+ return;
+ }
+ sendResponse({ type: "success", value: result });
+ });
+
+ exports.register = function (name, func) {
+ registeredFunctions[name] = func;
+ };
+
+ return exports;
+})();
diff --git a/browser/extensions/screenshots/background/deviceInfo.js b/browser/extensions/screenshots/background/deviceInfo.js
new file mode 100644
index 0000000000..2a4b15f5ff
--- /dev/null
+++ b/browser/extensions/screenshots/background/deviceInfo.js
@@ -0,0 +1,39 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals catcher, browser, navigator */
+
+"use strict";
+
+this.deviceInfo = (function () {
+ const manifest = browser.runtime.getManifest();
+
+ let platformInfo = {};
+ catcher.watchPromise(
+ browser.runtime.getPlatformInfo().then(info => {
+ platformInfo = info;
+ })
+ );
+
+ return function deviceInfo() {
+ let match = navigator.userAgent.match(/Chrom(?:e|ium)\/([0-9.]{1,1000})/);
+ const chromeVersion = match ? match[1] : null;
+ match = navigator.userAgent.match(/Firefox\/([0-9.]{1,1000})/);
+ const firefoxVersion = match ? match[1] : null;
+ const appName = chromeVersion ? "chrome" : "firefox";
+
+ return {
+ addonVersion: manifest.version,
+ platform: platformInfo.os,
+ architecture: platformInfo.arch,
+ version: firefoxVersion || chromeVersion,
+ // These don't seem to apply to Chrome:
+ // build: system.build,
+ // platformVersion: system.platformVersion,
+ userAgent: navigator.userAgent,
+ appVendor: appName,
+ appName,
+ };
+ };
+})();
diff --git a/browser/extensions/screenshots/background/main.js b/browser/extensions/screenshots/background/main.js
new file mode 100644
index 0000000000..594ba798ee
--- /dev/null
+++ b/browser/extensions/screenshots/background/main.js
@@ -0,0 +1,238 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals browser, getStrings, selectorLoader, analytics, communication, catcher, log, senderror, startBackground, blobConverters, startSelectionWithOnboarding */
+
+"use strict";
+
+this.main = (function () {
+ const exports = {};
+
+ const { incrementCount } = analytics;
+
+ const manifest = browser.runtime.getManifest();
+ let backend;
+
+ exports.setBackend = function (newBackend) {
+ backend = newBackend;
+ backend = backend.replace(/\/*$/, "");
+ };
+
+ exports.getBackend = function () {
+ return backend;
+ };
+
+ communication.register("getBackend", () => {
+ return backend;
+ });
+
+ for (const permission of manifest.permissions) {
+ if (/^https?:\/\//.test(permission)) {
+ exports.setBackend(permission);
+ break;
+ }
+ }
+
+ function toggleSelector(tab) {
+ return analytics
+ .refreshTelemetryPref()
+ .then(() => selectorLoader.toggle(tab.id))
+ .catch(error => {
+ if (
+ error.message &&
+ /Missing host permission for the tab/.test(error.message)
+ ) {
+ error.noReport = true;
+ }
+ error.popupMessage = "UNSHOOTABLE_PAGE";
+ throw error;
+ });
+ }
+
+ // This is called by startBackground.js, where is registered as a click
+ // handler for the webextension page action.
+ exports.onClicked = catcher.watchFunction(tab => {
+ _startShotFlow(tab, "toolbar-button");
+ });
+
+ exports.onClickedContextMenu = catcher.watchFunction(tab => {
+ _startShotFlow(tab, "context-menu");
+ });
+
+ exports.onShortcut = catcher.watchFunction(tab => {
+ _startShotFlow(tab, "keyboard-shortcut");
+ });
+
+ const _startShotFlow = (tab, inputType) => {
+ if (!tab) {
+ // Not in a page/tab context, ignore
+ return;
+ }
+ if (!urlEnabled(tab.url)) {
+ senderror.showError({
+ popupMessage: "UNSHOOTABLE_PAGE",
+ });
+ return;
+ }
+
+ catcher.watchPromise(
+ toggleSelector(tab).catch(error => {
+ throw error;
+ })
+ );
+ };
+
+ function urlEnabled(url) {
+ // Allow screenshots on urls related to web pages in reader mode.
+ if (url && url.startsWith("about:reader?url=")) {
+ return true;
+ }
+ if (
+ isShotOrMyShotPage(url) ||
+ /^(?:about|data|moz-extension):/i.test(url) ||
+ isBlacklistedUrl(url)
+ ) {
+ return false;
+ }
+ return true;
+ }
+
+ function isShotOrMyShotPage(url) {
+ // It's okay to take a shot of any pages except shot pages and My Shots
+ if (!url.startsWith(backend)) {
+ return false;
+ }
+ const path = url
+ .substr(backend.length)
+ .replace(/^\/*/, "")
+ .replace(/[?#].*/, "");
+ if (path === "shots") {
+ return true;
+ }
+ if (/^[^/]{1,4000}\/[^/]{1,4000}$/.test(path)) {
+ // Blocks {:id}/{:domain}, but not /, /privacy, etc
+ return true;
+ }
+ return false;
+ }
+
+ function isBlacklistedUrl(url) {
+ // These specific domains are not allowed for general WebExtension permission reasons
+ // Discussion: https://bugzilla.mozilla.org/show_bug.cgi?id=1310082
+ // List of domains copied from: https://searchfox.org/mozilla-central/source/browser/app/permissions#18-19
+ // Note we disable it here to be informative, the security check is done in WebExtension code
+ const badDomains = ["testpilot.firefox.com"];
+ let domain = url.replace(/^https?:\/\//i, "");
+ domain = domain.replace(/\/.*/, "").replace(/:.*/, "");
+ domain = domain.toLowerCase();
+ return badDomains.includes(domain);
+ }
+
+ communication.register("getStrings", (sender, ids) => {
+ return getStrings(ids.map(id => ({ id })));
+ });
+
+ communication.register("captureTelemetry", (sender, ...args) => {
+ catcher.watchPromise(incrementCount(...args));
+ });
+
+ communication.register("openShot", async (sender, { url, copied }) => {
+ if (copied) {
+ const id = crypto.randomUUID();
+ const [title, message] = await getStrings([
+ { id: "screenshots-notification-link-copied-title" },
+ { id: "screenshots-notification-link-copied-details" },
+ ]);
+ return browser.notifications.create(id, {
+ type: "basic",
+ iconUrl: "chrome://browser/content/screenshots/copied-notification.svg",
+ title,
+ message,
+ });
+ }
+ return null;
+ });
+
+ communication.register("copyShotToClipboard", async (sender, blob) => {
+ let buffer = await blobConverters.blobToArray(blob);
+ await browser.clipboard.setImageData(buffer, blob.type.split("/", 2)[1]);
+
+ const [title, message] = await getStrings([
+ { id: "screenshots-notification-image-copied-title" },
+ { id: "screenshots-notification-image-copied-details" },
+ ]);
+
+ catcher.watchPromise(incrementCount("copy"));
+ return browser.notifications.create({
+ type: "basic",
+ iconUrl: "chrome://browser/content/screenshots/copied-notification.svg",
+ title,
+ message,
+ });
+ });
+
+ communication.register("downloadShot", (sender, info) => {
+ // 'data:' urls don't work directly, let's use a Blob
+ // see http://stackoverflow.com/questions/40269862/save-data-uri-as-file-using-downloads-download-api
+ const blob = blobConverters.dataUrlToBlob(info.url);
+ const url = URL.createObjectURL(blob);
+ let downloadId;
+ const onChangedCallback = catcher.watchFunction(function (change) {
+ if (!downloadId || downloadId !== change.id) {
+ return;
+ }
+ if (change.state && change.state.current !== "in_progress") {
+ URL.revokeObjectURL(url);
+ browser.downloads.onChanged.removeListener(onChangedCallback);
+ }
+ });
+ browser.downloads.onChanged.addListener(onChangedCallback);
+ catcher.watchPromise(incrementCount("download"));
+ return browser.windows.getLastFocused().then(windowInfo => {
+ return browser.downloads
+ .download({
+ url,
+ incognito: windowInfo.incognito,
+ filename: info.filename,
+ })
+ .catch(error => {
+ // We are not logging error message when user cancels download
+ if (error && error.message && !error.message.includes("canceled")) {
+ log.error(error.message);
+ }
+ })
+ .then(id => {
+ downloadId = id;
+ });
+ });
+ });
+
+ communication.register("abortStartShot", () => {
+ // Note, we only show the error but don't report it, as we know that we can't
+ // take shots of these pages:
+ senderror.showError({
+ popupMessage: "UNSHOOTABLE_PAGE",
+ });
+ });
+
+ // A Screenshots page wants us to start/force onboarding
+ communication.register("requestOnboarding", sender => {
+ return startSelectionWithOnboarding(sender.tab);
+ });
+
+ communication.register("getPlatformOs", () => {
+ return catcher.watchPromise(
+ browser.runtime.getPlatformInfo().then(platformInfo => {
+ return platformInfo.os;
+ })
+ );
+ });
+
+ // This allows the web site show notifications through sitehelper.js
+ communication.register("showNotification", (sender, notification) => {
+ return browser.notifications.create(notification);
+ });
+
+ return exports;
+})();
diff --git a/browser/extensions/screenshots/background/selectorLoader.js b/browser/extensions/screenshots/background/selectorLoader.js
new file mode 100644
index 0000000000..4749f73b30
--- /dev/null
+++ b/browser/extensions/screenshots/background/selectorLoader.js
@@ -0,0 +1,139 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals browser, catcher, communication, log, main */
+
+"use strict";
+
+// eslint-disable-next-line no-var
+var global = this;
+
+this.selectorLoader = (function () {
+ const exports = {};
+
+ // These modules are loaded in order, first standardScripts and then selectorScripts
+ // The order is important due to dependencies
+ const standardScripts = [
+ "log.js",
+ "catcher.js",
+ "assertIsTrusted.js",
+ "assertIsBlankDocument.js",
+ "blobConverters.js",
+ "background/selectorLoader.js",
+ "selector/callBackground.js",
+ "selector/util.js",
+ ];
+
+ const selectorScripts = [
+ "clipboard.js",
+ "build/selection.js",
+ "build/shot.js",
+ "randomString.js",
+ "domainFromUrl.js",
+ "build/inlineSelectionCss.js",
+ "selector/documentMetadata.js",
+ "selector/ui.js",
+ "selector/shooter.js",
+ "selector/uicontrol.js",
+ ];
+
+ exports.unloadIfLoaded = function (tabId) {
+ return browser.tabs
+ .executeScript(tabId, {
+ code: "this.selectorLoader && this.selectorLoader.unloadModules()",
+ runAt: "document_start",
+ })
+ .then(result => {
+ return result && result[0];
+ });
+ };
+
+ exports.testIfLoaded = function (tabId) {
+ if (loadingTabs.has(tabId)) {
+ return true;
+ }
+ return browser.tabs
+ .executeScript(tabId, {
+ code: "!!this.selectorLoader",
+ runAt: "document_start",
+ })
+ .then(result => {
+ return result && result[0];
+ });
+ };
+
+ const loadingTabs = new Set();
+
+ exports.loadModules = function (tabId) {
+ loadingTabs.add(tabId);
+ catcher.watchPromise(
+ executeModules(tabId, standardScripts.concat(selectorScripts)).then(
+ () => {
+ loadingTabs.delete(tabId);
+ }
+ )
+ );
+ };
+
+ function executeModules(tabId, scripts) {
+ let lastPromise = Promise.resolve(null);
+ scripts.forEach(file => {
+ lastPromise = lastPromise.then(() => {
+ return browser.tabs
+ .executeScript(tabId, {
+ file,
+ runAt: "document_start",
+ })
+ .catch(error => {
+ log.error("error in script:", file, error);
+ error.scriptName = file;
+ throw error;
+ });
+ });
+ });
+ return lastPromise.then(
+ () => {
+ log.debug("finished loading scripts:", scripts.join(" "));
+ },
+ error => {
+ exports.unloadIfLoaded(tabId);
+ catcher.unhandled(error);
+ throw error;
+ }
+ );
+ }
+
+ exports.unloadModules = function () {
+ const watchFunction = catcher.watchFunction;
+ const allScripts = standardScripts.concat(selectorScripts);
+ const moduleNames = allScripts.map(filename =>
+ filename.replace(/^.*\//, "").replace(/\.js$/, "")
+ );
+ moduleNames.reverse();
+ for (const moduleName of moduleNames) {
+ const moduleObj = global[moduleName];
+ if (moduleObj && moduleObj.unload) {
+ try {
+ watchFunction(moduleObj.unload)();
+ } catch (e) {
+ // ignore (watchFunction handles it)
+ }
+ }
+ delete global[moduleName];
+ }
+ return true;
+ };
+
+ exports.toggle = function (tabId) {
+ return exports.unloadIfLoaded(tabId).then(wasLoaded => {
+ if (!wasLoaded) {
+ exports.loadModules(tabId);
+ }
+ return !wasLoaded;
+ });
+ };
+
+ return exports;
+})();
+null;
diff --git a/browser/extensions/screenshots/background/senderror.js b/browser/extensions/screenshots/background/senderror.js
new file mode 100644
index 0000000000..3d5eae5ec6
--- /dev/null
+++ b/browser/extensions/screenshots/background/senderror.js
@@ -0,0 +1,144 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals startBackground, analytics, communication, catcher, log, browser, getStrings */
+
+"use strict";
+
+this.senderror = (function () {
+ const exports = {};
+
+ // Do not show an error more than every ERROR_TIME_LIMIT milliseconds:
+ const ERROR_TIME_LIMIT = 3000;
+
+ const messages = {
+ REQUEST_ERROR: {
+ titleKey: "screenshots-request-error-title",
+ infoKey: "screenshots-request-error-details",
+ },
+ CONNECTION_ERROR: {
+ titleKey: "screenshots-connection-error-title",
+ infoKey: "screenshots-connection-error-details",
+ },
+ LOGIN_ERROR: {
+ titleKey: "screenshots-request-error-title",
+ infoKey: "screenshots-login-error-details",
+ },
+ LOGIN_CONNECTION_ERROR: {
+ titleKey: "screenshots-connection-error-title",
+ infoKey: "screenshots-connection-error-details",
+ },
+ UNSHOOTABLE_PAGE: {
+ titleKey: "screenshots-unshootable-page-error-title",
+ infoKey: "screenshots-unshootable-page-error-details",
+ },
+ EMPTY_SELECTION: {
+ titleKey: "screenshots-empty-selection-error-title",
+ },
+ PRIVATE_WINDOW: {
+ titleKey: "screenshots-private-window-error-title",
+ infoKey: "screenshots-private-window-error-details",
+ },
+ generic: {
+ titleKey: "screenshots-generic-error-title",
+ infoKey: "screenshots-generic-error-details",
+ showMessage: true,
+ },
+ };
+
+ communication.register("reportError", (sender, error) => {
+ catcher.unhandled(error);
+ });
+
+ let lastErrorTime;
+
+ exports.showError = async function (error) {
+ if (lastErrorTime && Date.now() - lastErrorTime < ERROR_TIME_LIMIT) {
+ return;
+ }
+ lastErrorTime = Date.now();
+ const id = crypto.randomUUID();
+ let popupMessage = error.popupMessage || "generic";
+ if (!messages[popupMessage]) {
+ popupMessage = "generic";
+ }
+
+ let item = messages[popupMessage];
+ if (!("title" in item)) {
+ let keys = [{ id: item.titleKey }];
+ if ("infoKey" in item) {
+ keys.push({ id: item.infoKey });
+ }
+
+ [item.title, item.info] = await getStrings(keys);
+ }
+
+ let title = item.title;
+ let message = item.info || "";
+ const showMessage = item.showMessage;
+ if (error.message && showMessage) {
+ if (message) {
+ message += "\n" + error.message;
+ } else {
+ message = error.message;
+ }
+ }
+ if (Date.now() - startBackground.startTime > 5 * 1000) {
+ browser.notifications.create(id, {
+ type: "basic",
+ // FIXME: need iconUrl for an image, see #2239
+ title,
+ message,
+ });
+ }
+ };
+
+ exports.reportError = function (e) {
+ if (!analytics.isTelemetryEnabled()) {
+ log.error("Telemetry disabled. Not sending critical error:", e);
+ return;
+ }
+ const exception = new Error(e.message);
+ exception.stack = e.multilineStack || e.stack || undefined;
+
+ // To improve Sentry reporting & grouping, replace the
+ // moz-extension://$uuid base URL with a generic resource:// URL.
+ if (exception.stack) {
+ exception.stack = exception.stack.replace(
+ /moz-extension:\/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g,
+ "resource://screenshots-addon"
+ );
+ }
+ const rest = {};
+ for (const attr in e) {
+ if (
+ ![
+ "name",
+ "message",
+ "stack",
+ "multilineStack",
+ "popupMessage",
+ "version",
+ "sentryPublicDSN",
+ "help",
+ "fromMakeError",
+ ].includes(attr)
+ ) {
+ rest[attr] = e[attr];
+ }
+ }
+ rest.stack = exception.stack;
+ };
+
+ catcher.registerHandler(errorObj => {
+ if (!errorObj.noPopup) {
+ exports.showError(errorObj);
+ }
+ if (!errorObj.noReport) {
+ exports.reportError(errorObj);
+ }
+ });
+
+ return exports;
+})();
diff --git a/browser/extensions/screenshots/background/startBackground.js b/browser/extensions/screenshots/background/startBackground.js
new file mode 100644
index 0000000000..5d35db3638
--- /dev/null
+++ b/browser/extensions/screenshots/background/startBackground.js
@@ -0,0 +1,123 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals browser, main, communication, manifest */
+
+/* This file handles:
+ clicks on the WebExtension page action
+ browser.contextMenus.onClicked
+ browser.runtime.onMessage
+ and loads the rest of the background page in response to those events, forwarding
+ the events to main.onClicked, main.onClickedContextMenu, or communication.onMessage
+*/
+
+const startTime = Date.now();
+
+// Set up to be able to use fluent:
+(function () {
+ let link = document.createElement("link");
+ link.setAttribute("rel", "localization");
+ link.setAttribute("href", "browser/screenshots.ftl");
+ document.head.appendChild(link);
+
+ link = document.createElement("link");
+ link.setAttribute("rel", "localization");
+ link.setAttribute("href", "toolkit/branding/brandings.ftl");
+ document.head.appendChild(link);
+})();
+
+this.getStrings = async function (ids) {
+ if (document.readyState != "complete") {
+ await new Promise(resolve =>
+ window.addEventListener("load", resolve, { once: true })
+ );
+ }
+ await document.l10n.ready;
+ return document.l10n.formatValues(ids);
+};
+
+let zoomFactor = 1;
+this.getZoomFactor = function () {
+ return zoomFactor;
+};
+
+this.startBackground = (function () {
+ const exports = { startTime };
+
+ const backgroundScripts = [
+ "log.js",
+ "catcher.js",
+ "blobConverters.js",
+ "background/selectorLoader.js",
+ "background/communication.js",
+ "background/senderror.js",
+ "build/shot.js",
+ "build/thumbnailGenerator.js",
+ "background/analytics.js",
+ "background/deviceInfo.js",
+ "background/takeshot.js",
+ "background/main.js",
+ ];
+
+ browser.experiments.screenshots.onScreenshotCommand.addListener(
+ async type => {
+ try {
+ let [[tab]] = await Promise.all([
+ browser.tabs.query({ currentWindow: true, active: true }),
+ loadIfNecessary(),
+ ]);
+ zoomFactor = await browser.tabs.getZoom(tab.id);
+ if (type === "contextMenu") {
+ main.onClickedContextMenu(tab);
+ } else if (type === "toolbar" || type === "quickaction") {
+ main.onClicked(tab);
+ } else if (type === "shortcut") {
+ main.onShortcut(tab);
+ }
+ } catch (error) {
+ console.error("Error loading Screenshots:", error);
+ }
+ }
+ );
+
+ browser.runtime.onMessage.addListener((req, sender, sendResponse) => {
+ loadIfNecessary()
+ .then(() => {
+ return communication.onMessage(req, sender, sendResponse);
+ })
+ .catch(error => {
+ console.error("Error loading Screenshots:", error);
+ });
+ return true;
+ });
+
+ let loadedPromise;
+
+ function loadIfNecessary() {
+ if (loadedPromise) {
+ return loadedPromise;
+ }
+ loadedPromise = Promise.resolve();
+ backgroundScripts.forEach(script => {
+ loadedPromise = loadedPromise.then(() => {
+ return new Promise((resolve, reject) => {
+ const tag = document.createElement("script");
+ tag.src = browser.runtime.getURL(script);
+ tag.onload = () => {
+ resolve();
+ };
+ tag.onerror = error => {
+ const exc = new Error(`Error loading script: ${error.message}`);
+ exc.scriptName = script;
+ reject(exc);
+ };
+ document.head.appendChild(tag);
+ });
+ });
+ });
+ return loadedPromise;
+ }
+
+ return exports;
+})();
diff --git a/browser/extensions/screenshots/background/takeshot.js b/browser/extensions/screenshots/background/takeshot.js
new file mode 100644
index 0000000000..c8e229247b
--- /dev/null
+++ b/browser/extensions/screenshots/background/takeshot.js
@@ -0,0 +1,85 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals browser, communication, getZoomFactor, shot, main, catcher, analytics, blobConverters, thumbnailGenerator */
+
+"use strict";
+
+this.takeshot = (function () {
+ const exports = {};
+ const MAX_CANVAS_DIMENSION = 32767;
+
+ communication.register(
+ "screenshotPage",
+ (sender, selectedPos, screenshotType, devicePixelRatio) => {
+ return screenshotPage(selectedPos, screenshotType, devicePixelRatio);
+ }
+ );
+
+ communication.register("getZoomFactor", sender => {
+ return getZoomFactor();
+ });
+
+ function screenshotPage(pos, screenshotType, devicePixelRatio) {
+ pos.width = Math.min(pos.right - pos.left, MAX_CANVAS_DIMENSION);
+ pos.height = Math.min(pos.bottom - pos.top, MAX_CANVAS_DIMENSION);
+
+ // If we are printing the full page or a truncated full page,
+ // we must pass in this rectangle to preview the entire image
+ let options = { format: "png" };
+ if (
+ screenshotType === "fullPage" ||
+ screenshotType === "fullPageTruncated"
+ ) {
+ let rectangle = {
+ x: 0,
+ y: 0,
+ width: pos.width,
+ height: pos.height,
+ };
+ options.rect = rectangle;
+ options.resetScrollPosition = true;
+ } else if (screenshotType != "visible") {
+ let rectangle = {
+ x: pos.left,
+ y: pos.top,
+ width: pos.width,
+ height: pos.height,
+ };
+ options.rect = rectangle;
+ }
+
+ return catcher.watchPromise(
+ browser.tabs.captureTab(null, options).then(dataUrl => {
+ const image = new Image();
+ image.src = dataUrl;
+ return new Promise((resolve, reject) => {
+ image.onload = catcher.watchFunction(() => {
+ const xScale = devicePixelRatio;
+ const yScale = devicePixelRatio;
+ const canvas = document.createElement("canvas");
+ canvas.height = pos.height * yScale;
+ canvas.width = pos.width * xScale;
+ const context = canvas.getContext("2d");
+ context.drawImage(
+ image,
+ 0,
+ 0,
+ pos.width * xScale,
+ pos.height * yScale,
+ 0,
+ 0,
+ pos.width * xScale,
+ pos.height * yScale
+ );
+ const result = canvas.toDataURL();
+ resolve(result);
+ });
+ });
+ })
+ );
+ }
+
+ return exports;
+})();
diff --git a/browser/extensions/screenshots/blank.html b/browser/extensions/screenshots/blank.html
new file mode 100644
index 0000000000..fcd85bbe86
--- /dev/null
+++ b/browser/extensions/screenshots/blank.html
@@ -0,0 +1,7 @@
+<!DOCTYPE html>
+
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ - You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<meta charset="utf-8" />
diff --git a/browser/extensions/screenshots/blobConverters.js b/browser/extensions/screenshots/blobConverters.js
new file mode 100644
index 0000000000..4e727ff271
--- /dev/null
+++ b/browser/extensions/screenshots/blobConverters.js
@@ -0,0 +1,48 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+this.blobConverters = (function () {
+ const exports = {};
+
+ exports.dataUrlToBlob = function (url) {
+ const binary = atob(url.split(",", 2)[1]);
+ let contentType = exports.getTypeFromDataUrl(url);
+ if (contentType !== "image/png" && contentType !== "image/jpeg") {
+ contentType = "image/png";
+ }
+ const data = Uint8Array.from(binary, char => char.charCodeAt(0));
+ const blob = new Blob([data], { type: contentType });
+ return blob;
+ };
+
+ exports.getTypeFromDataUrl = function (url) {
+ let contentType = url.split(",", 1)[0];
+ contentType = contentType.split(";", 1)[0];
+ contentType = contentType.split(":", 2)[1];
+ return contentType;
+ };
+
+ exports.blobToArray = function (blob) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.addEventListener("loadend", function () {
+ resolve(reader.result);
+ });
+ reader.readAsArrayBuffer(blob);
+ });
+ };
+
+ exports.blobToDataUrl = function (blob) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.addEventListener("loadend", function () {
+ resolve(reader.result);
+ });
+ reader.readAsDataURL(blob);
+ });
+ };
+
+ return exports;
+})();
+null;
diff --git a/browser/extensions/screenshots/build/inlineSelectionCss.js b/browser/extensions/screenshots/build/inlineSelectionCss.js
new file mode 100644
index 0000000000..fa31b642df
--- /dev/null
+++ b/browser/extensions/screenshots/build/inlineSelectionCss.js
@@ -0,0 +1,667 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* Created from build/server/static/css/inline-selection.css */
+window.inlineSelectionCss = `
+.button, .highlight-button-cancel, .highlight-button-download, .highlight-button-copy {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ column-gap: 8px;
+ border: 0;
+ border-radius: 3px;
+ cursor: pointer;
+ font-size: 16px;
+ font-weight: 400;
+ height: 40px;
+ min-width: 40px;
+ outline: none;
+ padding: 0 10px;
+ position: relative;
+ text-align: center;
+ text-decoration: none;
+ transition: background 150ms cubic-bezier(0.07, 0.95, 0, 1), border 150ms cubic-bezier(0.07, 0.95, 0, 1);
+ user-select: none;
+ white-space: nowrap; }
+ .button.hidden, .hidden.highlight-button-cancel, .hidden.highlight-button-download, .hidden.highlight-button-copy {
+ display: none; }
+ .button.small, .small.highlight-button-cancel, .small.highlight-button-download, .small.highlight-button-copy {
+ height: 32px;
+ line-height: 32px;
+ padding: 0 8px; }
+ .button.active, .active.highlight-button-cancel, .active.highlight-button-download, .active.highlight-button-copy {
+ background-color: #dedede; }
+ .button.tiny, .tiny.highlight-button-cancel, .tiny.highlight-button-download, .tiny.highlight-button-copy {
+ font-size: 14px;
+ height: 26px;
+ border: 1px solid #c7c7c7; }
+ .button.tiny:hover, .tiny.highlight-button-cancel:hover, .tiny.highlight-button-download:hover, .tiny.highlight-button-copy:hover, .button.tiny:focus, .tiny.highlight-button-cancel:focus, .tiny.highlight-button-download:focus, .tiny.highlight-button-copy:focus {
+ background: #ededf0;
+ border-color: #989898; }
+ .button.tiny:active, .tiny.highlight-button-cancel:active, .tiny.highlight-button-download:active, .tiny.highlight-button-copy:active {
+ background: #dedede;
+ border-color: #989898; }
+ .button.block-button, .block-button.highlight-button-cancel, .block-button.highlight-button-download, .block-button.highlight-button-copy {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-sizing: border-box;
+ border: 0;
+ border-inline-end: 1px solid #c7c7c7;
+ box-shadow: 0;
+ border-radius: 0;
+ flex-shrink: 0;
+ font-size: 20px;
+ height: 100px;
+ line-height: 100%;
+ overflow: hidden; }
+ @media (max-width: 719px) {
+ .button.block-button, .block-button.highlight-button-cancel, .block-button.highlight-button-download, .block-button.highlight-button-copy {
+ justify-content: flex-start;
+ font-size: 16px;
+ height: 72px;
+ margin-inline-end: 10px;
+ padding: 0 5px; } }
+ .button.block-button:hover, .block-button.highlight-button-cancel:hover, .block-button.highlight-button-download:hover, .block-button.highlight-button-copy:hover {
+ background: #ededf0; }
+ .button.block-button:active, .block-button.highlight-button-cancel:active, .block-button.highlight-button-download:active, .block-button.highlight-button-copy:active {
+ background: #dedede; }
+ .button.download, .download.highlight-button-cancel, .download.highlight-button-download, .download.highlight-button-copy, .button.edit, .edit.highlight-button-cancel, .edit.highlight-button-download, .edit.highlight-button-copy, .button.trash, .trash.highlight-button-cancel, .trash.highlight-button-download, .trash.highlight-button-copy, .button.share, .share.highlight-button-cancel, .share.highlight-button-download, .share.highlight-button-copy, .button.flag, .flag.highlight-button-cancel, .flag.highlight-button-download, .flag.highlight-button-copy {
+ background-repeat: no-repeat;
+ background-size: 50%;
+ background-position: center;
+ margin-inline-end: 10px;
+ transition: background-color 150ms cubic-bezier(0.07, 0.95, 0, 1); }
+ .button.download, .download.highlight-button-cancel, .download.highlight-button-download, .download.highlight-button-copy {
+ background-image: url("chrome://browser/content/screenshots/download.svg"); }
+ .button.download:hover, .download.highlight-button-cancel:hover, .download.highlight-button-download:hover, .download.highlight-button-copy:hover {
+ background-color: #ededf0; }
+ .button.download:active, .download.highlight-button-cancel:active, .download.highlight-button-download:active, .download.highlight-button-copy:active {
+ background-color: #dedede; }
+ .button.share, .share.highlight-button-cancel, .share.highlight-button-download, .share.highlight-button-copy {
+ background-image: url("../img/icon-share.svg"); }
+ .button.share:hover, .share.highlight-button-cancel:hover, .share.highlight-button-download:hover, .share.highlight-button-copy:hover {
+ background-color: #ededf0; }
+ .button.share.active, .share.active.highlight-button-cancel, .share.active.highlight-button-download, .share.active.highlight-button-copy, .button.share:active, .share.highlight-button-cancel:active, .share.highlight-button-download:active, .share.highlight-button-copy:active {
+ background-color: #dedede; }
+ .button.share.newicon, .share.newicon.highlight-button-cancel, .share.newicon.highlight-button-download, .share.newicon.highlight-button-copy {
+ background-image: url("../img/icon-share-alternate.svg"); }
+ .button.trash, .trash.highlight-button-cancel, .trash.highlight-button-download, .trash.highlight-button-copy {
+ background-image: url("../img/icon-trash.svg"); }
+ .button.trash:hover, .trash.highlight-button-cancel:hover, .trash.highlight-button-download:hover, .trash.highlight-button-copy:hover {
+ background-color: #ededf0; }
+ .button.trash:active, .trash.highlight-button-cancel:active, .trash.highlight-button-download:active, .trash.highlight-button-copy:active {
+ background-color: #dedede; }
+ .button.edit, .edit.highlight-button-cancel, .edit.highlight-button-download, .edit.highlight-button-copy {
+ background-image: url("../img/icon-edit.svg"); }
+ .button.edit:hover, .edit.highlight-button-cancel:hover, .edit.highlight-button-download:hover, .edit.highlight-button-copy:hover {
+ background-color: #ededf0; }
+ .button.edit:active, .edit.highlight-button-cancel:active, .edit.highlight-button-download:active, .edit.highlight-button-copy:active {
+ background-color: #dedede; }
+
+.app-body {
+ background: #f9f9fa;
+ color: #38383d; }
+ .app-body a {
+ color: #0a84ff; }
+
+.highlight-color-scheme {
+ background: #0a84ff;
+ color: #fff; }
+ .highlight-color-scheme a {
+ color: #fff;
+ text-decoration: underline; }
+
+.alt-color-scheme {
+ background: #38383d;
+ color: #f9f9fa; }
+ .alt-color-scheme h1 {
+ color: #6f7fb6; }
+ .alt-color-scheme a {
+ color: #e1e1e6;
+ text-decoration: underline; }
+
+.button.primary, .primary.highlight-button-cancel, .highlight-button-download, .primary.highlight-button-copy {
+ background-color: #0a84ff;
+ color: #fff; }
+ .button.primary:hover, .primary.highlight-button-cancel:hover, .highlight-button-download:hover, .primary.highlight-button-copy:hover, .button.primary:focus, .primary.highlight-button-cancel:focus, .highlight-button-download:focus, .primary.highlight-button-copy:focus {
+ background-color: #0072e5; }
+ .button.primary:active, .primary.highlight-button-cancel:active, .highlight-button-download:active, .primary.highlight-button-copy:active {
+ background-color: #0065cc; }
+
+.button.secondary, .highlight-button-cancel, .secondary.highlight-button-download, .highlight-button-copy {
+ background-color: #f9f9fa;
+ color: #38383d; }
+ .button.secondary:hover, .highlight-button-cancel:hover, .secondary.highlight-button-download:hover, .highlight-button-copy:hover {
+ background-color: #ededf0; }
+ .button.secondary:active, .highlight-button-cancel:active, .secondary.highlight-button-download:active, .highlight-button-copy:active {
+ background-color: #dedede; }
+
+.button.transparent, .transparent.highlight-button-cancel, .transparent.highlight-button-download, .transparent.highlight-button-copy {
+ background-color: transparent;
+ color: #38383d; }
+ .button.transparent:hover, .transparent.highlight-button-cancel:hover, .transparent.highlight-button-download:hover, .transparent.highlight-button-copy:hover {
+ background-color: #ededf0; }
+ .button.transparent:focus, .transparent.highlight-button-cancel:focus, .transparent.highlight-button-download:focus, .transparent.highlight-button-copy:focus, .button.transparent:active, .transparent.highlight-button-cancel:active, .transparent.highlight-button-download:active, .transparent.highlight-button-copy:active {
+ background-color: #dedede; }
+
+.button.warning, .warning.highlight-button-cancel, .warning.highlight-button-download, .warning.highlight-button-copy {
+ color: #fff;
+ background: #d92215; }
+ .button.warning:hover, .warning.highlight-button-cancel:hover, .warning.highlight-button-download:hover, .warning.highlight-button-copy:hover, .button.warning:focus, .warning.highlight-button-cancel:focus, .warning.highlight-button-download:focus, .warning.highlight-button-copy:focus {
+ background: #b81d12; }
+ .button.warning:active, .warning.highlight-button-cancel:active, .warning.highlight-button-download:active, .warning.highlight-button-copy:active {
+ background: #a11910; }
+
+.subtitle-link {
+ color: #0a84ff; }
+
+.loader {
+ background: rgba(12, 12, 13, 0.2);
+ border-radius: 2px;
+ height: 4px;
+ overflow: hidden;
+ position: relative;
+ width: 200px; }
+
+.loader-inner {
+ animation: bounce infinite alternate 1250ms cubic-bezier(0.7, 0, 0.3, 1);
+ background: #45a1ff;
+ border-radius: 2px;
+ height: 4px;
+ transform: translateX(-40px);
+ width: 50px; }
+
+@keyframes bounce {
+ 0% {
+ transform: translateX(-40px); }
+ 100% {
+ transform: translate(190px); } }
+
+@keyframes fade-in {
+ 0% {
+ opacity: 0; }
+ 100% {
+ opacity: 1; } }
+
+@keyframes pop {
+ 0% {
+ transform: scale(1); }
+ 97% {
+ transform: scale(1.04); }
+ 100% {
+ transform: scale(1); } }
+
+@keyframes pulse {
+ 0% {
+ opacity: 0.3;
+ transform: scale(1); }
+ 70% {
+ opacity: 0.25;
+ transform: scale(1.04); }
+ 100% {
+ opacity: 0.3;
+ transform: scale(1); } }
+
+@keyframes slide-left {
+ 0% {
+ opacity: 0;
+ transform: translate3d(160px, 0, 0); }
+ 100% {
+ opacity: 1;
+ transform: translate3d(0, 0, 0); } }
+
+@keyframes bounce-in {
+ 0% {
+ opacity: 0;
+ transform: scale(1); }
+ 60% {
+ opacity: 1;
+ transform: scale(1.02); }
+ 100% {
+ transform: scale(1); } }
+
+.mover-target {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ pointer-events: auto;
+ position: absolute;
+ z-index: 5; }
+
+.highlight,
+.mover-target {
+ background-color: transparent;
+ background-image: none; }
+
+.mover-target,
+.bghighlight {
+ border: 0; }
+
+.hover-highlight {
+ animation: fade-in 125ms forwards cubic-bezier(0.07, 0.95, 0, 1);
+ background: rgba(255, 255, 255, 0.2);
+ border-radius: 1px;
+ pointer-events: none;
+ position: absolute;
+ z-index: 10000000000; }
+ .hover-highlight::before {
+ border: 2px dashed rgba(255, 255, 255, 0.4);
+ bottom: 0;
+ content: "";
+ inset-inline-start: 0;
+ position: absolute;
+ inset-inline-end: 0;
+ top: 0; }
+ /* When prefers contrast is fully supported, we should change these quereies to cover both high and low prefers contrast cases */
+ @media (forced-colors: active) {
+ .hover-highlight {
+ background-color: white;
+ opacity: 0.2; } }
+
+.mover-target.direction-topLeft {
+ cursor: nwse-resize;
+ height: 60px;
+ left: -30px;
+ top: -30px;
+ width: 60px; }
+
+.mover-target.direction-top {
+ cursor: ns-resize;
+ height: 60px;
+ inset-inline-start: 0;
+ top: -30px;
+ width: 100%;
+ z-index: 4; }
+
+.mover-target.direction-topRight {
+ cursor: nesw-resize;
+ height: 60px;
+ right: -30px;
+ top: -30px;
+ width: 60px; }
+
+.mover-target.direction-left {
+ cursor: ew-resize;
+ height: 100%;
+ left: -30px;
+ top: 0;
+ width: 60px;
+ z-index: 4; }
+
+.mover-target.direction-right {
+ cursor: ew-resize;
+ height: 100%;
+ right: -30px;
+ top: 0;
+ width: 60px;
+ z-index: 4; }
+
+.mover-target.direction-bottomLeft {
+ bottom: -30px;
+ cursor: nesw-resize;
+ height: 60px;
+ left: -30px;
+ width: 60px; }
+
+.mover-target.direction-bottom {
+ bottom: -30px;
+ cursor: ns-resize;
+ height: 60px;
+ inset-inline-start: 0;
+ width: 100%;
+ z-index: 4; }
+
+.mover-target.direction-bottomRight {
+ bottom: -30px;
+ cursor: nwse-resize;
+ height: 60px;
+ right: -30px;
+ width: 60px; }
+
+.mover-target:hover .mover {
+ transform: scale(1.05); }
+
+.mover {
+ background-color: #fff;
+ border-radius: 50%;
+ box-shadow: 0 0 4px rgba(0, 0, 0, 0.5);
+ height: 16px;
+ opacity: 1;
+ position: relative;
+ transition: transform 125ms cubic-bezier(0.07, 0.95, 0, 1);
+ width: 16px; }
+ .small-selection .mover {
+ height: 10px;
+ width: 10px; }
+
+.direction-topLeft .mover,
+.direction-left .mover,
+.direction-bottomLeft .mover {
+ left: -1px; }
+
+.direction-topLeft .mover,
+.direction-top .mover,
+.direction-topRight .mover {
+ top: -1px; }
+
+.direction-topRight .mover,
+.direction-right .mover,
+.direction-bottomRight .mover {
+ right: -1px; }
+
+.direction-bottomRight .mover,
+.direction-bottom .mover,
+.direction-bottomLeft .mover {
+ bottom: -1px; }
+
+.bghighlight {
+ background-color: rgba(0, 0, 0, 0.7);
+ position: absolute;
+ z-index: 9999999999; }
+ /* When prefers contrast is fully supported, we should change these quereies to cover both high and low prefers contrast cases */
+ @media (forced-colors: active) {
+ .bghighlight {
+ background-color: black;
+ opacity: 0.7; } }
+
+.preview-overlay {
+ align-items: center;
+ background-color: rgba(0, 0, 0, 0.7);
+ display: flex;
+ height: 100%;
+ justify-content: center;
+ inset-inline-start: 0;
+ margin: 0;
+ padding: 0;
+ position: fixed;
+ top: 0;
+ width: 100%;
+ z-index: 9999999999; }
+ /* When prefers contrast is fully supported, we should change these quereies to cover both high and low prefers contrast cases */
+ @media (forced-colors: active) {
+ .preview-overlay {
+ background-color: black;
+ opacity: 0.7; } }
+
+.precision-cursor {
+ cursor: crosshair; }
+
+.highlight {
+ border-radius: 1px;
+ border: 2px dashed rgba(255, 255, 255, 0.8);
+ box-sizing: border-box;
+ cursor: move;
+ position: absolute;
+ z-index: 9999999999; }
+ /* When prefers contrast is fully supported, we should change these quereies to cover both high and low prefers contrast cases */
+ @media (forced-colors: active) {
+ .highlight {
+ border: 2px dashed white;
+ opacity: 1.0; } }
+
+.highlight-buttons {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ bottom: -58px;
+ position: absolute;
+ inset-inline-end: 5px;
+ z-index: 6; }
+ .bottom-selection .highlight-buttons {
+ bottom: 5px; }
+ .left-selection .highlight-buttons {
+ inset-inline-end: auto;
+ inset-inline-start: 5px; }
+ .highlight-buttons > button {
+ box-shadow: 0 0 0 1px rgba(12, 12, 13, 0.1), 0 2px 8px rgba(12, 12, 13, 0.1); }
+
+.highlight-button-cancel {
+ margin: 5px;
+ width: 40px; }
+
+.highlight-button-download {
+ margin: 5px;
+ width: auto;
+ font-size: 18px; }
+
+.highlight-button-download img {
+ height: 16px;
+ width: 16px;
+}
+
+.highlight-button-download:-moz-locale-dir(rtl) {
+ flex-direction: reverse;
+}
+
+.highlight-button-download img:-moz-locale-dir(ltr) {
+ padding-inline-end: 8px;
+}
+
+.highlight-button-download img:-moz-locale-dir(rtl) {
+ padding-inline-start: 8px;
+}
+
+.highlight-button-copy {
+ margin: 5px;
+ width: auto; }
+
+.highlight-button-copy img {
+ height: 16px;
+ width: 16px;
+}
+
+.highlight-button-copy:-moz-locale-dir(rtl) {
+ flex-direction: reverse;
+}
+
+.highlight-button-copy img:-moz-locale-dir(ltr) {
+ padding-inline-end: 8px;
+}
+
+.highlight-button-copy img:-moz-locale-dir(rtl) {
+ padding-inline-start: 8px;
+}
+
+.pixel-dimensions {
+ position: absolute;
+ pointer-events: none;
+ font-weight: bold;
+ font-family: -apple-system, BlinkMacSystemFont, "segoe ui", "helvetica neue", helvetica, ubuntu, roboto, noto, arial, sans-serif;
+ font-size: 70%;
+ color: #000;
+ text-shadow: -1px -1px 0 #fff, 1px -1px 0 #fff, -1px 1px 0 #fff, 1px 1px 0 #fff; }
+
+.preview-buttons {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ padding-inline-end: 4px;
+ inset-inline-end: 0;
+ width: 100%;
+ position: absolute;
+ height: 60px;
+ border-radius: 4px 4px 0 0;
+ background: rgba(249, 249, 250, 0.8);
+ top: 0;
+ border: 1px solid rgba(249, 249, 250, 0.2);
+ border-bottom: 0;
+ box-sizing: border-box; }
+
+.preview-image {
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+ justify-content: center;
+ margin: 24px auto;
+ position: relative;
+ max-width: 80%;
+ max-height: 95%;
+ text-align: center;
+ animation-delay: 50ms;
+ display: flex; }
+
+.preview-image-wrapper {
+ background: rgba(249, 249, 250, 0.8);
+ border-radius: 0 0 4px 4px;
+ display: block;
+ height: auto;
+ max-width: 100%;
+ min-width: 320px;
+ overflow-y: scroll;
+ padding: 0 60px;
+ margin-top: 60px;
+ border: 1px solid rgba(249, 249, 250, 0.2);
+ border-top: 0; }
+
+.preview-image-wrapper > img {
+ box-shadow: 0 0 0 1px rgba(12, 12, 13, 0.1), 0 2px 8px rgba(12, 12, 13, 0.1);
+ height: auto;
+ margin-bottom: 60px;
+ max-width: 100%;
+ width: 100%; }
+
+.fixed-container {
+ align-items: center;
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+ justify-content: center;
+ inset-inline-start: 0;
+ margin: 0;
+ padding: 0;
+ pointer-events: none;
+ position: fixed;
+ top: 0;
+ width: 100%; }
+
+.face-container {
+ position: relative;
+ width: 64px;
+ height: 64px; }
+
+.face {
+ width: 62.4px;
+ height: 62.4px;
+ display: block;
+ background-image: url("chrome://browser/content/screenshots/icon-welcome-face-without-eyes.svg"); }
+
+.eye {
+ background-color: #fff;
+ width: 10.8px;
+ height: 14.6px;
+ position: absolute;
+ border-radius: 100%;
+ overflow: hidden;
+ inset-inline-start: 16.4px;
+ top: 19.8px; }
+
+.eyeball {
+ position: absolute;
+ width: 6px;
+ height: 6px;
+ background-color: #000;
+ border-radius: 50%;
+ inset-inline-start: 2.4px;
+ top: 4.3px;
+ z-index: 10; }
+
+.left {
+ margin-inline-start: 0; }
+
+.right {
+ margin-inline-start: 20px; }
+
+.preview-instructions {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ animation: pulse 125mm cubic-bezier(0.07, 0.95, 0, 1);
+ color: #fff;
+ font-family: -apple-system, BlinkMacSystemFont, "segoe ui", "helvetica neue", helvetica, ubuntu, roboto, noto, arial, sans-serif;
+ font-size: 24px;
+ line-height: 32px;
+ text-align: center;
+ padding-top: 20px;
+ width: 400px;
+ user-select: none; }
+
+.cancel-shot {
+ background-color: transparent;
+ cursor: pointer;
+ outline: none;
+ border-radius: 3px;
+ border: 1px #9b9b9b solid;
+ color: #fff;
+ cursor: pointer;
+ font-family: -apple-system, BlinkMacSystemFont, "segoe ui", "helvetica neue", helvetica, ubuntu, roboto, noto, arial, sans-serif;
+ font-size: 16px;
+ margin-top: 40px;
+ padding: 10px 25px;
+ pointer-events: all; }
+
+.all-buttons-container {
+ display: flex;
+ flex-direction: row-reverse;
+ background: #f5f5f5;
+ border-radius: 2px;
+ box-sizing: border-box;
+ height: 80px;
+ padding: 8px;
+ position: absolute;
+ inset-inline-end: 8px;
+ top: 8px;
+ box-shadow: 0 0 0 1px rgba(12, 12, 13, 0.1), 0 2px 8px rgba(12, 12, 13, 0.1); }
+ .all-buttons-container .spacer {
+ background-color: #c9c9c9;
+ flex: 0 0 1px;
+ height: 80px;
+ margin: 0 10px;
+ position: relative;
+ top: -8px; }
+ .all-buttons-container button {
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+ justify-content: flex-end;
+ color: #3e3d40;
+ background-color: #f5f5f5;
+ background-position: center top;
+ background-repeat: no-repeat;
+ background-size: 46px 46px;
+ border: 1px solid transparent;
+ cursor: pointer;
+ height: 100%;
+ min-width: 90px;
+ padding: 46px 5px 5px;
+ pointer-events: all;
+ transition: border 150ms cubic-bezier(0.07, 0.95, 0, 1), background-color 150ms cubic-bezier(0.07, 0.95, 0, 1);
+ white-space: nowrap; }
+ .all-buttons-container button:hover {
+ background-color: #ebebeb;
+ border: 1px solid #c7c7c7; }
+ .all-buttons-container button:active {
+ background-color: #dedede;
+ border: 1px solid #989898; }
+ .all-buttons-container .full-page {
+ background-image: url("chrome://browser/content/screenshots/menu-fullpage.svg"); }
+ .all-buttons-container .visible {
+ background-image: url("chrome://browser/content/screenshots/menu-visible.svg"); }
+
+@keyframes pulse {
+ 0% {
+ transform: scale(1); }
+ 50% {
+ transform: scale(1.06); }
+ 100% {
+ transform: scale(1); } }
+
+@keyframes fade-in {
+ 0% {
+ opacity: 0; }
+ 100% {
+ opacity: 1; } }
+
+`;
+null;
diff --git a/browser/extensions/screenshots/build/selection.js b/browser/extensions/screenshots/build/selection.js
new file mode 100644
index 0000000000..db93dce72b
--- /dev/null
+++ b/browser/extensions/screenshots/build/selection.js
@@ -0,0 +1,126 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+this.selection = (function () {
+ let exports = {};
+ class Selection {
+ constructor(x1, y1, x2, y2) {
+ this.x1 = x1;
+ this.y1 = y1;
+ this.x2 = x2;
+ this.y2 = y2;
+ }
+
+ get top() {
+ return Math.min(this.y1, this.y2);
+ }
+ set top(val) {
+ if (this.y1 < this.y2) {
+ this.y1 = val;
+ } else {
+ this.y2 = val;
+ }
+ }
+
+ get bottom() {
+ return Math.max(this.y1, this.y2);
+ }
+ set bottom(val) {
+ if (this.y1 > this.y2) {
+ this.y1 = val;
+ } else {
+ this.y2 = val;
+ }
+ }
+
+ get left() {
+ return Math.min(this.x1, this.x2);
+ }
+ set left(val) {
+ if (this.x1 < this.x2) {
+ this.x1 = val;
+ } else {
+ this.x2 = val;
+ }
+ }
+
+ get right() {
+ return Math.max(this.x1, this.x2);
+ }
+ set right(val) {
+ if (this.x1 > this.x2) {
+ this.x1 = val;
+ } else {
+ this.x2 = val;
+ }
+ }
+
+ get width() {
+ return Math.abs(this.x2 - this.x1);
+ }
+ get height() {
+ return Math.abs(this.y2 - this.y1);
+ }
+
+ rect() {
+ return {
+ top: Math.floor(this.top),
+ left: Math.floor(this.left),
+ bottom: Math.floor(this.bottom),
+ right: Math.floor(this.right),
+ };
+ }
+
+ union(other) {
+ return new Selection(
+ Math.min(this.left, other.left),
+ Math.min(this.top, other.top),
+ Math.max(this.right, other.right),
+ Math.max(this.bottom, other.bottom)
+ );
+ }
+
+ /** Sort x1/x2 and y1/y2 so x1<x2, y1<y2 */
+ sortCoords() {
+ if (this.x1 > this.x2) {
+ [this.x1, this.x2] = [this.x2, this.x1];
+ }
+ if (this.y1 > this.y2) {
+ [this.y1, this.y2] = [this.y2, this.y1];
+ }
+ }
+
+ clone() {
+ return new Selection(this.x1, this.y1, this.x2, this.y2);
+ }
+
+ toJSON() {
+ return {
+ left: this.left,
+ right: this.right,
+ top: this.top,
+ bottom: this.bottom,
+ };
+ }
+
+ static getBoundingClientRect(el) {
+ if (!el.getBoundingClientRect) {
+ // Typically the <html> element or somesuch
+ return null;
+ }
+ const rect = el.getBoundingClientRect();
+ if (!rect) {
+ return null;
+ }
+ return new Selection(rect.left, rect.top, rect.right, rect.bottom);
+ }
+ }
+
+ if (typeof exports !== "undefined") {
+ exports.Selection = Selection;
+ }
+
+ return exports;
+})();
+null;
diff --git a/browser/extensions/screenshots/build/shot.js b/browser/extensions/screenshots/build/shot.js
new file mode 100644
index 0000000000..7153562de3
--- /dev/null
+++ b/browser/extensions/screenshots/build/shot.js
@@ -0,0 +1,888 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals process, require */
+
+this.shot = (function () {
+ let exports = {}; // Note: in this library we can't use any "system" dependencies because this can be used from multiple
+ // environments
+
+ const isNode =
+ typeof process !== "undefined" &&
+ Object.prototype.toString.call(process) === "[object process]";
+ const URL = (isNode && require("url").URL) || window.URL;
+
+ /** Throws an error if the condition isn't true. Any extra arguments after the condition
+ are used as console.error() arguments. */
+ function assert(condition, ...args) {
+ if (condition) {
+ return;
+ }
+ console.error("Failed assertion", ...args);
+ throw new Error(`Failed assertion: ${args.join(" ")}`);
+ }
+
+ /** True if `url` is a valid URL */
+ function isUrl(url) {
+ try {
+ const parsed = new URL(url);
+
+ if (parsed.protocol === "view-source:") {
+ return isUrl(url.substr("view-source:".length));
+ }
+
+ return true;
+ } catch (e) {
+ return false;
+ }
+ }
+
+ function isValidClipImageUrl(url) {
+ return isUrl(url) && !(url.indexOf(")") > -1);
+ }
+
+ function assertUrl(url) {
+ if (!url) {
+ throw new Error("Empty value is not URL");
+ }
+ if (!isUrl(url)) {
+ const exc = new Error("Not a URL");
+ exc.scheme = url.split(":")[0];
+ throw exc;
+ }
+ }
+
+ function isSecureWebUri(url) {
+ return isUrl(url) && url.toLowerCase().startsWith("https");
+ }
+
+ function assertOrigin(url) {
+ assertUrl(url);
+ if (url.search(/^https?:/i) !== -1) {
+ let newUrl = new URL(url);
+ if (newUrl.pathname != "/") {
+ throw new Error("Bad origin, might include path");
+ }
+ }
+ }
+
+ function originFromUrl(url) {
+ if (!url) {
+ return null;
+ }
+ if (url.search(/^https?:/i) === -1) {
+ // Non-HTTP URLs don't have an origin
+ return null;
+ }
+ try {
+ let tryUrl = new URL(url);
+ return tryUrl.origin;
+ } catch {
+ return null;
+ }
+ }
+
+ /** Check if the given object has all of the required attributes, and no extra
+ attributes exception those in optional */
+ function checkObject(obj, required, optional) {
+ if (typeof obj !== "object" || obj === null) {
+ throw new Error(
+ "Cannot check non-object: " +
+ typeof obj +
+ " that is " +
+ JSON.stringify(obj)
+ );
+ }
+ required = required || [];
+ for (const attr of required) {
+ if (!(attr in obj)) {
+ return false;
+ }
+ }
+ optional = optional || [];
+ for (const attr in obj) {
+ if (!required.includes(attr) && !optional.includes(attr)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /** Create a JSON object from a normal object, given the required and optional
+ attributes (filtering out any other attributes). Optional attributes are
+ only kept when they are truthy. */
+ function jsonify(obj, required, optional) {
+ required = required || [];
+ const result = {};
+ for (const attr of required) {
+ result[attr] = obj[attr];
+ }
+ optional = optional || [];
+ for (const attr of optional) {
+ if (obj[attr]) {
+ result[attr] = obj[attr];
+ }
+ }
+ return result;
+ }
+
+ /** True if the two objects look alike. Null, undefined, and absent properties
+ are all treated as equivalent. Traverses objects and arrays */
+ function deepEqual(a, b) {
+ if ((a === null || a === undefined) && (b === null || b === undefined)) {
+ return true;
+ }
+ if (typeof a !== "object" || typeof b !== "object") {
+ return a === b;
+ }
+ if (Array.isArray(a)) {
+ if (!Array.isArray(b)) {
+ return false;
+ }
+ if (a.length !== b.length) {
+ return false;
+ }
+ for (let i = 0; i < a.length; i++) {
+ if (!deepEqual(a[i], b[i])) {
+ return false;
+ }
+ }
+ }
+ if (Array.isArray(b)) {
+ return false;
+ }
+ const seen = new Set();
+ for (const attr of Object.keys(a)) {
+ if (!deepEqual(a[attr], b[attr])) {
+ return false;
+ }
+ seen.add(attr);
+ }
+ for (const attr of Object.keys(b)) {
+ if (!seen.has(attr)) {
+ if (!deepEqual(a[attr], b[attr])) {
+ return false;
+ }
+ }
+ }
+ return true;
+ }
+
+ function makeRandomId() {
+ // Note: this isn't for secure contexts, only for non-conflicting IDs
+ let id = "";
+ while (id.length < 12) {
+ let num;
+ if (!id) {
+ num = Date.now() % Math.pow(36, 3);
+ } else {
+ num = Math.floor(Math.random() * Math.pow(36, 3));
+ }
+ id += num.toString(36);
+ }
+ return id;
+ }
+
+ class AbstractShot {
+ constructor(backend, id, attrs) {
+ attrs = attrs || {};
+ assert(
+ /^[a-zA-Z0-9]{1,4000}\/[a-z0-9._-]{1,4000}$/.test(id),
+ "Bad ID (should be alphanumeric):",
+ JSON.stringify(id)
+ );
+ this._backend = backend;
+ this._id = id;
+ this.origin = attrs.origin || null;
+ this.fullUrl = attrs.fullUrl || null;
+ if (!attrs.fullUrl && attrs.url) {
+ console.warn("Received deprecated attribute .url");
+ this.fullUrl = attrs.url;
+ }
+ if (this.origin && !isSecureWebUri(this.origin)) {
+ this.origin = "";
+ }
+ if (this.fullUrl && !isSecureWebUri(this.fullUrl)) {
+ this.fullUrl = "";
+ }
+ this.docTitle = attrs.docTitle || null;
+ this.userTitle = attrs.userTitle || null;
+ this.createdDate = attrs.createdDate || Date.now();
+ this.siteName = attrs.siteName || null;
+ this.images = [];
+ if (attrs.images) {
+ this.images = attrs.images.map(json => new this.Image(json));
+ }
+ this.openGraph = attrs.openGraph || null;
+ this.twitterCard = attrs.twitterCard || null;
+ this.documentSize = attrs.documentSize || null;
+ this.thumbnail = attrs.thumbnail || null;
+ this.abTests = attrs.abTests || null;
+ this.firefoxChannel = attrs.firefoxChannel || null;
+ this._clips = {};
+ if (attrs.clips) {
+ for (const clipId in attrs.clips) {
+ const clip = attrs.clips[clipId];
+ this._clips[clipId] = new this.Clip(this, clipId, clip);
+ }
+ }
+
+ const isProd =
+ typeof process !== "undefined" && process.env.NODE_ENV === "production";
+
+ for (const attr in attrs) {
+ if (
+ attr !== "clips" &&
+ attr !== "id" &&
+ !this.REGULAR_ATTRS.includes(attr) &&
+ !this.DEPRECATED_ATTRS.includes(attr)
+ ) {
+ if (isProd) {
+ console.warn("Unexpected attribute: " + attr);
+ } else {
+ throw new Error("Unexpected attribute: " + attr);
+ }
+ } else if (attr === "id") {
+ console.warn("passing id in attrs in AbstractShot constructor");
+ console.trace();
+ assert(attrs.id === this.id);
+ }
+ }
+ }
+
+ /** Update any and all attributes in the json object, with deep updating
+ of `json.clips` */
+ update(json) {
+ const ALL_ATTRS = ["clips"].concat(this.REGULAR_ATTRS);
+ assert(
+ checkObject(json, [], ALL_ATTRS),
+ "Bad attr to new Shot():",
+ Object.keys(json)
+ );
+ for (const attr in json) {
+ if (attr === "clips") {
+ continue;
+ }
+ if (
+ typeof json[attr] === "object" &&
+ typeof this[attr] === "object" &&
+ this[attr] !== null
+ ) {
+ let val = this[attr];
+ if (val.toJSON) {
+ val = val.toJSON();
+ }
+ if (!deepEqual(json[attr], val)) {
+ this[attr] = json[attr];
+ }
+ } else if (json[attr] !== this[attr] && (json[attr] || this[attr])) {
+ this[attr] = json[attr];
+ }
+ }
+ if (json.clips) {
+ for (const clipId in json.clips) {
+ if (!json.clips[clipId]) {
+ this.delClip(clipId);
+ } else if (!this.getClip(clipId)) {
+ this.setClip(clipId, json.clips[clipId]);
+ } else if (
+ !deepEqual(this.getClip(clipId).toJSON(), json.clips[clipId])
+ ) {
+ this.setClip(clipId, json.clips[clipId]);
+ }
+ }
+ }
+ }
+
+ /** Returns a JSON version of this shot */
+ toJSON() {
+ const result = {};
+ for (const attr of this.REGULAR_ATTRS) {
+ let val = this[attr];
+ if (val && val.toJSON) {
+ val = val.toJSON();
+ }
+ result[attr] = val;
+ }
+ result.clips = {};
+ for (const attr in this._clips) {
+ result.clips[attr] = this._clips[attr].toJSON();
+ }
+ return result;
+ }
+
+ /** A more minimal JSON representation for creating indexes of shots */
+ asRecallJson() {
+ const result = { clips: {} };
+ for (const attr of this.RECALL_ATTRS) {
+ let val = this[attr];
+ if (val && val.toJSON) {
+ val = val.toJSON();
+ }
+ result[attr] = val;
+ }
+ for (const name of this.clipNames()) {
+ result.clips[name] = this.getClip(name).toJSON();
+ }
+ return result;
+ }
+
+ get backend() {
+ return this._backend;
+ }
+
+ get id() {
+ return this._id;
+ }
+
+ get url() {
+ return this.fullUrl || this.origin;
+ }
+ set url(val) {
+ throw new Error(".url is read-only");
+ }
+
+ get fullUrl() {
+ return this._fullUrl;
+ }
+ set fullUrl(val) {
+ if (val) {
+ assertUrl(val);
+ }
+ this._fullUrl = val || undefined;
+ }
+
+ get origin() {
+ return this._origin;
+ }
+ set origin(val) {
+ if (val) {
+ assertOrigin(val);
+ }
+ this._origin = val || undefined;
+ }
+
+ get isOwner() {
+ return this._isOwner;
+ }
+
+ set isOwner(val) {
+ this._isOwner = val || undefined;
+ }
+
+ get filename() {
+ let filenameTitle = this.title;
+ const date = new Date(this.createdDate);
+ /* eslint-disable no-control-regex */
+ filenameTitle = filenameTitle
+ .replace(/[\\/]/g, "_")
+ .replace(/[\u200e\u200f\u202a-\u202e]/g, "")
+ .replace(/[\x00-\x1f\x7f-\x9f:*?|"<>;,+=\[\]]+/g, " ")
+ .replace(/^[\s\u180e.]+|[\s\u180e.]+$/g, "");
+ /* eslint-enable no-control-regex */
+ filenameTitle = filenameTitle.replace(/\s{1,4000}/g, " ");
+ const currentDateTime = new Date(
+ date.getTime() - date.getTimezoneOffset() * 60 * 1000
+ ).toISOString();
+ const filenameDate = currentDateTime.substring(0, 10);
+ const filenameTime = currentDateTime.substring(11, 19).replace(/:/g, "-");
+ let clipFilename = `Screenshot ${filenameDate} at ${filenameTime} ${filenameTitle}`;
+
+ // Crop the filename size at less than 246 bytes, so as to leave
+ // room for the extension and an ellipsis [...]. Note that JS
+ // strings are UTF16 but the filename will be converted to UTF8
+ // when saving which could take up more space, and we want a
+ // maximum of 255 bytes (not characters). Here, we iterate
+ // and crop at shorter and shorter points until we fit into
+ // 255 bytes.
+ let suffix = "";
+ for (let cropSize = 246; cropSize >= 0; cropSize -= 32) {
+ if (new Blob([clipFilename]).size > 246) {
+ clipFilename = clipFilename.substring(0, cropSize);
+ suffix = "[...]";
+ } else {
+ break;
+ }
+ }
+
+ clipFilename += suffix;
+
+ const clip = this.getClip(this.clipNames()[0]);
+ let extension = ".png";
+ if (clip && clip.image && clip.image.type) {
+ if (clip.image.type === "jpeg") {
+ extension = ".jpg";
+ }
+ }
+ return clipFilename + extension;
+ }
+
+ get urlDisplay() {
+ if (!this.url) {
+ return null;
+ }
+ if (/^https?:\/\//i.test(this.url)) {
+ let txt = this.url;
+ txt = txt.replace(/^[a-z]{1,4000}:\/\//i, "");
+ txt = txt.replace(/\/.{0,4000}/, "");
+ txt = txt.replace(/^www\./i, "");
+ return txt;
+ } else if (this.url.startsWith("data:")) {
+ return "data:url";
+ }
+ let txt = this.url;
+ txt = txt.replace(/\?.{0,4000}/, "");
+ return txt;
+ }
+
+ get viewUrl() {
+ const url = this.backend + "/" + this.id;
+ return url;
+ }
+
+ get creatingUrl() {
+ let url = `${this.backend}/creating/${this.id}`;
+ url += `?title=${encodeURIComponent(this.title || "")}`;
+ url += `&url=${encodeURIComponent(this.url)}`;
+ return url;
+ }
+
+ get jsonUrl() {
+ return this.backend + "/data/" + this.id;
+ }
+
+ get oembedUrl() {
+ return this.backend + "/oembed?url=" + encodeURIComponent(this.viewUrl);
+ }
+
+ get docTitle() {
+ return this._title;
+ }
+ set docTitle(val) {
+ assert(val === null || typeof val === "string", "Bad docTitle:", val);
+ this._title = val;
+ }
+
+ get openGraph() {
+ return this._openGraph || null;
+ }
+ set openGraph(val) {
+ assert(val === null || typeof val === "object", "Bad openGraph:", val);
+ if (val) {
+ assert(
+ checkObject(val, [], this._OPENGRAPH_PROPERTIES),
+ "Bad attr to openGraph:",
+ Object.keys(val)
+ );
+ this._openGraph = val;
+ } else {
+ this._openGraph = null;
+ }
+ }
+
+ get twitterCard() {
+ return this._twitterCard || null;
+ }
+ set twitterCard(val) {
+ assert(val === null || typeof val === "object", "Bad twitterCard:", val);
+ if (val) {
+ assert(
+ checkObject(val, [], this._TWITTERCARD_PROPERTIES),
+ "Bad attr to twitterCard:",
+ Object.keys(val)
+ );
+ this._twitterCard = val;
+ } else {
+ this._twitterCard = null;
+ }
+ }
+
+ get userTitle() {
+ return this._userTitle;
+ }
+ set userTitle(val) {
+ assert(val === null || typeof val === "string", "Bad userTitle:", val);
+ this._userTitle = val;
+ }
+
+ get title() {
+ // FIXME: we shouldn't support both openGraph.title and ogTitle
+ const ogTitle = this.openGraph && this.openGraph.title;
+ const twitterTitle = this.twitterCard && this.twitterCard.title;
+ let title =
+ this.userTitle || ogTitle || twitterTitle || this.docTitle || this.url;
+ if (Array.isArray(title)) {
+ title = title[0];
+ }
+ if (!title) {
+ title = "Screenshot";
+ }
+ return title;
+ }
+
+ get createdDate() {
+ return this._createdDate;
+ }
+ set createdDate(val) {
+ assert(val === null || typeof val === "number", "Bad createdDate:", val);
+ this._createdDate = val;
+ }
+
+ clipNames() {
+ const names = Object.getOwnPropertyNames(this._clips);
+ names.sort(function (a, b) {
+ return a.sortOrder < b.sortOrder ? 1 : 0;
+ });
+ return names;
+ }
+ getClip(name) {
+ return this._clips[name];
+ }
+ addClip(val) {
+ const name = makeRandomId();
+ this.setClip(name, val);
+ return name;
+ }
+ setClip(name, val) {
+ const clip = new this.Clip(this, name, val);
+ this._clips[name] = clip;
+ }
+ delClip(name) {
+ if (!this._clips[name]) {
+ throw new Error("No existing clip with id: " + name);
+ }
+ delete this._clips[name];
+ }
+ delAllClips() {
+ this._clips = {};
+ }
+ biggestClipSortOrder() {
+ let biggest = 0;
+ for (const clipId in this._clips) {
+ biggest = Math.max(biggest, this._clips[clipId].sortOrder);
+ }
+ return biggest;
+ }
+ updateClipUrl(clipId, clipUrl) {
+ const clip = this.getClip(clipId);
+ if (clip && clip.image) {
+ clip.image.url = clipUrl;
+ } else {
+ console.warn("Tried to update the url of a clip with no image:", clip);
+ }
+ }
+
+ get siteName() {
+ return this._siteName || null;
+ }
+ set siteName(val) {
+ assert(typeof val === "string" || !val);
+ this._siteName = val;
+ }
+
+ get documentSize() {
+ return this._documentSize;
+ }
+ set documentSize(val) {
+ assert(typeof val === "object" || !val);
+ if (val) {
+ assert(
+ checkObject(
+ val,
+ ["height", "width"],
+ "Bad attr to documentSize:",
+ Object.keys(val)
+ )
+ );
+ assert(typeof val.height === "number");
+ assert(typeof val.width === "number");
+ this._documentSize = val;
+ } else {
+ this._documentSize = null;
+ }
+ }
+
+ get thumbnail() {
+ return this._thumbnail;
+ }
+ set thumbnail(val) {
+ assert(typeof val === "string" || !val);
+ if (val) {
+ assert(isUrl(val));
+ this._thumbnail = val;
+ } else {
+ this._thumbnail = null;
+ }
+ }
+
+ get abTests() {
+ return this._abTests;
+ }
+ set abTests(val) {
+ if (val === null || val === undefined) {
+ this._abTests = null;
+ return;
+ }
+ assert(
+ typeof val === "object",
+ "abTests should be an object, not:",
+ typeof val
+ );
+ assert(!Array.isArray(val), "abTests should not be an Array");
+ for (const name in val) {
+ assert(
+ val[name] && typeof val[name] === "string",
+ `abTests.${name} should be a string:`,
+ typeof val[name]
+ );
+ }
+ this._abTests = val;
+ }
+
+ get firefoxChannel() {
+ return this._firefoxChannel;
+ }
+ set firefoxChannel(val) {
+ if (val === null || val === undefined) {
+ this._firefoxChannel = null;
+ return;
+ }
+ assert(
+ typeof val === "string",
+ "firefoxChannel should be a string, not:",
+ typeof val
+ );
+ this._firefoxChannel = val;
+ }
+ }
+
+ AbstractShot.prototype.REGULAR_ATTRS = `
+origin fullUrl docTitle userTitle createdDate images
+siteName openGraph twitterCard documentSize
+thumbnail abTests firefoxChannel
+`.split(/\s+/g);
+
+ // Attributes that will be accepted in the constructor, but ignored/dropped
+ AbstractShot.prototype.DEPRECATED_ATTRS = `
+microdata history ogTitle createdDevice head body htmlAttrs bodyAttrs headAttrs
+readable hashtags comments showPage isPublic resources url
+fullScreenThumbnail favicon
+`.split(/\s+/g);
+
+ AbstractShot.prototype.RECALL_ATTRS = `
+url docTitle userTitle createdDate openGraph twitterCard images thumbnail
+`.split(/\s+/g);
+
+ AbstractShot.prototype._OPENGRAPH_PROPERTIES = `
+title type url image audio description determiner locale site_name video
+image:secure_url image:type image:width image:height
+video:secure_url video:type video:width image:height
+audio:secure_url audio:type
+article:published_time article:modified_time article:expiration_time article:author article:section article:tag
+book:author book:isbn book:release_date book:tag
+profile:first_name profile:last_name profile:username profile:gender
+`.split(/\s+/g);
+
+ AbstractShot.prototype._TWITTERCARD_PROPERTIES = `
+card site title description image
+player player:width player:height player:stream player:stream:content_type
+`.split(/\s+/g);
+
+ /** Represents one found image in the document (not a clip) */
+ class _Image {
+ // FIXME: either we have to notify the shot of updates, or make
+ // this read-only
+ constructor(json) {
+ assert(typeof json === "object", "Clip Image given a non-object", json);
+ assert(
+ checkObject(json, ["url"], ["dimensions", "title", "alt"]),
+ "Bad attrs for Image:",
+ Object.keys(json)
+ );
+ assert(isUrl(json.url), "Bad Image url:", json.url);
+ this.url = json.url;
+ assert(
+ !json.dimensions ||
+ (typeof json.dimensions.x === "number" &&
+ typeof json.dimensions.y === "number"),
+ "Bad Image dimensions:",
+ json.dimensions
+ );
+ this.dimensions = json.dimensions;
+ assert(
+ typeof json.title === "string" || !json.title,
+ "Bad Image title:",
+ json.title
+ );
+ this.title = json.title;
+ assert(
+ typeof json.alt === "string" || !json.alt,
+ "Bad Image alt:",
+ json.alt
+ );
+ this.alt = json.alt;
+ }
+
+ toJSON() {
+ return jsonify(this, ["url"], ["dimensions"]);
+ }
+ }
+
+ AbstractShot.prototype.Image = _Image;
+
+ /** Represents a clip, either a text or image clip */
+ class _Clip {
+ constructor(shot, id, json) {
+ this._shot = shot;
+ assert(
+ checkObject(json, ["createdDate", "image"], ["sortOrder"]),
+ "Bad attrs for Clip:",
+ Object.keys(json)
+ );
+ assert(typeof id === "string" && id, "Bad Clip id:", id);
+ this._id = id;
+ this.createdDate = json.createdDate;
+ if ("sortOrder" in json) {
+ assert(
+ typeof json.sortOrder === "number" || !json.sortOrder,
+ "Bad Clip sortOrder:",
+ json.sortOrder
+ );
+ }
+ if ("sortOrder" in json) {
+ this.sortOrder = json.sortOrder;
+ } else {
+ const biggestOrder = shot.biggestClipSortOrder();
+ this.sortOrder = biggestOrder + 100;
+ }
+ this.image = json.image;
+ }
+
+ toString() {
+ return `[Shot Clip id=${this.id} sortOrder=${this.sortOrder} image ${this.image.dimensions.x}x${this.image.dimensions.y}]`;
+ }
+
+ toJSON() {
+ return jsonify(this, ["createdDate"], ["sortOrder", "image"]);
+ }
+
+ get id() {
+ return this._id;
+ }
+
+ get createdDate() {
+ return this._createdDate;
+ }
+ set createdDate(val) {
+ assert(typeof val === "number" || !val, "Bad Clip createdDate:", val);
+ this._createdDate = val;
+ }
+
+ get image() {
+ return this._image;
+ }
+ set image(image) {
+ if (!image) {
+ this._image = undefined;
+ return;
+ }
+ assert(
+ checkObject(
+ image,
+ ["url"],
+ ["dimensions", "text", "location", "captureType", "type"]
+ ),
+ "Bad attrs for Clip Image:",
+ Object.keys(image)
+ );
+ assert(isValidClipImageUrl(image.url), "Bad Clip image URL:", image.url);
+ assert(
+ image.captureType === "madeSelection" ||
+ image.captureType === "selection" ||
+ image.captureType === "visible" ||
+ image.captureType === "auto" ||
+ image.captureType === "fullPage" ||
+ image.captureType === "fullPageTruncated" ||
+ !image.captureType,
+ "Bad image.captureType:",
+ image.captureType
+ );
+ assert(
+ typeof image.text === "string" || !image.text,
+ "Bad Clip image text:",
+ image.text
+ );
+ if (image.dimensions) {
+ assert(
+ typeof image.dimensions.x === "number" &&
+ typeof image.dimensions.y === "number",
+ "Bad Clip image dimensions:",
+ image.dimensions
+ );
+ }
+ if (image.type) {
+ assert(
+ image.type === "png" || image.type === "jpeg",
+ "Unexpected image type:",
+ image.type
+ );
+ }
+ assert(
+ image.location &&
+ typeof image.location.left === "number" &&
+ typeof image.location.right === "number" &&
+ typeof image.location.top === "number" &&
+ typeof image.location.bottom === "number",
+ "Bad Clip image pixel location:",
+ image.location
+ );
+ if (
+ image.location.topLeftElement ||
+ image.location.topLeftOffset ||
+ image.location.bottomRightElement ||
+ image.location.bottomRightOffset
+ ) {
+ assert(
+ typeof image.location.topLeftElement === "string" &&
+ image.location.topLeftOffset &&
+ typeof image.location.topLeftOffset.x === "number" &&
+ typeof image.location.topLeftOffset.y === "number" &&
+ typeof image.location.bottomRightElement === "string" &&
+ image.location.bottomRightOffset &&
+ typeof image.location.bottomRightOffset.x === "number" &&
+ typeof image.location.bottomRightOffset.y === "number",
+ "Bad Clip image element location:",
+ image.location
+ );
+ }
+ this._image = image;
+ }
+
+ isDataUrl() {
+ if (this.image) {
+ return this.image.url.startsWith("data:");
+ }
+ return false;
+ }
+
+ get sortOrder() {
+ return this._sortOrder || null;
+ }
+ set sortOrder(val) {
+ assert(typeof val === "number" || !val, "Bad Clip sortOrder:", val);
+ this._sortOrder = val;
+ }
+ }
+
+ AbstractShot.prototype.Clip = _Clip;
+
+ if (typeof exports !== "undefined") {
+ exports.AbstractShot = AbstractShot;
+ exports.originFromUrl = originFromUrl;
+ exports.isValidClipImageUrl = isValidClipImageUrl;
+ }
+
+ return exports;
+})();
+null;
diff --git a/browser/extensions/screenshots/build/thumbnailGenerator.js b/browser/extensions/screenshots/build/thumbnailGenerator.js
new file mode 100644
index 0000000000..c80ccb6bac
--- /dev/null
+++ b/browser/extensions/screenshots/build/thumbnailGenerator.js
@@ -0,0 +1,190 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+this.thumbnailGenerator = (function () {
+ let exports = {}; // This is used in webextension/background/takeshot.js,
+ // server/src/pages/shot/controller.js, and
+ // server/scr/pages/shotindex/view.js. It is used in a browser
+ // environment.
+
+ // Resize down 1/2 at a time produces better image quality.
+ // Not quite as good as using a third-party filter (which will be
+ // slower), but good enough.
+ const maxResizeScaleFactor = 0.5;
+
+ // The shot will be scaled or cropped down to 210px on x, and cropped or
+ // scaled down to a maximum of 280px on y.
+ // x: 210
+ // y: <= 280
+ const maxThumbnailWidth = 210;
+ const maxThumbnailHeight = 280;
+
+ /**
+ * @param {int} imageHeight Height in pixels of the original image.
+ * @param {int} imageWidth Width in pixels of the original image.
+ * @returns {width, height, scaledX, scaledY}
+ */
+ function getThumbnailDimensions(imageWidth, imageHeight) {
+ const displayAspectRatio = 3 / 4;
+ const imageAspectRatio = imageWidth / imageHeight;
+ let thumbnailImageWidth, thumbnailImageHeight;
+ let scaledX, scaledY;
+
+ if (imageAspectRatio > displayAspectRatio) {
+ // "Landscape" mode
+ // Scale on y, crop on x
+ const yScaleFactor =
+ imageHeight > maxThumbnailHeight
+ ? maxThumbnailHeight / imageHeight
+ : 1.0;
+ thumbnailImageHeight = scaledY = Math.round(imageHeight * yScaleFactor);
+ scaledX = Math.round(imageWidth * yScaleFactor);
+ thumbnailImageWidth = Math.min(scaledX, maxThumbnailWidth);
+ } else {
+ // "Portrait" mode
+ // Scale on x, crop on y
+ const xScaleFactor =
+ imageWidth > maxThumbnailWidth ? maxThumbnailWidth / imageWidth : 1.0;
+ thumbnailImageWidth = scaledX = Math.round(imageWidth * xScaleFactor);
+ scaledY = Math.round(imageHeight * xScaleFactor);
+ // The CSS could widen the image, in which case we crop more off of y.
+ thumbnailImageHeight = Math.min(
+ scaledY,
+ maxThumbnailHeight,
+ maxThumbnailHeight / (maxThumbnailWidth / imageWidth)
+ );
+ }
+
+ return {
+ width: thumbnailImageWidth,
+ height: thumbnailImageHeight,
+ scaledX,
+ scaledY,
+ };
+ }
+
+ /**
+ * @param {dataUrl} String Data URL of the original image.
+ * @param {int} imageHeight Height in pixels of the original image.
+ * @param {int} imageWidth Width in pixels of the original image.
+ * @param {String} urlOrBlob 'blob' for a blob, otherwise data url.
+ * @returns A promise that resolves to the data URL or blob of the thumbnail image, or null.
+ */
+ function createThumbnail(dataUrl, imageWidth, imageHeight, urlOrBlob) {
+ // There's cost associated with generating, transmitting, and storing
+ // thumbnails, so we'll opt out if the image size is below a certain threshold
+ const thumbnailThresholdFactor = 1.2;
+ const thumbnailWidthThreshold =
+ maxThumbnailWidth * thumbnailThresholdFactor;
+ const thumbnailHeightThreshold =
+ maxThumbnailHeight * thumbnailThresholdFactor;
+
+ if (
+ imageWidth <= thumbnailWidthThreshold &&
+ imageHeight <= thumbnailHeightThreshold
+ ) {
+ // Do not create a thumbnail.
+ return Promise.resolve(null);
+ }
+
+ const thumbnailDimensions = getThumbnailDimensions(imageWidth, imageHeight);
+
+ return new Promise((resolve, reject) => {
+ const thumbnailImage = new Image();
+ let srcWidth = imageWidth;
+ let srcHeight = imageHeight;
+ let destWidth, destHeight;
+
+ thumbnailImage.onload = function () {
+ destWidth = Math.round(srcWidth * maxResizeScaleFactor);
+ destHeight = Math.round(srcHeight * maxResizeScaleFactor);
+ if (
+ destWidth <= thumbnailDimensions.scaledX ||
+ destHeight <= thumbnailDimensions.scaledY
+ ) {
+ srcWidth = Math.round(
+ srcWidth * (thumbnailDimensions.width / thumbnailDimensions.scaledX)
+ );
+ srcHeight = Math.round(
+ srcHeight *
+ (thumbnailDimensions.height / thumbnailDimensions.scaledY)
+ );
+ destWidth = thumbnailDimensions.width;
+ destHeight = thumbnailDimensions.height;
+ }
+
+ const thumbnailCanvas = document.createElement("canvas");
+ thumbnailCanvas.width = destWidth;
+ thumbnailCanvas.height = destHeight;
+ const ctx = thumbnailCanvas.getContext("2d");
+ ctx.imageSmoothingEnabled = false;
+
+ ctx.drawImage(
+ thumbnailImage,
+ 0,
+ 0,
+ srcWidth,
+ srcHeight,
+ 0,
+ 0,
+ destWidth,
+ destHeight
+ );
+
+ if (
+ thumbnailCanvas.width <= thumbnailDimensions.width ||
+ thumbnailCanvas.height <= thumbnailDimensions.height
+ ) {
+ if (urlOrBlob === "blob") {
+ thumbnailCanvas.toBlob(blob => {
+ resolve(blob);
+ });
+ } else {
+ resolve(thumbnailCanvas.toDataURL("image/png"));
+ }
+ return;
+ }
+
+ srcWidth = destWidth;
+ srcHeight = destHeight;
+ thumbnailImage.src = thumbnailCanvas.toDataURL();
+ };
+ thumbnailImage.src = dataUrl;
+ });
+ }
+
+ function createThumbnailUrl(shot) {
+ const image = shot.getClip(shot.clipNames()[0]).image;
+ if (!image.url) {
+ return Promise.resolve(null);
+ }
+ return createThumbnail(
+ image.url,
+ image.dimensions.x,
+ image.dimensions.y,
+ "dataurl"
+ );
+ }
+
+ function createThumbnailBlobFromPromise(shot, blobToUrlPromise) {
+ return blobToUrlPromise.then(dataUrl => {
+ const image = shot.getClip(shot.clipNames()[0]).image;
+ return createThumbnail(
+ dataUrl,
+ image.dimensions.x,
+ image.dimensions.y,
+ "blob"
+ );
+ });
+ }
+
+ if (typeof exports !== "undefined") {
+ exports.getThumbnailDimensions = getThumbnailDimensions;
+ exports.createThumbnailUrl = createThumbnailUrl;
+ exports.createThumbnailBlobFromPromise = createThumbnailBlobFromPromise;
+ }
+
+ return exports;
+})();
+null;
diff --git a/browser/extensions/screenshots/catcher.js b/browser/extensions/screenshots/catcher.js
new file mode 100644
index 0000000000..1644f86ca0
--- /dev/null
+++ b/browser/extensions/screenshots/catcher.js
@@ -0,0 +1,101 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// eslint-disable-next-line no-var
+var global = this;
+
+this.catcher = (function () {
+ const exports = {};
+
+ let handler;
+
+ let queue = [];
+
+ const log = global.log;
+
+ exports.unhandled = function (error, info) {
+ if (!error.noReport) {
+ log.error("Unhandled error:", error, info);
+ }
+ const e = makeError(error, info);
+ if (!handler) {
+ queue.push(e);
+ } else {
+ handler(e);
+ }
+ };
+
+ /** Turn an exception into an error object */
+ function makeError(exc, info) {
+ let result;
+ if (exc.fromMakeError) {
+ result = exc;
+ } else {
+ result = {
+ fromMakeError: true,
+ name: exc.name || "ERROR",
+ message: String(exc),
+ stack: exc.stack,
+ };
+ for (const attr in exc) {
+ result[attr] = exc[attr];
+ }
+ }
+ if (info) {
+ for (const attr of Object.keys(info)) {
+ result[attr] = info[attr];
+ }
+ }
+ return result;
+ }
+
+ /** Wrap the function, and if it raises any exceptions then call unhandled() */
+ exports.watchFunction = function watchFunction(func, quiet) {
+ return function () {
+ try {
+ return func.apply(this, arguments);
+ } catch (e) {
+ if (!quiet) {
+ exports.unhandled(e);
+ }
+ throw e;
+ }
+ };
+ };
+
+ exports.watchPromise = function watchPromise(promise, quiet) {
+ return promise.catch(e => {
+ if (quiet) {
+ if (!e.noReport) {
+ log.debug("------Error in promise:", e);
+ log.debug(e.stack);
+ }
+ } else {
+ if (!e.noReport) {
+ log.error("------Error in promise:", e);
+ log.error(e.stack);
+ }
+ exports.unhandled(makeError(e));
+ }
+ throw e;
+ });
+ };
+
+ exports.registerHandler = function (h) {
+ if (handler) {
+ log.error("registerHandler called after handler was already registered");
+ return;
+ }
+ handler = h;
+ for (const error of queue) {
+ handler(error);
+ }
+ queue = [];
+ };
+
+ return exports;
+})();
+null;
diff --git a/browser/extensions/screenshots/clipboard.js b/browser/extensions/screenshots/clipboard.js
new file mode 100644
index 0000000000..d4dbc38a14
--- /dev/null
+++ b/browser/extensions/screenshots/clipboard.js
@@ -0,0 +1,64 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals catcher, assertIsBlankDocument, browser */
+
+"use strict";
+
+this.clipboard = (function () {
+ const exports = {};
+
+ exports.copy = function (text) {
+ return new Promise((resolve, reject) => {
+ const element = document.createElement("iframe");
+ element.src = browser.runtime.getURL("blank.html");
+ // We can't actually hide the iframe while copying, but we can make
+ // it close to invisible:
+ element.style.opacity = "0";
+ element.style.width = "1px";
+ element.style.height = "1px";
+ element.style.display = "block";
+ element.addEventListener(
+ "load",
+ catcher.watchFunction(() => {
+ try {
+ const doc = element.contentDocument;
+ assertIsBlankDocument(doc);
+ const el = doc.createElement("textarea");
+ doc.body.appendChild(el);
+ el.value = text;
+ if (!text) {
+ const exc = new Error("Clipboard copy given empty text");
+ exc.noPopup = true;
+ catcher.unhandled(exc);
+ }
+ el.select();
+ if (doc.activeElement !== el) {
+ const unhandledTag = doc.activeElement
+ ? doc.activeElement.tagName
+ : "No active element";
+ const exc = new Error("Clipboard el.select failed");
+ exc.activeElement = unhandledTag;
+ exc.noPopup = true;
+ catcher.unhandled(exc);
+ }
+ const copied = doc.execCommand("copy");
+ if (!copied) {
+ catcher.unhandled(new Error("Clipboard copy failed"));
+ }
+ el.remove();
+ resolve(copied);
+ } finally {
+ element.remove();
+ }
+ }),
+ { once: true }
+ );
+ document.body.appendChild(element);
+ });
+ };
+
+ return exports;
+})();
+null;
diff --git a/browser/extensions/screenshots/domainFromUrl.js b/browser/extensions/screenshots/domainFromUrl.js
new file mode 100644
index 0000000000..b610007a34
--- /dev/null
+++ b/browser/extensions/screenshots/domainFromUrl.js
@@ -0,0 +1,32 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/** Returns the domain of a URL, but safely and in ASCII; URLs without domains
+ (such as about:blank) return the scheme, Unicode domains get stripped down
+ to ASCII */
+
+"use strict";
+
+this.domainFromUrl = (function () {
+ return function urlDomainForId(location) {
+ // eslint-disable-line no-unused-vars
+ let domain = location.hostname;
+ if (!domain) {
+ domain = location.origin.split(":")[0];
+ if (!domain) {
+ domain = "unknown";
+ }
+ }
+ if (domain.search(/^[a-z0-9._-]{1,1000}$/i) === -1) {
+ // Probably a unicode domain; we could use punycode but it wouldn't decode
+ // well in the URL anyway. Instead we'll punt.
+ domain = domain.replace(/[^a-z0-9._-]/gi, "");
+ if (!domain) {
+ domain = "site";
+ }
+ }
+ return domain;
+ };
+})();
+null;
diff --git a/browser/extensions/screenshots/experiments/screenshots/api.js b/browser/extensions/screenshots/experiments/screenshots/api.js
new file mode 100644
index 0000000000..d3e60aee77
--- /dev/null
+++ b/browser/extensions/screenshots/experiments/screenshots/api.js
@@ -0,0 +1,56 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals browser, AppConstants, Services, ExtensionAPI, ExtensionCommon */
+
+"use strict";
+
+const TOPIC = "menuitem-screenshot-extension";
+
+this.screenshots = class extends ExtensionAPI {
+ getAPI(context) {
+ let EventManager = ExtensionCommon.EventManager;
+
+ return {
+ experiments: {
+ screenshots: {
+ // If you are checking for 'nightly', also check for 'nightly-try'.
+ //
+ // Otherwise, just use the standard builds, but be aware of the many
+ // non-standard options that also exist (as of August 2018).
+ //
+ // Standard builds:
+ // 'esr' - ESR channel
+ // 'release' - release channel
+ // 'beta' - beta channel
+ // 'nightly' - nightly channel
+ // Non-standard / deprecated builds:
+ // 'aurora' - deprecated aurora channel (still observed in dxr)
+ // 'default' - local builds from source
+ // 'nightly-try' - nightly Try builds (QA may occasionally need to test with these)
+ getUpdateChannel() {
+ return AppConstants.MOZ_UPDATE_CHANNEL;
+ },
+ isHistoryEnabled() {
+ return Services.prefs.getBoolPref("places.history.enabled", true);
+ },
+ onScreenshotCommand: new EventManager({
+ context,
+ name: "experiments.screenshots.onScreenshotCommand",
+ register: fire => {
+ let observer = (subject, topic, data) => {
+ let type = data;
+ fire.sync(type);
+ };
+ Services.obs.addObserver(observer, TOPIC);
+ return () => {
+ Services.obs.removeObserver(observer, TOPIC);
+ };
+ },
+ }).api(),
+ },
+ },
+ };
+ }
+};
diff --git a/browser/extensions/screenshots/experiments/screenshots/schema.json b/browser/extensions/screenshots/experiments/screenshots/schema.json
new file mode 100644
index 0000000000..354af8c722
--- /dev/null
+++ b/browser/extensions/screenshots/experiments/screenshots/schema.json
@@ -0,0 +1,35 @@
+[
+ {
+ "namespace": "experiments.screenshots",
+ "description": "Firefox Screenshots internal API",
+ "functions": [
+ {
+ "name": "getUpdateChannel",
+ "type": "function",
+ "description": "Returns the Firefox channel (AppConstants.MOZ_UPDATE_CHANNEL)",
+ "parameters": [],
+ "async": true
+ },
+ {
+ "name": "isHistoryEnabled",
+ "type": "function",
+ "description": "Returns the value of the 'places.history.enabled' preference",
+ "parameters": [],
+ "async": true
+ }
+ ],
+ "events": [
+ {
+ "name": "onScreenshotCommand",
+ "type": "function",
+ "description": "Fired when the command event for the Screenshots menuitem is fired.",
+ "parameters": [
+ {
+ "name": "isContextMenuClick",
+ "type": "boolean"
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/browser/extensions/screenshots/log.js b/browser/extensions/screenshots/log.js
new file mode 100644
index 0000000000..585633486d
--- /dev/null
+++ b/browser/extensions/screenshots/log.js
@@ -0,0 +1,50 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals buildSettings */
+/* eslint-disable no-console */
+
+"use strict";
+
+this.log = (function () {
+ const exports = {};
+ const logLevel = "warn";
+
+ const levels = ["debug", "info", "warn", "error"];
+ const shouldLog = {};
+
+ {
+ let startLogging = false;
+ for (const level of levels) {
+ if (logLevel === level) {
+ startLogging = true;
+ }
+ if (startLogging) {
+ shouldLog[level] = true;
+ }
+ }
+ }
+
+ function stub() {}
+ exports.debug = exports.info = exports.warn = exports.error = stub;
+
+ if (shouldLog.debug) {
+ exports.debug = console.debug;
+ }
+
+ if (shouldLog.info) {
+ exports.info = console.info;
+ }
+
+ if (shouldLog.warn) {
+ exports.warn = console.warn;
+ }
+
+ if (shouldLog.error) {
+ exports.error = console.error;
+ }
+
+ return exports;
+})();
+null;
diff --git a/browser/extensions/screenshots/manifest.json b/browser/extensions/screenshots/manifest.json
new file mode 100644
index 0000000000..d5b164f058
--- /dev/null
+++ b/browser/extensions/screenshots/manifest.json
@@ -0,0 +1,56 @@
+{
+ "manifest_version": 2,
+ "name": "Firefox Screenshots",
+ "version": "39.0.1",
+ "description": "Take clips and screenshots from the Web and save them temporarily or permanently.",
+ "author": "Mozilla <screenshots-feedback@mozilla.com>",
+ "homepage_url": "https://github.com/mozilla-services/screenshots",
+ "incognito": "spanning",
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "screenshots@mozilla.org",
+ "strict_min_version": "57.0a1"
+ }
+ },
+ "l10n_resources": ["browser/screenshots.ftl"],
+ "background": {
+ "scripts": ["background/startBackground.js"]
+ },
+ "content_scripts": [
+ {
+ "matches": ["https://screenshots.firefox.com/*"],
+ "js": [
+ "log.js",
+ "catcher.js",
+ "selector/callBackground.js",
+ "sitehelper.js"
+ ]
+ }
+ ],
+ "web_accessible_resources": ["blank.html"],
+ "permissions": [
+ "activeTab",
+ "downloads",
+ "tabs",
+ "storage",
+ "notifications",
+ "clipboardWrite",
+ "contextMenus",
+ "mozillaAddons",
+ "telemetry",
+ "<all_urls>",
+ "https://screenshots.firefox.com/",
+ "resource://pdf.js/",
+ "about:reader*"
+ ],
+ "experiment_apis": {
+ "screenshots": {
+ "schema": "experiments/screenshots/schema.json",
+ "parent": {
+ "scopes": ["addon_parent"],
+ "script": "experiments/screenshots/api.js",
+ "paths": [["experiments", "screenshots"]]
+ }
+ }
+ }
+}
diff --git a/browser/extensions/screenshots/moz.build b/browser/extensions/screenshots/moz.build
new file mode 100644
index 0000000000..f71d5b2ac6
--- /dev/null
+++ b/browser/extensions/screenshots/moz.build
@@ -0,0 +1,60 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+with Files("**"):
+ BUG_COMPONENT = ("Firefox", "Screenshots")
+
+# This file list is automatically generated by Screenshots' export scripts.
+# AUTOMATIC INSERTION START
+FINAL_TARGET_FILES.features["screenshots@mozilla.org"] += [
+ "assertIsBlankDocument.js",
+ "assertIsTrusted.js",
+ "blank.html",
+ "blobConverters.js",
+ "catcher.js",
+ "clipboard.js",
+ "domainFromUrl.js",
+ "log.js",
+ "manifest.json",
+ "moz.build",
+ "randomString.js",
+ "sitehelper.js",
+]
+
+FINAL_TARGET_FILES.features["screenshots@mozilla.org"]["background"] += [
+ "background/analytics.js",
+ "background/communication.js",
+ "background/deviceInfo.js",
+ "background/main.js",
+ "background/selectorLoader.js",
+ "background/senderror.js",
+ "background/startBackground.js",
+ "background/takeshot.js",
+]
+
+FINAL_TARGET_FILES.features["screenshots@mozilla.org"]["build"] += [
+ "build/inlineSelectionCss.js",
+ "build/selection.js",
+ "build/shot.js",
+ "build/thumbnailGenerator.js",
+]
+
+FINAL_TARGET_FILES.features["screenshots@mozilla.org"]["experiments"][
+ "screenshots"
+] += ["experiments/screenshots/api.js", "experiments/screenshots/schema.json"]
+
+FINAL_TARGET_FILES.features["screenshots@mozilla.org"]["selector"] += [
+ "selector/callBackground.js",
+ "selector/documentMetadata.js",
+ "selector/shooter.js",
+ "selector/ui.js",
+ "selector/uicontrol.js",
+ "selector/util.js",
+]
+
+# AUTOMATIC INSERTION END
+
+BROWSER_CHROME_MANIFESTS += ["test/browser/browser.ini"]
diff --git a/browser/extensions/screenshots/randomString.js b/browser/extensions/screenshots/randomString.js
new file mode 100644
index 0000000000..8ab27c73af
--- /dev/null
+++ b/browser/extensions/screenshots/randomString.js
@@ -0,0 +1,19 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* exported randomString */
+
+"use strict";
+
+this.randomString = function randomString(length, chars) {
+ const randomStringChars =
+ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
+ chars = chars || randomStringChars;
+ let result = "";
+ for (let i = 0; i < length; i++) {
+ result += chars[Math.floor(Math.random() * chars.length)];
+ }
+ return result;
+};
+null;
diff --git a/browser/extensions/screenshots/selector/callBackground.js b/browser/extensions/screenshots/selector/callBackground.js
new file mode 100644
index 0000000000..d92a6ace17
--- /dev/null
+++ b/browser/extensions/screenshots/selector/callBackground.js
@@ -0,0 +1,35 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals log, browser */
+
+"use strict";
+
+this.callBackground = function callBackground(funcName, ...args) {
+ return browser.runtime.sendMessage({ funcName, args }).then(result => {
+ if (result && result.type === "success") {
+ return result.value;
+ } else if (result && result.type === "error") {
+ const exc = new Error(result.message || "Unknown error");
+ exc.name = "BackgroundError";
+ if ("errorCode" in result) {
+ exc.errorCode = result.errorCode;
+ }
+ if ("popupMessage" in result) {
+ exc.popupMessage = result.popupMessage;
+ }
+ throw exc;
+ } else {
+ log.error("Unexpected background result:", result);
+ const exc = new Error(
+ `Bad response type from background page: ${
+ (result && result.type) || undefined
+ }`
+ );
+ exc.resultType = result ? result.type || "undefined" : "undefined result";
+ throw exc;
+ }
+ });
+};
+null;
diff --git a/browser/extensions/screenshots/selector/documentMetadata.js b/browser/extensions/screenshots/selector/documentMetadata.js
new file mode 100644
index 0000000000..fe75b2bbae
--- /dev/null
+++ b/browser/extensions/screenshots/selector/documentMetadata.js
@@ -0,0 +1,93 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+this.documentMetadata = (function () {
+ function findSiteName() {
+ let el = document.querySelector("meta[property~='og:site_name'][content]");
+ if (el) {
+ return el.getAttribute("content");
+ }
+ // nytimes.com uses this property:
+ el = document.querySelector("meta[name='cre'][content]");
+ if (el) {
+ return el.getAttribute("content");
+ }
+ return null;
+ }
+
+ function getOpenGraph() {
+ const openGraph = {};
+ // If you update this, also update _OPENGRAPH_PROPERTIES in shot.js:
+ const forceSingle = `title type url`.split(" ");
+ const openGraphProperties = `
+ title type url image audio description determiner locale site_name video
+ image:secure_url image:type image:width image:height
+ video:secure_url video:type video:width image:height
+ audio:secure_url audio:type
+ article:published_time article:modified_time article:expiration_time article:author article:section article:tag
+ book:author book:isbn book:release_date book:tag
+ profile:first_name profile:last_name profile:username profile:gender
+ `.split(/\s+/g);
+ for (const prop of openGraphProperties) {
+ let elems = document.querySelectorAll(
+ `meta[property~='og:${prop}'][content]`
+ );
+ if (forceSingle.includes(prop) && elems.length > 1) {
+ elems = [elems[0]];
+ }
+ let value;
+ if (elems.length > 1) {
+ value = [];
+ for (const elem of elems) {
+ const v = elem.getAttribute("content");
+ if (v) {
+ value.push(v);
+ }
+ }
+ if (!value.length) {
+ value = null;
+ }
+ } else if (elems.length === 1) {
+ value = elems[0].getAttribute("content");
+ }
+ if (value) {
+ openGraph[prop] = value;
+ }
+ }
+ return openGraph;
+ }
+
+ function getTwitterCard() {
+ const twitterCard = {};
+ // If you update this, also update _TWITTERCARD_PROPERTIES in shot.js:
+ const properties = `
+ card site title description image
+ player player:width player:height player:stream player:stream:content_type
+ `.split(/\s+/g);
+ for (const prop of properties) {
+ const elem = document.querySelector(
+ `meta[name='twitter:${prop}'][content]`
+ );
+ if (elem) {
+ const value = elem.getAttribute("content");
+ if (value) {
+ twitterCard[prop] = value;
+ }
+ }
+ }
+ return twitterCard;
+ }
+
+ return function documentMetadata() {
+ const result = {};
+ result.docTitle = document.title;
+ result.siteName = findSiteName();
+ result.openGraph = getOpenGraph();
+ result.twitterCard = getTwitterCard();
+ return result;
+ };
+})();
+null;
diff --git a/browser/extensions/screenshots/selector/shooter.js b/browser/extensions/screenshots/selector/shooter.js
new file mode 100644
index 0000000000..da9f97e7cd
--- /dev/null
+++ b/browser/extensions/screenshots/selector/shooter.js
@@ -0,0 +1,163 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals global, browser, documentMetadata, util, uicontrol, ui, catcher */
+/* globals domainFromUrl, randomString, shot, blobConverters */
+
+"use strict";
+
+this.shooter = (function () {
+ // eslint-disable-line no-unused-vars
+ const exports = {};
+ const { AbstractShot } = shot;
+
+ const RANDOM_STRING_LENGTH = 16;
+ let backend;
+ let shotObject;
+ const callBackground = global.callBackground;
+
+ function regexpEscape(str) {
+ // http://stackoverflow.com/questions/3115150/how-to-escape-regular-expression-special-characters-using-javascript
+ return str.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
+ }
+
+ function sanitizeError(data) {
+ const href = new RegExp(regexpEscape(window.location.href), "g");
+ const origin = new RegExp(
+ `${regexpEscape(window.location.origin)}[^ \t\n\r",>]*`,
+ "g"
+ );
+ const json = JSON.stringify(data)
+ .replace(href, "REDACTED_HREF")
+ .replace(origin, "REDACTED_URL");
+ const result = JSON.parse(json);
+ return result;
+ }
+
+ catcher.registerHandler(errorObj => {
+ callBackground("reportError", sanitizeError(errorObj));
+ });
+
+ function hideUIFrame() {
+ ui.iframe.hide();
+ return Promise.resolve(null);
+ }
+
+ function screenshotPage(dataUrl, selectedPos, type, screenshotTaskFn) {
+ let promise = Promise.resolve(dataUrl);
+
+ if (!dataUrl) {
+ promise = callBackground(
+ "screenshotPage",
+ selectedPos.toJSON(),
+ type,
+ window.devicePixelRatio
+ );
+ }
+
+ catcher.watchPromise(
+ promise.then(dataLoc => {
+ screenshotTaskFn(dataLoc);
+ })
+ );
+ }
+
+ exports.downloadShot = function (selectedPos, previewDataUrl, type) {
+ const shotPromise = previewDataUrl
+ ? Promise.resolve(previewDataUrl)
+ : hideUIFrame();
+ catcher.watchPromise(
+ shotPromise.then(dataUrl => {
+ screenshotPage(dataUrl, selectedPos, type, url => {
+ let typeFromDataUrl = blobConverters.getTypeFromDataUrl(url);
+ typeFromDataUrl = typeFromDataUrl
+ ? typeFromDataUrl.split("/", 2)[1]
+ : null;
+ shotObject.delAllClips();
+ shotObject.addClip({
+ createdDate: Date.now(),
+ image: {
+ url,
+ type: typeFromDataUrl,
+ location: selectedPos,
+ },
+ });
+ ui.triggerDownload(url, shotObject.filename);
+ uicontrol.deactivate();
+ });
+ })
+ );
+ };
+
+ exports.preview = function (selectedPos, type) {
+ catcher.watchPromise(
+ hideUIFrame().then(dataUrl => {
+ screenshotPage(dataUrl, selectedPos, type, url => {
+ ui.iframe.usePreview();
+ ui.Preview.display(url);
+ });
+ })
+ );
+ };
+
+ let copyInProgress = null;
+ exports.copyShot = function (selectedPos, previewDataUrl, type) {
+ // This is pretty slow. We'll ignore additional user triggered copy events
+ // while it is in progress.
+ if (copyInProgress) {
+ return;
+ }
+ // A max of five seconds in case some error occurs.
+ copyInProgress = setTimeout(() => {
+ copyInProgress = null;
+ }, 5000);
+
+ const unsetCopyInProgress = () => {
+ if (copyInProgress) {
+ clearTimeout(copyInProgress);
+ copyInProgress = null;
+ }
+ };
+ const shotPromise = previewDataUrl
+ ? Promise.resolve(previewDataUrl)
+ : hideUIFrame();
+ catcher.watchPromise(
+ shotPromise.then(dataUrl => {
+ screenshotPage(dataUrl, selectedPos, type, url => {
+ const blob = blobConverters.dataUrlToBlob(url);
+ catcher.watchPromise(
+ callBackground("copyShotToClipboard", blob).then(() => {
+ uicontrol.deactivate();
+ unsetCopyInProgress();
+ }, unsetCopyInProgress)
+ );
+ });
+ })
+ );
+ };
+
+ exports.sendEvent = function (...args) {
+ const maybeOptions = args[args.length - 1];
+
+ if (typeof maybeOptions === "object") {
+ maybeOptions.incognito = browser.extension.inIncognitoContext;
+ } else {
+ args.push({ incognito: browser.extension.inIncognitoContext });
+ }
+ };
+
+ catcher.watchFunction(() => {
+ shotObject = new AbstractShot(
+ backend,
+ randomString(RANDOM_STRING_LENGTH) + "/" + domainFromUrl(location),
+ {
+ origin: shot.originFromUrl(location.href),
+ }
+ );
+ shotObject.update(documentMetadata());
+ })();
+
+ return exports;
+})();
+null;
diff --git a/browser/extensions/screenshots/selector/ui.js b/browser/extensions/screenshots/selector/ui.js
new file mode 100644
index 0000000000..c8433a8f84
--- /dev/null
+++ b/browser/extensions/screenshots/selector/ui.js
@@ -0,0 +1,904 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals browser, log, util, catcher, inlineSelectionCss, callBackground, assertIsTrusted, assertIsBlankDocument, blobConverters */
+
+"use strict";
+
+this.ui = (function () {
+ // eslint-disable-line no-unused-vars
+ const exports = {};
+ const SAVE_BUTTON_HEIGHT = 50;
+
+ const { watchFunction } = catcher;
+
+ exports.isHeader = function (el) {
+ while (el) {
+ if (
+ el.classList &&
+ (el.classList.contains("visible") ||
+ el.classList.contains("full-page") ||
+ el.classList.contains("cancel-shot"))
+ ) {
+ return true;
+ }
+ el = el.parentNode;
+ }
+ return false;
+ };
+
+ const substitutedCss = inlineSelectionCss.replace(
+ /MOZ_EXTENSION([^"]+)/g,
+ (match, filename) => {
+ return browser.runtime.getURL(filename);
+ }
+ );
+
+ function makeEl(tagName, className) {
+ if (!iframe.document()) {
+ throw new Error("Attempted makeEl before iframe was initialized");
+ }
+ const el = iframe.document().createElement(tagName);
+ if (className) {
+ el.className = className;
+ }
+ return el;
+ }
+
+ function onResize() {
+ if (this.sizeTracking.windowDelayer) {
+ clearTimeout(this.sizeTracking.windowDelayer);
+ }
+ this.sizeTracking.windowDelayer = setTimeout(
+ watchFunction(() => {
+ this.updateElementSize(true);
+ }),
+ 50
+ );
+ }
+
+ function initializeIframe() {
+ const el = document.createElement("iframe");
+ el.src = browser.runtime.getURL("blank.html");
+ el.style.zIndex = "99999999999";
+ el.style.border = "none";
+ el.style.top = "0";
+ el.style.left = "0";
+ el.style.margin = "0";
+ el.scrolling = "no";
+ el.style.clip = "auto";
+ el.style.backgroundColor = "transparent";
+ el.style.colorScheme = "light";
+ return el;
+ }
+
+ const iframeSelection = (exports.iframeSelection = {
+ element: null,
+ addClassName: "",
+ sizeTracking: {
+ timer: null,
+ windowDelayer: null,
+ lastHeight: null,
+ lastWidth: null,
+ },
+ document: null,
+ window: null,
+ display(installHandlerOnDocument) {
+ return new Promise((resolve, reject) => {
+ if (!this.element) {
+ this.element = initializeIframe();
+ this.element.id = "firefox-screenshots-selection-iframe";
+ this.element.style.display = "none";
+ this.element.style.setProperty("max-width", "none", "important");
+ this.element.style.setProperty("max-height", "none", "important");
+ this.element.style.setProperty("position", "absolute", "important");
+ this.element.setAttribute("role", "dialog");
+ this.updateElementSize();
+ this.element.addEventListener(
+ "load",
+ watchFunction(() => {
+ this.document = this.element.contentDocument;
+ this.window = this.element.contentWindow;
+ assertIsBlankDocument(this.document);
+ // eslint-disable-next-line no-unsanitized/property
+ this.document.documentElement.innerHTML = `
+ <head>
+ <style>${substitutedCss}</style>
+ <title></title>
+ </head>
+ <body></body>`;
+ installHandlerOnDocument(this.document);
+ if (this.addClassName) {
+ this.document.body.className = this.addClassName;
+ }
+ this.document.documentElement.dir =
+ browser.i18n.getMessage("@@bidi_dir");
+ this.document.documentElement.lang =
+ browser.i18n.getMessage("@@ui_locale");
+ resolve();
+ }),
+ { once: true }
+ );
+ document.body.appendChild(this.element);
+ } else {
+ resolve();
+ }
+ });
+ },
+
+ hide() {
+ this.element.style.display = "none";
+ this.stopSizeWatch();
+ },
+
+ unhide() {
+ this.updateElementSize();
+ this.element.style.display = "block";
+ this.initSizeWatch();
+ this.element.focus();
+ },
+
+ updateElementSize(force) {
+ // Note: if someone sizes down the page, then the iframe will keep the
+ // document from naturally shrinking. We use force to temporarily hide
+ // the element so that we can tell if the document shrinks
+ const visible = this.element.style.display !== "none";
+ if (force && visible) {
+ this.element.style.display = "none";
+ }
+ const height = Math.max(
+ document.documentElement.clientHeight,
+ document.body.clientHeight,
+ document.documentElement.scrollHeight,
+ document.body.scrollHeight
+ );
+ if (height !== this.sizeTracking.lastHeight) {
+ this.sizeTracking.lastHeight = height;
+ this.element.style.height = height + "px";
+ }
+ // Do not use window.innerWidth since that includes the width of the
+ // scroll bar.
+ const width = Math.max(
+ document.documentElement.clientWidth,
+ document.body.clientWidth,
+ document.documentElement.scrollWidth,
+ document.body.scrollWidth
+ );
+ if (width !== this.sizeTracking.lastWidth) {
+ this.sizeTracking.lastWidth = width;
+ this.element.style.width = width + "px";
+ // Since this frame has an absolute position relative to the parent
+ // document, if the parent document's body has a relative position and
+ // left and/or top not at 0, then the left and/or top of the parent
+ // document's body is not at (0, 0) of the viewport. That makes the
+ // frame shifted relative to the viewport. These margins negates that.
+ if (window.getComputedStyle(document.body).position === "relative") {
+ const docBoundingRect =
+ document.documentElement.getBoundingClientRect();
+ const bodyBoundingRect = document.body.getBoundingClientRect();
+ this.element.style.marginLeft = `-${
+ bodyBoundingRect.left - docBoundingRect.left
+ }px`;
+ this.element.style.marginTop = `-${
+ bodyBoundingRect.top - docBoundingRect.top
+ }px`;
+ }
+ }
+ if (force && visible) {
+ this.element.style.display = "block";
+ }
+ },
+
+ initSizeWatch() {
+ this.stopSizeWatch();
+ this.sizeTracking.timer = setInterval(
+ watchFunction(this.updateElementSize.bind(this)),
+ 2000
+ );
+ window.addEventListener("resize", this.onResize, true);
+ },
+
+ stopSizeWatch() {
+ if (this.sizeTracking.timer) {
+ clearTimeout(this.sizeTracking.timer);
+ this.sizeTracking.timer = null;
+ }
+ if (this.sizeTracking.windowDelayer) {
+ clearTimeout(this.sizeTracking.windowDelayer);
+ this.sizeTracking.windowDelayer = null;
+ }
+ this.sizeTracking.lastHeight = this.sizeTracking.lastWidth = null;
+ window.removeEventListener("resize", this.onResize, true);
+ },
+
+ getElementFromPoint(x, y) {
+ this.element.style.pointerEvents = "none";
+ let el;
+ try {
+ el = document.elementFromPoint(x, y);
+ } finally {
+ this.element.style.pointerEvents = "";
+ }
+ return el;
+ },
+
+ remove() {
+ this.stopSizeWatch();
+ util.removeNode(this.element);
+ this.element = this.document = this.window = null;
+ },
+ });
+
+ iframeSelection.onResize = watchFunction(
+ assertIsTrusted(onResize.bind(iframeSelection)),
+ true
+ );
+
+ const iframePreSelection = (exports.iframePreSelection = {
+ element: null,
+ document: null,
+ window: null,
+ display(installHandlerOnDocument, standardOverlayCallbacks) {
+ return new Promise((resolve, reject) => {
+ if (!this.element) {
+ this.element = initializeIframe();
+ this.element.id = "firefox-screenshots-preselection-iframe";
+ this.element.style.setProperty("position", "fixed", "important");
+ this.element.style.width = "100%";
+ this.element.style.height = "100%";
+ this.element.style.setProperty("max-width", "none", "important");
+ this.element.style.setProperty("max-height", "none", "important");
+ this.element.setAttribute("role", "dialog");
+ this.element.addEventListener(
+ "load",
+ watchFunction(() => {
+ this.document = this.element.contentDocument;
+ this.window = this.element.contentWindow;
+ assertIsBlankDocument(this.document);
+ // eslint-disable-next-line no-unsanitized/property
+ this.document.documentElement.innerHTML = `
+ <head>
+ <link rel="localization" href="browser/screenshots.ftl">
+ <style>${substitutedCss}</style>
+ <title></title>
+ </head>
+ <body>
+ <div class="preview-overlay precision-cursor">
+ <div class="fixed-container">
+ <div class="face-container">
+ <div class="eye left"><div class="eyeball"></div></div>
+ <div class="eye right"><div class="eyeball"></div></div>
+ <div class="face"></div>
+ </div>
+ <div class="preview-instructions" data-l10n-id="screenshots-instructions"></div>
+ <button class="cancel-shot" data-l10n-id="screenshots-cancel-button"></button>
+ <div class="all-buttons-container">
+ <button class="visible" tabindex="2" data-l10n-id="screenshots-save-visible-button"></button>
+ <button class="full-page" tabindex="1" data-l10n-id="screenshots-save-page-button"></button>
+ </div>
+ </div>
+ </div>
+ </body>`;
+ installHandlerOnDocument(this.document);
+ if (this.addClassName) {
+ this.document.body.className = this.addClassName;
+ }
+ this.document.documentElement.dir =
+ browser.i18n.getMessage("@@bidi_dir");
+ this.document.documentElement.lang =
+ browser.i18n.getMessage("@@ui_locale");
+ const overlay = this.document.querySelector(".preview-overlay");
+ overlay
+ .querySelector(".visible")
+ .addEventListener(
+ "click",
+ watchFunction(
+ assertIsTrusted(standardOverlayCallbacks.onClickVisible)
+ )
+ );
+ overlay
+ .querySelector(".full-page")
+ .addEventListener(
+ "click",
+ watchFunction(
+ assertIsTrusted(standardOverlayCallbacks.onClickFullPage)
+ )
+ );
+ overlay
+ .querySelector(".cancel-shot")
+ .addEventListener(
+ "click",
+ watchFunction(
+ assertIsTrusted(standardOverlayCallbacks.onClickCancel)
+ )
+ );
+
+ resolve();
+ }),
+ { once: true }
+ );
+ document.body.appendChild(this.element);
+ } else {
+ resolve();
+ }
+ });
+ },
+
+ hide() {
+ window.removeEventListener(
+ "scroll",
+ watchFunction(assertIsTrusted(this.onScroll))
+ );
+ window.removeEventListener("resize", this.onResize, true);
+ if (this.element) {
+ this.element.style.display = "none";
+ }
+ },
+
+ unhide() {
+ window.addEventListener(
+ "scroll",
+ watchFunction(assertIsTrusted(this.onScroll))
+ );
+ window.addEventListener("resize", this.onResize, true);
+ this.element.style.display = "block";
+ this.element.focus();
+ },
+
+ onScroll() {
+ exports.HoverBox.hide();
+ },
+
+ getElementFromPoint(x, y) {
+ this.element.style.pointerEvents = "none";
+ let el;
+ try {
+ el = document.elementFromPoint(x, y);
+ } finally {
+ this.element.style.pointerEvents = "";
+ }
+ return el;
+ },
+
+ remove() {
+ this.hide();
+ util.removeNode(this.element);
+ this.element = this.document = this.window = null;
+ },
+ });
+
+ let msgsPromise = callBackground("getStrings", [
+ "screenshots-cancel-button",
+ "screenshots-copy-button-tooltip",
+ "screenshots-download-button-tooltip",
+ "screenshots-copy-button",
+ "screenshots-download-button",
+ ]);
+
+ const iframePreview = (exports.iframePreview = {
+ element: null,
+ document: null,
+ window: null,
+ display(installHandlerOnDocument, standardOverlayCallbacks) {
+ return new Promise((resolve, reject) => {
+ if (!this.element) {
+ this.element = initializeIframe();
+ this.element.id = "firefox-screenshots-preview-iframe";
+ this.element.style.display = "none";
+ this.element.style.setProperty("position", "fixed", "important");
+ this.element.style.height = "100%";
+ this.element.style.width = "100%";
+ this.element.style.setProperty("max-width", "none", "important");
+ this.element.style.setProperty("max-height", "none", "important");
+ this.element.setAttribute("role", "dialog");
+ this.element.onload = watchFunction(() => {
+ msgsPromise.then(([cancelTitle, copyTitle, downloadTitle]) => {
+ assertIsBlankDocument(this.element.contentDocument);
+ this.document = this.element.contentDocument;
+ this.window = this.element.contentWindow;
+ // eslint-disable-next-line no-unsanitized/property
+ this.document.documentElement.innerHTML = `
+ <head>
+ <link rel="localization" href="browser/screenshots.ftl">
+ <style>${substitutedCss}</style>
+ <title></title>
+ </head>
+ <body>
+ <div class="preview-overlay">
+ <div class="preview-image">
+ <div class="preview-buttons">
+ <button class="highlight-button-cancel" title="${cancelTitle}">
+ <img src="chrome://browser/content/screenshots/cancel.svg"/>
+ </button>
+ <button class="highlight-button-copy" title="${copyTitle}">
+ <img src="chrome://browser/content/screenshots/copy.svg"/>
+ <span data-l10n-id="screenshots-copy-button"/>
+ </button>
+ <button class="highlight-button-download" title="${downloadTitle}">
+ <img src="chrome://browser/content/screenshots/download-white.svg"/>
+ <span data-l10n-id="screenshots-download-button"/>
+ </button>
+ </div>
+ <div class="preview-image-wrapper"></div>
+ </div>
+ <div class="loader" style="display:none">
+ <div class="loader-inner"></div>
+ </div>
+ </div>
+ </body>`;
+
+ installHandlerOnDocument(this.document);
+ this.document.documentElement.dir =
+ browser.i18n.getMessage("@@bidi_dir");
+ this.document.documentElement.lang =
+ browser.i18n.getMessage("@@ui_locale");
+
+ const overlay = this.document.querySelector(".preview-overlay");
+ overlay
+ .querySelector(".highlight-button-copy")
+ .addEventListener(
+ "click",
+ watchFunction(
+ assertIsTrusted(standardOverlayCallbacks.onCopyPreview)
+ )
+ );
+ overlay
+ .querySelector(".highlight-button-download")
+ .addEventListener(
+ "click",
+ watchFunction(
+ assertIsTrusted(standardOverlayCallbacks.onDownloadPreview)
+ )
+ );
+ overlay
+ .querySelector(".highlight-button-cancel")
+ .addEventListener(
+ "click",
+ watchFunction(
+ assertIsTrusted(standardOverlayCallbacks.cancel)
+ )
+ );
+ resolve();
+ });
+ });
+ document.body.appendChild(this.element);
+ } else {
+ resolve();
+ }
+ });
+ },
+
+ hide() {
+ if (this.element) {
+ this.element.style.display = "none";
+ }
+ },
+
+ unhide() {
+ this.element.style.display = "block";
+ this.element.focus();
+ },
+
+ showLoader() {
+ this.document.body.querySelector(".preview-image").style.display = "none";
+ this.document.body.querySelector(".loader").style.display = "";
+ },
+
+ remove() {
+ this.hide();
+ util.removeNode(this.element);
+ this.element = null;
+ this.document = null;
+ },
+ });
+
+ iframePreSelection.onResize = watchFunction(
+ onResize.bind(iframePreSelection),
+ true
+ );
+
+ const iframe = (exports.iframe = {
+ currentIframe: iframePreSelection,
+ display(installHandlerOnDocument, standardOverlayCallbacks) {
+ return iframeSelection
+ .display(installHandlerOnDocument)
+ .then(() =>
+ iframePreSelection.display(
+ installHandlerOnDocument,
+ standardOverlayCallbacks
+ )
+ )
+ .then(() =>
+ iframePreview.display(
+ installHandlerOnDocument,
+ standardOverlayCallbacks
+ )
+ );
+ },
+
+ hide() {
+ this.currentIframe.hide();
+ },
+
+ unhide() {
+ this.currentIframe.unhide();
+ },
+
+ showLoader() {
+ if (this.currentIframe.showLoader) {
+ this.currentIframe.showLoader();
+ this.currentIframe.unhide();
+ }
+ },
+
+ getElementFromPoint(x, y) {
+ return this.currentIframe.getElementFromPoint(x, y);
+ },
+
+ remove() {
+ iframeSelection.remove();
+ iframePreSelection.remove();
+ iframePreview.remove();
+ },
+
+ getContentWindow() {
+ return this.currentIframe.element.contentWindow;
+ },
+
+ document() {
+ return this.currentIframe.document;
+ },
+
+ useSelection() {
+ if (
+ this.currentIframe === iframePreSelection ||
+ this.currentIframe === iframePreview
+ ) {
+ this.hide();
+ }
+ this.currentIframe = iframeSelection;
+ this.unhide();
+ },
+
+ usePreSelection() {
+ if (
+ this.currentIframe === iframeSelection ||
+ this.currentIframe === iframePreview
+ ) {
+ this.hide();
+ }
+ this.currentIframe = iframePreSelection;
+ this.unhide();
+ },
+
+ usePreview() {
+ if (
+ this.currentIframe === iframeSelection ||
+ this.currentIframe === iframePreSelection
+ ) {
+ this.hide();
+ }
+ this.currentIframe = iframePreview;
+ this.unhide();
+ },
+ });
+
+ const movements = [
+ "topLeft",
+ "top",
+ "topRight",
+ "left",
+ "right",
+ "bottomLeft",
+ "bottom",
+ "bottomRight",
+ ];
+
+ /** Creates the selection box */
+ exports.Box = {
+ async display(pos, callbacks) {
+ await this._createEl();
+ if (callbacks !== undefined && callbacks.cancel) {
+ // We use onclick here because we don't want addEventListener
+ // to add multiple event handlers to the same button
+ this.cancel.onclick = watchFunction(assertIsTrusted(callbacks.cancel));
+ this.cancel.style.display = "";
+ } else {
+ this.cancel.style.display = "none";
+ }
+ if (callbacks !== undefined && callbacks.download) {
+ this.download.removeAttribute("disabled");
+ this.download.onclick = watchFunction(
+ assertIsTrusted(e => {
+ this.download.setAttribute("disabled", true);
+ callbacks.download(e);
+ e.preventDefault();
+ e.stopPropagation();
+ return false;
+ })
+ );
+ this.download.style.display = "";
+ } else {
+ this.download.style.display = "none";
+ }
+ if (callbacks !== undefined && callbacks.copy) {
+ this.copy.removeAttribute("disabled");
+ this.copy.onclick = watchFunction(
+ assertIsTrusted(e => {
+ this.copy.setAttribute("disabled", true);
+ callbacks.copy(e);
+ e.preventDefault();
+ e.stopPropagation();
+ })
+ );
+ this.copy.style.display = "";
+ } else {
+ this.copy.style.display = "none";
+ }
+
+ const winBottom = window.innerHeight;
+ const pageYOffset = window.pageYOffset;
+
+ if (pos.right - pos.left < 78 || pos.bottom - pos.top < 78) {
+ this.el.classList.add("small-selection");
+ } else {
+ this.el.classList.remove("small-selection");
+ }
+
+ // if the selection bounding box is w/in SAVE_BUTTON_HEIGHT px of the bottom of
+ // the window, flip controls into the box
+ if (pos.bottom > winBottom + pageYOffset - SAVE_BUTTON_HEIGHT) {
+ this.el.classList.add("bottom-selection");
+ } else {
+ this.el.classList.remove("bottom-selection");
+ }
+
+ if (pos.right < 200) {
+ this.el.classList.add("left-selection");
+ } else {
+ this.el.classList.remove("left-selection");
+ }
+ this.el.style.top = `${pos.top}px`;
+ this.el.style.left = `${pos.left}px`;
+ this.el.style.height = `${pos.bottom - pos.top}px`;
+ this.el.style.width = `${pos.right - pos.left}px`;
+ this.bgTop.style.top = "0px";
+ this.bgTop.style.height = `${pos.top}px`;
+ this.bgTop.style.left = "0px";
+ this.bgTop.style.width = "100%";
+ this.bgBottom.style.top = `${pos.bottom}px`;
+ this.bgBottom.style.height = `calc(100vh - ${pos.bottom}px)`;
+ this.bgBottom.style.left = "0px";
+ this.bgBottom.style.width = "100%";
+ this.bgLeft.style.top = `${pos.top}px`;
+ this.bgLeft.style.height = `${pos.bottom - pos.top}px`;
+ this.bgLeft.style.left = "0px";
+ this.bgLeft.style.width = `${pos.left}px`;
+ this.bgRight.style.top = `${pos.top}px`;
+ this.bgRight.style.height = `${pos.bottom - pos.top}px`;
+ this.bgRight.style.left = `${pos.right}px`;
+ this.bgRight.style.width = `calc(100% - ${pos.right}px)`;
+ },
+
+ // used to eventually move the download-only warning
+ // when a user ends scrolling or ends resizing a window
+ delayExecution(delay, cb) {
+ let timer;
+ return function () {
+ if (typeof timer !== "undefined") {
+ clearTimeout(timer);
+ }
+ timer = setTimeout(cb, delay);
+ };
+ },
+
+ remove() {
+ for (const name of ["el", "bgTop", "bgLeft", "bgRight", "bgBottom"]) {
+ if (name in this) {
+ util.removeNode(this[name]);
+ this[name] = null;
+ }
+ }
+ },
+
+ async _createEl() {
+ let boxEl = this.el;
+ if (boxEl) {
+ return;
+ }
+ let [cancelTitle, copyTitle, downloadTitle, copyText, downloadText] =
+ await msgsPromise;
+ boxEl = makeEl("div", "highlight");
+ const buttons = makeEl("div", "highlight-buttons");
+ const cancel = makeEl("button", "highlight-button-cancel");
+ const cancelImg = makeEl("img");
+ cancelImg.src = "chrome://browser/content/screenshots/cancel.svg";
+ cancel.title = cancelTitle;
+ cancel.appendChild(cancelImg);
+ buttons.appendChild(cancel);
+
+ const copy = makeEl("button", "highlight-button-copy");
+ copy.title = copyTitle;
+ const copyImg = makeEl("img");
+ const copyString = makeEl("span");
+ copyString.textContent = copyText;
+ copyImg.src = "chrome://browser/content/screenshots/copy.svg";
+ copy.appendChild(copyImg);
+ copy.appendChild(copyString);
+ buttons.appendChild(copy);
+
+ const download = makeEl("button", "highlight-button-download");
+ const downloadImg = makeEl("img");
+ downloadImg.src =
+ "chrome://browser/content/screenshots/download-white.svg";
+ download.appendChild(downloadImg);
+ download.append(downloadText);
+ download.title = downloadTitle;
+ buttons.appendChild(download);
+ this.cancel = cancel;
+ this.download = download;
+ this.copy = copy;
+
+ boxEl.appendChild(buttons);
+ for (const name of movements) {
+ const elTarget = makeEl("div", "mover-target direction-" + name);
+ const elMover = makeEl("div", "mover");
+ elTarget.appendChild(elMover);
+ boxEl.appendChild(elTarget);
+ }
+ this.bgTop = makeEl("div", "bghighlight");
+ iframe.document().body.appendChild(this.bgTop);
+ this.bgLeft = makeEl("div", "bghighlight");
+ iframe.document().body.appendChild(this.bgLeft);
+ this.bgRight = makeEl("div", "bghighlight");
+ iframe.document().body.appendChild(this.bgRight);
+ this.bgBottom = makeEl("div", "bghighlight");
+ iframe.document().body.appendChild(this.bgBottom);
+ iframe.document().body.appendChild(boxEl);
+ this.el = boxEl;
+ },
+
+ draggerDirection(target) {
+ while (target) {
+ if (target.nodeType === document.ELEMENT_NODE) {
+ if (target.classList.contains("mover-target")) {
+ for (const name of movements) {
+ if (target.classList.contains("direction-" + name)) {
+ return name;
+ }
+ }
+ catcher.unhandled(new Error("Surprising mover element"), {
+ element: target.outerHTML,
+ });
+ log.warn("Got mover-target that wasn't a specific direction");
+ }
+ }
+ target = target.parentNode;
+ }
+ return null;
+ },
+
+ isSelection(target) {
+ while (target) {
+ if (target.tagName === "BUTTON") {
+ return false;
+ }
+ if (
+ target.nodeType === document.ELEMENT_NODE &&
+ target.classList.contains("highlight")
+ ) {
+ return true;
+ }
+ target = target.parentNode;
+ }
+ return false;
+ },
+
+ isControl(target) {
+ while (target) {
+ if (
+ target.nodeType === document.ELEMENT_NODE &&
+ target.classList.contains("highlight-buttons")
+ ) {
+ return true;
+ }
+ target = target.parentNode;
+ }
+ return false;
+ },
+
+ el: null,
+ boxTopEl: null,
+ boxLeftEl: null,
+ boxRightEl: null,
+ boxBottomEl: null,
+ };
+
+ exports.HoverBox = {
+ el: null,
+
+ display(rect) {
+ if (!this.el) {
+ this.el = makeEl("div", "hover-highlight");
+ iframe.document().body.appendChild(this.el);
+ }
+ this.el.style.display = "";
+ this.el.style.top = rect.top - 1 + "px";
+ this.el.style.left = rect.left - 1 + "px";
+ this.el.style.width = rect.right - rect.left + 2 + "px";
+ this.el.style.height = rect.bottom - rect.top + 2 + "px";
+ },
+
+ hide() {
+ if (this.el) {
+ this.el.style.display = "none";
+ }
+ },
+
+ remove() {
+ util.removeNode(this.el);
+ this.el = null;
+ },
+ };
+
+ exports.PixelDimensions = {
+ el: null,
+ xEl: null,
+ yEl: null,
+ display(xPos, yPos, x, y) {
+ if (!this.el) {
+ this.el = makeEl("div", "pixel-dimensions");
+ this.xEl = makeEl("div");
+ this.el.appendChild(this.xEl);
+ this.yEl = makeEl("div");
+ this.el.appendChild(this.yEl);
+ iframe.document().body.appendChild(this.el);
+ }
+ this.xEl.textContent = Math.round(x);
+ this.yEl.textContent = Math.round(y);
+ this.el.style.top = yPos + 12 + "px";
+ this.el.style.left = xPos + 12 + "px";
+ },
+ remove() {
+ util.removeNode(this.el);
+ this.el = this.xEl = this.yEl = null;
+ },
+ };
+
+ exports.Preview = {
+ display(dataUrl) {
+ const img = makeEl("IMG");
+ const imgBlob = blobConverters.dataUrlToBlob(dataUrl);
+ img.src = iframe.getContentWindow().URL.createObjectURL(imgBlob);
+ iframe
+ .document()
+ .querySelector(".preview-image-wrapper")
+ .appendChild(img);
+ },
+ };
+
+ /** Removes every UI this module creates */
+ exports.remove = function () {
+ for (const name in exports) {
+ if (name.startsWith("iframe")) {
+ continue;
+ }
+ if (typeof exports[name] === "object" && exports[name].remove) {
+ exports[name].remove();
+ }
+ }
+ exports.iframe.remove();
+ };
+
+ exports.triggerDownload = function (url, filename) {
+ return catcher.watchPromise(
+ callBackground("downloadShot", { url, filename })
+ );
+ };
+
+ exports.unload = exports.remove;
+
+ return exports;
+})();
+null;
diff --git a/browser/extensions/screenshots/selector/uicontrol.js b/browser/extensions/screenshots/selector/uicontrol.js
new file mode 100644
index 0000000000..b690281083
--- /dev/null
+++ b/browser/extensions/screenshots/selector/uicontrol.js
@@ -0,0 +1,1026 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals log, catcher, util, ui, slides, global */
+/* globals shooter, callBackground, selectorLoader, assertIsTrusted, selection */
+
+"use strict";
+
+this.uicontrol = (function () {
+ const exports = {};
+
+ /** ********************************************************
+ * selection
+ */
+
+ /* States:
+
+ "crosshairs":
+ Nothing has happened, and the crosshairs will follow the movement of the mouse
+ "draggingReady":
+ The user has pressed the mouse button, but hasn't moved enough to create a selection
+ "dragging":
+ The user has pressed down a mouse button, and is dragging out an area far enough to show a selection
+ "selected":
+ The user has selected an area
+ "resizing":
+ The user is resizing the selection
+ "cancelled":
+ Everything has been cancelled
+ "previewing":
+ The user is previewing the full-screen/visible image
+
+ A mousedown goes from crosshairs to dragging.
+ A mouseup goes from dragging to selected
+ A click outside of the selection goes from selected to crosshairs
+ A click on one of the draggers goes from selected to resizing
+
+ State variables:
+
+ state (string, one of the above)
+ mousedownPos (object with x/y during draggingReady, shows where the selection started)
+ selectedPos (object with x/y/h/w during selected or dragging, gives the entire selection)
+ resizeDirection (string: top, topLeft, etc, during resizing)
+ resizeStartPos (x/y position where resizing started)
+ mouseupNoAutoselect (true if a mouseup in draggingReady should not trigger autoselect)
+
+ */
+
+ const { watchFunction, watchPromise } = catcher;
+
+ const MAX_PAGE_HEIGHT = 10000;
+ const MAX_PAGE_WIDTH = 10000;
+ // An autoselection smaller than these will be ignored entirely:
+ const MIN_DETECT_ABSOLUTE_HEIGHT = 10;
+ const MIN_DETECT_ABSOLUTE_WIDTH = 30;
+ // An autoselection smaller than these will not be preferred:
+ const MIN_DETECT_HEIGHT = 30;
+ const MIN_DETECT_WIDTH = 100;
+ // An autoselection bigger than either of these will be ignored:
+ const MAX_DETECT_HEIGHT = Math.max(window.innerHeight + 100, 700);
+ const MAX_DETECT_WIDTH = Math.max(window.innerWidth + 100, 1000);
+ // This is how close (in pixels) you can get to the edge of the window and then
+ // it will scroll:
+ const SCROLL_BY_EDGE = 20;
+ // This is how wide the inboard scrollbars are, generally 0 except on Mac
+ const SCROLLBAR_WIDTH = window.navigator.platform.match(/Mac/i) ? 17 : 0;
+
+ const { Selection } = selection;
+ const { sendEvent } = shooter;
+ const log = global.log;
+
+ function round10(n) {
+ return Math.floor(n / 10) * 10;
+ }
+
+ function eventOptionsForBox(box) {
+ return {
+ cd1: round10(Math.abs(box.bottom - box.top)),
+ cd2: round10(Math.abs(box.right - box.left)),
+ };
+ }
+
+ function eventOptionsForResize(boxStart, boxEnd) {
+ return {
+ cd1: round10(
+ boxEnd.bottom - boxEnd.top - (boxStart.bottom - boxStart.top)
+ ),
+ cd2: round10(
+ boxEnd.right - boxEnd.left - (boxStart.right - boxStart.left)
+ ),
+ };
+ }
+
+ function eventOptionsForMove(posStart, posEnd) {
+ return {
+ cd1: round10(posEnd.y - posStart.y),
+ cd2: round10(posEnd.x - posStart.x),
+ };
+ }
+
+ function downloadShot() {
+ const previewDataUrl = captureType === "fullPageTruncated" ? null : dataUrl;
+ // Downloaded shots don't have dimension limits
+ removeDimensionLimitsOnFullPageShot();
+ shooter.downloadShot(selectedPos, previewDataUrl, captureType);
+ }
+
+ function copyShot() {
+ const previewDataUrl = captureType === "fullPageTruncated" ? null : dataUrl;
+ // Copied shots don't have dimension limits
+ removeDimensionLimitsOnFullPageShot();
+ shooter.copyShot(selectedPos, previewDataUrl, captureType);
+ }
+
+ /** *********************************************
+ * State and stateHandlers infrastructure
+ */
+
+ // This enumerates all the anchors on the selection, and what part of the
+ // selection they move:
+ const movements = {
+ topLeft: ["x1", "y1"],
+ top: [null, "y1"],
+ topRight: ["x2", "y1"],
+ left: ["x1", null],
+ right: ["x2", null],
+ bottomLeft: ["x1", "y2"],
+ bottom: [null, "y2"],
+ bottomRight: ["x2", "y2"],
+ move: ["*", "*"],
+ };
+
+ const doNotAutoselectTags = {
+ H1: true,
+ H2: true,
+ H3: true,
+ H4: true,
+ H5: true,
+ H6: true,
+ };
+
+ let captureType;
+
+ function removeDimensionLimitsOnFullPageShot() {
+ if (captureType === "fullPageTruncated") {
+ captureType = "fullPage";
+ selectedPos = new Selection(
+ 0,
+ 0,
+ getDocumentWidth(),
+ getDocumentHeight()
+ );
+ }
+ }
+
+ const standardDisplayCallbacks = {
+ cancel: () => {
+ sendEvent("cancel-shot", "overlay-cancel-button");
+ exports.deactivate();
+ },
+ download: () => {
+ sendEvent("download-shot", "overlay-download-button");
+ downloadShot();
+ },
+ copy: () => {
+ sendEvent("copy-shot", "overlay-copy-button");
+ copyShot();
+ },
+ };
+
+ const standardOverlayCallbacks = {
+ cancel: () => {
+ sendEvent("cancel-shot", "cancel-preview-button");
+ exports.deactivate();
+ },
+ onClickCancel: e => {
+ sendEvent("cancel-shot", "cancel-selection-button");
+ e.preventDefault();
+ e.stopPropagation();
+ exports.deactivate();
+ },
+ onClickVisible: () => {
+ callBackground("captureTelemetry", "visible");
+ sendEvent("capture-visible", "selection-button");
+ selectedPos = new Selection(
+ window.scrollX,
+ window.scrollY,
+ window.scrollX + document.documentElement.clientWidth,
+ window.scrollY + window.innerHeight
+ );
+ captureType = "visible";
+ setState("previewing");
+ },
+ onClickFullPage: () => {
+ callBackground("captureTelemetry", "full_page");
+ sendEvent("capture-full-page", "selection-button");
+ captureType = "fullPage";
+ const width = getDocumentWidth();
+ if (width > MAX_PAGE_WIDTH) {
+ captureType = "fullPageTruncated";
+ }
+ const height = getDocumentHeight();
+ if (height > MAX_PAGE_HEIGHT) {
+ captureType = "fullPageTruncated";
+ }
+ selectedPos = new Selection(0, 0, width, height);
+ setState("previewing");
+ },
+ onDownloadPreview: () => {
+ sendEvent(
+ `download-${captureType
+ .replace(/([a-z])([A-Z])/g, "$1-$2")
+ .toLowerCase()}`,
+ "download-preview-button"
+ );
+ downloadShot();
+ },
+ onCopyPreview: () => {
+ sendEvent(
+ `copy-${captureType.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase()}`,
+ "copy-preview-button"
+ );
+ copyShot();
+ },
+ };
+
+ /** Holds all the objects that handle events for each state: */
+ const stateHandlers = {};
+
+ function getState() {
+ return getState.state;
+ }
+ getState.state = "cancel";
+
+ function setState(s) {
+ if (!stateHandlers[s]) {
+ throw new Error("Unknown state: " + s);
+ }
+ const cur = getState.state;
+ const handler = stateHandlers[cur];
+ if (handler.end) {
+ handler.end();
+ }
+ getState.state = s;
+ if (stateHandlers[s].start) {
+ stateHandlers[s].start();
+ }
+ }
+
+ /** Various values that the states use: */
+ let mousedownPos;
+ let selectedPos;
+ let resizeDirection;
+ let resizeStartPos;
+ let resizeStartSelected;
+ let resizeHasMoved;
+ let mouseupNoAutoselect = false;
+ let autoDetectRect;
+
+ /** Represents a single x/y point, typically for a mouse click that doesn't have a drag: */
+ class Pos {
+ constructor(x, y) {
+ this.x = x;
+ this.y = y;
+ }
+
+ elementFromPoint() {
+ return ui.iframe.getElementFromPoint(
+ this.x - window.pageXOffset,
+ this.y - window.pageYOffset
+ );
+ }
+
+ distanceTo(x, y) {
+ return Math.sqrt(Math.pow(this.x - x, 2) + Math.pow(this.y - y, 2));
+ }
+ }
+
+ /** *********************************************
+ * all stateHandlers
+ */
+
+ let dataUrl;
+
+ stateHandlers.previewing = {
+ start() {
+ shooter.preview(selectedPos, captureType);
+ },
+ };
+
+ stateHandlers.crosshairs = {
+ cachedEl: null,
+
+ start() {
+ selectedPos = mousedownPos = null;
+ this.cachedEl = null;
+ watchPromise(
+ ui.iframe
+ .display(installHandlersOnDocument, standardOverlayCallbacks)
+ .then(() => {
+ ui.iframe.usePreSelection();
+ ui.Box.remove();
+ })
+ );
+ },
+
+ mousemove(event) {
+ ui.PixelDimensions.display(
+ event.pageX,
+ event.pageY,
+ event.pageX,
+ event.pageY
+ );
+ if (
+ event.target.classList &&
+ !event.target.classList.contains("preview-overlay")
+ ) {
+ // User is hovering over a toolbar button or control
+ autoDetectRect = null;
+ if (this.cachedEl) {
+ this.cachedEl = null;
+ }
+ ui.HoverBox.hide();
+ return;
+ }
+ let el;
+ if (
+ event.target.classList &&
+ event.target.classList.contains("preview-overlay")
+ ) {
+ // The hover is on the overlay, so we need to figure out the real element
+ el = ui.iframe.getElementFromPoint(
+ event.pageX + window.scrollX - window.pageXOffset,
+ event.pageY + window.scrollY - window.pageYOffset
+ );
+ const xpos = Math.floor(
+ (10 * (event.pageX - window.innerWidth / 2)) / window.innerWidth
+ );
+ const ypos = Math.floor(
+ (10 * (event.pageY - window.innerHeight / 2)) / window.innerHeight
+ );
+
+ for (let i = 0; i < 2; i++) {
+ const move = `translate(${xpos}px, ${ypos}px)`;
+ event.target.getElementsByClassName("eyeball")[i].style.transform =
+ move;
+ }
+ } else {
+ // The hover is on the element we care about, so we use that
+ el = event.target;
+ }
+ if (this.cachedEl && this.cachedEl === el) {
+ // Still hovering over the same element
+ return;
+ }
+ this.cachedEl = el;
+ this.setAutodetectBasedOnElement(el);
+ },
+
+ setAutodetectBasedOnElement(el) {
+ let lastRect;
+ let lastNode;
+ let rect;
+ let attemptExtend = false;
+ let node = el;
+ while (node) {
+ rect = Selection.getBoundingClientRect(node);
+ if (!rect) {
+ rect = lastRect;
+ break;
+ }
+ if (rect.width < MIN_DETECT_WIDTH || rect.height < MIN_DETECT_HEIGHT) {
+ // Avoid infinite loop for elements with zero or nearly zero height,
+ // like non-clearfixed float parents with or without borders.
+ break;
+ }
+ if (rect.width > MAX_DETECT_WIDTH || rect.height > MAX_DETECT_HEIGHT) {
+ // Then the last rectangle is better
+ rect = lastRect;
+ attemptExtend = true;
+ break;
+ }
+ if (
+ rect.width >= MIN_DETECT_WIDTH &&
+ rect.height >= MIN_DETECT_HEIGHT
+ ) {
+ if (!doNotAutoselectTags[node.tagName]) {
+ break;
+ }
+ }
+ lastRect = rect;
+ lastNode = node;
+ node = node.parentNode;
+ }
+ if (rect && node) {
+ const evenBetter = this.evenBetterElement(node, rect);
+ if (evenBetter) {
+ node = lastNode = evenBetter;
+ rect = Selection.getBoundingClientRect(evenBetter);
+ attemptExtend = false;
+ }
+ }
+ if (rect && attemptExtend) {
+ let extendNode = lastNode.nextSibling;
+ while (extendNode) {
+ if (extendNode.nodeType === document.ELEMENT_NODE) {
+ break;
+ }
+ extendNode = extendNode.nextSibling;
+ if (!extendNode) {
+ const parent = lastNode.parentNode;
+ for (let i = 0; i < parent.childNodes.length; i++) {
+ if (parent.childNodes[i] === lastNode) {
+ extendNode = parent.childNodes[i + 1];
+ }
+ }
+ }
+ }
+ if (extendNode) {
+ const extendSelection = Selection.getBoundingClientRect(extendNode);
+ const extendRect = rect.union(extendSelection);
+ if (
+ extendRect.width <= MAX_DETECT_WIDTH &&
+ extendRect.height <= MAX_DETECT_HEIGHT
+ ) {
+ rect = extendRect;
+ }
+ }
+ }
+
+ if (
+ rect &&
+ (rect.width < MIN_DETECT_ABSOLUTE_WIDTH ||
+ rect.height < MIN_DETECT_ABSOLUTE_HEIGHT)
+ ) {
+ rect = null;
+ }
+ if (!rect) {
+ ui.HoverBox.hide();
+ } else {
+ ui.HoverBox.display(rect);
+ }
+ autoDetectRect = rect;
+ },
+
+ /** When we find an element, maybe there's one that's just a little bit better... */
+ evenBetterElement(node, origRect) {
+ let el = node.parentNode;
+ const ELEMENT_NODE = document.ELEMENT_NODE;
+ while (el && el.nodeType === ELEMENT_NODE) {
+ if (!el.getAttribute) {
+ return null;
+ }
+ const role = el.getAttribute("role");
+ if (
+ role === "article" ||
+ (el.className &&
+ typeof el.className === "string" &&
+ el.className.search("tweet ") !== -1)
+ ) {
+ const rect = Selection.getBoundingClientRect(el);
+ if (!rect) {
+ return null;
+ }
+ if (
+ rect.width <= MAX_DETECT_WIDTH &&
+ rect.height <= MAX_DETECT_HEIGHT
+ ) {
+ return el;
+ }
+ return null;
+ }
+ el = el.parentNode;
+ }
+ return null;
+ },
+
+ mousedown(event) {
+ // FIXME: this is happening but we don't know why, we'll track it now
+ // but avoid popping up messages:
+ if (typeof ui === "undefined") {
+ const exc = new Error("Undefined ui in mousedown");
+ exc.unloadTime = unloadTime;
+ exc.nowTime = Date.now();
+ exc.noPopup = true;
+ exc.noReport = true;
+ throw exc;
+ }
+ if (ui.isHeader(event.target)) {
+ return undefined;
+ }
+ // If the pageX is greater than this, then probably it's an attempt to get
+ // to the scrollbar, or an actual scroll, and not an attempt to start the
+ // selection:
+ const maxX = window.innerWidth - SCROLLBAR_WIDTH;
+ if (event.pageX >= maxX) {
+ event.stopPropagation();
+ event.preventDefault();
+ return false;
+ }
+
+ mousedownPos = new Pos(
+ event.pageX + window.scrollX,
+ event.pageY + window.scrollY
+ );
+ setState("draggingReady");
+ event.stopPropagation();
+ event.preventDefault();
+ return false;
+ },
+
+ end() {
+ ui.HoverBox.remove();
+ ui.PixelDimensions.remove();
+ },
+ };
+
+ stateHandlers.draggingReady = {
+ minMove: 40, // px
+ minAutoImageWidth: 40,
+ minAutoImageHeight: 40,
+ maxAutoElementWidth: 800,
+ maxAutoElementHeight: 600,
+
+ start() {
+ ui.iframe.usePreSelection();
+ ui.Box.remove();
+ },
+
+ mousemove(event) {
+ if (mousedownPos.distanceTo(event.pageX, event.pageY) > this.minMove) {
+ selectedPos = new Selection(
+ mousedownPos.x,
+ mousedownPos.y,
+ event.pageX + window.scrollX,
+ event.pageY + window.scrollY
+ );
+ mousedownPos = null;
+ setState("dragging");
+ }
+ },
+
+ mouseup(event) {
+ // If we don't get into "dragging" then we attempt an autoselect
+ if (mouseupNoAutoselect) {
+ sendEvent("cancel-selection", "selection-background-mousedown");
+ setState("crosshairs");
+ return false;
+ }
+ if (autoDetectRect) {
+ selectedPos = autoDetectRect;
+ selectedPos.x1 += window.scrollX;
+ selectedPos.y1 += window.scrollY;
+ selectedPos.x2 += window.scrollX;
+ selectedPos.y2 += window.scrollY;
+ autoDetectRect = null;
+ mousedownPos = null;
+ ui.iframe.useSelection();
+ ui.Box.display(selectedPos, standardDisplayCallbacks);
+ sendEvent(
+ "make-selection",
+ "selection-click",
+ eventOptionsForBox(selectedPos)
+ );
+ setState("selected");
+ sendEvent("autoselect");
+ callBackground("captureTelemetry", "element");
+ } else {
+ sendEvent("no-selection", "no-element-found");
+ setState("crosshairs");
+ }
+ return undefined;
+ },
+
+ click(event) {
+ this.mouseup(event);
+ },
+
+ findGoodEl() {
+ let el = mousedownPos.elementFromPoint();
+ if (!el) {
+ return null;
+ }
+ const isGoodEl = element => {
+ if (element.nodeType !== document.ELEMENT_NODE) {
+ return false;
+ }
+ if (element.tagName === "IMG") {
+ const rect = element.getBoundingClientRect();
+ return (
+ rect.width >= this.minAutoImageWidth &&
+ rect.height >= this.minAutoImageHeight
+ );
+ }
+ const display = window.getComputedStyle(element).display;
+ if (["block", "inline-block", "table"].includes(display)) {
+ return true;
+ // FIXME: not sure if this is useful:
+ // let rect = el.getBoundingClientRect();
+ // return rect.width <= this.maxAutoElementWidth && rect.height <= this.maxAutoElementHeight;
+ }
+ return false;
+ };
+ while (el) {
+ if (isGoodEl(el)) {
+ return el;
+ }
+ el = el.parentNode;
+ }
+ return null;
+ },
+
+ end() {
+ mouseupNoAutoselect = false;
+ },
+ };
+
+ stateHandlers.dragging = {
+ start() {
+ ui.iframe.useSelection();
+ ui.Box.display(selectedPos);
+ },
+
+ mousemove(event) {
+ selectedPos.x2 = util.truncateX(event.pageX);
+ selectedPos.y2 = util.truncateY(event.pageY);
+ scrollIfByEdge(event.pageX, event.pageY);
+ ui.Box.display(selectedPos);
+ ui.PixelDimensions.display(
+ event.pageX,
+ event.pageY,
+ selectedPos.width,
+ selectedPos.height
+ );
+ },
+
+ mouseup(event) {
+ selectedPos.x2 = util.truncateX(event.pageX);
+ selectedPos.y2 = util.truncateY(event.pageY);
+ ui.Box.display(selectedPos, standardDisplayCallbacks);
+ sendEvent(
+ "make-selection",
+ "selection-drag",
+ eventOptionsForBox({
+ top: selectedPos.y1,
+ bottom: selectedPos.y2,
+ left: selectedPos.x1,
+ right: selectedPos.x2,
+ })
+ );
+ setState("selected");
+ callBackground("captureTelemetry", "custom");
+ },
+
+ end() {
+ ui.PixelDimensions.remove();
+ },
+ };
+
+ stateHandlers.selected = {
+ start() {
+ ui.iframe.useSelection();
+ },
+
+ mousedown(event) {
+ const target = event.target;
+ if (target.tagName === "HTML") {
+ // This happens when you click on the scrollbar
+ return undefined;
+ }
+ const direction = ui.Box.draggerDirection(target);
+ if (direction) {
+ sendEvent("start-resize-selection", "handle");
+ stateHandlers.resizing.startResize(event, direction);
+ } else if (ui.Box.isSelection(target)) {
+ sendEvent("start-move-selection", "selection");
+ stateHandlers.resizing.startResize(event, "move");
+ } else if (!ui.Box.isControl(target)) {
+ mousedownPos = new Pos(event.pageX, event.pageY);
+ setState("crosshairs");
+ }
+ event.preventDefault();
+ return false;
+ },
+ };
+
+ stateHandlers.resizing = {
+ start() {
+ ui.iframe.useSelection();
+ selectedPos.sortCoords();
+ },
+
+ startResize(event, direction) {
+ selectedPos.sortCoords();
+ resizeDirection = direction;
+ resizeStartPos = new Pos(event.pageX, event.pageY);
+ resizeStartSelected = selectedPos.clone();
+ resizeHasMoved = false;
+ setState("resizing");
+ },
+
+ mousemove(event) {
+ this._resize(event);
+ if (resizeDirection !== "move") {
+ ui.PixelDimensions.display(
+ event.pageX,
+ event.pageY,
+ selectedPos.width,
+ selectedPos.height
+ );
+ }
+ return false;
+ },
+
+ mouseup(event) {
+ this._resize(event);
+ sendEvent("selection-resized");
+ ui.Box.display(selectedPos, standardDisplayCallbacks);
+ if (resizeHasMoved) {
+ if (resizeDirection === "move") {
+ const startPos = new Pos(
+ resizeStartSelected.left,
+ resizeStartSelected.top
+ );
+ const endPos = new Pos(selectedPos.left, selectedPos.top);
+ sendEvent(
+ "move-selection",
+ "mouseup",
+ eventOptionsForMove(startPos, endPos)
+ );
+ } else {
+ sendEvent(
+ "resize-selection",
+ "mouseup",
+ eventOptionsForResize(resizeStartSelected, selectedPos)
+ );
+ }
+ } else if (resizeDirection === "move") {
+ sendEvent("keep-resize-selection", "mouseup");
+ } else {
+ sendEvent("keep-move-selection", "mouseup");
+ }
+ setState("selected");
+ callBackground("captureTelemetry", "custom");
+ },
+
+ _resize(event) {
+ const diffX = event.pageX - resizeStartPos.x;
+ const diffY = event.pageY - resizeStartPos.y;
+ const movement = movements[resizeDirection];
+ if (movement[0]) {
+ let moveX = movement[0];
+ moveX = moveX === "*" ? ["x1", "x2"] : [moveX];
+ for (const moveDir of moveX) {
+ selectedPos[moveDir] = util.truncateX(
+ resizeStartSelected[moveDir] + diffX
+ );
+ }
+ }
+ if (movement[1]) {
+ let moveY = movement[1];
+ moveY = moveY === "*" ? ["y1", "y2"] : [moveY];
+ for (const moveDir of moveY) {
+ selectedPos[moveDir] = util.truncateY(
+ resizeStartSelected[moveDir] + diffY
+ );
+ }
+ }
+ if (diffX || diffY) {
+ resizeHasMoved = true;
+ }
+ scrollIfByEdge(event.pageX, event.pageY);
+ ui.Box.display(selectedPos);
+ },
+
+ end() {
+ resizeDirection = resizeStartPos = resizeStartSelected = null;
+ selectedPos.sortCoords();
+ ui.PixelDimensions.remove();
+ },
+ };
+
+ stateHandlers.cancel = {
+ start() {
+ ui.iframe.hide();
+ ui.Box.remove();
+ },
+ };
+
+ function getDocumentWidth() {
+ return Math.max(
+ document.body && document.body.clientWidth,
+ document.documentElement.clientWidth,
+ document.body && document.body.scrollWidth,
+ document.documentElement.scrollWidth
+ );
+ }
+ function getDocumentHeight() {
+ return Math.max(
+ document.body && document.body.clientHeight,
+ document.documentElement.clientHeight,
+ document.body && document.body.scrollHeight,
+ document.documentElement.scrollHeight
+ );
+ }
+
+ function scrollIfByEdge(pageX, pageY) {
+ const top = window.scrollY;
+ const bottom = top + window.innerHeight;
+ const left = window.scrollX;
+ const right = left + window.innerWidth;
+ if (pageY + SCROLL_BY_EDGE >= bottom && bottom < getDocumentHeight()) {
+ window.scrollBy(0, SCROLL_BY_EDGE);
+ } else if (pageY - SCROLL_BY_EDGE <= top) {
+ window.scrollBy(0, -SCROLL_BY_EDGE);
+ }
+ if (pageX + SCROLL_BY_EDGE >= right && right < getDocumentWidth()) {
+ window.scrollBy(SCROLL_BY_EDGE, 0);
+ } else if (pageX - SCROLL_BY_EDGE <= left) {
+ window.scrollBy(-SCROLL_BY_EDGE, 0);
+ }
+ }
+
+ /** *********************************************
+ * Selection communication
+ */
+
+ exports.activate = function () {
+ if (!document.body) {
+ callBackground("abortStartShot");
+ const tagName = String(document.documentElement.tagName || "").replace(
+ /[^a-z0-9]/gi,
+ ""
+ );
+ sendEvent("abort-start-shot", `document-is-${tagName}`);
+ selectorLoader.unloadModules();
+ return;
+ }
+ if (isFrameset()) {
+ callBackground("abortStartShot");
+ sendEvent("abort-start-shot", "frame-page");
+ selectorLoader.unloadModules();
+ return;
+ }
+ addHandlers();
+ setState("crosshairs");
+ };
+
+ function isFrameset() {
+ return document.body.tagName === "FRAMESET";
+ }
+
+ exports.deactivate = function () {
+ try {
+ sendEvent("internal", "deactivate");
+ setState("cancel");
+ selectorLoader.unloadModules();
+ } catch (e) {
+ log.error("Error in deactivate", e);
+ // Sometimes this fires so late that the document isn't available
+ // We don't care about the exception, so we swallow it here
+ }
+ };
+
+ let unloadTime = 0;
+
+ exports.unload = function () {
+ // Note that ui.unload() will be called on its own
+ unloadTime = Date.now();
+ removeHandlers();
+ };
+
+ /** *********************************************
+ * Event handlers
+ */
+
+ const primedDocumentHandlers = new Map();
+ let registeredDocumentHandlers = [];
+
+ function addHandlers() {
+ ["mouseup", "mousedown", "mousemove", "click"].forEach(eventName => {
+ const fn = watchFunction(
+ assertIsTrusted(function (event) {
+ if (typeof event.button === "number" && event.button !== 0) {
+ // Not a left click
+ return undefined;
+ }
+ if (
+ event.ctrlKey ||
+ event.shiftKey ||
+ event.altKey ||
+ event.metaKey
+ ) {
+ // Modified click of key
+ return undefined;
+ }
+ const state = getState();
+ const handler = stateHandlers[state];
+ if (handler[event.type]) {
+ return handler[event.type](event);
+ }
+ return undefined;
+ })
+ );
+ primedDocumentHandlers.set(eventName, fn);
+ });
+ primedDocumentHandlers.set(
+ "keyup",
+ watchFunction(assertIsTrusted(keyupHandler))
+ );
+ primedDocumentHandlers.set(
+ "keydown",
+ watchFunction(assertIsTrusted(keydownHandler))
+ );
+ window.document.addEventListener(
+ "visibilitychange",
+ visibilityChangeHandler
+ );
+ window.addEventListener("beforeunload", beforeunloadHandler);
+ }
+
+ let mousedownSetOnDocument = false;
+
+ function installHandlersOnDocument(docObj) {
+ for (const [eventName, handler] of primedDocumentHandlers) {
+ const watchHandler = watchFunction(handler);
+ const useCapture = eventName !== "keyup";
+ docObj.addEventListener(eventName, watchHandler, useCapture);
+ registeredDocumentHandlers.push({
+ name: eventName,
+ doc: docObj,
+ handler: watchHandler,
+ useCapture,
+ });
+ }
+ if (!mousedownSetOnDocument) {
+ const mousedownHandler = primedDocumentHandlers.get("mousedown");
+ document.addEventListener("mousedown", mousedownHandler, true);
+ registeredDocumentHandlers.push({
+ name: "mousedown",
+ doc: document,
+ handler: mousedownHandler,
+ useCapture: true,
+ });
+ mousedownSetOnDocument = true;
+ }
+ }
+
+ function beforeunloadHandler() {
+ sendEvent("cancel-shot", "tab-load");
+ exports.deactivate();
+ }
+
+ function keydownHandler(event) {
+ // In MacOS, the keyup event for 'c' is not fired when performing cmd+c.
+ if (
+ event.code === "KeyC" &&
+ (event.ctrlKey || event.metaKey) &&
+ ["previewing", "selected"].includes(getState.state)
+ ) {
+ catcher.watchPromise(
+ callBackground("getPlatformOs").then(os => {
+ if (
+ (event.ctrlKey && os !== "mac") ||
+ (event.metaKey && os === "mac")
+ ) {
+ sendEvent("copy-shot", "keyboard-copy");
+ copyShot();
+ }
+ })
+ );
+ }
+ }
+
+ function keyupHandler(event) {
+ if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) {
+ // unused modifier keys
+ return;
+ }
+ if ((event.key || event.code) === "Escape") {
+ sendEvent("cancel-shot", "keyboard-escape");
+ exports.deactivate();
+ }
+ // Enter to trigger Download by default. But if the user tabbed to
+ // select another button, then we do not want this.
+ if (
+ (event.key || event.code) === "Enter" &&
+ getState.state === "selected" &&
+ ui.iframe.document().activeElement.tagName === "BODY"
+ ) {
+ sendEvent("download-shot", "keyboard-enter");
+ downloadShot();
+ }
+ }
+
+ function visibilityChangeHandler(event) {
+ // The document is the event target
+ if (event.target.hidden) {
+ sendEvent("internal", "document-hidden");
+ }
+ }
+
+ function removeHandlers() {
+ window.removeEventListener("beforeunload", beforeunloadHandler);
+ window.document.removeEventListener(
+ "visibilitychange",
+ visibilityChangeHandler
+ );
+ for (const {
+ name,
+ doc,
+ handler,
+ useCapture,
+ } of registeredDocumentHandlers) {
+ doc.removeEventListener(name, handler, !!useCapture);
+ }
+ registeredDocumentHandlers = [];
+ }
+
+ catcher.watchFunction(exports.activate)();
+
+ return exports;
+})();
+
+null;
diff --git a/browser/extensions/screenshots/selector/util.js b/browser/extensions/screenshots/selector/util.js
new file mode 100644
index 0000000000..31366d3047
--- /dev/null
+++ b/browser/extensions/screenshots/selector/util.js
@@ -0,0 +1,124 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+this.util = (function () {
+ // eslint-disable-line no-unused-vars
+ const exports = {};
+
+ /** Removes a node from its document, if it's a node and the node is attached to a parent */
+ exports.removeNode = function (el) {
+ if (el && el.parentNode) {
+ el.remove();
+ }
+ };
+
+ /** Truncates the X coordinate to the document size */
+ exports.truncateX = function (x) {
+ const max = Math.max(
+ document.documentElement.clientWidth,
+ document.body.clientWidth,
+ document.documentElement.scrollWidth,
+ document.body.scrollWidth
+ );
+ if (x < 0) {
+ return 0;
+ } else if (x > max) {
+ return max;
+ }
+ return x;
+ };
+
+ /** Truncates the Y coordinate to the document size */
+ exports.truncateY = function (y) {
+ const max = Math.max(
+ document.documentElement.clientHeight,
+ document.body.clientHeight,
+ document.documentElement.scrollHeight,
+ document.body.scrollHeight
+ );
+ if (y < 0) {
+ return 0;
+ } else if (y > max) {
+ return max;
+ }
+ return y;
+ };
+
+ // Pixels of wiggle the captured region gets in captureSelectedText:
+ const CAPTURE_WIGGLE = 10;
+ const ELEMENT_NODE = document.ELEMENT_NODE;
+
+ exports.captureEnclosedText = function (box) {
+ const scrollX = window.scrollX;
+ const scrollY = window.scrollY;
+ const text = [];
+ function traverse(el) {
+ let elBox = el.getBoundingClientRect();
+ elBox = {
+ top: elBox.top + scrollY,
+ bottom: elBox.bottom + scrollY,
+ left: elBox.left + scrollX,
+ right: elBox.right + scrollX,
+ };
+ if (
+ elBox.bottom < box.top ||
+ elBox.top > box.bottom ||
+ elBox.right < box.left ||
+ elBox.left > box.right
+ ) {
+ // Totally outside of the box
+ return;
+ }
+ if (
+ elBox.bottom > box.bottom + CAPTURE_WIGGLE ||
+ elBox.top < box.top - CAPTURE_WIGGLE ||
+ elBox.right > box.right + CAPTURE_WIGGLE ||
+ elBox.left < box.left - CAPTURE_WIGGLE
+ ) {
+ // Partially outside the box
+ for (let i = 0; i < el.childNodes.length; i++) {
+ const child = el.childNodes[i];
+ if (child.nodeType === ELEMENT_NODE) {
+ traverse(child);
+ }
+ }
+ return;
+ }
+ addText(el);
+ }
+ function addText(el) {
+ let t;
+ if (el.tagName === "IMG") {
+ t = el.getAttribute("alt") || el.getAttribute("title");
+ } else if (el.tagName === "A") {
+ t = el.innerText;
+ if (
+ el.getAttribute("href") &&
+ !el.getAttribute("href").startsWith("#")
+ ) {
+ t += " (" + el.href + ")";
+ }
+ } else {
+ t = el.innerText;
+ }
+ if (t) {
+ text.push(t);
+ }
+ }
+ traverse(document.body);
+ if (text.length) {
+ let result = text.join("\n");
+ result = result.replace(/^\s+/, "");
+ result = result.replace(/\s+$/, "");
+ result = result.replace(/[ \t]+\n/g, "\n");
+ return result;
+ }
+ return null;
+ };
+
+ return exports;
+})();
+null;
diff --git a/browser/extensions/screenshots/sitehelper.js b/browser/extensions/screenshots/sitehelper.js
new file mode 100644
index 0000000000..719e76dad2
--- /dev/null
+++ b/browser/extensions/screenshots/sitehelper.js
@@ -0,0 +1,63 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals catcher, callBackground, content */
+/** This is a content script added to all screenshots.firefox.com pages, and allows the site to
+ communicate with the add-on */
+
+"use strict";
+
+this.sitehelper = (function () {
+ catcher.registerHandler(errorObj => {
+ callBackground("reportError", errorObj);
+ });
+
+ const capabilities = {};
+ function registerListener(name, func) {
+ capabilities[name] = name;
+ document.addEventListener(name, func);
+ }
+
+ function sendCustomEvent(name, detail) {
+ if (typeof detail === "object") {
+ // Note sending an object can lead to security problems, while a string
+ // is safe to transfer:
+ detail = JSON.stringify(detail);
+ }
+ document.dispatchEvent(new CustomEvent(name, { detail }));
+ }
+
+ registerListener(
+ "delete-everything",
+ catcher.watchFunction(event => {
+ // FIXME: reset some data in the add-on
+ }, false)
+ );
+
+ registerListener(
+ "copy-to-clipboard",
+ catcher.watchFunction(event => {
+ catcher.watchPromise(callBackground("copyShotToClipboard", event.detail));
+ })
+ );
+
+ registerListener(
+ "show-notification",
+ catcher.watchFunction(event => {
+ catcher.watchPromise(callBackground("showNotification", event.detail));
+ })
+ );
+
+ // Depending on the script loading order, the site might get the addon-present event,
+ // but probably won't - instead the site will ask for that event after it has loaded
+ registerListener(
+ "request-addon-present",
+ catcher.watchFunction(() => {
+ sendCustomEvent("addon-present", capabilities);
+ })
+ );
+
+ sendCustomEvent("addon-present", capabilities);
+})();
+null;
diff --git a/browser/extensions/screenshots/test/browser/browser.ini b/browser/extensions/screenshots/test/browser/browser.ini
new file mode 100644
index 0000000000..cdca87ae88
--- /dev/null
+++ b/browser/extensions/screenshots/test/browser/browser.ini
@@ -0,0 +1,22 @@
+[DEFAULT]
+prefs =
+ # The Screenshots extension is disabled by default in Mochitests. We re-enable
+ # it here, since it's a more realistic configuration.
+ extensions.screenshots.disabled=false
+
+[browser_screenshot_button.js]
+[browser_screenshots_dimensions.js]
+https_first_disabled = true
+# Bug 1714237 Disabled on tsan due to timeouts interacting with the UI
+# Bug 1714210 Disabled on headless which doesnt support image data on the clipboard
+skip-if =
+ headless || tsan
+ os == 'win' # Bug 1714295
+ os == 'linux' && bits == 64 # Bug 1714295
+[browser_screenshots_download.js]
+[browser_screenshots_injection.js]
+
+support-files =
+ head.js
+ injection-page.html
+ green2vh.html
diff --git a/browser/extensions/screenshots/test/browser/browser_screenshot_button.js b/browser/extensions/screenshots/test/browser/browser_screenshot_button.js
new file mode 100644
index 0000000000..84c5758a1d
--- /dev/null
+++ b/browser/extensions/screenshots/test/browser/browser_screenshot_button.js
@@ -0,0 +1,75 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+"use strict";
+
+add_task(async function testScreenshotButtonDisabled() {
+ info("Test the Screenshots button in the panel");
+
+ CustomizableUI.addWidgetToArea(
+ "screenshot-button",
+ CustomizableUI.AREA_NAVBAR
+ );
+
+ let screenshotBtn = document.getElementById("screenshot-button");
+ Assert.ok(screenshotBtn, "The screenshots button was added to the nav bar");
+
+ await BrowserTestUtils.withNewTab("https://example.com/", () => {
+ Assert.equal(
+ screenshotBtn.disabled,
+ false,
+ "Screenshots button is enabled"
+ );
+ });
+ await BrowserTestUtils.withNewTab("about:home", () => {
+ Assert.equal(
+ screenshotBtn.disabled,
+ true,
+ "Screenshots button is now disabled"
+ );
+ });
+});
+
+add_task(async function test_disabledMultiWindow() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_GREEN_PAGE,
+ },
+ async browser => {
+ let helper = new ScreenshotsHelper(browser);
+ await helper.triggerUIFromToolbar();
+
+ let screenshotBtn = document.getElementById("screenshot-button");
+ Assert.ok(
+ screenshotBtn,
+ "The screenshots button was added to the nav bar"
+ );
+
+ info("Waiting for the preselect UI");
+ await helper.waitForUIContent(
+ helper.selector.preselectIframe,
+ helper.selector.fullPageButton
+ );
+
+ let newWin = await BrowserTestUtils.openNewBrowserWindow();
+ await BrowserTestUtils.closeWindow(newWin);
+
+ let deactivatedPromise = helper.waitForToolbarButtonDeactivation();
+ await deactivatedPromise;
+ info("Screenshots is deactivated");
+
+ await EventUtils.synthesizeAndWaitKey("VK_ESCAPE", {});
+ await BrowserTestUtils.waitForCondition(() => {
+ return !screenshotBtn.disabled;
+ });
+
+ Assert.equal(
+ screenshotBtn.disabled,
+ false,
+ "Screenshots button is enabled"
+ );
+ }
+ );
+});
diff --git a/browser/extensions/screenshots/test/browser/browser_screenshots_dimensions.js b/browser/extensions/screenshots/test/browser/browser_screenshots_dimensions.js
new file mode 100644
index 0000000000..8b32d7968b
--- /dev/null
+++ b/browser/extensions/screenshots/test/browser/browser_screenshots_dimensions.js
@@ -0,0 +1,109 @@
+add_task(async function test_fullPageScreenshot() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_GREEN_PAGE,
+ },
+ async browser => {
+ let helper = new ScreenshotsHelper(browser);
+ let contentInfo = await helper.getContentDimensions();
+ ok(contentInfo, "Got dimensions back from the content");
+
+ await helper.triggerUIFromToolbar();
+
+ await helper.clickUIElement(
+ helper.selector.preselectIframe,
+ helper.selector.fullPageButton
+ );
+
+ info("Waiting for the preview UI and the copy button");
+ await helper.waitForUIContent(
+ helper.selector.previewIframe,
+ helper.selector.copyButton
+ );
+
+ let deactivatedPromise = helper.waitForToolbarButtonDeactivation();
+ let clipboardChanged = waitForRawClipboardChange();
+
+ await helper.clickUIElement(
+ helper.selector.previewIframe,
+ helper.selector.copyButton
+ );
+
+ info("Waiting for clipboard change");
+ await clipboardChanged;
+
+ await deactivatedPromise;
+ info("Screenshots is deactivated");
+
+ let result = await getImageSizeFromClipboard(browser);
+ is(
+ result.width,
+ contentInfo.documentWidth,
+ "Got expected screenshot width"
+ );
+ is(
+ result.height,
+ contentInfo.documentHeight,
+ "Got expected screenshot height"
+ );
+ }
+ );
+});
+
+add_task(async function test_visiblePageScreenshot() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_GREEN_PAGE,
+ },
+ async browser => {
+ const DEVICE_PIXEL_RATIO = window.devicePixelRatio;
+ let helper = new ScreenshotsHelper(browser);
+ let contentInfo = await helper.getContentDimensions();
+ ok(contentInfo, "Got dimensions back from the content");
+
+ await helper.triggerUIFromToolbar();
+
+ await helper.clickUIElement(
+ helper.selector.preselectIframe,
+ helper.selector.visiblePageButton
+ );
+
+ info("Waiting for the preview UI and the copy button");
+ await helper.waitForUIContent(
+ helper.selector.previewIframe,
+ helper.selector.copyButton
+ );
+
+ let deactivatedPromise = helper.waitForToolbarButtonDeactivation();
+ let clipboardChanged = waitForRawClipboardChange();
+
+ await helper.clickUIElement(
+ helper.selector.previewIframe,
+ helper.selector.copyButton
+ );
+
+ info("Waiting for clipboard change");
+ await clipboardChanged;
+
+ await deactivatedPromise;
+ info("Screenshots is deactivated");
+
+ let result = await getImageSizeFromClipboard(browser);
+ info("result: " + JSON.stringify(result, null, 2));
+ info("contentInfo: " + JSON.stringify(contentInfo, null, 2));
+
+ is(
+ result.width,
+ contentInfo.documentWidth * DEVICE_PIXEL_RATIO,
+ "Got expected screenshot width"
+ );
+ is(
+ result.height,
+ contentInfo.clientHeight * DEVICE_PIXEL_RATIO,
+ "Got expected screenshot height"
+ );
+ }
+ );
+});
diff --git a/browser/extensions/screenshots/test/browser/browser_screenshots_download.js b/browser/extensions/screenshots/test/browser/browser_screenshots_download.js
new file mode 100644
index 0000000000..3e32f06da6
--- /dev/null
+++ b/browser/extensions/screenshots/test/browser/browser_screenshots_download.js
@@ -0,0 +1,98 @@
+add_task(async function test_fullPageScreenshot() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_GREEN_PAGE,
+ },
+
+ async browser => {
+ const tests = [
+ { title: "Green Page", expected: "Green Page.png" },
+ { title: "\tA*B\\/+?<>\u200f\x1fC ", expected: "A B__ C.png" },
+ {
+ title: "簡単".repeat(35),
+ expected: " " + "簡単".repeat(35) + ".png",
+ },
+ {
+ title: "簡単".repeat(36),
+ expected: " " + "簡単".repeat(26) + "[...].png",
+ },
+ {
+ title: "簡単".repeat(56) + "?",
+ expected: " " + "簡単".repeat(26) + "[...].png",
+ },
+ ];
+
+ for (let test of tests) {
+ info("Testing with title " + test.title);
+
+ await SpecialPowers.spawn(browser, [test.title], titleToUse => {
+ content.document.title = titleToUse;
+ });
+
+ let helper = new ScreenshotsHelper(browser);
+ await helper.triggerUIFromToolbar();
+
+ await helper.clickUIElement(
+ helper.selector.preselectIframe,
+ helper.selector.fullPageButton
+ );
+
+ info("Waiting for the preview UI and the download button");
+ await helper.waitForUIContent(
+ helper.selector.previewIframe,
+ helper.selector.downloadButton
+ );
+
+ let publicList = await Downloads.getList(Downloads.PUBLIC);
+ let downloadPromise = new Promise(resolve => {
+ let downloadView = {
+ onDownloadAdded(download) {
+ publicList.removeView(downloadView);
+ resolve(download);
+ },
+ };
+
+ publicList.addView(downloadView);
+ });
+
+ await helper.clickUIElement(
+ helper.selector.previewIframe,
+ helper.selector.downloadButton
+ );
+
+ let download = await downloadPromise;
+ let filename = PathUtils.filename(download.target.path);
+ ok(
+ filename.endsWith(test.expected),
+ "Used correct filename '" +
+ filename +
+ "', expected: '" +
+ test.expected +
+ "'"
+ );
+
+ await task_resetState();
+ }
+ }
+ );
+});
+
+// This is from browser/components/downloads/test/browser/head.js
+async function task_resetState() {
+ let publicList = await Downloads.getList(Downloads.PUBLIC);
+ let downloads = await publicList.getAll();
+ for (let download of downloads) {
+ await publicList.remove(download);
+ await download.finalize(true);
+ if (await IOUtils.exists(download.target.path)) {
+ if (Services.appinfo.OS === "WINNT") {
+ // We need to make the file writable to delete it on Windows.
+ await IOUtils.setPermissions(download.target.path, 0o600);
+ }
+ await IOUtils.remove(download.target.path);
+ }
+ }
+
+ DownloadsPanel.hidePanel();
+}
diff --git a/browser/extensions/screenshots/test/browser/browser_screenshots_injection.js b/browser/extensions/screenshots/test/browser/browser_screenshots_injection.js
new file mode 100644
index 0000000000..dba932a81d
--- /dev/null
+++ b/browser/extensions/screenshots/test/browser/browser_screenshots_injection.js
@@ -0,0 +1,82 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PATH = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+
+/**
+ * Check that web content cannot break into screenshots.
+ */
+add_task(async function test_inject_srcdoc() {
+ // If Screenshots was disabled, enable it just for this test.
+ const addon = await AddonManager.getAddonByID("screenshots@mozilla.org");
+ const isEnabled = addon.enabled;
+ if (!isEnabled) {
+ await addon.enable({ allowSystemAddons: true });
+ registerCleanupFunction(async () => {
+ await addon.disable({ allowSystemAddons: true });
+ });
+ }
+
+ await BrowserTestUtils.withNewTab(
+ TEST_PATH + "injection-page.html",
+ async browser => {
+ // Set up the content hijacking. Do this so we can see it without
+ // awaiting - the promise should never resolve.
+ let response = null;
+ let responsePromise = SpecialPowers.spawn(browser, [], () => {
+ return new Promise(resolve => {
+ // We can't pass `resolve` directly because of sandboxing.
+ // `responseHandler` gets invoked from the content page.
+ content.wrappedJSObject.responseHandler = Cu.exportFunction(function (
+ arg
+ ) {
+ resolve(arg);
+ },
+ content);
+ });
+ }).then(
+ r => {
+ ok(false, "Should not have gotten HTML but got: " + r);
+ response = r;
+ },
+ () => {
+ // Do nothing - we expect this to error when the test finishes
+ // and the actor is destroyed, while the promise still hasn't
+ // been resolved. We need to catch it in order not to throw
+ // uncaught rejection errors and inadvertently fail the test.
+ }
+ );
+
+ let error;
+ let errorPromise = new Promise(resolve => {
+ SpecialPowers.registerConsoleListener(msg => {
+ if (
+ msg.message?.match(/iframe URL does not match expected blank.html/)
+ ) {
+ error = msg;
+ resolve();
+ }
+ });
+ });
+
+ // Now try to start the screenshot flow:
+ CustomizableUI.addWidgetToArea(
+ "screenshot-button",
+ CustomizableUI.AREA_NAVBAR
+ );
+
+ let screenshotBtn = document.getElementById("screenshot-button");
+ screenshotBtn.click();
+ await Promise.race([errorPromise, responsePromise]);
+ ok(error, "Should get the relevant error: " + error?.message);
+ ok(!response, "Should not get a response from the webpage.");
+
+ SpecialPowers.postConsoleSentinel();
+ }
+ );
+});
diff --git a/browser/extensions/screenshots/test/browser/green2vh.html b/browser/extensions/screenshots/test/browser/green2vh.html
new file mode 100644
index 0000000000..bb25e2a021
--- /dev/null
+++ b/browser/extensions/screenshots/test/browser/green2vh.html
@@ -0,0 +1,23 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <style>
+ html, body {
+ padding: 0;
+ margin: 0;
+ }
+ body {
+ background: rgb(0,255,0);
+ min-height: 200vh;
+ }
+ </style>
+ <script>
+ window.addEventListener("DOMContentLoaded", () => {
+ console.log(window.screen);
+ });
+ </script>
+</head>
+<body>
+</body>
+</html>
diff --git a/browser/extensions/screenshots/test/browser/head.js b/browser/extensions/screenshots/test/browser/head.js
new file mode 100644
index 0000000000..f999df71af
--- /dev/null
+++ b/browser/extensions/screenshots/test/browser/head.js
@@ -0,0 +1,230 @@
+"use strict";
+
+const TEST_ROOT = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "http://example.com"
+);
+const TEST_GREEN_PAGE = TEST_ROOT + "green2vh.html";
+
+const gScreenshotUISelectors = {
+ preselectIframe: "#firefox-screenshots-preselection-iframe",
+ fullPageButton: "button.full-page",
+ visiblePageButton: "button.visible",
+ previewIframe: "#firefox-screenshots-preview-iframe",
+ copyButton: "button.highlight-button-copy",
+ downloadButton: "button.highlight-button-download",
+};
+
+class ScreenshotsHelper {
+ constructor(browser) {
+ this.browser = browser;
+ this.selector = gScreenshotUISelectors;
+ }
+
+ get toolbarButton() {
+ return document.getElementById("screenshot-button");
+ }
+
+ async triggerUIFromToolbar() {
+ let button = this.toolbarButton;
+ ok(
+ BrowserTestUtils.is_visible(button),
+ "The screenshot toolbar button is visible"
+ );
+ EventUtils.synthesizeMouseAtCenter(button, {});
+ // Make sure the Screenshots UI is loaded before yielding
+ await this.waitForUIContent(
+ this.selector.preselectIframe,
+ this.selector.fullPageButton
+ );
+ }
+
+ async waitForUIContent(iframeSel, elemSel) {
+ await SpecialPowers.spawn(
+ this.browser,
+ [iframeSel, elemSel],
+ async function (iframeSelector, elemSelector) {
+ info(
+ `in waitForUIContent content function, iframeSelector: ${iframeSelector}, elemSelector: ${elemSelector}`
+ );
+ let iframe;
+ await ContentTaskUtils.waitForCondition(() => {
+ iframe = content.document.querySelector(iframeSelector);
+ if (!iframe || !ContentTaskUtils.is_visible(iframe)) {
+ info("in waitForUIContent, no visible iframe yet");
+ return false;
+ }
+ let elem = iframe.contentDocument.querySelector(elemSelector);
+ info(
+ "in waitForUIContent, got visible elem: " +
+ (elem && ContentTaskUtils.is_visible(elem))
+ );
+ return elem && ContentTaskUtils.is_visible(elem);
+ });
+ // wait a frame for the screenshots UI to finish any init
+ await new content.Promise(res => content.requestAnimationFrame(res));
+ }
+ );
+ }
+
+ async clickUIElement(iframeSel, elemSel) {
+ await SpecialPowers.spawn(
+ this.browser,
+ [iframeSel, elemSel],
+ async function (iframeSelector, elemSelector) {
+ info(
+ `in clickScreenshotsUIElement content function, iframeSelector: ${iframeSelector}, elemSelector: ${elemSelector}`
+ );
+ const EventUtils = ContentTaskUtils.getEventUtils(content);
+ let iframe = content.document.querySelector(iframeSelector);
+ let elem = iframe.contentDocument.querySelector(elemSelector);
+ info(`Found the thing to click: ${elemSelector}: ${!!elem}`);
+
+ EventUtils.synthesizeMouseAtCenter(elem, {}, iframe.contentWindow);
+ await new content.Promise(res => content.requestAnimationFrame(res));
+ }
+ );
+ }
+
+ waitForToolbarButtonDeactivation() {
+ return BrowserTestUtils.waitForCondition(() => {
+ return !this.toolbarButton.style.cssText.includes("icon-highlight");
+ });
+ }
+
+ getContentDimensions() {
+ return SpecialPowers.spawn(this.browser, [], async function () {
+ let doc = content.document;
+ let rect = doc.documentElement.getBoundingClientRect();
+ return {
+ clientHeight: doc.documentElement.clientHeight,
+ clientWidth: doc.documentElement.clientWidth,
+ currentURI: doc.documentURI,
+ documentHeight: Math.round(rect.height),
+ documentWidth: Math.round(rect.width),
+ };
+ });
+ }
+}
+
+function waitForRawClipboardChange() {
+ const initialClipboardData = Date.now().toString();
+ SpecialPowers.clipboardCopyString(initialClipboardData);
+
+ let promiseChanged = BrowserTestUtils.waitForCondition(() => {
+ let data;
+ try {
+ data = getRawClipboardData("image/png");
+ } catch (e) {
+ console.log("Failed to get image/png clipboard data:", e);
+ return false;
+ }
+ return data && initialClipboardData !== data;
+ });
+ return promiseChanged;
+}
+
+function getRawClipboardData(flavor) {
+ const whichClipboard = Services.clipboard.kGlobalClipboard;
+ const xferable = Cc["@mozilla.org/widget/transferable;1"].createInstance(
+ Ci.nsITransferable
+ );
+ xferable.init(null);
+ xferable.addDataFlavor(flavor);
+ Services.clipboard.getData(xferable, whichClipboard);
+ let data = {};
+ try {
+ xferable.getTransferData(flavor, data);
+ } catch (e) {}
+ data = data.value || null;
+ return data;
+}
+
+/**
+ * A helper that returns the size of the image that was just put into the clipboard by the
+ * :screenshot command.
+ * @return The {width, height} dimension object.
+ */
+async function getImageSizeFromClipboard(browser) {
+ let flavor = "image/png";
+ let image = getRawClipboardData(flavor);
+ ok(image, "screenshot data exists on the clipboard");
+
+ // Due to the differences in how images could be stored in the clipboard the
+ // checks below are needed. The clipboard could already provide the image as
+ // byte streams or as image container. If it's not possible obtain a
+ // byte stream, the function throws.
+
+ if (image instanceof Ci.imgIContainer) {
+ image = Cc["@mozilla.org/image/tools;1"]
+ .getService(Ci.imgITools)
+ .encodeImage(image, flavor);
+ }
+
+ if (!(image instanceof Ci.nsIInputStream)) {
+ throw new Error("Unable to read image data");
+ }
+
+ const binaryStream = Cc["@mozilla.org/binaryinputstream;1"].createInstance(
+ Ci.nsIBinaryInputStream
+ );
+ binaryStream.setInputStream(image);
+ const available = binaryStream.available();
+ const buffer = new ArrayBuffer(available);
+ is(
+ binaryStream.readArrayBuffer(available, buffer),
+ available,
+ "Read expected amount of data"
+ );
+
+ // We are going to load the image in the content page to measure its size.
+ // We don't want to insert the image directly in the browser's document
+ // which could mess all sorts of things up
+ return SpecialPowers.spawn(browser, [buffer], async function (_buffer) {
+ const img = content.document.createElement("img");
+ const loaded = new Promise(r => {
+ img.addEventListener("load", r, { once: true });
+ });
+
+ const url = content.URL.createObjectURL(
+ new Blob([_buffer], { type: "image/png" })
+ );
+
+ img.src = url;
+ content.document.documentElement.appendChild(img);
+
+ info("Waiting for the clipboard image to load in the content page");
+ await loaded;
+
+ img.remove();
+ content.URL.revokeObjectURL(url);
+
+ // TODO: could get pixel data as well so we can check colors at specific locations
+ return {
+ width: img.width,
+ height: img.height,
+ };
+ });
+}
+
+add_setup(async function common_initialize() {
+ // Ensure Screenshots is initially enabled for all tests
+ const addon = await AddonManager.getAddonByID("screenshots@mozilla.org");
+ const isEnabled = addon.enabled;
+ if (!isEnabled) {
+ await addon.enable({ allowSystemAddons: true });
+ registerCleanupFunction(async () => {
+ await addon.disable({ allowSystemAddons: true });
+ });
+ }
+ // Add the Screenshots button to the toolbar for all tests
+ CustomizableUI.addWidgetToArea(
+ "screenshot-button",
+ CustomizableUI.AREA_NAVBAR
+ );
+ registerCleanupFunction(() =>
+ CustomizableUI.removeWidgetFromArea("screenshot-button")
+ );
+ let screenshotBtn = document.getElementById("screenshot-button");
+ Assert.ok(screenshotBtn, "The screenshots button was added to the nav bar");
+});
diff --git a/browser/extensions/screenshots/test/browser/injection-page.html b/browser/extensions/screenshots/test/browser/injection-page.html
new file mode 100644
index 0000000000..ffbda3b25b
--- /dev/null
+++ b/browser/extensions/screenshots/test/browser/injection-page.html
@@ -0,0 +1,23 @@
+<body>
+<script>
+let callback = function(mutationsList, observer) {
+ for (let mutation of mutationsList) {
+ let [added] = mutation.addedNodes;
+ if (added instanceof HTMLIFrameElement && added.id == "firefox-screenshots-preview-iframe") {
+ added.srcdoc = "<html></html>";
+ // Now we have to wait for the doc to be populated.
+ let interval = setInterval(() => {
+ console.log(added.contentDocument.innerHTML);
+ if (added.contentDocument.body.innerHTML) {
+ clearInterval(interval);
+ window.responseHandler(added.contentDocument.body.innerHTML);
+ }
+ }, 100);
+ observer.disconnect();
+ }
+ }
+};
+var observer = new MutationObserver(callback);
+observer.observe(document.body, {childList: true});
+</script>
+</body>
diff --git a/browser/extensions/search-detection/extension/api.js b/browser/extensions/search-detection/extension/api.js
new file mode 100644
index 0000000000..873a2ecedd
--- /dev/null
+++ b/browser/extensions/search-detection/extension/api.js
@@ -0,0 +1,264 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* global ExtensionCommon, ExtensionAPI, Services, XPCOMUtils, ExtensionUtils */
+
+const { AddonManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+);
+const { WebRequest } = ChromeUtils.importESModule(
+ "resource://gre/modules/WebRequest.sys.mjs"
+);
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonSearchEngine: "resource://gre/modules/AddonSearchEngine.sys.mjs",
+});
+
+// eslint-disable-next-line mozilla/reject-importGlobalProperties
+XPCOMUtils.defineLazyGlobalGetters(this, ["ChannelWrapper"]);
+
+XPCOMUtils.defineLazyGetter(this, "searchInitialized", () => {
+ if (Services.search.isInitialized) {
+ return Promise.resolve();
+ }
+
+ return ExtensionUtils.promiseObserved(
+ "browser-search-service",
+ (_, data) => data === "init-complete"
+ );
+});
+
+const SEARCH_TOPIC_ENGINE_MODIFIED = "browser-search-engine-modified";
+
+this.addonsSearchDetection = class extends ExtensionAPI {
+ getAPI(context) {
+ const { extension } = context;
+
+ // We want to temporarily store the first monitored URLs that have been
+ // redirected, indexed by request IDs, so that the background script can
+ // find the add-on IDs to report.
+ this.firstMatchedUrls = {};
+
+ return {
+ addonsSearchDetection: {
+ // `getMatchPatterns()` returns a map where each key is an URL pattern
+ // to monitor and its corresponding value is a list of add-on IDs
+ // (search engines).
+ //
+ // Note: We don't return a simple list of URL patterns because the
+ // background script might want to lookup add-on IDs for a given URL in
+ // the case of server-side redirects.
+ async getMatchPatterns() {
+ const patterns = {};
+
+ try {
+ await searchInitialized;
+ const visibleEngines = await Services.search.getEngines();
+
+ visibleEngines.forEach(engine => {
+ if (!(engine instanceof lazy.AddonSearchEngine)) {
+ return;
+ }
+ const { _extensionID, _urls } = engine.wrappedJSObject;
+
+ if (!_extensionID) {
+ // OpenSearch engines don't have an extension ID.
+ return;
+ }
+
+ _urls
+ // We only want to collect "search URLs" (and not "suggestion"
+ // ones for instance). See `URL_TYPE` in `SearchUtils.jsm`.
+ .filter(({ type }) => type === "text/html")
+ .forEach(({ template }) => {
+ // If this is changed, double check the code in the background
+ // script because `webRequestCancelledHandler` splits patterns
+ // on `*` to retrieve URL prefixes.
+ const pattern = template.split("?")[0] + "*";
+
+ // Multiple search engines could register URL templates that
+ // would become the same URL pattern as defined above so we
+ // store a list of extension IDs per URL pattern.
+ if (!patterns[pattern]) {
+ patterns[pattern] = [];
+ }
+
+ // We exclude built-in search engines because we don't need
+ // to report them.
+ if (
+ !patterns[pattern].includes(_extensionID) &&
+ !_extensionID.endsWith("@search.mozilla.org")
+ ) {
+ patterns[pattern].push(_extensionID);
+ }
+ });
+ });
+ } catch (err) {
+ console.error(err);
+ }
+
+ return patterns;
+ },
+
+ // `getAddonVersion()` returns the add-on version if it exists.
+ async getAddonVersion(addonId) {
+ const addon = await AddonManager.getAddonByID(addonId);
+
+ return addon && addon.version;
+ },
+
+ // `getPublicSuffix()` returns the public suffix/Effective TLD Service
+ // of the given URL.
+ // See: https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIEffectiveTLDService
+ async getPublicSuffix(url) {
+ try {
+ return Services.eTLD.getBaseDomain(Services.io.newURI(url));
+ } catch (err) {
+ console.error(err);
+ return null;
+ }
+ },
+
+ // `onSearchEngineModified` is an event that occurs when the list of
+ // search engines has changed, e.g., a new engine has been added or an
+ // engine has been removed.
+ //
+ // See: https://searchfox.org/mozilla-central/source/toolkit/components/search/SearchUtils.jsm#145-152
+ onSearchEngineModified: new ExtensionCommon.EventManager({
+ context,
+ name: "addonsSearchDetection.onSearchEngineModified",
+ register: fire => {
+ const onSearchEngineModifiedObserver = (
+ aSubject,
+ aTopic,
+ aData
+ ) => {
+ if (
+ aTopic !== SEARCH_TOPIC_ENGINE_MODIFIED ||
+ // We are only interested in these modified types.
+ !["engine-added", "engine-removed", "engine-changed"].includes(
+ aData
+ )
+ ) {
+ return;
+ }
+
+ fire.async();
+ };
+
+ Services.obs.addObserver(
+ onSearchEngineModifiedObserver,
+ SEARCH_TOPIC_ENGINE_MODIFIED
+ );
+
+ return () => {
+ Services.obs.removeObserver(
+ onSearchEngineModifiedObserver,
+ SEARCH_TOPIC_ENGINE_MODIFIED
+ );
+ };
+ },
+ }).api(),
+
+ // `onRedirected` is an event fired after a request has stopped and
+ // this request has been redirected once or more. The registered
+ // listeners will received the following properties:
+ //
+ // - `addonId`: the add-on ID that redirected the request, if any.
+ // - `firstUrl`: the first monitored URL of the request that has
+ // been redirected.
+ // - `lastUrl`: the last URL loaded for the request, after one or
+ // more redirects.
+ onRedirected: new ExtensionCommon.EventManager({
+ context,
+ name: "addonsSearchDetection.onRedirected",
+ register: (fire, filter) => {
+ const stopListener = event => {
+ if (event.type != "stop") {
+ return;
+ }
+
+ const wrapper = event.currentTarget;
+ const { channel, id: requestId } = wrapper;
+
+ // When we detected a redirect, we read the request property,
+ // hoping to find an add-on ID corresponding to the add-on that
+ // initiated the redirect. It might not return anything when the
+ // redirect is a search server-side redirect but it can also be
+ // caused by an error.
+ let addonId;
+ try {
+ addonId = channel
+ ?.QueryInterface(Ci.nsIPropertyBag)
+ ?.getProperty("redirectedByExtension");
+ } catch (err) {
+ console.error(err);
+ }
+
+ const firstUrl = this.firstMatchedUrls[requestId];
+ // We don't need this entry anymore.
+ delete this.firstMatchedUrls[requestId];
+
+ const lastUrl = wrapper.finalURL;
+
+ if (!firstUrl || !lastUrl) {
+ // Something went wrong but there is nothing we can do at this
+ // point.
+ return;
+ }
+
+ fire.sync({ addonId, firstUrl, lastUrl });
+ };
+
+ const listener = ({ requestId, url, originUrl }) => {
+ // We exclude requests not originating from the location bar,
+ // bookmarks and other "system-ish" requests.
+ if (originUrl !== undefined) {
+ return;
+ }
+
+ // Keep the first monitored URL that was redirected for the
+ // request until the request has stopped.
+ if (!this.firstMatchedUrls[requestId]) {
+ this.firstMatchedUrls[requestId] = url;
+
+ const wrapper = ChannelWrapper.getRegisteredChannel(
+ requestId,
+ context.extension.policy,
+ context.xulBrowser.frameLoader.remoteTab
+ );
+
+ wrapper.addEventListener("stop", stopListener);
+ }
+ };
+
+ WebRequest.onBeforeRedirect.addListener(
+ listener,
+ // filter
+ {
+ types: ["main_frame"],
+ urls: ExtensionUtils.parseMatchPatterns(filter.urls),
+ },
+ // info
+ [],
+ // listener details
+ {
+ addonId: extension.id,
+ policy: extension.policy,
+ blockingAllowed: false,
+ }
+ );
+
+ return () => {
+ WebRequest.onBeforeRedirect.removeListener(listener);
+ };
+ },
+ }).api(),
+ },
+ };
+ }
+};
diff --git a/browser/extensions/search-detection/extension/background.js b/browser/extensions/search-detection/extension/background.js
new file mode 100644
index 0000000000..043bb0243f
--- /dev/null
+++ b/browser/extensions/search-detection/extension/background.js
@@ -0,0 +1,178 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* global browser */
+
+const TELEMETRY_CATEGORY = "addonsSearchDetection";
+// methods
+const TELEMETRY_METHOD_ETLD_CHANGE = "etld_change";
+// objects
+const TELEMETRY_OBJECT_WEBREQUEST = "webrequest";
+const TELEMETRY_OBJECT_OTHER = "other";
+// values
+const TELEMETRY_VALUE_EXTENSION = "extension";
+const TELEMETRY_VALUE_SERVER = "server";
+
+class AddonsSearchDetection {
+ constructor() {
+ // The key is an URL pattern to monitor and its corresponding value is a
+ // list of add-on IDs.
+ this.matchPatterns = {};
+
+ browser.telemetry.registerEvents(TELEMETRY_CATEGORY, {
+ [TELEMETRY_METHOD_ETLD_CHANGE]: {
+ methods: [TELEMETRY_METHOD_ETLD_CHANGE],
+ objects: [TELEMETRY_OBJECT_WEBREQUEST, TELEMETRY_OBJECT_OTHER],
+ extra_keys: ["addonId", "addonVersion", "from", "to"],
+ record_on_release: true,
+ },
+ });
+
+ this.onRedirectedListener = this.onRedirectedListener.bind(this);
+ }
+
+ async getMatchPatterns() {
+ try {
+ this.matchPatterns =
+ await browser.addonsSearchDetection.getMatchPatterns();
+ } catch (err) {
+ console.error(`failed to retrieve the list of URL patterns: ${err}`);
+ this.matchPatterns = {};
+ }
+
+ return this.matchPatterns;
+ }
+
+ // When the search service changes the set of engines that are enabled, we
+ // update our pattern matching in the webrequest listeners (go to the bottom
+ // of this file for the search service events we listen to).
+ async monitor() {
+ // If there is already a listener, remove it so that we can re-add one
+ // after. This is because we're using the same listener with different URL
+ // patterns (when the list of search engines changes).
+ if (
+ browser.addonsSearchDetection.onRedirected.hasListener(
+ this.onRedirectedListener
+ )
+ ) {
+ browser.addonsSearchDetection.onRedirected.removeListener(
+ this.onRedirectedListener
+ );
+ }
+ // If there is already a listener, remove it so that we can re-add one
+ // after. This is because we're using the same listener with different URL
+ // patterns (when the list of search engines changes).
+ if (browser.webRequest.onBeforeRequest.hasListener(this.noOpListener)) {
+ browser.webRequest.onBeforeRequest.removeListener(this.noOpListener);
+ }
+
+ // Retrieve the list of URL patterns to monitor with our listener.
+ //
+ // Note: search suggestions are system principal requests, so webRequest
+ // cannot intercept them.
+ const matchPatterns = await this.getMatchPatterns();
+ const patterns = Object.keys(matchPatterns);
+
+ if (patterns.length === 0) {
+ return;
+ }
+
+ browser.webRequest.onBeforeRequest.addListener(
+ this.noOpListener,
+ { types: ["main_frame"], urls: patterns },
+ ["blocking"]
+ );
+
+ browser.addonsSearchDetection.onRedirected.addListener(
+ this.onRedirectedListener,
+ { urls: patterns }
+ );
+ }
+
+ // This listener is required to force the registration of traceable channels.
+ noOpListener() {
+ // Do nothing.
+ }
+
+ async onRedirectedListener({ addonId, firstUrl, lastUrl }) {
+ // When we do not have an add-on ID (in the request property bag), we
+ // likely detected a search server-side redirect.
+ const maybeServerSideRedirect = !addonId;
+
+ let addonIds = [];
+ // Search server-side redirects are possible because an extension has
+ // registered a search engine, which is why we can (hopefully) retrieve the
+ // add-on ID.
+ if (maybeServerSideRedirect) {
+ addonIds = this.getAddonIdsForUrl(firstUrl);
+ } else if (addonId) {
+ addonIds = [addonId];
+ }
+
+ if (addonIds.length === 0) {
+ // No add-on ID means there is nothing we can report.
+ return;
+ }
+
+ // This is the monitored URL that was first redirected.
+ const from = await browser.addonsSearchDetection.getPublicSuffix(firstUrl);
+ // This is the final URL after redirect(s).
+ const to = await browser.addonsSearchDetection.getPublicSuffix(lastUrl);
+
+ if (from === to) {
+ // We do not want to report redirects to same public suffixes. However,
+ // we will report redirects from public suffixes belonging to a same
+ // entity (.e.g., `example.com` -> `example.fr`).
+ //
+ // Known limitation: if a redirect chain starts and ends with the same
+ // public suffix, we won't report any event, even if the chain contains
+ // different public suffixes in between.
+ return;
+ }
+
+ const telemetryObject = maybeServerSideRedirect
+ ? TELEMETRY_OBJECT_OTHER
+ : TELEMETRY_OBJECT_WEBREQUEST;
+ const telemetryValue = maybeServerSideRedirect
+ ? TELEMETRY_VALUE_SERVER
+ : TELEMETRY_VALUE_EXTENSION;
+
+ for (const id of addonIds) {
+ const addonVersion = await browser.addonsSearchDetection.getAddonVersion(
+ id
+ );
+ const extra = { addonId: id, addonVersion, from, to };
+
+ browser.telemetry.recordEvent(
+ TELEMETRY_CATEGORY,
+ TELEMETRY_METHOD_ETLD_CHANGE,
+ telemetryObject,
+ telemetryValue,
+ extra
+ );
+ }
+ }
+
+ getAddonIdsForUrl(url) {
+ for (const pattern of Object.keys(this.matchPatterns)) {
+ // `getMatchPatterns()` returns the prefix plus "*".
+ const urlPrefix = pattern.slice(0, -1);
+
+ if (url.startsWith(urlPrefix)) {
+ return this.matchPatterns[pattern];
+ }
+ }
+
+ return [];
+ }
+}
+
+const exp = new AddonsSearchDetection();
+exp.monitor();
+
+browser.addonsSearchDetection.onSearchEngineModified.addListener(async () => {
+ await exp.monitor();
+});
diff --git a/browser/extensions/search-detection/extension/manifest.json b/browser/extensions/search-detection/extension/manifest.json
new file mode 100644
index 0000000000..bbfa304654
--- /dev/null
+++ b/browser/extensions/search-detection/extension/manifest.json
@@ -0,0 +1,32 @@
+{
+ "manifest_version": 2,
+ "name": "Add-ons Search Detection",
+ "hidden": true,
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "addons-search-detection@mozilla.com"
+ }
+ },
+ "version": "2.0.0",
+ "description": "",
+ "experiment_apis": {
+ "addonsSearchDetection": {
+ "schema": "schema.json",
+ "parent": {
+ "scopes": ["addon_parent"],
+ "script": "api.js",
+ "events": [],
+ "paths": [["addonsSearchDetection"]]
+ }
+ }
+ },
+ "permissions": [
+ "<all_urls>",
+ "telemetry",
+ "webRequest",
+ "webRequestBlocking"
+ ],
+ "background": {
+ "scripts": ["background.js"]
+ }
+}
diff --git a/browser/extensions/search-detection/extension/schema.json b/browser/extensions/search-detection/extension/schema.json
new file mode 100644
index 0000000000..e3c77e3f3d
--- /dev/null
+++ b/browser/extensions/search-detection/extension/schema.json
@@ -0,0 +1,60 @@
+[
+ {
+ "namespace": "addonsSearchDetection",
+ "functions": [
+ {
+ "name": "getMatchPatterns",
+ "type": "function",
+ "async": true,
+ "parameters": []
+ },
+ {
+ "name": "getAddonVersion",
+ "type": "function",
+ "async": true,
+ "parameters": [{ "name": "addonId", "type": "string" }]
+ },
+ {
+ "name": "getPublicSuffix",
+ "type": "function",
+ "async": true,
+ "parameters": [{ "name": "url", "type": "string" }]
+ }
+ ],
+ "events": [
+ {
+ "name": "onSearchEngineModified",
+ "type": "function",
+ "parameters": []
+ },
+ {
+ "name": "onRedirected",
+ "type": "function",
+ "parameters": [
+ {
+ "name": "details",
+ "type": "object",
+ "properties": {
+ "addonId": { "type": "string" },
+ "firstUrl": { "type": "string" },
+ "lastUrl": { "type": "string" }
+ }
+ }
+ ],
+ "extraParameters": [
+ {
+ "name": "filter",
+ "type": "object",
+ "properties": {
+ "urls": {
+ "type": "array",
+ "items": { "type": "string" },
+ "minItems": 1
+ }
+ }
+ }
+ ]
+ }
+ ]
+ }
+]
diff --git a/browser/extensions/search-detection/jar.mn b/browser/extensions/search-detection/jar.mn
new file mode 100644
index 0000000000..377c2be080
--- /dev/null
+++ b/browser/extensions/search-detection/jar.mn
@@ -0,0 +1,7 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+browser.jar:
+% resource builtin-addons %builtin-addons/ contentaccessible=yes
+ builtin-addons/search-detection/ (extension/**)
diff --git a/browser/extensions/search-detection/moz.build b/browser/extensions/search-detection/moz.build
new file mode 100644
index 0000000000..7aa40597b1
--- /dev/null
+++ b/browser/extensions/search-detection/moz.build
@@ -0,0 +1,10 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+JAR_MANIFESTS += ["jar.mn"]
+
+BROWSER_CHROME_MANIFESTS += ["tests/browser/browser.ini"]
+
+with Files("**"):
+ BUG_COMPONENT = ("WebExtensions", "General")
diff --git a/browser/extensions/search-detection/tests/browser/.eslintrc.js b/browser/extensions/search-detection/tests/browser/.eslintrc.js
new file mode 100644
index 0000000000..e57058ecb1
--- /dev/null
+++ b/browser/extensions/search-detection/tests/browser/.eslintrc.js
@@ -0,0 +1,7 @@
+"use strict";
+
+module.exports = {
+ env: {
+ webextensions: true,
+ },
+};
diff --git a/browser/extensions/search-detection/tests/browser/browser.ini b/browser/extensions/search-detection/tests/browser/browser.ini
new file mode 100644
index 0000000000..1bd22fe386
--- /dev/null
+++ b/browser/extensions/search-detection/tests/browser/browser.ini
@@ -0,0 +1,9 @@
+[DEFAULT]
+support-files =
+ redirect.sjs
+
+[browser_client_side_redirection.js]
+[browser_extension_loaded.js]
+[browser_server_side_redirection.js]
+skip-if =
+ os == "win" && os_version == "6.1" # Skip on Azure - frequent failure
diff --git a/browser/extensions/search-detection/tests/browser/browser_client_side_redirection.js b/browser/extensions/search-detection/tests/browser/browser_client_side_redirection.js
new file mode 100644
index 0000000000..5dad39dba4
--- /dev/null
+++ b/browser/extensions/search-detection/tests/browser/browser_client_side_redirection.js
@@ -0,0 +1,204 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+
+const TELEMETRY_EVENTS_FILTERS = {
+ category: "addonsSearchDetection",
+ method: "etld_change",
+};
+
+// The search-detection built-in add-on registers dynamic events.
+const TELEMETRY_TEST_UTILS_OPTIONS = { clear: true, process: "dynamic" };
+
+async function testClientSideRedirect({
+ background,
+ permissions,
+ telemetryExpected = false,
+}) {
+ Services.telemetry.clearEvents();
+
+ // Load an extension that does a client-side redirect. We expect this
+ // extension to be reported in a Telemetry event when `telemetryExpected` is
+ // set to `true`.
+ const addonId = "some@addon-id";
+ const addonVersion = "1.2.3";
+
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ version: addonVersion,
+ browser_specific_settings: { gecko: { id: addonId } },
+ permissions,
+ },
+ useAddonManager: "temporary",
+ background,
+ });
+
+ await extension.startup();
+ await extension.awaitMessage("ready");
+
+ // Simulate a search (with the test search engine) by navigating to it.
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "https://example.com/search?q=babar",
+ },
+ () => {}
+ );
+
+ await extension.unload();
+
+ TelemetryTestUtils.assertEvents(
+ telemetryExpected
+ ? [
+ {
+ object: "webrequest",
+ value: "extension",
+ extra: {
+ addonId,
+ addonVersion,
+ from: "example.com",
+ to: "mochi.test",
+ },
+ },
+ ]
+ : [],
+ TELEMETRY_EVENTS_FILTERS,
+ TELEMETRY_TEST_UTILS_OPTIONS
+ );
+}
+
+add_setup(async function () {
+ const searchEngineName = "test search engine";
+
+ let searchEngine;
+
+ // This cleanup function has to be registered before the one registered
+ // internally by loadExtension, otherwise it is going to trigger a test
+ // failure (because it will be called too late).
+ registerCleanupFunction(async () => {
+ await searchEngine.unload();
+ ok(
+ !Services.search.getEngineByName(searchEngineName),
+ "test search engine unregistered"
+ );
+ });
+
+ searchEngine = ExtensionTestUtils.loadExtension({
+ manifest: {
+ chrome_settings_overrides: {
+ search_provider: {
+ name: searchEngineName,
+ keyword: "test",
+ search_url: "https://example.com/?q={searchTerms}",
+ },
+ },
+ },
+ // NOTE: the search extension needs to be installed through the
+ // AddonManager to be correctly unregistered when it is uninstalled.
+ useAddonManager: "temporary",
+ });
+
+ await searchEngine.startup();
+ await AddonTestUtils.waitForSearchProviderStartup(searchEngine);
+ ok(
+ Services.search.getEngineByName(searchEngineName),
+ "test search engine registered"
+ );
+});
+
+add_task(function test_onBeforeRequest() {
+ return testClientSideRedirect({
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ () => {
+ return {
+ redirectUrl: "http://mochi.test:8888/",
+ };
+ },
+ { urls: ["*://example.com/*"] },
+ ["blocking"]
+ );
+
+ browser.test.sendMessage("ready");
+ },
+ permissions: ["webRequest", "webRequestBlocking", "*://example.com/*"],
+ telemetryExpected: true,
+ });
+});
+
+add_task(function test_onBeforeRequest_url_not_monitored() {
+ // Here, we load an extension that does a client-side redirect. Because this
+ // extension does not listen to the URL of the search engine registered
+ // above, we don't expect this extension to be reported in a Telemetry event.
+ return testClientSideRedirect({
+ background() {
+ browser.webRequest.onBeforeRequest.addListener(
+ () => {
+ return {
+ redirectUrl: "http://mochi.test:8888/",
+ };
+ },
+ { urls: ["*://google.com/*"] },
+ ["blocking"]
+ );
+
+ browser.test.sendMessage("ready");
+ },
+ permissions: ["webRequest", "webRequestBlocking", "*://google.com/*"],
+ telemetryExpected: false,
+ });
+});
+
+add_task(function test_onHeadersReceived() {
+ return testClientSideRedirect({
+ background() {
+ browser.webRequest.onHeadersReceived.addListener(
+ () => {
+ return {
+ redirectUrl: "http://mochi.test:8888/",
+ };
+ },
+ { urls: ["*://example.com/*"], types: ["main_frame"] },
+ ["blocking"]
+ );
+
+ browser.test.sendMessage("ready");
+ },
+ permissions: ["webRequest", "webRequestBlocking", "*://example.com/*"],
+ telemetryExpected: true,
+ });
+});
+
+add_task(function test_onHeadersReceived_url_not_monitored() {
+ // Here, we load an extension that does a client-side redirect. Because this
+ // extension does not listen to the URL of the search engine registered
+ // above, we don't expect this extension to be reported in a Telemetry event.
+ return testClientSideRedirect({
+ background() {
+ browser.webRequest.onHeadersReceived.addListener(
+ () => {
+ return {
+ redirectUrl: "http://mochi.test:8888/",
+ };
+ },
+ { urls: ["*://google.com/*"], types: ["main_frame"] },
+ ["blocking"]
+ );
+
+ browser.test.sendMessage("ready");
+ },
+ permissions: ["webRequest", "webRequestBlocking", "*://google.com/*"],
+ telemetryExpected: false,
+ });
+});
diff --git a/browser/extensions/search-detection/tests/browser/browser_extension_loaded.js b/browser/extensions/search-detection/tests/browser/browser_extension_loaded.js
new file mode 100644
index 0000000000..65f6ed09a8
--- /dev/null
+++ b/browser/extensions/search-detection/tests/browser/browser_extension_loaded.js
@@ -0,0 +1,15 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_searchDetection_isActive() {
+ let addon = await AddonManager.getAddonByID(
+ "addons-search-detection@mozilla.com"
+ );
+
+ ok(addon, "Add-on exists");
+ ok(addon.isActive, "Add-on is active");
+ ok(addon.isBuiltin, "Add-on is built-in");
+ ok(addon.hidden, "Add-on is hidden");
+});
diff --git a/browser/extensions/search-detection/tests/browser/browser_server_side_redirection.js b/browser/extensions/search-detection/tests/browser/browser_server_side_redirection.js
new file mode 100644
index 0000000000..ea235406a4
--- /dev/null
+++ b/browser/extensions/search-detection/tests/browser/browser_server_side_redirection.js
@@ -0,0 +1,260 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+
+const TELEMETRY_EVENTS_FILTERS = {
+ category: "addonsSearchDetection",
+ method: "etld_change",
+};
+
+// The search-detection built-in add-on registers dynamic events.
+const TELEMETRY_TEST_UTILS_OPTIONS = { clear: true, process: "dynamic" };
+
+const REDIRECT_SJS =
+ "browser/browser/extensions/search-detection/tests/browser/redirect.sjs?q={searchTerms}";
+// This URL will redirect to `example.net`, which is different than
+// `*.example.com`. That will be the final URL of a redirect chain:
+// www.example.com -> example.net
+const SEARCH_URL_WWW = `https://www.example.com/${REDIRECT_SJS}`;
+// This URL will redirect to `www.example.com`, which will create a redirect
+// chain with two hops:
+// test2.example.com -> www.example.com -> example.net
+const SEARCH_URL_TEST2 = `https://test2.example.com/${REDIRECT_SJS}`;
+// This URL will redirect to `test2.example.com`, which will create a redirect
+// chain with three hops:
+// test1.example.com -> test2.example.com -> www.example.com -> example.net
+const SEARCH_URL_TEST1 = `https://test1.example.com/${REDIRECT_SJS}`;
+
+const TEST_SEARCH_ENGINE_ADDON_ID = "some@addon-id";
+const TEST_SEARCH_ENGINE_ADDON_VERSION = "4.5.6";
+
+const testServerSideRedirect = async ({
+ searchURL,
+ expectedEvents,
+ tabURL,
+}) => {
+ Services.telemetry.clearEvents();
+
+ const searchEngineName = "test search engine";
+ // Load a default search engine because the add-on we are testing here
+ // monitors the search engines.
+ const searchEngine = ExtensionTestUtils.loadExtension({
+ manifest: {
+ version: TEST_SEARCH_ENGINE_ADDON_VERSION,
+ browser_specific_settings: {
+ gecko: { id: TEST_SEARCH_ENGINE_ADDON_ID },
+ },
+ chrome_settings_overrides: {
+ search_provider: {
+ name: searchEngineName,
+ keyword: "test",
+ search_url: searchURL,
+ },
+ },
+ },
+ useAddonManager: "temporary",
+ });
+
+ await searchEngine.startup();
+ ok(
+ Services.search.getEngineByName(searchEngineName),
+ "test search engine registered"
+ );
+ await AddonTestUtils.waitForSearchProviderStartup(searchEngine);
+
+ // Simulate a search (with the test search engine) by navigating to it.
+ const url = tabURL || searchURL.replace("{searchTerms}", "some terms");
+ await BrowserTestUtils.withNewTab("about:blank", async browser => {
+ // Wait for the tab to be fully loaded.
+ let loaded = BrowserTestUtils.browserLoaded(browser);
+ BrowserTestUtils.loadURIString(browser, url);
+ await loaded;
+ });
+
+ await searchEngine.unload();
+ ok(
+ !Services.search.getEngineByName(searchEngineName),
+ "test search engine unregistered"
+ );
+
+ TelemetryTestUtils.assertEvents(
+ expectedEvents,
+ TELEMETRY_EVENTS_FILTERS,
+ TELEMETRY_TEST_UTILS_OPTIONS
+ );
+};
+
+add_task(function test_redirect_final() {
+ return testServerSideRedirect({
+ // www.example.com -> example.net
+ searchURL: SEARCH_URL_WWW,
+ expectedEvents: [
+ {
+ object: "other",
+ value: "server",
+ extra: {
+ addonId: TEST_SEARCH_ENGINE_ADDON_ID,
+ addonVersion: TEST_SEARCH_ENGINE_ADDON_VERSION,
+ from: "example.com",
+ to: "example.net",
+ },
+ },
+ ],
+ });
+});
+
+add_task(function test_redirect_two_hops() {
+ return testServerSideRedirect({
+ // test2.example.com -> www.example.com -> example.net
+ searchURL: SEARCH_URL_TEST2,
+ expectedEvents: [
+ {
+ object: "other",
+ value: "server",
+ extra: {
+ addonId: TEST_SEARCH_ENGINE_ADDON_ID,
+ addonVersion: TEST_SEARCH_ENGINE_ADDON_VERSION,
+ from: "example.com",
+ to: "example.net",
+ },
+ },
+ ],
+ });
+});
+
+add_task(function test_redirect_three_hops() {
+ return testServerSideRedirect({
+ // test1.example.com -> test2.example.com -> www.example.com -> example.net
+ searchURL: SEARCH_URL_TEST1,
+ expectedEvents: [
+ {
+ object: "other",
+ value: "server",
+ extra: {
+ addonId: TEST_SEARCH_ENGINE_ADDON_ID,
+ addonVersion: TEST_SEARCH_ENGINE_ADDON_VERSION,
+ from: "example.com",
+ to: "example.net",
+ },
+ },
+ ],
+ });
+});
+
+add_task(function test_no_event_when_search_engine_not_used() {
+ return testServerSideRedirect({
+ // www.example.com -> example.net
+ searchURL: SEARCH_URL_WWW,
+ // We do not expect any events because the user is not using the search
+ // engine that was registered.
+ tabURL: "http://mochi.test:8888/search?q=foobar",
+ expectedEvents: [],
+ });
+});
+
+add_task(function test_redirect_chain_does_not_start_on_first_request() {
+ return testServerSideRedirect({
+ // www.example.com -> example.net
+ searchURL: SEARCH_URL_WWW,
+ // User first navigates to an URL that isn't monitored and will be
+ // redirected to another URL that is monitored.
+ tabURL: `http://mochi.test:8888/browser/browser/extensions/search-detection/tests/browser/redirect.sjs?q={searchTerms}`,
+ expectedEvents: [
+ {
+ object: "other",
+ value: "server",
+ extra: {
+ addonId: TEST_SEARCH_ENGINE_ADDON_ID,
+ addonVersion: TEST_SEARCH_ENGINE_ADDON_VERSION,
+ // We expect this and not `mochi.test` because we do not monitor
+ // `mochi.test`, only `example.com`, which is coming from the search
+ // engine registered in the test setup.
+ from: "example.com",
+ to: "example.net",
+ },
+ },
+ ],
+ });
+});
+
+add_task(async function test_two_extensions_reported() {
+ Services.telemetry.clearEvents();
+
+ const searchEngines = [];
+ for (const [addonId, addonVersion, isDefault] of [
+ ["1-addon@guid", "1.2", false],
+ ["2-addon@guid", "3.4", true],
+ ]) {
+ const searchEngine = ExtensionTestUtils.loadExtension({
+ manifest: {
+ version: addonVersion,
+ browser_specific_settings: {
+ gecko: { id: addonId },
+ },
+ chrome_settings_overrides: {
+ search_provider: {
+ is_default: isDefault,
+ name: `test search engine - ${addonId}`,
+ keyword: "test",
+ search_url: `${SEARCH_URL_WWW}&id=${addonId}`,
+ },
+ },
+ },
+ useAddonManager: "temporary",
+ });
+
+ await searchEngine.startup();
+ await AddonTestUtils.waitForSearchProviderStartup(searchEngine);
+
+ searchEngines.push(searchEngine);
+ }
+
+ // Simulate a search by navigating to it.
+ const url = SEARCH_URL_WWW.replace("{searchTerms}", "some terms");
+ await BrowserTestUtils.withNewTab("about:blank", async browser => {
+ // Wait for the tab to be fully loaded.
+ let loaded = BrowserTestUtils.browserLoaded(browser);
+ BrowserTestUtils.loadURIString(browser, url);
+ await loaded;
+ });
+
+ await Promise.all(searchEngines.map(engine => engine.unload()));
+
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ object: "other",
+ value: "server",
+ extra: {
+ addonId: "1-addon@guid",
+ addonVersion: "1.2",
+ from: "example.com",
+ to: "example.net",
+ },
+ },
+ {
+ object: "other",
+ value: "server",
+ extra: {
+ addonId: "2-addon@guid",
+ addonVersion: "3.4",
+ from: "example.com",
+ to: "example.net",
+ },
+ },
+ ],
+ TELEMETRY_EVENTS_FILTERS,
+ TELEMETRY_TEST_UTILS_OPTIONS
+ );
+});
diff --git a/browser/extensions/search-detection/tests/browser/redirect.sjs b/browser/extensions/search-detection/tests/browser/redirect.sjs
new file mode 100644
index 0000000000..27cb29b32e
--- /dev/null
+++ b/browser/extensions/search-detection/tests/browser/redirect.sjs
@@ -0,0 +1,32 @@
+const REDIRECT_SJS =
+ "browser/browser/extensions/search-detection/tests/browser/redirect.sjs";
+
+// This handler is used to create redirect chains with multiple sub-domains,
+// and the next hop is defined by the current `host`.
+function handleRequest(request, response) {
+ let newLocation;
+
+ // test1.example.com -> test2.example.com -> www.example.com -> example.net
+ switch (request.host) {
+ case "test1.example.com":
+ newLocation = `https://test2.example.com/${REDIRECT_SJS}`;
+ break;
+ case "test2.example.com":
+ newLocation = `https://www.example.com/${REDIRECT_SJS}`;
+ break;
+ case "www.example.com":
+ newLocation = "https://example.net/";
+ break;
+ // We redirect `mochi.test` to `www` in
+ // `test_redirect_chain_does_not_start_on_first_request()`.
+ case "mochi.test":
+ newLocation = `https://www.example.com/${REDIRECT_SJS}`;
+ break;
+ default:
+ // Redirect to a different website in case of unexpected events.
+ newLocation = "https://mozilla.org/";
+ }
+
+ response.setStatusLine(request.httpVersion, 302, "Found");
+ response.setHeader("Location", newLocation);
+}
diff --git a/browser/extensions/webcompat/about-compat/AboutCompat.jsm b/browser/extensions/webcompat/about-compat/AboutCompat.jsm
new file mode 100644
index 0000000000..7c844726f8
--- /dev/null
+++ b/browser/extensions/webcompat/about-compat/AboutCompat.jsm
@@ -0,0 +1,42 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["AboutCompat"];
+
+const Services =
+ globalThis.Services ||
+ ChromeUtils.import("resource://gre/modules/Services.jsm").Services;
+
+const addonID = "webcompat@mozilla.org";
+const addonPageRelativeURL = "/about-compat/aboutCompat.html";
+
+function AboutCompat() {
+ this.chromeURL =
+ WebExtensionPolicy.getByID(addonID).getURL(addonPageRelativeURL);
+}
+AboutCompat.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["nsIAboutModule"]),
+ getURIFlags() {
+ return (
+ Ci.nsIAboutModule.URI_MUST_LOAD_IN_EXTENSION_PROCESS |
+ Ci.nsIAboutModule.IS_SECURE_CHROME_UI
+ );
+ },
+
+ newChannel(aURI, aLoadInfo) {
+ const uri = Services.io.newURI(this.chromeURL);
+ const channel = Services.io.newChannelFromURIWithLoadInfo(uri, aLoadInfo);
+ channel.originalURI = aURI;
+
+ channel.owner = (
+ Services.scriptSecurityManager.createContentPrincipal ||
+ // Handles fallback to earlier versions.
+ // eslint-disable-next-line mozilla/valid-services-property
+ Services.scriptSecurityManager.createCodebasePrincipal
+ )(uri, aLoadInfo.originAttributes);
+ return channel;
+ },
+};
diff --git a/browser/extensions/webcompat/about-compat/aboutCompat.css b/browser/extensions/webcompat/about-compat/aboutCompat.css
new file mode 100644
index 0000000000..b51db7f9f5
--- /dev/null
+++ b/browser/extensions/webcompat/about-compat/aboutCompat.css
@@ -0,0 +1,187 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@media (any-pointer: fine) {
+ :root {
+ font-family: sans-serif;
+ margin: 40px auto;
+ min-width: 30em;
+ max-width: 60em;
+ }
+
+ table {
+ width: 100%;
+ padding-bottom: 2em;
+ border-spacing: 0;
+ }
+
+ td {
+ border-bottom: 1px solid var(--in-content-border-color);
+ }
+
+ td:last-child > button {
+ float: inline-end;
+ }
+}
+
+/* Mobile UI where common.css is not loaded */
+
+@media (any-pointer: coarse), (any-pointer: none) {
+ * {
+ margin: 0;
+ padding: 0;
+ }
+
+ :root {
+ --background-color: #fff;
+ --text-color: #0c0c0d;
+ --border-color: #e1e1e2;
+ --button-background-color: #f5f5f5;
+ --selected-tab-text-color: #0061e0;
+ }
+
+ @media (prefers-color-scheme: dark) {
+ :root {
+ --background-color: #292833;
+ --text-color: #f9f9fa;
+ --border-color: rgba(255, 255, 255, 0.15);
+ --button-background-color: rgba(0, 0, 0, 0.15);
+ --selected-tab-text-color: #00ddff;
+ }
+ }
+
+ body {
+ background-color: var(--background-color);
+ color: var(--text-color);
+ font: message-box;
+ font-size: 14px;
+ -moz-text-size-adjust: none;
+ display: grid;
+ grid-template-areas: "a b c" "d d d";
+ grid-template-columns: 1fr 1fr 1fr;
+ grid-template-rows: fit-content(100%) 1fr;
+ }
+
+ .tab[data-l10n-id="label-overrides"] {
+ grid-area: a;
+ }
+
+ .tab[data-l10n-id="label-interventions"] {
+ grid-area: b;
+ }
+
+ .tab[data-l10n-id="label-smartblock"] {
+ grid-area: c;
+ }
+
+ table {
+ grid-area: d;
+ }
+
+ table,
+ tr,
+ p {
+ display: block;
+ }
+
+ table {
+ border-top: 2px solid var(--border-color);
+ margin-top: -2px;
+ width: 100%;
+ z-index: 1;
+ display: none;
+ }
+
+ tr {
+ border-bottom: 1px solid var(--border-color);
+ padding: 0;
+ }
+
+ a {
+ color: inherit;
+ font-size: 94%;
+ }
+
+ .tab {
+ cursor: pointer;
+ z-index: 2;
+ display: inline-block;
+ text-align: left;
+ border-block: 2px solid transparent;
+ font-size: 1em;
+ font-weight: bold;
+ padding: 1em;
+ }
+
+ .tab.active {
+ color: var(--selected-tab-text-color);
+ border-bottom-color: currentColor;
+ margin-bottom: 0;
+ padding-bottom: calc(1em + 2px);
+ }
+
+ .tab.active + table {
+ display: block;
+ }
+
+ td {
+ grid-area: b;
+ padding-left: 1em;
+ }
+
+ td:first-child {
+ grid-area: a;
+ padding-top: 1em;
+ }
+
+ td:last-child {
+ grid-area: c;
+ padding-bottom: 1em;
+ }
+
+ tr {
+ display: grid;
+ grid-template-areas: "a c" "b c";
+ grid-template-columns: 1fr 6.5em;
+ }
+
+ td[colspan="4"] {
+ padding: 1em;
+ font-style: italic;
+ text-align: center;
+ }
+
+ td:not([colspan]):nth-child(1) {
+ font-weight: bold;
+ padding-bottom: 0.25em;
+ }
+
+ td:nth-child(2) {
+ padding-bottom: 1em;
+ }
+
+ td:nth-child(3) {
+ display: flex;
+ padding: 0;
+ }
+
+ button {
+ cursor: pointer;
+ width: 100%;
+ height: 100%;
+ background: var(--button-background-color);
+ color: inherit;
+ inset-inline-end: 0;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ border-inline-start: 1px solid var(--border-color);
+ font-weight: 600;
+ appearance: none;
+ }
+
+ button::-moz-focus-inner {
+ border: 0;
+ }
+}
diff --git a/browser/extensions/webcompat/about-compat/aboutCompat.html b/browser/extensions/webcompat/about-compat/aboutCompat.html
new file mode 100644
index 0000000000..d820f20ee2
--- /dev/null
+++ b/browser/extensions/webcompat/about-compat/aboutCompat.html
@@ -0,0 +1,51 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!DOCTYPE html>
+<html>
+ <head>
+ <base />
+
+ <!-- If you change this script tag you must update the hash in the extension's
+ `content_security_policy` 'sha256-MmZkN2QaIHhfRWPZ8TVRjijTn5Ci1iEabtTEWrt9CCo=' -->
+ <script>
+ /* globals browser */ document.head.firstElementChild.href =
+ browser.runtime.getURL("");
+ </script>
+
+ <meta charset="utf-8" />
+ <meta name="color-scheme" content="light dark" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <link rel="stylesheet" href="about-compat/aboutCompat.css" />
+ <link
+ rel="stylesheet"
+ media="screen and (pointer:fine), projection"
+ type="text/css"
+ href="chrome://global/skin/in-content/common.css"
+ />
+ <link rel="localization" href="toolkit/about/aboutCompat.ftl" />
+ <title data-l10n-id="text-title"></title>
+ <script src="about-compat/aboutCompat.js"></script>
+ </head>
+ <body>
+ <h2 class="tab active" data-l10n-id="label-overrides"></h2>
+ <table id="overrides">
+ <col />
+ <col />
+ <col />
+ </table>
+ <h2 class="tab" data-l10n-id="label-interventions"></h2>
+ <table id="interventions">
+ <col />
+ <col />
+ <col />
+ </table>
+ <h2 class="tab" data-l10n-id="label-smartblock"></h2>
+ <table id="smartblock" class="shims">
+ <col />
+ <col />
+ <col />
+ </table>
+ </body>
+</html>
diff --git a/browser/extensions/webcompat/about-compat/aboutCompat.js b/browser/extensions/webcompat/about-compat/aboutCompat.js
new file mode 100644
index 0000000000..e01b853877
--- /dev/null
+++ b/browser/extensions/webcompat/about-compat/aboutCompat.js
@@ -0,0 +1,283 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* globals browser */
+
+let availablePatches;
+
+const portToAddon = (function () {
+ let port;
+
+ function connect() {
+ port = browser.runtime.connect({ name: "AboutCompatTab" });
+ port.onMessage.addListener(onMessageFromAddon);
+ port.onDisconnect.addListener(e => {
+ port = undefined;
+ });
+ }
+
+ connect();
+
+ async function send(message) {
+ if (port) {
+ return port.postMessage(message);
+ }
+ return Promise.reject("background script port disconnected");
+ }
+
+ return { send };
+})();
+
+const $ = function (sel) {
+ return document.querySelector(sel);
+};
+
+const DOMContentLoadedPromise = new Promise(resolve => {
+ document.addEventListener(
+ "DOMContentLoaded",
+ () => {
+ resolve();
+ },
+ { once: true }
+ );
+});
+
+Promise.all([
+ browser.runtime.sendMessage("getAllInterventions"),
+ DOMContentLoadedPromise,
+]).then(([info]) => {
+ document.body.addEventListener("click", async evt => {
+ const ele = evt.target;
+ if (ele.nodeName === "BUTTON") {
+ const row = ele.closest("[data-id]");
+ if (row) {
+ evt.preventDefault();
+ ele.disabled = true;
+ const id = row.getAttribute("data-id");
+ try {
+ await browser.runtime.sendMessage({ command: "toggle", id });
+ } catch (_) {
+ ele.disabled = false;
+ }
+ }
+ } else if (ele.classList.contains("tab")) {
+ document.querySelectorAll(".tab").forEach(tab => {
+ tab.classList.remove("active");
+ });
+ ele.classList.add("active");
+ }
+ });
+
+ availablePatches = info;
+ redraw();
+});
+
+function onMessageFromAddon(msg) {
+ const alsoShowHidden = location.hash === "#all";
+
+ if ("interventionsChanged" in msg) {
+ redrawTable($("#interventions"), msg.interventionsChanged, alsoShowHidden);
+ }
+
+ if ("overridesChanged" in msg) {
+ redrawTable($("#overrides"), msg.overridesChanged, alsoShowHidden);
+ }
+
+ if ("shimsChanged" in msg) {
+ updateShimTables(msg.shimsChanged, alsoShowHidden);
+ }
+
+ const id = msg.toggling || msg.toggled;
+ const button = $(`[data-id="${id}"] button`);
+ if (!button) {
+ return;
+ }
+ const active = msg.active;
+ document.l10n.setAttributes(
+ button,
+ active ? "label-disable" : "label-enable"
+ );
+ button.disabled = !!msg.toggling;
+}
+
+function redraw() {
+ if (!availablePatches) {
+ return;
+ }
+ const { overrides, interventions, shims } = availablePatches;
+ const alsoShowHidden = location.hash === "#all";
+ redrawTable($("#overrides"), overrides, alsoShowHidden);
+ redrawTable($("#interventions"), interventions, alsoShowHidden);
+ updateShimTables(shims, alsoShowHidden);
+}
+
+function clearTableAndAddMessage(table, msgId) {
+ table.querySelectorAll("tr").forEach(tr => {
+ tr.remove();
+ });
+
+ const tr = document.createElement("tr");
+ tr.className = "message";
+ tr.id = msgId;
+
+ const td = document.createElement("td");
+ td.setAttribute("colspan", "3");
+ document.l10n.setAttributes(td, msgId);
+ tr.appendChild(td);
+
+ table.appendChild(tr);
+}
+
+function hideMessagesOnTable(table) {
+ table.querySelectorAll("tr.message").forEach(tr => {
+ tr.remove();
+ });
+}
+
+function updateShimTables(shimsChanged, alsoShowHidden) {
+ const tables = document.querySelectorAll("table.shims");
+ if (!tables.length) {
+ return;
+ }
+
+ for (const { bug, disabledReason, hidden, id, name, type } of shimsChanged) {
+ // if any shim is disabled by global pref, all of them are. just show the
+ // "disabled in about:config" message on each shim table in that case.
+ if (disabledReason === "globalPref") {
+ for (const table of tables) {
+ clearTableAndAddMessage(table, "text-disabled-in-about-config");
+ }
+ return;
+ }
+
+ // otherwise, find which table the shim belongs in. if there is none,
+ // ignore the shim (we're not showing it on the UI for whatever reason).
+ const table = document.querySelector(`table.shims#${type}`);
+ if (!table) {
+ continue;
+ }
+
+ // similarly, skip shims hidden from the UI (only for testing, etc).
+ if (!alsoShowHidden && hidden) {
+ continue;
+ }
+
+ // also, hide the shim if it is disabled because it is not meant for this
+ // platform, release (etc) rather than being disabled by pref/about:compat
+ const notApplicable =
+ disabledReason &&
+ disabledReason !== "pref" &&
+ disabledReason !== "session";
+ if (!alsoShowHidden && notApplicable) {
+ continue;
+ }
+
+ // create an updated table-row for the shim
+ const tr = document.createElement("tr");
+ tr.setAttribute("data-id", id);
+
+ let td = document.createElement("td");
+ td.innerText = name;
+ tr.appendChild(td);
+
+ td = document.createElement("td");
+ const a = document.createElement("a");
+ a.href = `https://bugzilla.mozilla.org/show_bug.cgi?id=${bug}`;
+ document.l10n.setAttributes(a, "label-more-information", { bug });
+ a.target = "_blank";
+ td.appendChild(a);
+ tr.appendChild(td);
+
+ td = document.createElement("td");
+ tr.appendChild(td);
+ const button = document.createElement("button");
+ document.l10n.setAttributes(
+ button,
+ disabledReason ? "label-enable" : "label-disable"
+ );
+ td.appendChild(button);
+
+ // is it already in the table?
+ const row = table.querySelector(`tr[data-id="${id}"]`);
+ if (row) {
+ row.replaceWith(tr);
+ } else {
+ table.appendChild(tr);
+ }
+ }
+
+ for (const table of tables) {
+ if (!table.querySelector("tr:not(.message)")) {
+ // no shims? then add a message that none are available for this platform/config
+ clearTableAndAddMessage(table, `text-no-${table.id}`);
+ } else {
+ // otherwise hide any such message, since we have shims on the list
+ hideMessagesOnTable(table);
+ }
+ }
+}
+
+function redrawTable(table, data, alsoShowHidden) {
+ const df = document.createDocumentFragment();
+ table.querySelectorAll("tr").forEach(tr => {
+ tr.remove();
+ });
+
+ let noEntriesMessage;
+ if (data === false) {
+ noEntriesMessage = "text-disabled-in-about-config";
+ } else if (data.length === 0) {
+ noEntriesMessage = `text-no-${table.id}`;
+ }
+
+ if (noEntriesMessage) {
+ const tr = document.createElement("tr");
+ df.appendChild(tr);
+
+ const td = document.createElement("td");
+ td.setAttribute("colspan", "3");
+ document.l10n.setAttributes(td, noEntriesMessage);
+ tr.appendChild(td);
+
+ table.appendChild(df);
+ return;
+ }
+
+ for (const row of data) {
+ if (row.hidden && !alsoShowHidden) {
+ continue;
+ }
+
+ const tr = document.createElement("tr");
+ tr.setAttribute("data-id", row.id);
+ df.appendChild(tr);
+
+ let td = document.createElement("td");
+ td.innerText = row.domain;
+ tr.appendChild(td);
+
+ td = document.createElement("td");
+ const a = document.createElement("a");
+ const bug = row.bug;
+ a.href = `https://bugzilla.mozilla.org/show_bug.cgi?id=${bug}`;
+ document.l10n.setAttributes(a, "label-more-information", { bug });
+ a.target = "_blank";
+ td.appendChild(a);
+ tr.appendChild(td);
+
+ td = document.createElement("td");
+ tr.appendChild(td);
+ const button = document.createElement("button");
+ document.l10n.setAttributes(
+ button,
+ row.active ? "label-disable" : "label-enable"
+ );
+ td.appendChild(button);
+ }
+ table.appendChild(df);
+}
+
+window.onhashchange = redraw;
diff --git a/browser/extensions/webcompat/about-compat/aboutPage.js b/browser/extensions/webcompat/about-compat/aboutPage.js
new file mode 100644
index 0000000000..0f2e7c4ad4
--- /dev/null
+++ b/browser/extensions/webcompat/about-compat/aboutPage.js
@@ -0,0 +1,46 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* global ExtensionAPI, XPCOMUtils */
+
+const Services =
+ globalThis.Services ||
+ ChromeUtils.import("resource://gre/modules/Services.jsm").Services;
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "resProto",
+ "@mozilla.org/network/protocol;1?name=resource",
+ "nsISubstitutingProtocolHandler"
+);
+
+const ResourceSubstitution = "webcompat";
+const ProcessScriptURL = "resource://webcompat/aboutPageProcessScript.js";
+const ContractID = "@mozilla.org/network/protocol/about;1?what=compat";
+
+this.aboutPage = class extends ExtensionAPI {
+ onStartup() {
+ const { rootURI } = this.extension;
+
+ resProto.setSubstitution(
+ ResourceSubstitution,
+ Services.io.newURI("about-compat/", null, rootURI)
+ );
+
+ if (!(ContractID in Cc)) {
+ Services.ppmm.loadProcessScript(ProcessScriptURL, true);
+ this.processScriptRegistered = true;
+ }
+ }
+
+ onShutdown() {
+ resProto.setSubstitution(ResourceSubstitution, null);
+
+ if (this.processScriptRegistered) {
+ Services.ppmm.removeDelayedProcessScript(ProcessScriptURL);
+ }
+ }
+};
diff --git a/browser/extensions/webcompat/about-compat/aboutPage.json b/browser/extensions/webcompat/about-compat/aboutPage.json
new file mode 100644
index 0000000000..42e6114188
--- /dev/null
+++ b/browser/extensions/webcompat/about-compat/aboutPage.json
@@ -0,0 +1,6 @@
+[
+ {
+ "namespace": "aboutCompat",
+ "description": "Enables the about:compat page"
+ }
+]
diff --git a/browser/extensions/webcompat/about-compat/aboutPageProcessScript.js b/browser/extensions/webcompat/about-compat/aboutPageProcessScript.js
new file mode 100644
index 0000000000..13cb4fd0bf
--- /dev/null
+++ b/browser/extensions/webcompat/about-compat/aboutPageProcessScript.js
@@ -0,0 +1,34 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* eslint-env mozilla/process-script */
+
+"use strict";
+
+// Note: This script is used only when a static registration for our
+// component is not already present in the libxul binary.
+
+const Cm = Components.manager.QueryInterface(Ci.nsIComponentRegistrar);
+
+const classID = Components.ID("{97bf9550-2a7b-11e9-b56e-0800200c9a66}");
+
+if (!Cm.isCIDRegistered(classID)) {
+ const { ComponentUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/ComponentUtils.sys.mjs"
+ );
+
+ const factory = ComponentUtils.generateSingletonFactory(function () {
+ const { AboutCompat } = ChromeUtils.import(
+ "resource://webcompat/AboutCompat.jsm"
+ );
+ return new AboutCompat();
+ });
+
+ Cm.registerFactory(
+ classID,
+ "about:compat",
+ "@mozilla.org/network/protocol/about;1?what=compat",
+ factory
+ );
+}
diff --git a/browser/extensions/webcompat/components.conf b/browser/extensions/webcompat/components.conf
new file mode 100644
index 0000000000..ca5a6c3dbd
--- /dev/null
+++ b/browser/extensions/webcompat/components.conf
@@ -0,0 +1,17 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# Note: This file will add static component registration entries for our
+# components to the libxul binary, though the actual component JSMs will be
+# packaged with the extension.
+Classes = [
+ {
+ 'cid': '{97bf9550-2a7b-11e9-b56e-0800200c9a66}',
+ 'contract_ids': ['@mozilla.org/network/protocol/about;1?what=compat'],
+ 'jsm': 'resource://webcompat/AboutCompat.jsm',
+ 'constructor': 'AboutCompat',
+ },
+]
diff --git a/browser/extensions/webcompat/data/injections.js b/browser/extensions/webcompat/data/injections.js
new file mode 100644
index 0000000000..3c0dc58bce
--- /dev/null
+++ b/browser/extensions/webcompat/data/injections.js
@@ -0,0 +1,1059 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* globals module, require */
+
+// This is a hack for the tests.
+if (typeof InterventionHelpers === "undefined") {
+ var InterventionHelpers = require("../lib/intervention_helpers");
+}
+
+/**
+ * For detailed information on our policies, and a documention on this format
+ * and its possibilites, please check the Mozilla-Wiki at
+ *
+ * https://wiki.mozilla.org/Compatibility/Go_Faster_Addon/Override_Policies_and_Workflows#User_Agent_overrides
+ */
+const AVAILABLE_INJECTIONS = [
+ {
+ id: "testbed-injection",
+ platform: "all",
+ domain: "webcompat-addon-testbed.herokuapp.com",
+ bug: "0000000",
+ hidden: true,
+ contentScripts: {
+ matches: ["*://webcompat-addon-testbed.herokuapp.com/*"],
+ css: [
+ {
+ file: "injections/css/bug0000000-testbed-css-injection.css",
+ },
+ ],
+ js: [
+ {
+ file: "injections/js/bug0000000-testbed-js-injection.js",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1452707",
+ platform: "all",
+ domain: "ib.absa.co.za",
+ bug: "1452707",
+ contentScripts: {
+ matches: ["https://ib.absa.co.za/*"],
+ js: [
+ {
+ file: "injections/js/bug1452707-window.controllers-shim-ib.absa.co.za.js",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1457335",
+ platform: "desktop",
+ domain: "histography.io",
+ bug: "1457335",
+ contentScripts: {
+ matches: ["*://histography.io/*"],
+ js: [
+ {
+ file: "injections/js/bug1457335-histography.io-ua-change.js",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1472075",
+ platform: "desktop",
+ domain: "bankofamerica.com",
+ bug: "1472075",
+ contentScripts: {
+ matches: [
+ "*://*.bankofamerica.com/*",
+ "*://*.ml.com/*", // #120104
+ ],
+ js: [
+ {
+ file: "injections/js/bug1472075-bankofamerica.com-ua-change.js",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1579159",
+ platform: "android",
+ domain: "m.tailieu.vn",
+ bug: "1579159",
+ contentScripts: {
+ matches: ["*://m.tailieu.vn/*", "*://m.elib.vn/*"],
+ js: [
+ {
+ file: "injections/js/bug1579159-m.tailieu.vn-pdfjs-worker-disable.js",
+ },
+ ],
+ allFrames: true,
+ },
+ },
+ {
+ id: "bug1583366",
+ platform: "desktop",
+ domain: "Download prompt for files with no content-type",
+ bug: "1583366",
+ data: {
+ urls: ["https://ads-us.rd.linksynergy.com/as.php*"],
+ contentType: {
+ name: "content-type",
+ value: "text/html; charset=utf-8",
+ },
+ },
+ customFunc: "noSniffFix",
+ },
+ {
+ id: "bug1570328",
+ platform: "android",
+ domain: "developer.apple.com",
+ bug: "1570328",
+ contentScripts: {
+ matches: ["*://developer.apple.com/*"],
+ css: [
+ {
+ file: "injections/css/bug1570328-developer-apple.com-transform-scale.css",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1575000",
+ platform: "all",
+ domain: "apply.lloydsbank.co.uk",
+ bug: "1575000",
+ contentScripts: {
+ matches: ["*://apply.lloydsbank.co.uk/*"],
+ css: [
+ {
+ file: "injections/css/bug1575000-apply.lloydsbank.co.uk-radio-buttons-fix.css",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1605611",
+ platform: "android",
+ domain: "maps.google.com",
+ bug: "1605611",
+ contentScripts: {
+ matches: InterventionHelpers.matchPatternsForGoogle(
+ "*://www.google.",
+ "/maps*"
+ ),
+ css: [
+ {
+ file: "injections/css/bug1605611-maps.google.com-directions-time.css",
+ },
+ ],
+ js: [
+ {
+ file: "injections/js/bug1605611-maps.google.com-directions-time.js",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1610344",
+ platform: "all",
+ domain: "directv.com.co",
+ bug: "1610344",
+ contentScripts: {
+ matches: [
+ "https://*.directv.com.co/*",
+ "https://*.directv.com.ec/*", // bug 1827706
+ ],
+ css: [
+ {
+ file: "injections/css/bug1610344-directv.com.co-hide-unsupported-message.css",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1644830",
+ platform: "desktop",
+ domain: "usps.com",
+ bug: "1644830",
+ contentScripts: {
+ matches: ["https://*.usps.com/*"],
+ css: [
+ {
+ file: "injections/css/bug1644830-missingmail.usps.com-checkboxes-not-visible.css",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1651917",
+ platform: "android",
+ domain: "teletrader.com",
+ bug: "1651917",
+ contentScripts: {
+ matches: ["*://*.teletrader.com/*"],
+ css: [
+ {
+ file: "injections/css/bug1651917-teletrader.com.body-transform-origin.css",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1653075",
+ platform: "desktop",
+ domain: "livescience.com",
+ bug: "1653075",
+ contentScripts: {
+ matches: ["*://*.livescience.com/*"],
+ css: [
+ {
+ file: "injections/css/bug1653075-livescience.com-scrollbar-width.css",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1654877",
+ platform: "android",
+ domain: "preev.com",
+ bug: "1654877",
+ contentScripts: {
+ matches: ["*://preev.com/*"],
+ css: [
+ {
+ file: "injections/css/bug1654877-preev.com-moz-appearance-fix.css",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1654907",
+ platform: "android",
+ domain: "reactine.ca",
+ bug: "1654907",
+ contentScripts: {
+ matches: ["*://*.reactine.ca/*"],
+ css: [
+ {
+ file: "injections/css/bug1654907-reactine.ca-hide-unsupported.css",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1631811",
+ platform: "all",
+ domain: "datastudio.google.com",
+ bug: "1631811",
+ contentScripts: {
+ matches: ["https://datastudio.google.com/embed/reporting/*"],
+ js: [
+ {
+ file: "injections/js/bug1631811-datastudio.google.com-indexedDB.js",
+ },
+ ],
+ allFrames: true,
+ },
+ },
+ {
+ id: "bug1694470",
+ platform: "android",
+ domain: "m.myvidster.com",
+ bug: "1694470",
+ contentScripts: {
+ matches: ["https://m.myvidster.com/*"],
+ css: [
+ {
+ file: "injections/css/bug1694470-myvidster.com-content-not-shown.css",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1731825",
+ platform: "desktop",
+ domain: "Office 365 email handling prompt",
+ bug: "1731825",
+ contentScripts: {
+ matches: [
+ "*://*.live.com/*",
+ "*://*.office.com/*",
+ "*://*.sharepoint.com/*",
+ "*://*.office365.com/*",
+ ],
+ js: [
+ {
+ file: "injections/js/bug1731825-office365-email-handling-prompt-autohide.js",
+ },
+ ],
+ allFrames: true,
+ },
+ },
+ {
+ id: "bug1707795",
+ platform: "desktop",
+ domain: "Office Excel spreadsheets",
+ bug: "1707795",
+ contentScripts: {
+ matches: [
+ "*://*.live.com/*",
+ "*://*.office.com/*",
+ "*://*.sharepoint.com/*",
+ ],
+ css: [
+ {
+ file: "injections/css/bug1707795-office365-sheets-overscroll-disable.css",
+ },
+ ],
+ allFrames: true,
+ },
+ },
+ {
+ id: "bug1712833",
+ platform: "all",
+ domain: "buskocchi.desuca.co.jp",
+ bug: "1712833",
+ contentScripts: {
+ matches: ["*://buskocchi.desuca.co.jp/*"],
+ css: [
+ {
+ file: "injections/css/bug1712833-buskocchi.desuca.co.jp-fix-map-height.css",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1722955",
+ platform: "android",
+ domain: "frontgate.com",
+ bug: "1722955",
+ contentScripts: {
+ matches: ["*://*.frontgate.com/*"],
+ js: [
+ {
+ file: "lib/ua_helpers.js",
+ },
+ {
+ file: "injections/js/bug1722955-frontgate.com-ua-override.js",
+ },
+ ],
+ allFrames: true,
+ },
+ },
+ {
+ id: "bug1724764",
+ platform: "android",
+ domain: "Issues related to missing window.print",
+ bug: "1724764",
+ contentScripts: {
+ matches: [
+ "*://*.edupage.org/*", // 1804477 and 1800118
+ ],
+ js: [
+ {
+ file: "injections/js/bug1724764-window-print.js",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1724868",
+ platform: "android",
+ domain: "news.yahoo.co.jp",
+ bug: "1724868",
+ contentScripts: {
+ matches: ["*://news.yahoo.co.jp/articles/*", "*://s.yimg.jp/*"],
+ js: [
+ {
+ file: "injections/js/bug1724868-news.yahoo.co.jp-ua-override.js",
+ },
+ ],
+ allFrames: true,
+ },
+ },
+ {
+ id: "bug1741234",
+ platform: "all",
+ domain: "patient.alphalabs.ca",
+ bug: "1741234",
+ contentScripts: {
+ matches: ["*://patient.alphalabs.ca/*"],
+ css: [
+ {
+ file: "injections/css/bug1741234-patient.alphalabs.ca-height-fix.css",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1739489",
+ platform: "desktop",
+ domain: "Sites using draft.js",
+ bug: "1739489",
+ contentScripts: {
+ matches: [
+ "*://draftjs.org/*", // Bug 1739489
+ "*://www.facebook.com/*", // Bug 1739489
+ "*://twitter.com/*", // Bug 1776229
+ "*://mobile.twitter.com/*", // Bug 1776229
+ "*://*.reddit.com/*", // Bug 1829755
+ ],
+ js: [
+ {
+ file: "injections/js/bug1739489-draftjs-beforeinput.js",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1765947",
+ platform: "android",
+ domain: "veniceincoming.com",
+ bug: "1765947",
+ contentScripts: {
+ matches: ["*://veniceincoming.com/*"],
+ css: [
+ {
+ file: "injections/css/bug1765947-veniceincoming.com-left-fix.css",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug11769762",
+ platform: "all",
+ domain: "tiktok.com",
+ bug: "1769762",
+ contentScripts: {
+ matches: ["https://www.tiktok.com/*"],
+ js: [
+ {
+ file: "injections/js/bug1769762-tiktok.com-plugins-shim.js",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1770962",
+ platform: "all",
+ domain: "coldwellbankerhomes.com",
+ bug: "1770962",
+ contentScripts: {
+ matches: ["*://*.coldwellbankerhomes.com/*"],
+ css: [
+ {
+ file: "injections/css/bug1770962-coldwellbankerhomes.com-image-height.css",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1774490",
+ platform: "all",
+ domain: "rainews.it",
+ bug: "1774490",
+ contentScripts: {
+ matches: ["*://www.rainews.it/*"],
+ css: [
+ {
+ file: "injections/css/bug1774490-rainews.it-gallery-fix.css",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1774005",
+ platform: "all",
+ domain: "Sites relying on window.InstallTrigger",
+ bug: "1774005",
+ contentScripts: {
+ matches: [
+ "*://*.crunchyroll.com/*", // Bug 1777597
+ "*://*.ersthelfer.tv/*", // Bug 1817520
+ "*://*.webex.com/*", // Bug 1788934
+ "*://ifcinema.institutfrancais.com/*", // Bug 1806423
+ "*://islamionline.islamicbank.ps/*", // Bug 1821439
+ "*://*.itv.com/*", // Bug 1830203
+ "*://mobilevikings.be/*/registration/*", // Bug 1797400
+ "*://www.schoolnutritionandfitness.com/*", // Bug 1793761
+ ],
+ js: [
+ {
+ file: "injections/js/bug1774005-installtrigger-shim.js",
+ },
+ ],
+ allFrames: true,
+ },
+ },
+ {
+ id: "bug1784302",
+ platform: "android",
+ domain: "open.toutiao.com",
+ bug: "1784302",
+ contentScripts: {
+ matches: ["*://open.toutiao.com/*"],
+ js: [
+ {
+ file: "injections/js/bug1784302-effectiveType-shim.js",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1784141",
+ platform: "android",
+ domain: "aveeno.com and acuvue.com",
+ bug: "1784141",
+ contentScripts: {
+ matches: [
+ "*://*.aveeno.com/*",
+ "*://*.aveeno.ca/*",
+ "*://*.aveeno.com.au/*",
+ "*://*.aveeno.co.kr/*",
+ "*://*.aveeno.co.uk/*",
+ "*://*.aveeno.ie/*",
+ "*://*.acuvue.com/*", // 1804730
+ "*://*.acuvue.com.ar/*",
+ "*://*.acuvue.com.br/*",
+ "*://*.acuvue.ca/*",
+ "*://*.acuvue-fr.ca/*",
+ "*://*.acuvue.cl/*",
+ "*://*.acuvue.co.cr/*",
+ "*://*.acuvue.com.co/*",
+ "*://*.acuvue.com.do/*",
+ "*://*.acuvue.com.pe/*",
+ "*://*.acuvue.com.sv/*",
+ "*://*.acuvue.com.gt/*",
+ "*://*.acuvue.hn/*",
+ "*://*.acuvue.com.mx/*",
+ "*://*.acuvue.com.pa/*",
+ "*://*.acuvue.com.py/*",
+ "*://*.acuvue.com.pr/*",
+ "*://*.acuvue.com.uy/*",
+ "*://*.acuvue.com.au/*",
+ "*://*.acuvue.com.cn/*",
+ "*://*.acuvue.com.hk/*",
+ "*://*.acuvue.co.in/*",
+ "*://*.acuvue.co.id/*",
+ "*://acuvuevision.jp/*",
+ "*://*.acuvue.co.kr/*",
+ "*://*.acuvue.com.my/*",
+ "*://*.acuvue.co.nz/*",
+ "*://*.acuvue.com.sg/*",
+ "*://*.acuvue.com.tw/*",
+ "*://*.acuvue.co.th/*",
+ "*://*.acuvue.com.vn/*",
+ "*://*.acuvue.at/*",
+ "*://*.acuvue.be/*",
+ "*://*.fr.acuvue.be/*",
+ "*://*.acuvue-croatia.com/*",
+ "*://*.acuvue.cz/*",
+ "*://*.acuvue.dk/*",
+ "*://*.acuvue.fi/*",
+ "*://*.acuvue.fr/*",
+ "*://*.acuvue.de/*",
+ "*://*.acuvue.gr/*",
+ "*://*.acuvue.hu/*",
+ "*://*.acuvue.ie/*",
+ "*://*.acuvue.co.il/*",
+ "*://*.acuvue.it/*",
+ "*://*.acuvuekz.com/*",
+ "*://*.acuvue.lu/*",
+ "*://*.en.acuvuearabia.com/*",
+ "*://*.acuvuearabia.com/*",
+ "*://*.acuvue.nl/*",
+ "*://*.acuvue.no/*",
+ "*://*.acuvue.pl/*",
+ "*://*.acuvue.pt/*",
+ "*://*.acuvue.ro/*",
+ "*://*.acuvue.ru/*",
+ "*://*.acuvue.sk/*",
+ "*://*.acuvue.si/*",
+ "*://*.acuvue.co.za/*",
+ "*://*.jnjvision.com.tr/*",
+ "*://*.acuvue.co.uk/*",
+ "*://*.acuvue.ua/*",
+ "*://*.acuvue.com.pe/*",
+ "*://*.acuvue.es/*",
+ "*://*.acuvue.se/*",
+ "*://*.acuvue.ch/*",
+ ],
+ css: [
+ {
+ file: "injections/css/bug1784141-aveeno.com-acuvue.com-unsupported.css",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1784199",
+ platform: "all",
+ domain: "Sites based on Entrata Platform",
+ bug: "1784199",
+ contentScripts: {
+ matches: [
+ "*://*.aptsovation.com/*",
+ "*://*.avanabayview.com/*", // #118617
+ "*://*.breakpointeandcoronado.com/*", // #117735
+ "*://*.liveatlasathens.com/*", // #111189
+ "*://*.liveobserverpark.com/*", // #105244
+ "*://*.midwayurban.com/*", // #116523
+ "*://*.nhcalaska.com/*",
+ "*://*.prospectportal.com/*", // #115206
+ "*://*.securityproperties.com/*",
+ "*://*.theloftsorlando.com/*",
+ "*://*.vanallenapartments.com/*", // #120056
+ ],
+ css: [
+ {
+ file: "injections/css/bug1784199-entrata-platform-unsupported.css",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1795490",
+ platform: "android",
+ domain: "www.china-airlines.com",
+ bug: "1795490",
+ contentScripts: {
+ matches: ["*://www.china-airlines.com/*"],
+ js: [
+ {
+ file: "injections/js/bug1795490-www.china-airlines.com-undisable-date-fields-on-mobile.js",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1799968",
+ platform: "linux",
+ domain: "www.samsung.com",
+ bug: "1799968",
+ contentScripts: {
+ matches: ["*://www.samsung.com/*/watches/*/*"],
+ js: [
+ {
+ file: "injections/js/bug1799968-www.samsung.com-appVersion-linux-fix.js",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1799980",
+ platform: "all",
+ domain: "healow.com",
+ bug: "1799980",
+ contentScripts: {
+ matches: ["*://healow.com/*"],
+ js: [
+ {
+ file: "injections/js/bug1799980-healow.com-infinite-loop-fix.js",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1799994",
+ platform: "desktop",
+ domain: "www.vivobarefoot.com",
+ bug: "1799994",
+ contentScripts: {
+ matches: ["*://www.vivobarefoot.com/*"],
+ css: [
+ {
+ file: "injections/css/bug1799994-www.vivobarefoot.com-product-filters-fix.css",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1800000",
+ platform: "desktop",
+ domain: "www.honda.co.uk",
+ bug: "1800000",
+ contentScripts: {
+ matches: ["*://www.honda.co.uk/cars/book-a-service.html*"],
+ css: [
+ {
+ file: "injections/css/bug1800000-www.honda.co.uk-choose-dealer-button-fix.css",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1448747",
+ platform: "android",
+ domain: "FastClick breakage",
+ bug: "1448747",
+ contentScripts: {
+ matches: [
+ "*://*.co2meter.com/*", // 10959
+ "*://*.franmar.com/*", // 27273
+ "*://*.themusiclab.org/*", // 49667
+ "*://*.oregonfoodbank.org/*", // 53203
+ "*://*.fourbarrelcoffee.com/*", // 59427
+ "*://bluetokaicoffee.com/*", // 99867
+ "*://bathpublishing.com/*", // 100145
+ "*://dylantalkstone.com/*", // 101356
+ "*://renewd.com.au/*", // 104998
+ "*://*.lamudi.co.id/*", // 106767
+ "*://*.thehawksmoor.com/*", // 107549
+ "*://weaversofireland.com/*", // 116816
+ "*://*.iledefrance-mobilites.fr/*", // 117344
+ "*://*.lawnmowerpartsworld.com/*", // 117577
+ "*://*.discountcoffee.co.uk/*", // 118757
+ "*://torguard.net/*", // 120113
+ "*://*.arcsivr.com/*", // 120716
+ ],
+ js: [
+ {
+ file: "injections/js/bug1448747-fastclick-shim.js",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1818818",
+ platform: "android",
+ domain: "FastClick breakage - legacy",
+ bug: "1818818",
+ contentScripts: {
+ matches: [
+ "*://*.chatiw.com/*", // 5544
+ "*://*.wellcare.com/*", // 116595
+ ],
+ js: [
+ {
+ file: "injections/js/bug1818818-fastclick-legacy-shim.js",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1819476",
+ platform: "all",
+ domain: "axisbank.com",
+ bug: "1819476",
+ contentScripts: {
+ matches: ["*://*.axisbank.com/*"],
+ js: [
+ {
+ file: "injections/js/bug1819476-axisbank.com-webkitSpeechRecognition-shim.js",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1819450",
+ platform: "android",
+ domain: "cmbchina.com",
+ bug: "1819450",
+ contentScripts: {
+ matches: ["*://www.cmbchina.com/*", "*://cmbchina.com/*"],
+ js: [
+ {
+ file: "injections/js/bug1819450-cmbchina.com-ua-change.js",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1819678",
+ platform: "android",
+ domain: "cnki.net",
+ bug: "1819678",
+ contentScripts: {
+ matches: ["*://*.cnki.net/*"],
+ js: [
+ {
+ file: "injections/js/bug1819678-cnki.net-undisable-search-field.js",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1827678-webc77727",
+ platform: "android",
+ domain: "free4talk.com",
+ bug: "1827678",
+ contentScripts: {
+ matches: ["*://www.free4talk.com/*"],
+ js: [
+ {
+ file: "injections/js/bug1819678-free4talk.com-window-chrome-shim.js",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1827678-webc119017",
+ platform: "desktop",
+ domain: "nppes.cms.hhs.gov",
+ bug: "1827678",
+ contentScripts: {
+ matches: ["*://nppes.cms.hhs.gov/*"],
+ css: [
+ {
+ file: "injections/css/bug1819678-nppes.cms.hhs.gov-unsupported-banner.css",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1830776",
+ platform: "all",
+ domain: "blueshieldca.com",
+ bug: "1830776",
+ contentScripts: {
+ matches: ["*://*.blueshieldca.com/*"],
+ js: [
+ {
+ file: "injections/js/bug1830776-blueshieldca.com-unsupported.js",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1829949",
+ platform: "desktop",
+ domain: "tomshardware.com",
+ bug: "1829949",
+ contentScripts: {
+ matches: ["*://*.tomshardware.com/*"],
+ css: [
+ {
+ file: "injections/css/bug1829949-tomshardware.com-scrollbar-width.css",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1829952",
+ platform: "android",
+ domain: "eventer.co.il",
+ bug: "1829952",
+ contentScripts: {
+ matches: ["*://*.eventer.co.il/*"],
+ css: [
+ {
+ file: "injections/css/bug1829952-eventer.co.il-button-height.css",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1830747",
+ platform: "android",
+ domain: "my.babbel.com",
+ bug: "1830747",
+ contentScripts: {
+ matches: ["*://my.babbel.com/*"],
+ css: [
+ {
+ file: "injections/css/bug1830747-babbel.com-page-height.css",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1830752",
+ platform: "all",
+ domain: "afisha.ru",
+ bug: "1830752",
+ contentScripts: {
+ matches: ["*://*.afisha.ru/*"],
+ css: [
+ {
+ file: "injections/css/bug1830752-afisha.ru-slider-pointer-events.css",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1830761",
+ platform: "all",
+ domain: "91mobiles.com",
+ bug: "1830761",
+ contentScripts: {
+ matches: ["*://*.91mobiles.com/*"],
+ css: [
+ {
+ file: "injections/css/bug1830761-91mobiles.com-content-height.css",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1830796",
+ platform: "android",
+ domain: "copyleaks.com",
+ bug: "1830796",
+ contentScripts: {
+ matches: ["*://*.copyleaks.com/*"],
+ css: [
+ {
+ file: "injections/css/bug1830796-copyleaks.com-hide-unsupported.css",
+ },
+ ],
+ allFrames: true,
+ },
+ },
+ {
+ id: "bug1830810",
+ platform: "all",
+ domain: "interceramic.com",
+ bug: "1830810",
+ contentScripts: {
+ matches: ["*://interceramic.com/*"],
+ css: [
+ {
+ file: "injections/css/bug1830810-interceramic.com-hide-unsupported.css",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1830813",
+ platform: "desktop",
+ domain: "onstove.com",
+ bug: "1830813",
+ contentScripts: {
+ matches: ["*://*.onstove.com/*"],
+ css: [
+ {
+ file: "injections/css/bug1830813-page.onstove.com-hide-unsupported.css",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1831007",
+ platform: "all",
+ domain: "All international Nintendo domains",
+ bug: "1831007",
+ contentScripts: {
+ matches: [
+ "*://*.mojenintendo.cz/*",
+ "*://*.nintendo-europe.com/*",
+ "*://*.nintendo.at/*",
+ "*://*.nintendo.be/*",
+ "*://*.nintendo.ch/*",
+ "*://*.nintendo.co.il/*",
+ "*://*.nintendo.co.jp/*",
+ "*://*.nintendo.co.kr/*",
+ "*://*.nintendo.co.nz/*",
+ "*://*.nintendo.co.uk/*",
+ "*://*.nintendo.co.za/*",
+ "*://*.nintendo.com.au/*",
+ "*://*.nintendo.com.hk/*",
+ "*://*.nintendo.com/*",
+ "*://*.nintendo.de/*",
+ "*://*.nintendo.dk/*",
+ "*://*.nintendo.es/*",
+ "*://*.nintendo.fi/*",
+ "*://*.nintendo.fr/*",
+ "*://*.nintendo.gr/*",
+ "*://*.nintendo.hu/*",
+ "*://*.nintendo.it/*",
+ "*://*.nintendo.nl/*",
+ "*://*.nintendo.no/*",
+ "*://*.nintendo.pt/*",
+ "*://*.nintendo.ru/*",
+ "*://*.nintendo.se/*",
+ "*://*.nintendo.sk/*",
+ "*://*.nintendo.tw/*",
+ "*://*.nintendoswitch.com.cn/*",
+ ],
+ js: [
+ {
+ file: "injections/js/bug1831007-nintendo-window-OnetrustActiveGroups.js",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1836157",
+ platform: "android",
+ domain: "thai-masszazs.net",
+ bug: "1836157",
+ contentScripts: {
+ matches: ["*://www.thai-masszazs.net/en/*"],
+ js: [
+ {
+ file: "injections/js/bug1836157-thai-masszazs-niceScroll-disable.js",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1836103",
+ platform: "all",
+ domain: "autostar-novoross.ru",
+ bug: "1836103",
+ contentScripts: {
+ matches: ["*://autostar-novoross.ru/*"],
+ css: [
+ {
+ file: "injections/css/bug1836103-autostar-novoross.ru-make-map-taller.css",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1836105",
+ platform: "all",
+ domain: "cnn.com",
+ bug: "1836105",
+ contentScripts: {
+ matches: ["*://*.cnn.com/*"],
+ css: [
+ {
+ file: "injections/css/bug1836105-cnn.com-fix-blank-pages-when-printing.css",
+ },
+ ],
+ },
+ },
+ {
+ id: "bug1836177",
+ platform: "desktop",
+ domain: "clalit.co.il",
+ bug: "1836177",
+ contentScripts: {
+ matches: [
+ "*://e-services.clalit.co.il/OnlineWeb/General/InfoFullLogin.aspx*",
+ ],
+ css: [
+ {
+ file: "injections/css/bug1836177-clalit.co.il-hide-number-input-spinners.css",
+ },
+ ],
+ allFrames: true,
+ },
+ },
+ {
+ id: "bug1842437",
+ platform: "desktop",
+ domain: "www.youtube.com",
+ bug: "1842437",
+ contentScripts: {
+ matches: ["*://www.youtube.com/*"],
+ js: [
+ {
+ file: "injections/js/bug1842437-www.youtube.com-performance-now-precision.js",
+ },
+ ],
+ },
+ },
+];
+
+module.exports = AVAILABLE_INJECTIONS;
diff --git a/browser/extensions/webcompat/data/shims.js b/browser/extensions/webcompat/data/shims.js
new file mode 100644
index 0000000000..8e08dd6c95
--- /dev/null
+++ b/browser/extensions/webcompat/data/shims.js
@@ -0,0 +1,874 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* globals module, require */
+
+const AVAILABLE_SHIMS = [
+ {
+ hiddenInAboutCompat: true,
+ id: "LiveTestShim",
+ platform: "all",
+ name: "Live test shim",
+ bug: "livetest",
+ file: "live-test-shim.js",
+ matches: ["*://webcompat-addon-testbed.herokuapp.com/shims_test.js"],
+ needsShimHelpers: ["getOptions", "optIn"],
+ },
+ {
+ hiddenInAboutCompat: true,
+ id: "MochitestShim",
+ platform: "all",
+ branch: ["all:ignoredOtherPlatform"],
+ name: "Test shim for Mochitests",
+ bug: "mochitest",
+ file: "mochitest-shim-1.js",
+ matches: [
+ "*://example.com/browser/browser/extensions/webcompat/tests/browser/shims_test.js",
+ ],
+ needsShimHelpers: ["getOptions", "optIn"],
+ options: {
+ simpleOption: true,
+ complexOption: { a: 1, b: "test" },
+ branchValue: { value: true, branches: [] },
+ platformValue: { value: true, platform: "neverUsed" },
+ },
+ unblocksOnOptIn: ["*://trackertest.org/*"],
+ },
+ {
+ hiddenInAboutCompat: true,
+ disabled: true,
+ id: "MochitestShim2",
+ platform: "all",
+ name: "Test shim for Mochitests (disabled by default)",
+ bug: "mochitest",
+ file: "mochitest-shim-2.js",
+ matches: [
+ "*://example.com/browser/browser/extensions/webcompat/tests/browser/shims_test_2.js",
+ ],
+ needsShimHelpers: ["getOptions", "optIn"],
+ options: {
+ simpleOption: true,
+ complexOption: { a: 1, b: "test" },
+ branchValue: { value: true, branches: [] },
+ platformValue: { value: true, platform: "neverUsed" },
+ },
+ unblocksOnOptIn: ["*://trackertest.org/*"],
+ },
+ {
+ hiddenInAboutCompat: true,
+ id: "MochitestShim3",
+ platform: "all",
+ name: "Test shim for Mochitests (host)",
+ bug: "mochitest",
+ file: "mochitest-shim-3.js",
+ notHosts: ["example.com"],
+ matches: [
+ "*://example.com/browser/browser/extensions/webcompat/tests/browser/shims_test_3.js",
+ ],
+ },
+ {
+ hiddenInAboutCompat: true,
+ id: "MochitestShim4",
+ platform: "all",
+ name: "Test shim for Mochitests (notHost)",
+ bug: "mochitest",
+ file: "mochitest-shim-3.js",
+ hosts: ["example.net"],
+ matches: [
+ "*://example.com/browser/browser/extensions/webcompat/tests/browser/shims_test_3.js",
+ ],
+ },
+ {
+ hiddenInAboutCompat: true,
+ id: "MochitestShim5",
+ platform: "all",
+ name: "Test shim for Mochitests (branch)",
+ bug: "mochitest",
+ file: "mochitest-shim-3.js",
+ branches: ["never matches"],
+ matches: [
+ "*://example.com/browser/browser/extensions/webcompat/tests/browser/shims_test_3.js",
+ ],
+ },
+ {
+ hiddenInAboutCompat: true,
+ id: "MochitestShim6",
+ platform: "never matches",
+ name: "Test shim for Mochitests (platform)",
+ bug: "mochitest",
+ file: "mochitest-shim-3.js",
+ matches: [
+ "*://example.com/browser/browser/extensions/webcompat/tests/browser/shims_test_3.js",
+ ],
+ },
+ {
+ id: "AddThis",
+ platform: "all",
+ name: "AddThis",
+ bug: "1713694",
+ file: "addthis-angular.js",
+ matches: [
+ "*://s7.addthis.com/icons/official-addthis-angularjs/current/dist/official-addthis-angularjs.min.js*",
+ ],
+ onlyIfBlockedByETP: true,
+ },
+ {
+ id: "Adform",
+ platform: "all",
+ name: "Adform",
+ bug: "1713695",
+ file: "adform.js",
+ matches: [
+ "*://track.adform.net/serving/scripts/trackpoint/",
+ "*://track.adform.net/serving/scripts/trackpoint/async/",
+ {
+ patterns: ["*://track.adform.net/Serving/TrackPoint/*"],
+ target: "tracking-pixel.png",
+ types: ["image", "imageset", "xmlhttprequest"],
+ },
+ ],
+ onlyIfBlockedByETP: true,
+ },
+ {
+ id: "AdNexusAST",
+ platform: "all",
+ name: "AdNexus AST",
+ bug: "1734130",
+ file: "adnexus-ast.js",
+ matches: ["*://*.adnxs.com/*/ast.js*"],
+ onlyIfBlockedByETP: true,
+ },
+ {
+ id: "AdNexusPrebid",
+ platform: "all",
+ name: "AdNexus Prebid",
+ bug: "1713696",
+ file: "adnexus-prebid.js",
+ matches: ["*://*.adnxs.com/*/pb.js*", "*://*.adnxs.com/*/prebid*"],
+ onlyIfBlockedByETP: true,
+ },
+ {
+ id: "AdobeEverestJS",
+ platform: "all",
+ name: "Adobe EverestJS",
+ bug: "1728114",
+ file: "everest.js",
+ matches: ["*://www.everestjs.net/static/st.v3.js*"],
+ onlyIfBlockedByETP: true,
+ },
+ {
+ // keep this above AdSafeProtectedTrackingPixels
+ id: "AdSafeProtectedGoogleIMAAdapter",
+ platform: "all",
+ name: "Ad Safe Protected Google IMA Adapter",
+ bug: "1508639",
+ file: "adsafeprotected-ima.js",
+ matches: ["*://static.adsafeprotected.com/vans-adapter-google-ima.js"],
+ onlyIfBlockedByETP: true,
+ },
+ {
+ id: "AdsByGoogle",
+ platform: "all",
+ name: "Ads by Google",
+ bug: "1713726",
+ file: "google-ads.js",
+ matches: [
+ "*://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js",
+ {
+ patterns: [
+ "*://pagead2.googlesyndication.com/pagead/*.js*fcd=true",
+ "*://pagead2.googlesyndication.com/pagead/js/*.js*fcd=true",
+ ],
+ target: "empty-script.js",
+ types: ["xmlhttprequest"],
+ },
+ ],
+ onlyIfBlockedByETP: true,
+ },
+ {
+ id: "AdvertisingCom",
+ platform: "all",
+ name: "advertising.com",
+ bug: "1701685",
+ matches: [
+ {
+ patterns: ["*://pixel.advertising.com/firefox-etp"],
+ target: "tracking-pixel.png",
+ types: ["image", "imageset", "xmlhttprequest"],
+ onlyIfBlockedByETP: true,
+ },
+ {
+ patterns: ["*://cdn.cmp.advertising.com/firefox-etp"],
+ target: "empty-script.js",
+ types: ["xmlhttprequest"],
+ onlyIfBlockedByETP: true,
+ },
+ {
+ patterns: ["*://*.advertising.com/*.js*"],
+ target: "https://cdn.cmp.advertising.com/firefox-etp",
+ types: ["image", "imageset", "xmlhttprequest"],
+ onlyIfBlockedByETP: true,
+ },
+ {
+ patterns: ["*://*.advertising.com/*"],
+ target: "https://pixel.advertising.com/firefox-etp",
+ types: ["image", "imageset", "xmlhttprequest"],
+ onlyIfBlockedByETP: true,
+ },
+ ],
+ },
+ {
+ id: "Branch",
+ platform: "all",
+ name: "Branch Web SDK",
+ bug: "1716220",
+ file: "branch.js",
+ matches: ["*://cdn.branch.io/branch-latest.min.js*"],
+ onlyIfBlockedByETP: true,
+ },
+ {
+ id: "DoubleVerify",
+ platform: "all",
+ name: "DoubleVerify",
+ bug: "1771557",
+ file: "doubleverify.js",
+ matches: ["*://pub.doubleverify.com/signals/pub.js*"],
+ onlyIfBlockedByETP: true,
+ },
+ {
+ id: "AmazonTAM",
+ platform: "all",
+ name: "Amazon Transparent Ad Marketplace",
+ bug: "1713698",
+ file: "apstag.js",
+ matches: ["*://c.amazon-adsystem.com/aax2/apstag.js"],
+ onlyIfBlockedByETP: true,
+ },
+ {
+ id: "BmAuth",
+ platform: "all",
+ name: "BmAuth by 9c9media",
+ bug: "1486337",
+ file: "bmauth.js",
+ matches: ["*://auth.9c9media.ca/auth/main.js"],
+ onlyIfBlockedByETP: true,
+ },
+ {
+ id: "Chartbeat",
+ platform: "all",
+ name: "Chartbeat",
+ bug: "1713699",
+ file: "chartbeat.js",
+ matches: [
+ "*://static.chartbeat.com/js/chartbeat.js",
+ "*://static.chartbeat.com/js/chartbeat_video.js",
+ ],
+ onlyIfBlockedByETP: true,
+ },
+ {
+ id: "Criteo",
+ platform: "all",
+ name: "Criteo",
+ bug: "1713720",
+ file: "criteo.js",
+ matches: ["*://static.criteo.net/js/ld/publishertag.js"],
+ onlyIfBlockedByETP: true,
+ },
+ {
+ // keep this above AdSafeProtectedTrackingPixels
+ id: "Doubleclick",
+ platform: "all",
+ name: "Doubleclick",
+ bug: "1713693",
+ matches: [
+ {
+ patterns: [
+ "*://securepubads.g.doubleclick.net/gampad/*ad-blk*",
+ "*://pubads.g.doubleclick.net/gampad/*ad-blk*",
+ ],
+ target: "empty-shim.txt",
+ types: ["image", "imageset", "xmlhttprequest"],
+ },
+ {
+ patterns: [
+ "*://securepubads.g.doubleclick.net/gampad/*xml_vmap1*",
+ "*://pubads.g.doubleclick.net/gampad/*xml_vmap1*",
+ ],
+ target: "vmad.xml",
+ types: ["image", "imageset", "xmlhttprequest"],
+ },
+ {
+ patterns: [
+ "*://vast.adsafeprotected.com/vast*",
+ "*://securepubads.g.doubleclick.net/gampad/*xml_vmap2*",
+ "*://pubads.g.doubleclick.net/gampad/*xml_vmap2*",
+ ],
+ target: "vast2.xml",
+ types: ["image", "imageset", "xmlhttprequest"],
+ },
+ {
+ patterns: [
+ "*://securepubads.g.doubleclick.net/gampad/*ad*",
+ "*://pubads.g.doubleclick.net/gampad/*ad*",
+ ],
+ target: "vast3.xml",
+ types: ["image", "imageset", "xmlhttprequest"],
+ },
+ ],
+ onlyIfBlockedByETP: true,
+ },
+ {
+ id: "PBMWebAPIFixes",
+ platform: "all",
+ name: "Private Browsing Web APIs",
+ bug: "1773110",
+ runFirst: "private-browsing-web-api-fixes.js",
+ matches: [
+ "*://*.imgur.com/js/vendor.*.bundle.js",
+ "*://*.imgur.io/js/vendor.*.bundle.js",
+ "*://www.rva311.com/static/js/main.*.chunk.js",
+ "*://web-assets.toggl.com/app/assets/scripts/*.js", // bug 1783919
+ ],
+ onlyIfPrivateBrowsing: true,
+ },
+ {
+ id: "Eluminate",
+ platform: "all",
+ name: "Eluminate",
+ bug: "1503211",
+ file: "eluminate.js",
+ matches: ["*://libs.coremetrics.com/eluminate.js"],
+ onlyIfBlockedByETP: true,
+ },
+ {
+ id: "FacebookSDK",
+ platform: "all",
+ branches: ["nightly:android"],
+ name: "Facebook SDK",
+ bug: "1226498",
+ file: "facebook-sdk.js",
+ logos: ["facebook.svg", "play.svg"],
+ matches: [
+ "*://connect.facebook.net/*/sdk.js*",
+ "*://connect.facebook.net/*/all.js*",
+ {
+ patterns: ["*://www.facebook.com/platform/impression.php*"],
+ target: "tracking-pixel.png",
+ types: ["image", "imageset", "xmlhttprequest"],
+ },
+ ],
+ needsShimHelpers: ["optIn", "getOptions"],
+ onlyIfBlockedByETP: true,
+ unblocksOnOptIn: [
+ "*://connect.facebook.net/*/sdk.js*",
+ "*://connect.facebook.net/*/all.js*",
+ "*://*.xx.fbcdn.net/*", // covers:
+ // "*://scontent-.*-\d.xx.fbcdn.net/*",
+ // "*://static.xx.fbcdn.net/rsrc.php/*",
+ "*://graph.facebook.com/v2*access_token*",
+ "*://graph.facebook.com/v*/me*",
+ "*://graph.facebook.com/*/picture*",
+ "*://www.facebook.com/*/plugins/login_button.php*",
+ "*://www.facebook.com/x/oauth/status*",
+ {
+ patterns: [
+ "*://www.facebook.com/*/plugins/video.php*",
+ "*://www.facebook.com/rsrc.php/*",
+ ],
+ branches: ["nightly"],
+ },
+ ],
+ },
+ {
+ id: "Fastclick",
+ platform: "all",
+ name: "Fastclick",
+ bug: "1738220",
+ file: "fastclick.js",
+ matches: [
+ "*://secure.cdn.fastclick.net/js/cnvr-launcher/*/launcher-stub.min.js*",
+ ],
+ onlyIfBlockedByETP: true,
+ },
+ {
+ id: "GoogleAnalyticsAndTagManager",
+ platform: "all",
+ name: "Google Analytics and Tag Manager",
+ bug: "1713687",
+ file: "google-analytics-and-tag-manager.js",
+ matches: [
+ "*://www.google-analytics.com/analytics.js*",
+ "*://www.google-analytics.com/gtm/js*",
+ "*://www.googletagmanager.com/gtm.js*",
+ ],
+ onlyIfBlockedByETP: true,
+ },
+ {
+ id: "GoogleAnalyticsECommercePlugin",
+ platform: "all",
+ name: "Google Analytics E-Commerce Plugin",
+ bug: "1620533",
+ file: "google-analytics-ecommerce-plugin.js",
+ matches: ["*://www.google-analytics.com/plugins/ua/ec.js"],
+ onlyIfBlockedByETP: true,
+ },
+ {
+ id: "GoogleAnalyticsLegacy",
+ platform: "all",
+ name: "Google Analytics (legacy version)",
+ bug: "1487072",
+ file: "google-analytics-legacy.js",
+ matches: ["*://ssl.google-analytics.com/ga.js"],
+ onlyIfBlockedByETP: true,
+ },
+ {
+ id: "GoogleIMA",
+ platform: "all",
+ name: "Google Interactive Media Ads",
+ bug: "1713690",
+ file: "google-ima.js",
+ matches: [
+ "*://s0.2mdn.net/instream/html5/ima3.js",
+ "*://imasdk.googleapis.com/js/sdkloader/ima3.js",
+ ],
+ onlyIfBlockedByETP: true,
+ },
+ {
+ id: "GooglePageAd",
+ platform: "all",
+ name: "Google Page Ad",
+ bug: "1713692",
+ file: "google-page-ad.js",
+ matches: ["*://www.googleadservices.com/pagead/conversion_async.js"],
+ onlyIfBlockedByETP: true,
+ },
+ {
+ id: "GooglePublisherTags",
+ platform: "all",
+ name: "Google Publisher Tags",
+ bug: "1713685",
+ file: "google-publisher-tags.js",
+ matches: [
+ "*://www.googletagservices.com/tag/js/gpt.js*",
+ "*://pagead2.googlesyndication.com/tag/js/gpt.js*",
+ "*://pagead2.googlesyndication.com/gpt/pubads_impl_*.js*",
+ "*://securepubads.g.doubleclick.net/tag/js/gpt.js*",
+ "*://securepubads.g.doubleclick.net/gpt/pubads_impl_*.js*",
+ ],
+ onlyIfBlockedByETP: true,
+ },
+ {
+ id: "Google SafeFrame",
+ platform: "all",
+ name: "Google SafeFrame",
+ bug: "1713691",
+ matches: [
+ {
+ patterns: [
+ "*://tpc.googlesyndication.com/safeframe/*/html/container.html",
+ "*://*.safeframe.googlesyndication.com/safeframe/*/html/container.html",
+ ],
+ target: "google-safeframe.html",
+ types: ["sub_frame"],
+ },
+ ],
+ onlyIfBlockedByETP: true,
+ },
+ {
+ id: "GoogleTrends",
+ platform: "all",
+ name: "Google Trends",
+ bug: "1624914",
+ custom: "google-trends-dfpi-fix",
+ onlyIfDFPIActive: true,
+ matches: [
+ {
+ patterns: ["*://trends.google.com/trends/embed*"],
+ types: ["sub_frame"],
+ },
+ ],
+ },
+ {
+ id: "IAM",
+ platform: "all",
+ name: "INFOnline IAM",
+ bug: "1761774",
+ file: "iam.js",
+ matches: ["*://script.ioam.de/iam.js"],
+ onlyIfBlockedByETP: true,
+ },
+ {
+ // keep this above AdSafeProtectedTrackingPixels
+ id: "IASPET",
+ platform: "all",
+ name: "Integral Ad Science PET",
+ bug: "1713701",
+ file: "iaspet.js",
+ matches: [
+ "*://cdn.adsafeprotected.com/iasPET.1.js",
+ "*://static.adsafeprotected.com/iasPET.1.js",
+ ],
+ onlyIfBlockedByETP: true,
+ },
+ {
+ id: "MNet",
+ platform: "all",
+ name: "Media.net Ads",
+ bug: "1713703",
+ file: "empty-script.js",
+ matches: ["*://adservex.media.net/videoAds.js*"],
+ onlyIfBlockedByETP: true,
+ },
+ {
+ id: "Moat",
+ platform: "all",
+ name: "Moat",
+ bug: "1713704",
+ file: "moat.js",
+ matches: [
+ "*://*.moatads.com/*/moatad.js*",
+ "*://*.moatads.com/*/moatapi.js*",
+ "*://*.moatads.com/*/moatheader.js*",
+ "*://*.moatads.com/*/yi.js*",
+ ],
+ onlyIfBlockedByETP: true,
+ },
+ {
+ id: "Nielsen",
+ platform: "all",
+ name: "Nielsen",
+ bug: "1760754",
+ file: "nielsen.js",
+ matches: ["*://*.imrworldwide.com/v60.js"],
+ onlyIfBlockedByETP: true,
+ },
+ {
+ id: "Optimizely",
+ platform: "all",
+ name: "Optimizely",
+ bug: "1714431",
+ file: "optimizely.js",
+ matches: [
+ "*://cdn.optimizely.com/js/*.js",
+ "*://cdn.optimizely.com/public/*.js",
+ ],
+ onlyIfBlockedByETP: true,
+ },
+ {
+ id: "Rambler",
+ platform: "all",
+ name: "Rambler Authenticator",
+ bug: "1606428",
+ file: "rambler-authenticator.js",
+ matches: ["*://id.rambler.ru/rambler-id-helper/auth_events.js"],
+ needsShimHelpers: ["optIn"],
+ onlyIfBlockedByETP: true,
+ },
+ {
+ id: "RichRelevance",
+ platform: "all",
+ name: "Rich Relevance",
+ bug: "1713725",
+ file: "rich-relevance.js",
+ matches: ["*://media.richrelevance.com/rrserver/js/1.2/p13n.js"],
+ onlyIfBlockedByETP: true,
+ },
+ {
+ id: "Firebase",
+ platform: "all",
+ name: "Firebase",
+ bug: "1771783",
+ onlyIfPrivateBrowsing: true,
+ runFirst: "firebase.js",
+ matches: [
+ // bugs 1750699, 1767407
+ "*://www.gstatic.com/firebasejs/*/firebase-messaging.js*",
+ ],
+ contentScripts: [
+ {
+ cookieStoreId: "firefox-private",
+ js: "firebase.js",
+ runAt: "document_start",
+ matches: [
+ "*://www.homedepot.ca/*", // bug 1778993
+ "*://orangerie.eu/*", // bug 1758442
+ "*://web.whatsapp.com/*", // bug 1767407
+ "*://www.tripadvisor.com/*", // bug 1779536
+ "*://www.office.com/*", // bug 1783921
+ ],
+ },
+ ],
+ },
+ {
+ id: "StickyAdsTV",
+ platform: "all",
+ name: "StickyAdsTV",
+ bug: "1717806",
+ matches: [
+ {
+ patterns: ["https://ads.stickyadstv.com/firefox-etp"],
+ target: "tracking-pixel.png",
+ types: ["image", "imageset", "xmlhttprequest"],
+ onlyIfBlockedByETP: true,
+ },
+ {
+ patterns: [
+ "*://ads.stickyadstv.com/auto-user-sync*",
+ "*://ads.stickyadstv.com/user-matching*",
+ ],
+ target: "https://ads.stickyadstv.com/firefox-etp",
+ types: ["image", "imageset", "xmlhttprequest"],
+ onlyIfBlockedByETP: true,
+ },
+ ],
+ },
+ {
+ id: "Vidible",
+ branch: ["nightly"],
+ platform: "all",
+ name: "Vidible",
+ bug: "1713710",
+ file: "vidible.js",
+ logos: ["play.svg"],
+ matches: [
+ "*://*.vidible.tv/*/vidible-min.js*",
+ "*://vdb-cdn-files.s3.amazonaws.com/*/vidible-min.js*",
+ ],
+ needsShimHelpers: ["optIn"],
+ onlyIfBlockedByETP: true,
+ unblocksOnOptIn: [
+ "*://delivery.vidible.tv/jsonp/pid=*/vid=*/*.js*",
+ "*://delivery.vidible.tv/placement/*",
+ "*://img.vidible.tv/prod/*",
+ "*://cdn-ssl.vidible.tv/prod/player/js/*.js",
+ "*://hlsrv.vidible.tv/prod/*.m3u8*",
+ "*://videos.vidible.tv/prod/*.key*",
+ "*://videos.vidible.tv/prod/*.mp4*",
+ "*://videos.vidible.tv/prod/*.webm*",
+ "*://videos.vidible.tv/prod/*.ts*",
+ ],
+ },
+ {
+ id: "Kinja",
+ platform: "all",
+ name: "Kinja",
+ bug: "1656171",
+ contentScripts: [
+ {
+ js: "kinja.js",
+ matches: [
+ "*://www.avclub.com/*",
+ "*://deadspin.com/*",
+ "*://gizmodo.com/*",
+ "*://jalopnik.com/*",
+ "*://jezebel.com/*",
+ "*://kotaku.com/*",
+ "*://lifehacker.com/*",
+ "*://www.theonion.com/*",
+ "*://www.theroot.com/*",
+ "*://thetakeout.com/*",
+ "*://theinventory.com/*",
+ ],
+ runAt: "document_start",
+ allFrames: true,
+ },
+ ],
+ onlyIfDFPIActive: true,
+ },
+ {
+ id: "MicrosoftLogin",
+ platform: "desktop",
+ name: "Microsoft Login",
+ bug: "1638383",
+ requestStorageAccessForRedirect: [
+ ["*://web.powerva.microsoft.com/*", "*://login.microsoftonline.com/*"],
+ ["*://teams.microsoft.com/*", "*://login.microsoftonline.com/*"],
+ ["*://*.teams.microsoft.us/*", "*://login.microsoftonline.us/*"],
+ ],
+ contentScripts: [
+ {
+ js: "microsoftLogin.js",
+ matches: [
+ "*://web.powerva.microsoft.com/*",
+ "*://teams.microsoft.com/*",
+ "*://*.teams.microsoft.us/*",
+ ],
+ runAt: "document_start",
+ },
+ ],
+ onlyIfDFPIActive: true,
+ },
+ {
+ id: "MicrosoftVirtualAssistant",
+ platform: "all",
+ name: "Microsoft Virtual Assistant",
+ bug: "1801277",
+ contentScripts: [
+ {
+ js: "microsoftVirtualAssistant.js",
+ matches: ["*://publisher.liveperson.net/*"],
+ runAt: "document_start",
+ allFrames: true,
+ },
+ ],
+ },
+ {
+ id: "History",
+ platform: "all",
+ name: "History.com",
+ bug: "1624853",
+ contentScripts: [
+ {
+ js: "history.js",
+ matches: ["*://play.history.com/*"],
+ runAt: "document_start",
+ },
+ ],
+ onlyIfDFPIActive: true,
+ },
+ {
+ id: "Crave.ca",
+ platform: "all",
+ name: "Crave.ca",
+ bug: "1746439",
+ contentScripts: [
+ {
+ js: "crave-ca.js",
+ matches: ["*://account.bellmedia.ca/login*"],
+ runAt: "document_start",
+ },
+ ],
+ onlyIfDFPIActive: true,
+ },
+ {
+ id: "Instagram.com",
+ platform: "android",
+ name: "Instagram.com",
+ bug: "1804445",
+ contentScripts: [
+ {
+ js: "instagram.js",
+ matches: ["*://www.instagram.com/*"],
+ runAt: "document_start",
+ },
+ ],
+ onlyIfDFPIActive: true,
+ },
+ {
+ id: "MaxMindGeoIP",
+ platform: "all",
+ name: "MaxMind GeoIP",
+ bug: "1754389",
+ file: "maxmind-geoip.js",
+ matches: ["*://js.maxmind.com/js/apis/geoip2/*/geoip2.js"],
+ onlyIfBlockedByETP: true,
+ },
+ {
+ id: "WebTrends",
+ platform: "all",
+ name: "WebTrends",
+ bug: "1766414",
+ file: "webtrends.js",
+ matches: [
+ "*://s.webtrends.com/js/advancedLinkTracking.js",
+ "*://s.webtrends.com/js/webtrends.js",
+ "*://s.webtrends.com/js/webtrends.min.js",
+ ],
+ onlyIfBlockedByETP: true,
+ },
+ {
+ id: "Blogger",
+ platform: "all",
+ name: "Blogger",
+ bug: "1776869",
+ contentScripts: [
+ {
+ js: "blogger.js",
+ matches: ["*://www.blogger.com/comment/frame/*"],
+ runAt: "document_start",
+ allFrames: true,
+ },
+ {
+ js: "bloggerAccount.js",
+ matches: ["*://www.blogger.com/blog/*"],
+ runAt: "document_end",
+ },
+ ],
+ onlyIfDFPIActive: true,
+ },
+ {
+ // keep this below any other shims checking adsafeprotected URLs
+ id: "AdSafeProtectedTrackingPixels",
+ platform: "all",
+ name: "Ad Safe Protected tracking pixels",
+ bug: "1717806",
+ matches: [
+ {
+ patterns: ["https://static.adsafeprotected.com/firefox-etp-pixel"],
+ target: "tracking-pixel.png",
+ types: ["image", "imageset", "xmlhttprequest"],
+ },
+ {
+ patterns: ["https://static.adsafeprotected.com/firefox-etp-js"],
+ target: "empty-script.js",
+ types: ["xmlhttprequest"],
+ },
+ {
+ patterns: [
+ "*://*.adsafeprotected.com/*.gif*",
+ "*://*.adsafeprotected.com/*.png*",
+ ],
+ target: "https://static.adsafeprotected.com/firefox-etp-pixel",
+ types: ["image", "imageset", "xmlhttprequest"],
+ onlyIfBlockedByETP: true,
+ },
+ {
+ patterns: [
+ "*://*.adsafeprotected.com/*.js*",
+ "*://*.adsafeprotected.com/*/adj*",
+ "*://*.adsafeprotected.com/*/imp/*",
+ "*://*.adsafeprotected.com/*/Serving/*",
+ "*://*.adsafeprotected.com/*/unit/*",
+ "*://*.adsafeprotected.com/jload",
+ "*://*.adsafeprotected.com/jload?*",
+ "*://*.adsafeprotected.com/jsvid",
+ "*://*.adsafeprotected.com/jsvid?*",
+ "*://*.adsafeprotected.com/mon*",
+ "*://*.adsafeprotected.com/tpl",
+ "*://*.adsafeprotected.com/tpl?*",
+ "*://*.adsafeprotected.com/services/pub*",
+ ],
+ target: "https://static.adsafeprotected.com/firefox-etp-js",
+ types: ["image", "imageset", "xmlhttprequest"],
+ onlyIfBlockedByETP: true,
+ },
+ {
+ // note, fallback case seems to be an image
+ patterns: ["*://*.adsafeprotected.com/*"],
+ target: "https://static.adsafeprotected.com/firefox-etp-pixel",
+ types: ["image", "imageset", "xmlhttprequest"],
+ onlyIfBlockedByETP: true,
+ },
+ ],
+ },
+ {
+ id: "SpotifyEmbed",
+ platform: "all",
+ name: "SpotifyEmbed",
+ bug: "1792395",
+ contentScripts: [
+ {
+ js: "spotify-embed.js",
+ matches: ["*://open.spotify.com/embed/*"],
+ runAt: "document_start",
+ allFrames: true,
+ },
+ ],
+ onlyIfDFPIActive: true,
+ },
+];
+
+module.exports = AVAILABLE_SHIMS;
diff --git a/browser/extensions/webcompat/data/ua_overrides.js b/browser/extensions/webcompat/data/ua_overrides.js
new file mode 100644
index 0000000000..150d13465d
--- /dev/null
+++ b/browser/extensions/webcompat/data/ua_overrides.js
@@ -0,0 +1,1371 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* globals browser, module, require */
+
+// This is a hack for the tests.
+if (typeof InterventionHelpers === "undefined") {
+ var InterventionHelpers = require("../lib/intervention_helpers");
+}
+if (typeof UAHelpers === "undefined") {
+ var UAHelpers = require("../lib/ua_helpers");
+}
+
+/**
+ * For detailed information on our policies, and a documention on this format
+ * and its possibilites, please check the Mozilla-Wiki at
+ *
+ * https://wiki.mozilla.org/Compatibility/Go_Faster_Addon/Override_Policies_and_Workflows#User_Agent_overrides
+ */
+const AVAILABLE_UA_OVERRIDES = [
+ {
+ id: "testbed-override",
+ platform: "all",
+ domain: "webcompat-addon-testbed.herokuapp.com",
+ bug: "0000000",
+ config: {
+ hidden: true,
+ matches: ["*://webcompat-addon-testbed.herokuapp.com/*"],
+ uaTransformer: originalUA => {
+ return (
+ UAHelpers.getPrefix(originalUA) +
+ " AppleWebKit/537.36 (KHTML, like Gecko) Chrome/54.0.2840.98 Safari/537.36 for WebCompat"
+ );
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1577519 - directv.com - Create a UA override for directv.com for playback on desktop
+ * WebCompat issue #3846 - https://webcompat.com/issues/3846
+ *
+ * directv.com (attwatchtv.com) is blocking Firefox via UA sniffing. Spoofing as Chrome allows
+ * to access the site and playback works fine. This is former directvnow.com
+ */
+ id: "bug1577519",
+ platform: "desktop",
+ domain: "directv.com",
+ bug: "1577519",
+ config: {
+ matches: [
+ "*://*.attwatchtv.com/*",
+ "*://*.directv.com.ec/*", // bug 1827706
+ "*://*.directv.com/*",
+ ],
+ uaTransformer: originalUA => {
+ return (
+ UAHelpers.getPrefix(originalUA) +
+ " AppleWebKit/537.36 (KHTML, like Gecko) Chrome/76.0.3809.132 Safari/537.36"
+ );
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1570108 - steamcommunity.com - UA override for steamcommunity.com
+ * WebCompat issue #34171 - https://webcompat.com/issues/34171
+ *
+ * steamcommunity.com blocks chat feature for Firefox users showing unsupported browser message.
+ * When spoofing as Chrome the chat works fine
+ */
+ id: "bug1570108",
+ platform: "desktop",
+ domain: "steamcommunity.com",
+ bug: "1570108",
+ config: {
+ matches: ["*://steamcommunity.com/chat*"],
+ uaTransformer: originalUA => {
+ return (
+ UAHelpers.getPrefix(originalUA) +
+ " AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.142 Safari/537.36"
+ );
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1582582 - sling.com - UA override for sling.com
+ * WebCompat issue #17804 - https://webcompat.com/issues/17804
+ *
+ * sling.com blocks Firefox users showing unsupported browser message.
+ * When spoofing as Chrome playing content works fine
+ */
+ id: "bug1582582",
+ platform: "desktop",
+ domain: "sling.com",
+ bug: "1582582",
+ config: {
+ matches: ["https://watch.sling.com/*", "https://www.sling.com/*"],
+ uaTransformer: originalUA => {
+ return (
+ UAHelpers.getPrefix(originalUA) +
+ " AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36"
+ );
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1610026 - www.mobilesuica.com - UA override for www.mobilesuica.com
+ * WebCompat issue #4608 - https://webcompat.com/issues/4608
+ *
+ * mobilesuica.com showing unsupported message for Firefox users
+ * Spoofing as Chrome allows to access the page
+ */
+ id: "bug1610026",
+ platform: "all",
+ domain: "www.mobilesuica.com",
+ bug: "1610026",
+ config: {
+ matches: ["https://www.mobilesuica.com/*"],
+ uaTransformer: originalUA => {
+ return (
+ UAHelpers.getPrefix(originalUA) +
+ " AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.88 Safari/537.36"
+ );
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1385206 - Create UA override for rakuten.co.jp on Firefox Android
+ * (Imported from ua-update.json.in)
+ *
+ * rakuten.co.jp serves a Desktop version if Firefox is included in the UA.
+ */
+ id: "bug1385206",
+ platform: "android",
+ domain: "rakuten.co.jp",
+ bug: "1385206",
+ config: {
+ matches: ["*://*.rakuten.co.jp/*"],
+ uaTransformer: originalUA => {
+ return originalUA.replace(/Firefox.+$/, "");
+ },
+ },
+ },
+ {
+ /*
+ * Bug 969844 - mobile.de sends desktop site to Firefox on Android
+ *
+ * mobile.de sends the desktop site to Firefox Mobile.
+ * Spoofing as Chrome works fine.
+ */
+ id: "bug969844",
+ platform: "android",
+ domain: "mobile.de",
+ bug: "969844",
+ config: {
+ matches: ["*://*.mobile.de/*"],
+ uaTransformer: _ => {
+ return "Mozilla/5.0 (Linux; Android 6.0.1; SM-G920F Build/MMB29K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.91 Mobile Safari/537.36";
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1509873 - zmags.com - Add UA override for secure.viewer.zmags.com
+ * WebCompat issue #21576 - https://webcompat.com/issues/21576
+ *
+ * The zmags viewer locks out Firefox Mobile with a "Browser unsupported"
+ * message, but tests showed that it works just fine with a Chrome UA.
+ * Outreach attempts were unsuccessful, and as the site has a relatively
+ * high rank, we alter the UA.
+ */
+ id: "bug1509873",
+ platform: "android",
+ domain: "zmags.com",
+ bug: "1509873",
+ config: {
+ matches: ["*://*.viewer.zmags.com/*"],
+ uaTransformer: originalUA => {
+ return (
+ UAHelpers.getPrefix(originalUA) +
+ " AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.91 Mobile Safari/537.36"
+ );
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1574522 - UA override for enuri.com on Firefox for Android
+ * WebCompat issue #37139 - https://webcompat.com/issues/37139
+ *
+ * enuri.com returns a different template for Firefox on Android
+ * based on server side UA detection. This results in page content cut offs.
+ * Spoofing as Chrome fixes the issue
+ */
+ id: "bug1574522",
+ platform: "android",
+ domain: "enuri.com",
+ bug: "1574522",
+ config: {
+ matches: ["*://enuri.com/*"],
+ uaTransformer: _ => {
+ return "Mozilla/5.0 (Linux; Android 6.0.1; SM-G900M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/76.0.3809.111 Mobile Safari/537.36";
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1574564 - UA override for ceskatelevize.cz on Firefox for Android
+ * WebCompat issue #15467 - https://webcompat.com/issues/15467
+ *
+ * ceskatelevize sets streamingProtocol depending on the User-Agent it sees
+ * in the request headers, returning DASH for Chrome, HLS for iOS,
+ * and Flash for Firefox Mobile. Since Mobile has no Flash, the video
+ * doesn't work. Spoofing as Chrome makes the video play
+ */
+ id: "bug1574564",
+ platform: "android",
+ domain: "ceskatelevize.cz",
+ bug: "1574564",
+ config: {
+ matches: ["*://*.ceskatelevize.cz/*"],
+ uaTransformer: originalUA => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1577267 - UA override for metfone.com.kh on Firefox for Android
+ * WebCompat issue #16363 - https://webcompat.com/issues/16363
+ *
+ * metfone.com.kh has a server side UA detection which returns desktop site
+ * for Firefox for Android. Spoofing as Chrome allows to receive mobile version
+ */
+ id: "bug1577267",
+ platform: "android",
+ domain: "metfone.com.kh",
+ bug: "1577267",
+ config: {
+ matches: ["*://*.metfone.com.kh/*"],
+ uaTransformer: originalUA => {
+ return (
+ UAHelpers.getPrefix(originalUA) +
+ " AppleWebKit/537.36 (KHTML, like Gecko) Chrome/76.0.3809.111 Mobile Safari/537.36"
+ );
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1598198 - User Agent extension for Samsung's galaxy.store URLs
+ *
+ * Samsung's galaxy.store shortlinks are supposed to redirect to a Samsung
+ * intent:// URL on Samsung devices, but to an error page on other brands.
+ * As we do not provide device info in our user agent string, this check
+ * fails, and even Samsung users land on an error page if they use Firefox
+ * for Android.
+ * This intervention adds a simple "Samsung" identifier to the User Agent
+ * on only the Galaxy Store URLs if the device happens to be a Samsung.
+ */
+ id: "bug1598198",
+ platform: "android",
+ domain: "galaxy.store",
+ bug: "1598198",
+ config: {
+ matches: [
+ "*://galaxy.store/*",
+ "*://dev.galaxy.store/*",
+ "*://stg.galaxy.store/*",
+ ],
+ uaTransformer: originalUA => {
+ if (!browser.systemManufacturer) {
+ return originalUA;
+ }
+
+ const manufacturer = browser.systemManufacturer.getManufacturer();
+ if (manufacturer && manufacturer.toLowerCase() === "samsung") {
+ return originalUA.replace("Mobile;", "Mobile; Samsung;");
+ }
+
+ return originalUA;
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1595215 - UA overrides for Uniqlo sites
+ * Webcompat issue #38825 - https://webcompat.com/issues/38825
+ *
+ * To receive the proper mobile version instead of the desktop version or
+ * avoid redirect loop, the UA is spoofed.
+ */
+ id: "bug1595215",
+ platform: "android",
+ domain: "uniqlo.com",
+ bug: "1595215",
+ config: {
+ matches: ["*://*.uniqlo.com/*"],
+ uaTransformer: originalUA => {
+ return originalUA + " Mobile Safari";
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1622063 - UA override for wp1-ext.usps.gov
+ * Webcompat issue #29867 - https://webcompat.com/issues/29867
+ *
+ * The Job Search site for USPS does not work for Firefox Mobile
+ * browsers (a 500 is returned).
+ */
+ id: "bug1622063",
+ platform: "android",
+ domain: "wp1-ext.usps.gov",
+ bug: "1622063",
+ config: {
+ matches: ["*://wp1-ext.usps.gov/*"],
+ uaTransformer: originalUA => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1697324 - Update the override for mobile2.bmo.com
+ * Previously Bug 1622081 - UA override for mobile2.bmo.com
+ * Webcompat issue #45019 - https://webcompat.com/issues/45019
+ *
+ * Unless the UA string contains "Chrome", mobile2.bmo.com will
+ * display a modal saying the browser is out-of-date.
+ */
+ id: "bug1697324",
+ platform: "android",
+ domain: "mobile2.bmo.com",
+ bug: "1697324",
+ config: {
+ matches: ["*://mobile2.bmo.com/*"],
+ uaTransformer: originalUA => {
+ return originalUA + " Chrome";
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1628455 - UA override for autotrader.ca
+ * Webcompat issue #50961 - https://webcompat.com/issues/50961
+ *
+ * autotrader.ca is showing desktop site for Firefox on Android
+ * based on server side UA detection. Spoofing as Chrome allows to
+ * get mobile experience
+ */
+ id: "bug1628455",
+ platform: "android",
+ domain: "autotrader.ca",
+ bug: "1628455",
+ config: {
+ matches: ["https://*.autotrader.ca/*"],
+ uaTransformer: () => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1646791 - bancosantander.es - Re-add UA override.
+ * Bug 1665129 - *.gruposantander.es - Add wildcard domains.
+ * WebCompat issue #33462 - https://webcompat.com/issues/33462
+ * SuMo request - https://support.mozilla.org/es/questions/1291085
+ *
+ * santanderbank expects UA to have 'like Gecko', otherwise it runs
+ * xmlDoc.onload whose support has been dropped. It results in missing labels in forms
+ * and some other issues. Adding 'like Gecko' fixes those issues.
+ */
+ id: "bug1646791",
+ platform: "all",
+ domain: "santanderbank.com",
+ bug: "1646791",
+ config: {
+ matches: [
+ "*://*.bancosantander.es/*",
+ "*://*.gruposantander.es/*",
+ "*://*.santander.co.uk/*",
+ ],
+ uaTransformer: originalUA => {
+ // The first line related to Firefox 100 is for Bug 1743445.
+ // [TODO]: Remove when bug 1743429 gets backed out.
+ return UAHelpers.capVersionTo99(originalUA).replace(
+ "Gecko",
+ "like Gecko"
+ );
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1651292 - UA override for www.jp.square-enix.com
+ * Webcompat issue #53018 - https://webcompat.com/issues/53018
+ *
+ * Unless the UA string contains "Chrome 66+", a section of
+ * www.jp.square-enix.com will show a never ending LOADING
+ * page.
+ */
+ id: "bug1651292",
+ platform: "android",
+ domain: "www.jp.square-enix.com",
+ bug: "1651292",
+ config: {
+ matches: ["*://www.jp.square-enix.com/music/sem/page/FF7R/ost/*"],
+ uaTransformer: originalUA => {
+ return originalUA + " Chrome/83";
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1666754 - Mobile UA override for lffl.org
+ * Bug 1665720 - lffl.org article page takes 2x as much time to load on Moto G
+ *
+ * This site returns desktop site based on server side UA detection.
+ * Spoofing as Chrome allows to get mobile experience
+ */
+ id: "bug1666754",
+ platform: "android",
+ domain: "lffl.org",
+ bug: "1666754",
+ config: {
+ matches: ["*://*.lffl.org/*"],
+ uaTransformer: () => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1704673 - Add UA override for app.xiaomi.com
+ * Webcompat issue #66163 - https://webcompat.com/issues/66163
+ *
+ * The page isn’t redirecting properly error message received.
+ * Spoofing as Chrome makes the page load
+ */
+ id: "bug1704673",
+ platform: "android",
+ domain: "app.xiaomi.com",
+ bug: "1704673",
+ config: {
+ matches: ["*://app.xiaomi.com/*"],
+ uaTransformer: () => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1712807 - Add UA override for www.dealnews.com
+ * Webcompat issue #39341 - https://webcompat.com/issues/39341
+ *
+ * The sites shows Firefox a different layout compared to Chrome.
+ * Spoofing as Chrome fixes this.
+ */
+ id: "bug1712807",
+ platform: "android",
+ domain: "www.dealnews.com",
+ bug: "1712807",
+ config: {
+ matches: ["*://www.dealnews.com/*"],
+ uaTransformer: () => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1719859 - Add UA override for saxoinvestor.fr
+ * Webcompat issue #74678 - https://webcompat.com/issues/74678
+ *
+ * The site blocks Firefox with a server-side UA sniffer. Appending a
+ * Chrome version segment to the UA makes it work.
+ */
+ id: "bug1719859",
+ platform: "all",
+ domain: "saxoinvestor.fr",
+ bug: "1719859",
+ config: {
+ matches: ["*://*.saxoinvestor.fr/*"],
+ uaTransformer: originalUA => {
+ return originalUA + " Chrome/91.0.4472.114";
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1722954 - Add UA override for game.granbluefantasy.jp
+ * Webcompat issue #34310 - https://github.com/webcompat/web-bugs/issues/34310
+ *
+ * The website is sending a version of the site which is too small. Adding a partial
+ * safari iOS version of the UA sends us the right layout.
+ */
+ id: "bug1722954",
+ platform: "android",
+ domain: "granbluefantasy.jp",
+ bug: "1722954",
+ config: {
+ matches: ["*://*.granbluefantasy.jp/*"],
+ uaTransformer: originalUA => {
+ return originalUA + " iPhone OS 12_0 like Mac OS X";
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1738317 - Add UA override for vmos.cn
+ * Webcompat issue #90432 - https://github.com/webcompat/web-bugs/issues/90432
+ *
+ * Firefox for Android receives a desktop-only layout based on server-side
+ * UA sniffing. Spoofing as Chrome works fine.
+ */
+ id: "bug1738317",
+ platform: "android",
+ domain: "vmos.cn",
+ bug: "1738317",
+ config: {
+ matches: ["*://*.vmos.cn/*"],
+ uaTransformer: () => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1743627 - Add UA override for renaud-bray.com
+ * Webcompat issue #55276 - https://github.com/webcompat/web-bugs/issues/55276
+ *
+ * Firefox for Android depends on "Version/" being there in the UA string,
+ * or it'll throw a runtime error.
+ */
+ id: "bug1743627",
+ platform: "android",
+ domain: "renaud-bray.com",
+ bug: "1743627",
+ config: {
+ matches: ["*://*.renaud-bray.com/*"],
+ uaTransformer: originalUA => {
+ return originalUA + " Version/0";
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1743751 - Add UA override for slrclub.com
+ * Webcompat issue #91373 - https://github.com/webcompat/web-bugs/issues/91373
+ *
+ * On Firefox Android, the browser is receiving the desktop layout.
+ * Spoofing as Chrome works fine.
+ */
+ id: "bug1743751",
+ platform: "android",
+ domain: "slrclub.com",
+ bug: "1743751",
+ config: {
+ matches: ["*://*.slrclub.com/*"],
+ uaTransformer: () => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1743754 - Add UA override for slrclub.com
+ * Webcompat issue #86839 - https://github.com/webcompat/web-bugs/issues/86839
+ *
+ * On Firefox Android, the browser is failing a UA parsing on Firefox UA.
+ */
+ id: "bug1743754",
+ platform: "android",
+ domain: "workflow.base.vn",
+ bug: "1743754",
+ config: {
+ matches: ["*://workflow.base.vn/*"],
+ uaTransformer: () => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1743429 - Add UA override for sites broken with the Version 100 User Agent
+ *
+ * Some sites have issues with a UA string with Firefox version 100 or higher,
+ * so present as version 99 for now.
+ */
+ id: "bug1743429",
+ platform: "all",
+ domain: "Sites with known Version 100 User Agent breakage",
+ bug: "1743429",
+ config: {
+ matches: [
+ "*://411.ca/", // #121332
+ "*://*.commerzbank.de/*", // Bug 1767630
+ "*://*.mms.telekom.de/*", // #1800241
+ "*://ubank.com.au/*", // #104099
+ "*://wifi.sncf/*", // #100194
+ ],
+ uaTransformer: originalUA => {
+ return UAHelpers.capVersionTo99(originalUA);
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1753461 - UA override for serieson.naver.com
+ * Webcompat issue #99993 - https://webcompat.com/issues/97298
+ *
+ * The site locks out Firefox users unless a Chrome UA is given,
+ * and locks out Linux users as well (so we use Windows+Chrome).
+ */
+ id: "bug1753461",
+ platform: "desktop",
+ domain: "serieson.naver.com",
+ bug: "1753461",
+ config: {
+ matches: ["*://serieson.naver.com/*"],
+ uaTransformer: originalUA => {
+ return "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.99 Safari/537.36";
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1756872 - UA override for www.dolcegabbana.com
+ * Webcompat issue #99993 - https://webcompat.com/issues/99993
+ *
+ * The site's layout is broken on Firefox for Android
+ * without a full Chrome user-agent string.
+ */
+ id: "bug1756872",
+ platform: "android",
+ domain: "www.dolcegabbana.com",
+ bug: "1756872",
+ config: {
+ matches: ["*://www.dolcegabbana.com/*"],
+ uaTransformer: originalUA => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1771200 - UA override for animalplanet.com
+ * Webcompat issue #99993 - https://webcompat.com/issues/103727
+ *
+ * The videos are not playing and an error message is displayed
+ * in Firefox for Android, but work with Chrome UA
+ */
+ id: "bug1771200",
+ platform: "android",
+ domain: "animalplanet.com",
+ bug: "1771200",
+ config: {
+ matches: ["*://*.animalplanet.com/video/*"],
+ uaTransformer: originalUA => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1771200 - UA override for lazada.co.id
+ * Webcompat issue #106229 - https://webcompat.com/issues/106229
+ *
+ * The map is not playing and an error message is displayed
+ * in Firefox for Android, but work with Chrome UA
+ */
+ id: "bug1779059",
+ platform: "android",
+ domain: "lazada.co.id",
+ bug: "1779059",
+ config: {
+ matches: ["*://member-m.lazada.co.id/address/*"],
+ uaTransformer: originalUA => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1778168 - UA override for watch.antennaplus.gr
+ * Webcompat issue #106529 - https://webcompat.com/issues/106529
+ *
+ * The site's content is not loaded unless a Chrome UA is used,
+ * and breaks on Linux (so we claim Windows instead in that case).
+ */
+ id: "bug1778168",
+ platform: "desktop",
+ domain: "watch.antennaplus.gr",
+ bug: "1778168",
+ config: {
+ matches: ["*://watch.antennaplus.gr/*"],
+ uaTransformer: originalUA => {
+ return UAHelpers.getDeviceAppropriateChromeUA({
+ desktopOS: "nonLinux",
+ });
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1776897 - UA override for www.edencast.fr
+ * Webcompat issue #106545 - https://webcompat.com/issues/106545
+ *
+ * The site's podcast audio player does not load unless a Chrome UA is used.
+ */
+ id: "bug1776897",
+ platform: "all",
+ domain: "www.edencast.fr",
+ bug: "1776897",
+ config: {
+ matches: ["*://www.edencast.fr/zoomcast*"],
+ uaTransformer: originalUA => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1784361 - UA override for coldwellbankerhomes.com
+ * Webcompat issue #108535 - https://webcompat.com/issues/108535
+ *
+ * An error is thrown due to missing element, unless Chrome UA is used
+ */
+ id: "bug1784361",
+ platform: "android",
+ domain: "coldwellbankerhomes.com",
+ bug: "1784361",
+ config: {
+ matches: ["*://*.coldwellbankerhomes.com/*"],
+ uaTransformer: originalUA => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1786404 - UA override for business.help.royalmail.com
+ * Webcompat issue #109070 - https://webcompat.com/issues/109070
+ *
+ * Replacing `Firefox` with `FireFox` to evade one of their UA tests...
+ */
+ id: "bug1786404",
+ platform: "all",
+ domain: "business.help.royalmail.com",
+ bug: "1786404",
+ config: {
+ matches: ["*://business.help.royalmail.com/app/webforms/*"],
+ uaTransformer: originalUA => {
+ return originalUA.replace("Firefox", "FireFox");
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1790698 - UA override for wolf777.com
+ * Webcompat issue #103981 - https://webcompat.com/issues/103981
+ *
+ * Add 'Linux; ' next to the Android version or the site breaks
+ */
+ id: "bug1790698",
+ platform: "android",
+ domain: "wolf777.com",
+ bug: "1790698",
+ config: {
+ matches: ["*://wolf777.com/*"],
+ uaTransformer: originalUA => {
+ return originalUA.replace("Android", "Linux; Android");
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1800936 - UA override for cov19ent.kdca.go.kr
+ * Webcompat issue #110655 - https://webcompat.com/issues/110655
+ *
+ * Add 'Chrome;' to the UA for the site to load styles
+ */
+ id: "bug1800936",
+ platform: "all",
+ domain: "cov19ent.kdca.go.kr",
+ bug: "1800936",
+ config: {
+ matches: ["*://cov19ent.kdca.go.kr/*"],
+ uaTransformer: originalUA => {
+ return originalUA + " Chrome";
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1803131 - UA override for argaam.com
+ * Webcompat issue #113638 - https://webcompat.com/issues/113638
+ *
+ * To receive the proper mobile version instead of the desktop version
+ * the UA is spoofed.
+ */
+ id: "bug1803131",
+ platform: "android",
+ domain: "argaam.com",
+ bug: "1803131",
+ config: {
+ matches: ["*://*.argaam.com/*"],
+ uaTransformer: originalUA => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1819702 - UA override for feelgoodcontacts.com
+ * Webcompat issue #118030 - https://webcompat.com/issues/118030
+ *
+ * Spoof the UA to receive the mobile version instead
+ * of the broken desktop version for Android.
+ */
+ id: "bug1819702",
+ platform: "android",
+ domain: "feelgoodcontacts.com",
+ bug: "1819702",
+ config: {
+ matches: ["*://*.feelgoodcontacts.com/*"],
+ uaTransformer: originalUA => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1823966 - UA override for elearning.dmv.ca.gov
+ * Original report: https://bugzilla.mozilla.org/show_bug.cgi?id=1823785
+ */
+ id: "bug1823966",
+ platform: "all",
+ domain: "elearning.dmv.ca.gov",
+ bug: "1823966",
+ config: {
+ matches: ["*://*.elearning.dmv.ca.gov/*"],
+ uaTransformer: originalUA => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1827678 - UA override for admissions.nid.edu
+ * Webcompat issue #65753 - https://webcompat.com/issues/65753
+ */
+ id: "bug1827678-webc65753",
+ platform: "all",
+ domain: "admissions.nid.edu",
+ bug: "1827678",
+ config: {
+ matches: ["*://*.admissions.nid.edu/*"],
+ uaTransformer: originalUA => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1827678 - UA override for www.hepsiburada.com
+ * Webcompat issue #66888 - https://webcompat.com/issues/66888
+ */
+ id: "bug1827678-webc66888",
+ platform: "android",
+ domain: "www.hepsiburada.com",
+ bug: "1827678",
+ config: {
+ matches: ["*://www.hepsiburada.com/*"],
+ uaTransformer: originalUA => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1827678 - UA override for bankmandiri.co.id
+ * Webcompat issue #67924 - https://webcompat.com/issues/67924
+ */
+ id: "bug1827678-webc67924",
+ platform: "android",
+ domain: "bankmandiri.co.id",
+ bug: "1827678",
+ config: {
+ matches: ["*://*.bankmandiri.co.id/*"],
+ uaTransformer: originalUA => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1827678 - UA override for frankfred.com
+ * Webcompat issue #68007 - https://webcompat.com/issues/68007
+ */
+ id: "bug1827678-webc68007",
+ platform: "android",
+ domain: "frankfred.com",
+ bug: "1827678",
+ config: {
+ matches: ["*://*.frankfred.com/*"],
+ uaTransformer: originalUA => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1827678 - UA override for static.slots.lv
+ * Webcompat issue #68379 - https://webcompat.com/issues/68379
+ */
+ id: "bug1827678-webc68379",
+ platform: "android",
+ domain: "static.slots.lv",
+ bug: "1827678",
+ config: {
+ matches: ["*://static.slots.lv/*"],
+ uaTransformer: originalUA => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1827678 - UA override for mobile.onvue.com
+ * Webcompat issue #68520 - https://webcompat.com/issues/68520
+ */
+ id: "bug1827678-webc68520",
+ platform: "android",
+ domain: "mobile.onvue.com",
+ bug: "1827678",
+ config: {
+ matches: ["*://mobile.onvue.com/*"],
+ uaTransformer: originalUA => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1827678 - UA override for avizia.com
+ * Webcompat issue #68635 - https://webcompat.com/issues/68635
+ */
+ id: "bug1827678-webc68635",
+ platform: "all",
+ domain: "avizia.com",
+ bug: "1827678",
+ config: {
+ matches: ["*://*.avizia.com/*"],
+ uaTransformer: originalUA => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1827678 - UA override for www.yourtexasbenefits.com
+ * Webcompat issue #76785 - https://webcompat.com/issues/76785
+ */
+ id: "bug1827678-webc76785",
+ platform: "android",
+ domain: "www.yourtexasbenefits.com",
+ bug: "1827678",
+ config: {
+ matches: ["*://www.yourtexasbenefits.com/*"],
+ uaTransformer: originalUA => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1827678 - UA override for www.free4talk.com
+ * Webcompat issue #77727 - https://webcompat.com/issues/77727
+ */
+ id: "bug1827678-webc77727",
+ platform: "android",
+ domain: "www.free4talk.com",
+ bug: "1827678",
+ config: {
+ matches: ["*://www.free4talk.com/*"],
+ uaTransformer: originalUA => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1827678 - UA override for watch.indee.tv
+ * Webcompat issue #77912 - https://webcompat.com/issues/77912
+ */
+ id: "bug1827678-webc77912",
+ platform: "all",
+ domain: "watch.indee.tv",
+ bug: "1827678",
+ config: {
+ matches: ["*://watch.indee.tv/*"],
+ uaTransformer: originalUA => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1827678 - UA override for viewer-ebook.books.com.tw
+ * Webcompat issue #80180 - https://webcompat.com/issues/80180
+ */
+ id: "bug1827678-webc80180",
+ platform: "all",
+ domain: "viewer-ebook.books.com.tw",
+ bug: "1827678",
+ config: {
+ matches: ["*://viewer-ebook.books.com.tw/*"],
+ uaTransformer: originalUA => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1827678 - UA override for jelly.jd.com
+ * Webcompat issue #83269 - https://webcompat.com/issues/83269
+ */
+ id: "bug1827678-webc83269",
+ platform: "android",
+ domain: "jelly.jd.com",
+ bug: "1827678",
+ config: {
+ matches: ["*://jelly.jd.com/*"],
+ uaTransformer: originalUA => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1827678 - UA override for f2bbs.com
+ * Webcompat issue #84932 - https://webcompat.com/issues/84932
+ */
+ id: "bug1827678-webc84932",
+ platform: "android",
+ domain: "f2bbs.com",
+ bug: "1827678",
+ config: {
+ matches: ["*://f2bbs.com/*"],
+ uaTransformer: originalUA => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1827678 - UA override for kt.com
+ * Webcompat issue #119012 - https://webcompat.com/issues/119012
+ */
+ id: "bug1827678-webc119012",
+ platform: "all",
+ domain: "kt.com",
+ bug: "1827678",
+ config: {
+ matches: ["*://*.kt.com/*"],
+ uaTransformer: originalUA => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1827678 - UA override for oirsa.org
+ * Webcompat issue #119402 - https://webcompat.com/issues/119402
+ */
+ id: "bug1827678-webc119402",
+ platform: "all",
+ domain: "oirsa.org",
+ bug: "1827678",
+ config: {
+ matches: ["*://*.oirsa.org/*"],
+ uaTransformer: originalUA => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1827678 - UA override for sistema.ibglbrasil.com.br
+ * Webcompat issue #119785 - https://webcompat.com/issues/119785
+ */
+ id: "bug1827678-webc119785",
+ platform: "all",
+ domain: "sistema.ibglbrasil.com.br",
+ bug: "1827678",
+ config: {
+ matches: ["*://sistema.ibglbrasil.com.br/*"],
+ uaTransformer: originalUA => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1827678 - UA override for onp.cloud.waterloo.ca
+ * Webcompat issue #120450 - https://webcompat.com/issues/120450
+ */
+ id: "bug1827678-webc120450",
+ platform: "all",
+ domain: "onp.cloud.waterloo.ca",
+ bug: "1827678",
+ config: {
+ matches: ["*://onp.cloud.waterloo.ca/*"],
+ uaTransformer: originalUA => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1830739 - UA override for casino sites
+ *
+ * The sites are showing unsupported message with the same UI
+ */
+ id: "bug1830739",
+ platform: "android",
+ domain: "casino sites",
+ bug: "1830739",
+ config: {
+ matches: [
+ "*://*.captainjackcasino.com/*", // 79490
+ "*://*.casinoextreme.eu/*", // 118175
+ "*://*.cryptoloko.com/*", // 117911
+ "*://*.heapsowins.com/*", // 120027
+ "*://*.planet7casino.com/*", // 120609
+ "*://*.yebocasino.co.za/*", // 88409
+ ],
+ uaTransformer: originalUA => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1830821 - UA override for m.tworld.co.kr
+ * Webcompat issue #118998 - https://webcompat.com/issues/118998
+ */
+ id: "bug1830821-webc118998",
+ platform: "android",
+ domain: "m.tworld.co.kr",
+ bug: "1830821",
+ config: {
+ matches: ["*://m.tworld.co.kr/*"],
+ uaTransformer: originalUA => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1830821 - UA override for webcartop.jp
+ * Webcompat issue #113663 - https://webcompat.com/issues/113663
+ */
+ id: "bug1830821-webc113663",
+ platform: "android",
+ domain: "webcartop.jp",
+ bug: "1830821",
+ config: {
+ matches: ["*://*.webcartop.jp/*"],
+ uaTransformer: originalUA => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1830821 - UA override for enjoy.point.auone.jp
+ * Webcompat issue #90981 - https://webcompat.com/issues/90981
+ */
+ id: "bug1830821-webc90981",
+ platform: "android",
+ domain: "enjoy.point.auone.jp",
+ bug: "1830821",
+ config: {
+ matches: ["*://enjoy.point.auone.jp/*"],
+ uaTransformer: originalUA => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1751604 - UA override for /www.otsuka.co.jp/fib/
+ *
+ * The site's content is not loaded on mobile unless a Chrome UA is used.
+ */
+ id: "bug1829126",
+ platform: "android",
+ domain: "www.otsuka.co.jp",
+ bug: "1829126",
+ config: {
+ matches: ["*://www.otsuka.co.jp/fib/*"],
+ uaTransformer: originalUA => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1831441 - UA override for luna.amazon.com
+ *
+ * Games are unplayable unless a Chrome UA is used.
+ */
+ id: "bug1831441",
+ platform: "all",
+ domain: "luna.amazon.com",
+ bug: "1831441",
+ config: {
+ matches: ["*://luna.amazon.com/*"],
+ uaTransformer: originalUA => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1836109 - UA override for watch.tonton.com.my
+ *
+ * The site's content is not loaded unless a Chrome UA is used.
+ */
+ id: "bug1836109",
+ platform: "all",
+ domain: "watch.tonton.com.my",
+ bug: "1836109",
+ config: {
+ matches: ["*://watch.tonton.com.my/*"],
+ uaTransformer: originalUA => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1836112 - UA override for www.capcut.cn
+ *
+ * The site's content is not loaded unless a Chrome UA is used.
+ */
+ id: "bug1836112",
+ platform: "all",
+ domain: "www.capcut.cn",
+ bug: "1836112",
+ config: {
+ matches: ["*://www.capcut.cn/editor*"],
+ uaTransformer: originalUA => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1836116 - UA override for www.slushy.com
+ *
+ * The site's content is not loaded without a Chrome UA spoof.
+ */
+ id: "bug1836116",
+ platform: "all",
+ domain: "www.slushy.com",
+ bug: "1836116",
+ config: {
+ matches: ["*://www.slushy.com/*"],
+ uaTransformer: originalUA => {
+ return originalUA + " Chrome/113.0.0.0";
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1836135 - UA override for gts-pro.sdimedia.com
+ *
+ * The site's content is not loaded without a Chrome UA spoof.
+ */
+ id: "bug1836135",
+ platform: "all",
+ domain: "gts-pro.sdimedia.com",
+ bug: "1836135",
+ config: {
+ matches: ["*://gts-pro.sdimedia.com/*"],
+ uaTransformer: originalUA => {
+ return originalUA.replace("Firefox/", "Fx/") + " Chrome/113.0.0.0";
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1836140 - UA override for indices.iriworldwide.com
+ *
+ * The site's content is not loaded without a UA spoof.
+ */
+ id: "bug1836140",
+ platform: "all",
+ domain: "indices.iriworldwide.com",
+ bug: "1836140",
+ config: {
+ matches: ["*://indices.iriworldwide.com/covid19/*"],
+ uaTransformer: originalUA => {
+ return originalUA.replace("Firefox/", "Fx/");
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1836178 - UA override for atracker.pro
+ *
+ * The site's content is not loaded without a Chrome UA spoof.
+ */
+ id: "bug1836178",
+ platform: "all",
+ domain: "atracker.pro",
+ bug: "1836178",
+ config: {
+ matches: ["*://atracker.pro/*"],
+ uaTransformer: originalUA => {
+ return originalUA + " Chrome/113.0.0.0";
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1836181 - UA override for conference.amwell.com
+ *
+ * The site's content is not loaded unless a Chrome UA is used.
+ */
+ id: "bug1836181",
+ platform: "all",
+ domain: "conference.amwell.com",
+ bug: "1836181",
+ config: {
+ matches: ["*://conference.amwell.com/*"],
+ uaTransformer: originalUA => {
+ return UAHelpers.getDeviceAppropriateChromeUA();
+ },
+ },
+ },
+ {
+ /*
+ * Bug 1836182 - UA override for www.flatsatshadowglen.com
+ *
+ * The site's content is not loaded without a Chrome UA spoof.
+ */
+ id: "bug1836182",
+ platform: "all",
+ domain: "www.flatsatshadowglen.com",
+ bug: "1836182",
+ config: {
+ matches: ["*://www.flatsatshadowglen.com/*"],
+ uaTransformer: originalUA => {
+ return originalUA + " Chrome/113.0.0.0";
+ },
+ },
+ },
+];
+
+module.exports = AVAILABLE_UA_OVERRIDES;
diff --git a/browser/extensions/webcompat/experiment-apis/aboutConfigPrefs.js b/browser/extensions/webcompat/experiment-apis/aboutConfigPrefs.js
new file mode 100644
index 0000000000..21ad297dc1
--- /dev/null
+++ b/browser/extensions/webcompat/experiment-apis/aboutConfigPrefs.js
@@ -0,0 +1,53 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* global ExtensionAPI, ExtensionCommon, Services, XPCOMUtils */
+
+this.aboutConfigPrefs = class extends ExtensionAPI {
+ getAPI(context) {
+ const EventManager = ExtensionCommon.EventManager;
+ const extensionIDBase = context.extension.id.split("@")[0];
+ const extensionPrefNameBase = `extensions.${extensionIDBase}.`;
+
+ return {
+ aboutConfigPrefs: {
+ onPrefChange: new EventManager({
+ context,
+ name: "aboutConfigPrefs.onUAOverridesPrefChange",
+ register: (fire, name) => {
+ const prefName = `${extensionPrefNameBase}${name}`;
+ const callback = () => {
+ fire.async(name).catch(() => {}); // ignore Message Manager disconnects
+ };
+ Services.prefs.addObserver(prefName, callback);
+ return () => {
+ Services.prefs.removeObserver(prefName, callback);
+ };
+ },
+ }).api(),
+ async getBranch(branchName) {
+ const branch = `${extensionPrefNameBase}${branchName}.`;
+ return Services.prefs.getChildList(branch).map(pref => {
+ const name = pref.replace(branch, "");
+ return { name, value: Services.prefs.getBoolPref(pref) };
+ });
+ },
+ async getPref(name) {
+ try {
+ return Services.prefs.getBoolPref(
+ `${extensionPrefNameBase}${name}`
+ );
+ } catch (_) {
+ return undefined;
+ }
+ },
+ async setPref(name, value) {
+ Services.prefs.setBoolPref(`${extensionPrefNameBase}${name}`, value);
+ },
+ },
+ };
+ }
+};
diff --git a/browser/extensions/webcompat/experiment-apis/aboutConfigPrefs.json b/browser/extensions/webcompat/experiment-apis/aboutConfigPrefs.json
new file mode 100644
index 0000000000..44284f199c
--- /dev/null
+++ b/browser/extensions/webcompat/experiment-apis/aboutConfigPrefs.json
@@ -0,0 +1,72 @@
+[
+ {
+ "namespace": "aboutConfigPrefs",
+ "description": "experimental API extension to allow access to about:config preferences",
+ "events": [
+ {
+ "name": "onPrefChange",
+ "type": "function",
+ "parameters": [
+ {
+ "name": "name",
+ "type": "string",
+ "description": "The preference which changed"
+ }
+ ],
+ "extraParameters": [
+ {
+ "name": "name",
+ "type": "string",
+ "description": "The preference to monitor"
+ }
+ ]
+ }
+ ],
+ "functions": [
+ {
+ "name": "getBranch",
+ "type": "function",
+ "description": "Get all child prefs for a branch",
+ "parameters": [
+ {
+ "name": "branchName",
+ "type": "string",
+ "description": "The branch name"
+ }
+ ],
+ "async": true
+ },
+ {
+ "name": "getPref",
+ "type": "function",
+ "description": "Get a preference's value",
+ "parameters": [
+ {
+ "name": "name",
+ "type": "string",
+ "description": "The preference name"
+ }
+ ],
+ "async": true
+ },
+ {
+ "name": "setPref",
+ "type": "function",
+ "description": "Set a preference's value",
+ "parameters": [
+ {
+ "name": "name",
+ "type": "string",
+ "description": "The preference name"
+ },
+ {
+ "name": "value",
+ "type": "boolean",
+ "description": "The new value"
+ }
+ ],
+ "async": true
+ }
+ ]
+ }
+]
diff --git a/browser/extensions/webcompat/experiment-apis/appConstants.js b/browser/extensions/webcompat/experiment-apis/appConstants.js
new file mode 100644
index 0000000000..2869f299a4
--- /dev/null
+++ b/browser/extensions/webcompat/experiment-apis/appConstants.js
@@ -0,0 +1,28 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* global AppConstants, ExtensionAPI, XPCOMUtils */
+
+this.appConstants = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ appConstants: {
+ getReleaseBranch: () => {
+ if (AppConstants.NIGHTLY_BUILD) {
+ return "nightly";
+ } else if (AppConstants.MOZ_DEV_EDITION) {
+ return "dev_edition";
+ } else if (AppConstants.EARLY_BETA_OR_EARLIER) {
+ return "early_beta_or_earlier";
+ } else if (AppConstants.RELEASE_OR_BETA) {
+ return "release_or_beta";
+ }
+ return "unknown";
+ },
+ },
+ };
+ }
+};
diff --git a/browser/extensions/webcompat/experiment-apis/appConstants.json b/browser/extensions/webcompat/experiment-apis/appConstants.json
new file mode 100644
index 0000000000..cf04915eca
--- /dev/null
+++ b/browser/extensions/webcompat/experiment-apis/appConstants.json
@@ -0,0 +1,15 @@
+[
+ {
+ "namespace": "appConstants",
+ "description": "experimental API to expose some app constants",
+ "functions": [
+ {
+ "name": "getReleaseBranch",
+ "type": "function",
+ "description": "",
+ "async": true,
+ "parameters": []
+ }
+ ]
+ }
+]
diff --git a/browser/extensions/webcompat/experiment-apis/matchPatterns.js b/browser/extensions/webcompat/experiment-apis/matchPatterns.js
new file mode 100644
index 0000000000..422cba5fc4
--- /dev/null
+++ b/browser/extensions/webcompat/experiment-apis/matchPatterns.js
@@ -0,0 +1,30 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* global ExtensionAPI */
+
+this.matchPatterns = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ matchPatterns: {
+ getMatcher(patterns) {
+ const set = new MatchPatternSet(patterns);
+ return Cu.cloneInto(
+ {
+ matches: url => {
+ return set.matches(url);
+ },
+ },
+ context.cloneScope,
+ {
+ cloneFunctions: true,
+ }
+ );
+ },
+ },
+ };
+ }
+};
diff --git a/browser/extensions/webcompat/experiment-apis/matchPatterns.json b/browser/extensions/webcompat/experiment-apis/matchPatterns.json
new file mode 100644
index 0000000000..6fb4dc10fc
--- /dev/null
+++ b/browser/extensions/webcompat/experiment-apis/matchPatterns.json
@@ -0,0 +1,29 @@
+[
+ {
+ "namespace": "matchPatterns",
+ "description": "experimental API extension to expose MatchPattern functionality",
+ "functions": [
+ {
+ "name": "getMatcher",
+ "type": "function",
+ "description": "get a MatchPatternSet",
+ "parameters": [
+ {
+ "name": "patterns",
+ "description": "Array of string URL patterns to match",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ ],
+ "returns": {
+ "type": "object",
+ "properties": {
+ "matches": { "type": "function" }
+ }
+ }
+ }
+ ]
+ }
+]
diff --git a/browser/extensions/webcompat/experiment-apis/systemManufacturer.js b/browser/extensions/webcompat/experiment-apis/systemManufacturer.js
new file mode 100644
index 0000000000..b7dc68415c
--- /dev/null
+++ b/browser/extensions/webcompat/experiment-apis/systemManufacturer.js
@@ -0,0 +1,23 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* global ExtensionAPI, Services, XPCOMUtils */
+
+this.systemManufacturer = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ systemManufacturer: {
+ getManufacturer() {
+ try {
+ return Services.sysinfo.getProperty("manufacturer");
+ } catch (_) {
+ return undefined;
+ }
+ },
+ },
+ };
+ }
+};
diff --git a/browser/extensions/webcompat/experiment-apis/systemManufacturer.json b/browser/extensions/webcompat/experiment-apis/systemManufacturer.json
new file mode 100644
index 0000000000..c64fccc46d
--- /dev/null
+++ b/browser/extensions/webcompat/experiment-apis/systemManufacturer.json
@@ -0,0 +1,20 @@
+[
+ {
+ "namespace": "systemManufacturer",
+ "description": "experimental API extension to allow reading the device's manufacturer",
+ "functions": [
+ {
+ "name": "getManufacturer",
+ "type": "function",
+ "description": "Get the device's manufacturer",
+ "parameters": [],
+ "returns": {
+ "type": "string",
+ "properties": {},
+ "additionalProperties": { "type": "any" },
+ "description": "The manufacturer's name."
+ }
+ }
+ ]
+ }
+]
diff --git a/browser/extensions/webcompat/experiment-apis/trackingProtection.js b/browser/extensions/webcompat/experiment-apis/trackingProtection.js
new file mode 100644
index 0000000000..0f5d9a4233
--- /dev/null
+++ b/browser/extensions/webcompat/experiment-apis/trackingProtection.js
@@ -0,0 +1,216 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* global ExtensionAPI, ExtensionCommon, ExtensionParent, Services, XPCOMUtils */
+
+// eslint-disable-next-line mozilla/reject-importGlobalProperties
+XPCOMUtils.defineLazyGlobalGetters(this, ["URL", "ChannelWrapper"]);
+
+class AllowList {
+ constructor(id) {
+ this._id = id;
+ }
+
+ setShims(patterns, notHosts) {
+ this._shimPatterns = patterns;
+ this._shimMatcher = new MatchPatternSet(patterns || []);
+ this._shimNotHosts = notHosts || [];
+ return this;
+ }
+
+ setAllows(patterns, hosts) {
+ this._allowPatterns = patterns;
+ this._allowMatcher = new MatchPatternSet(patterns || []);
+ this._allowHosts = hosts || [];
+ return this;
+ }
+
+ shims(url, topHost) {
+ return (
+ this._shimMatcher?.matches(url) && !this._shimNotHosts?.includes(topHost)
+ );
+ }
+
+ allows(url, topHost) {
+ return (
+ this._allowMatcher?.matches(url) && this._allowHosts?.includes(topHost)
+ );
+ }
+}
+
+class Manager {
+ constructor() {
+ this._allowLists = new Map();
+ }
+
+ _getAllowList(id) {
+ if (!this._allowLists.has(id)) {
+ this._allowLists.set(id, new AllowList(id));
+ }
+ return this._allowLists.get(id);
+ }
+
+ _ensureStarted() {
+ if (this._classifierObserver) {
+ return;
+ }
+
+ this._unblockedChannelIds = new Set();
+ this._channelClassifier = Cc[
+ "@mozilla.org/url-classifier/channel-classifier-service;1"
+ ].getService(Ci.nsIChannelClassifierService);
+ this._classifierObserver = {};
+ this._classifierObserver.observe = (subject, topic, data) => {
+ switch (topic) {
+ case "http-on-stop-request": {
+ const { channelId } = subject.QueryInterface(Ci.nsIIdentChannel);
+ this._unblockedChannelIds.delete(channelId);
+ break;
+ }
+ case "urlclassifier-before-block-channel": {
+ const channel = subject.QueryInterface(
+ Ci.nsIUrlClassifierBlockedChannel
+ );
+ const { channelId, url } = channel;
+ let topHost;
+ try {
+ topHost = new URL(channel.topLevelUrl).hostname;
+ } catch (_) {
+ return;
+ }
+ // If anti-tracking webcompat is disabled, we only permit replacing
+ // channels, not fully unblocking them.
+ if (Manager.ENABLE_WEBCOMPAT) {
+ // if any allowlist unblocks the request entirely, we allow it
+ for (const allowList of this._allowLists.values()) {
+ if (allowList.allows(url, topHost)) {
+ this._unblockedChannelIds.add(channelId);
+ channel.allow();
+ return;
+ }
+ }
+ }
+ // otherwise, if any allowlist shims the request we say it's replaced
+ for (const allowList of this._allowLists.values()) {
+ if (allowList.shims(url, topHost)) {
+ this._unblockedChannelIds.add(channelId);
+ channel.replace();
+ return;
+ }
+ }
+ break;
+ }
+ }
+ };
+ Services.obs.addObserver(this._classifierObserver, "http-on-stop-request");
+ this._channelClassifier.addListener(this._classifierObserver);
+ }
+
+ stop() {
+ if (!this._classifierObserver) {
+ return;
+ }
+
+ Services.obs.removeObserver(
+ this._classifierObserver,
+ "http-on-stop-request"
+ );
+ this._channelClassifier.removeListener(this._classifierObserver);
+ delete this._channelClassifier;
+ delete this._classifierObserver;
+ }
+
+ wasChannelIdUnblocked(channelId) {
+ return this._unblockedChannelIds?.has(channelId);
+ }
+
+ allow(allowListId, patterns, hosts) {
+ this._ensureStarted();
+ this._getAllowList(allowListId).setAllows(patterns, hosts);
+ }
+
+ shim(allowListId, patterns, notHosts) {
+ this._ensureStarted();
+ this._getAllowList(allowListId).setShims(patterns, notHosts);
+ }
+
+ revoke(allowListId) {
+ this._allowLists.delete(allowListId);
+ }
+}
+var manager = new Manager();
+
+function getChannelId(context, requestId) {
+ const wrapper = ChannelWrapper.getRegisteredChannel(
+ requestId,
+ context.extension.policy,
+ context.xulBrowser.frameLoader.remoteTab
+ );
+ return wrapper?.channel?.QueryInterface(Ci.nsIIdentChannel)?.channelId;
+}
+
+var dFPIPrefName = "network.cookie.cookieBehavior";
+var dFPIPbPrefName = "network.cookie.cookieBehavior.pbmode";
+var dFPIStatus;
+function updateDFPIStatus() {
+ dFPIStatus = {
+ nonPbMode: 5 == Services.prefs.getIntPref(dFPIPrefName),
+ pbMode: 5 == Services.prefs.getIntPref(dFPIPbPrefName),
+ };
+}
+
+this.trackingProtection = class extends ExtensionAPI {
+ onShutdown(isAppShutdown) {
+ if (manager) {
+ manager.stop();
+ }
+ Services.prefs.removeObserver(dFPIPrefName, updateDFPIStatus);
+ Services.prefs.removeObserver(dFPIPbPrefName, updateDFPIStatus);
+ }
+
+ getAPI(context) {
+ Services.prefs.addObserver(dFPIPrefName, updateDFPIStatus);
+ Services.prefs.addObserver(dFPIPbPrefName, updateDFPIStatus);
+ updateDFPIStatus();
+
+ return {
+ trackingProtection: {
+ async shim(allowListId, patterns, notHosts) {
+ manager.shim(allowListId, patterns, notHosts);
+ },
+ async allow(allowListId, patterns, hosts) {
+ manager.allow(allowListId, patterns, hosts);
+ },
+ async revoke(allowListId) {
+ manager.revoke(allowListId);
+ },
+ async wasRequestUnblocked(requestId) {
+ if (!manager) {
+ return false;
+ }
+ const channelId = getChannelId(context, requestId);
+ if (!channelId) {
+ return false;
+ }
+ return manager.wasChannelIdUnblocked(channelId);
+ },
+ async isDFPIActive(isPrivate) {
+ if (isPrivate) {
+ return dFPIStatus.pbMode;
+ }
+ return dFPIStatus.nonPbMode;
+ },
+ },
+ };
+ }
+};
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ Manager,
+ "ENABLE_WEBCOMPAT",
+ "privacy.antitracking.enableWebcompat",
+ false
+);
diff --git a/browser/extensions/webcompat/experiment-apis/trackingProtection.json b/browser/extensions/webcompat/experiment-apis/trackingProtection.json
new file mode 100644
index 0000000000..c495f39add
--- /dev/null
+++ b/browser/extensions/webcompat/experiment-apis/trackingProtection.json
@@ -0,0 +1,102 @@
+[
+ {
+ "namespace": "trackingProtection",
+ "description": "experimental API allow requests through ETP",
+ "functions": [
+ {
+ "name": "isDFPIActive",
+ "type": "function",
+ "description": "Returns whether dFPI is active for private/non-private browsing tabs",
+ "parameters": [
+ {
+ "type": "boolean",
+ "name": "isPrivate"
+ }
+ ],
+ "async": true
+ },
+ {
+ "name": "shim",
+ "type": "function",
+ "description": "Set specified URL patterns as intended to be shimmed",
+ "parameters": [
+ {
+ "name": "allowlistId",
+ "description": "Identfier for the allow-list, so it may be added-to or revoked",
+ "type": "string"
+ },
+ {
+ "name": "patterns",
+ "description": "Array of match patterns",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "notHosts",
+ "description": "Hosts on which to not shim these patterns",
+ "type": "array",
+ "optional": true,
+ "items": {
+ "type": "string"
+ }
+ }
+ ]
+ },
+ {
+ "name": "allow",
+ "type": "function",
+ "description": "Set specified URL patterns as intended to be allowed through the content blocker for the specified top hosts",
+ "parameters": [
+ {
+ "name": "allowlistId",
+ "description": "Identfier for the allow-list, so it may be added-to or revoked",
+ "type": "string"
+ },
+ {
+ "name": "patterns",
+ "description": "Array of match patterns",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "hosts",
+ "description": "Hosts to allow the patterns on",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ ],
+ "async": true
+ },
+ {
+ "name": "revoke",
+ "type": "function",
+ "description": "Revokes the given allow-list entirely (both shims and allows)",
+ "parameters": [
+ {
+ "name": "allowListId",
+ "type": "string"
+ }
+ ],
+ "async": true
+ },
+ {
+ "name": "wasRequestUnblocked",
+ "type": "function",
+ "description": "Whether the given requestId was unblocked by any allowList",
+ "parameters": [
+ {
+ "name": "requestId",
+ "type": "string"
+ }
+ ],
+ "async": true
+ }
+ ]
+ }
+]
diff --git a/browser/extensions/webcompat/injections/css/bug0000000-testbed-css-injection.css b/browser/extensions/webcompat/injections/css/bug0000000-testbed-css-injection.css
new file mode 100644
index 0000000000..566685c2da
--- /dev/null
+++ b/browser/extensions/webcompat/injections/css/bug0000000-testbed-css-injection.css
@@ -0,0 +1,7 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#css-injection.red {
+ background-color: #0f0;
+}
diff --git a/browser/extensions/webcompat/injections/css/bug1570328-developer-apple.com-transform-scale.css b/browser/extensions/webcompat/injections/css/bug1570328-developer-apple.com-transform-scale.css
new file mode 100644
index 0000000000..05f7e685b9
--- /dev/null
+++ b/browser/extensions/webcompat/injections/css/bug1570328-developer-apple.com-transform-scale.css
@@ -0,0 +1,21 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * developer.apple.com - content of the page is shifted to the left
+ * Bug #1570328 - https://bugzilla.mozilla.org/show_bug.cgi?id=1570328
+ * WebCompat issue #4070 - https://webcompat.com/issues/4070
+ *
+ * The site is relying on zoom property which is not supported by Mozilla,
+ * see https://bugzilla.mozilla.org/show_bug.cgi?id=390936. Adding a combination
+ * of transform: scale(1.4), transform-origin and width fixes the issue
+ */
+@media only screen and (min-device-width: 320px) and (max-device-width: 980px),
+ (min-device-width: 1024px) and (max-device-width: 1024px) and (min-device-height: 1366px) and (max-device-height: 1366px) and (min-width: 320px) and (max-width: 980px) {
+ #tocContainer {
+ transform-origin: 0 0;
+ transform: scale(1.4);
+ width: 71.4%;
+ }
+}
diff --git a/browser/extensions/webcompat/injections/css/bug1575000-apply.lloydsbank.co.uk-radio-buttons-fix.css b/browser/extensions/webcompat/injections/css/bug1575000-apply.lloydsbank.co.uk-radio-buttons-fix.css
new file mode 100644
index 0000000000..e157dc6920
--- /dev/null
+++ b/browser/extensions/webcompat/injections/css/bug1575000-apply.lloydsbank.co.uk-radio-buttons-fix.css
@@ -0,0 +1,15 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * apply.lloydsbank.co.uk - radio buttons are misplaced
+ * Bug #1575000 - https://bugzilla.mozilla.org/show_bug.cgi?id=1575000
+ * WebCompat issue #34969 - https://webcompat.com/issues/34969
+ *
+ * Radio buttons are displaced to the left due to positioning issue of ::before
+ * pseudo element, adding position relative to it's parent fixes the issue.
+ */
+.radio-content-field .radio.inline label span.text {
+ position: relative;
+}
diff --git a/browser/extensions/webcompat/injections/css/bug1605611-maps.google.com-directions-time.css b/browser/extensions/webcompat/injections/css/bug1605611-maps.google.com-directions-time.css
new file mode 100644
index 0000000000..75ca4a8723
--- /dev/null
+++ b/browser/extensions/webcompat/injections/css/bug1605611-maps.google.com-directions-time.css
@@ -0,0 +1,16 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Bug 1605611 - Cannot change Departure/arrival dates in Google Maps on Android
+ *
+ * Google Maps hides a datetime-local in its directions picker by giving it
+ * z-index:-50000, which causes it to be unclickable in Firefox. Here we
+ * use opacity:0 instead to hide it, while letting it remain clickable.
+ */
+
+.ml-route-options-picker-container input[type="datetime-local"] {
+ z-index: unset;
+ opacity: 0;
+}
diff --git a/browser/extensions/webcompat/injections/css/bug1610344-directv.com.co-hide-unsupported-message.css b/browser/extensions/webcompat/injections/css/bug1610344-directv.com.co-hide-unsupported-message.css
new file mode 100644
index 0000000000..9f673bee95
--- /dev/null
+++ b/browser/extensions/webcompat/injections/css/bug1610344-directv.com.co-hide-unsupported-message.css
@@ -0,0 +1,17 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * directv.com.co - Browser is not supported message
+ * Bug #1610344 - https://bugzilla.mozilla.org/show_bug.cgi?id=1610344
+ * WebCompat issue #41822 - https://webcompat.com/issues/41822
+ *
+ * directv.com.co is showing a "This browser is not supported" message in
+ * Firefox. Our tests indicated that everything is working just fine, and our
+ * previous contact attempts have not been successful. This intervention
+ * hides the large red unsupported banner.
+ */
+.browser-compatible.compatible.incompatible {
+ display: none;
+}
diff --git a/browser/extensions/webcompat/injections/css/bug1644830-missingmail.usps.com-checkboxes-not-visible.css b/browser/extensions/webcompat/injections/css/bug1644830-missingmail.usps.com-checkboxes-not-visible.css
new file mode 100644
index 0000000000..fc1cb7489a
--- /dev/null
+++ b/browser/extensions/webcompat/injections/css/bug1644830-missingmail.usps.com-checkboxes-not-visible.css
@@ -0,0 +1,17 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * missingmail.usps.com - Unable to mark the check-boxes from "Disclaimer and
+ * Terms and Conditions" section
+ * Bug #1644830 - https://bugzilla.mozilla.org/show_bug.cgi?id=1644830
+ * WebCompat issue #53950 - https://webcompat.com/issues/53950
+ *
+ * missingmail.usps.com runs into a case of bug 997189, where an absolutely
+ * positioned inline-block element with floating siblings is shifter to the
+ * right, and thus invisible.
+ */
+.mrc-custom-checkbox-container input {
+ margin-left: -3rem;
+}
diff --git a/browser/extensions/webcompat/injections/css/bug1651917-teletrader.com.body-transform-origin.css b/browser/extensions/webcompat/injections/css/bug1651917-teletrader.com.body-transform-origin.css
new file mode 100644
index 0000000000..2c4429a301
--- /dev/null
+++ b/browser/extensions/webcompat/injections/css/bug1651917-teletrader.com.body-transform-origin.css
@@ -0,0 +1,18 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * teletrader.com - content is shifted down and right
+ * Bug #1651917 - https://bugzilla.mozilla.org/show_bug.cgi?id=1651917
+ * WebCompat issue #55217 - https://webcompat.com/issues/55217
+ *
+ * The content is shifted down and right, because they use webkit prefixes
+ * for scaling and redefining the origin. Firefox doesn't support
+ * -webkit-transform-origin-x/y
+ * This is the object of https://bugzilla.mozilla.org/show_bug.cgi?id=1584881
+ * Adding transform-origin: 0 0; to body fixes the issue
+ */
+body {
+ transform-origin: 0 0;
+}
diff --git a/browser/extensions/webcompat/injections/css/bug1653075-livescience.com-scrollbar-width.css b/browser/extensions/webcompat/injections/css/bug1653075-livescience.com-scrollbar-width.css
new file mode 100644
index 0000000000..ae14d1ec13
--- /dev/null
+++ b/browser/extensions/webcompat/injections/css/bug1653075-livescience.com-scrollbar-width.css
@@ -0,0 +1,17 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * livescience.com - a scrollbar covering navigation menu
+ * Bug #1653075 - https://bugzilla.mozilla.org/show_bug.cgi?id=1653075
+ *
+ * The scrollbar is covering navigation items and that makes them half hidden.
+ * There are some ::-webkit-scrollbar css rules applied to the scrollbar,
+ * making it thinner. Adding similar rules for Firefox fixes the issue.
+ */
+
+.trending__list {
+ scrollbar-width: thin;
+ scrollbar-color: #f9ae3b #f5f5f5;
+}
diff --git a/browser/extensions/webcompat/injections/css/bug1654877-preev.com-moz-appearance-fix.css b/browser/extensions/webcompat/injections/css/bug1654877-preev.com-moz-appearance-fix.css
new file mode 100644
index 0000000000..b13c3052f3
--- /dev/null
+++ b/browser/extensions/webcompat/injections/css/bug1654877-preev.com-moz-appearance-fix.css
@@ -0,0 +1,19 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * preev.com - typed numbers are not fully visible
+ * Bug #1654877 - https://bugzilla.mozilla.org/show_bug.cgi?id=1654877
+ * WebCompat issue #55099 - https://webcompat.com/issues/55099
+ *
+ * It's hard to see the entered number because the spin button is
+ * taking too much space. While there is -moz-appearance: textfield,
+ * -webkit-appearance: none; underneath supersedes it,
+ * leaving the spin button visible. Adding -moz-appearance: textfield;
+ * as a separate rule fixes the issue
+ */
+input[type="number"],
+input[type="text"] {
+ -moz-appearance: textfield;
+}
diff --git a/browser/extensions/webcompat/injections/css/bug1654907-reactine.ca-hide-unsupported.css b/browser/extensions/webcompat/injections/css/bug1654907-reactine.ca-hide-unsupported.css
new file mode 100644
index 0000000000..6cecb6658a
--- /dev/null
+++ b/browser/extensions/webcompat/injections/css/bug1654907-reactine.ca-hide-unsupported.css
@@ -0,0 +1,16 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * reactine.ca - Unsupported browser message
+ * Bug #1654907 - https://bugzilla.mozilla.org/show_bug.cgi?id=1654907
+ * WebCompat issue #55481 - https://webcompat.com/issues/55481
+ *
+ * reactine.ca is showing "Sorry this browser is not supported."
+ * message if Firefox for Android based on UA detection. Site seems
+ * to be working fine, so this intervention is to hide this message
+ */
+#browser-alert {
+ display: none !important;
+}
diff --git a/browser/extensions/webcompat/injections/css/bug1694470-myvidster.com-content-not-shown.css b/browser/extensions/webcompat/injections/css/bug1694470-myvidster.com-content-not-shown.css
new file mode 100644
index 0000000000..adec7101ba
--- /dev/null
+++ b/browser/extensions/webcompat/injections/css/bug1694470-myvidster.com-content-not-shown.css
@@ -0,0 +1,15 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * m.myvidster.com - Content is not shown
+ * Bug #1694470 - https://bugzilla.mozilla.org/show_bug.cgi?id=1694470
+ * WebCompat issue #67308 - https://webcompat.com/issues/67308
+ *
+ * The site depends on Sencha Touch and should receive some specific
+ * -webkit-box-flex be working.
+ */
+#home_refresh_var {
+ -webkit-box-flex: 1;
+}
diff --git a/browser/extensions/webcompat/injections/css/bug1707795-office365-sheets-overscroll-disable.css b/browser/extensions/webcompat/injections/css/bug1707795-office365-sheets-overscroll-disable.css
new file mode 100644
index 0000000000..7165ceb70f
--- /dev/null
+++ b/browser/extensions/webcompat/injections/css/bug1707795-office365-sheets-overscroll-disable.css
@@ -0,0 +1,12 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * www.office.com - There is an overscroll effect on Excel sheets which is
+ * not a very pleasant user experience. This invention disables it.
+ */
+
+.ewr-sheetcontainer {
+ overscroll-behavior: none;
+}
diff --git a/browser/extensions/webcompat/injections/css/bug1712833-buskocchi.desuca.co.jp-fix-map-height.css b/browser/extensions/webcompat/injections/css/bug1712833-buskocchi.desuca.co.jp-fix-map-height.css
new file mode 100644
index 0000000000..b4b8ca4a34
--- /dev/null
+++ b/browser/extensions/webcompat/injections/css/bug1712833-buskocchi.desuca.co.jp-fix-map-height.css
@@ -0,0 +1,13 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * buskocchi.desuca.co.jp - Ensure that the map has a height so it is visible.
+ * Bug #1712833 - https://bugzilla.mozilla.org/show_bug.cgi?id=1712833
+ * WebCompat issue #50837 - https://webcompat.com/issues/50837
+ */
+
+form[name="main"] {
+ height: 100%;
+}
diff --git a/browser/extensions/webcompat/injections/css/bug1741234-patient.alphalabs.ca-height-fix.css b/browser/extensions/webcompat/injections/css/bug1741234-patient.alphalabs.ca-height-fix.css
new file mode 100644
index 0000000000..3765d1de1e
--- /dev/null
+++ b/browser/extensions/webcompat/injections/css/bug1741234-patient.alphalabs.ca-height-fix.css
@@ -0,0 +1,13 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * patient.alphalabs.ca - "Continue" button is overlapped by displaced page footer
+ * Bug #1741234 - https://bugzilla.mozilla.org/show_bug.cgi?id=1741234
+ * WebCompat issue #93156 - https://webcompat.com/issues/93156
+ */
+
+body {
+ height: 100%;
+}
diff --git a/browser/extensions/webcompat/injections/css/bug1765947-veniceincoming.com-left-fix.css b/browser/extensions/webcompat/injections/css/bug1765947-veniceincoming.com-left-fix.css
new file mode 100644
index 0000000000..8c1ab7b2ae
--- /dev/null
+++ b/browser/extensions/webcompat/injections/css/bug1765947-veniceincoming.com-left-fix.css
@@ -0,0 +1,13 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * veniceincoming.com - site is not usable
+ * Bug #1765947 - https://bugzilla.mozilla.org/show_bug.cgi?id=1765947
+ * WebCompat issue #102133 - https://webcompat.com/issues/102133
+ */
+
+.tour-list .single-tour .mobile-link {
+ left: 0;
+}
diff --git a/browser/extensions/webcompat/injections/css/bug1770962-coldwellbankerhomes.com-image-height.css b/browser/extensions/webcompat/injections/css/bug1770962-coldwellbankerhomes.com-image-height.css
new file mode 100644
index 0000000000..f9460951af
--- /dev/null
+++ b/browser/extensions/webcompat/injections/css/bug1770962-coldwellbankerhomes.com-image-height.css
@@ -0,0 +1,18 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * coldwellbankerhomes.com - Property images are displayed squeezed
+ * Bug #1770962 - https://bugzilla.mozilla.org/show_bug.cgi?id=1770962
+ * WebCompat issue #102872 - https://webcompat.com/issues/102872
+ */
+
+.property-snapshot-psr-panel
+ .prop-pix
+ .photo-carousel.owl
+ .owl-stage-outer
+ .owl-item
+ img {
+ height: -moz-available;
+}
diff --git a/browser/extensions/webcompat/injections/css/bug1774490-rainews.it-gallery-fix.css b/browser/extensions/webcompat/injections/css/bug1774490-rainews.it-gallery-fix.css
new file mode 100644
index 0000000000..840e4ad7fb
--- /dev/null
+++ b/browser/extensions/webcompat/injections/css/bug1774490-rainews.it-gallery-fix.css
@@ -0,0 +1,13 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * rainews.it - Image slideshow is not shown
+ * Bug #1774490 - https://bugzilla.mozilla.org/show_bug.cgi?id=1774490
+ * WebCompat issue #105402 - https://webcompat.com/issues/105402
+ */
+
+.photogallery-swiper .swiper-slide {
+ height: auto;
+}
diff --git a/browser/extensions/webcompat/injections/css/bug1784141-aveeno.com-acuvue.com-unsupported.css b/browser/extensions/webcompat/injections/css/bug1784141-aveeno.com-acuvue.com-unsupported.css
new file mode 100644
index 0000000000..50b5bb2e90
--- /dev/null
+++ b/browser/extensions/webcompat/injections/css/bug1784141-aveeno.com-acuvue.com-unsupported.css
@@ -0,0 +1,17 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * aveeno.com and acuvue.com - Unsupported message is displayed
+ *
+ * Bug #1784141 - aveeno.com - https://bugzilla.mozilla.org/show_bug.cgi?id=1784141
+ * Bug #1804730 - acuvue.com - https://bugzilla.mozilla.org/show_bug.cgi?id=1804730
+ *
+ * WebCompat issue #103557 - https://webcompat.com/issues/103557
+ * WebCompat issue #103557 - https://webcompat.com/issues/110797
+ */
+
+#browser-alert {
+ display: none !important;
+}
diff --git a/browser/extensions/webcompat/injections/css/bug1784199-entrata-platform-unsupported.css b/browser/extensions/webcompat/injections/css/bug1784199-entrata-platform-unsupported.css
new file mode 100644
index 0000000000..d20b84a99b
--- /dev/null
+++ b/browser/extensions/webcompat/injections/css/bug1784199-entrata-platform-unsupported.css
@@ -0,0 +1,18 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * aptsovation.com - Unsupported message is displayed on sites based on Entrata platform
+ * Bug #1784199 - https://bugzilla.mozilla.org/show_bug.cgi?id=1784199
+ * WebCompat issue #100131 - https://webcompat.com/issues/100131
+ */
+
+* {
+ color: unset;
+}
+
+#propertyProduct,
+.banner_overlay {
+ display: none;
+}
diff --git a/browser/extensions/webcompat/injections/css/bug1799994-www.vivobarefoot.com-product-filters-fix.css b/browser/extensions/webcompat/injections/css/bug1799994-www.vivobarefoot.com-product-filters-fix.css
new file mode 100644
index 0000000000..fd42fd7648
--- /dev/null
+++ b/browser/extensions/webcompat/injections/css/bug1799994-www.vivobarefoot.com-product-filters-fix.css
@@ -0,0 +1,17 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * www.vivobarefoot.com - product filters cannot be interacted-with
+ * Bug #1799994 - https://bugzilla.mozilla.org/show_bug.cgi?id=1799994
+ * WebCompat issue #108752 - https://webcompat.com/issues/108752
+ *
+ * The breakage is actually correct behavior, but because of Chrome
+ * bug https://bugs.chromium.org/p/chromium/issues/detail?id=606208
+ * it is currently not breaking on Chrome. We can work around it by
+ * bumping the z-index of the filter options.
+ */
+.page-products .filter-options {
+ z-index: 2;
+}
diff --git a/browser/extensions/webcompat/injections/css/bug1800000-www.honda.co.uk-choose-dealer-button-fix.css b/browser/extensions/webcompat/injections/css/bug1800000-www.honda.co.uk-choose-dealer-button-fix.css
new file mode 100644
index 0000000000..5ce3d9f15c
--- /dev/null
+++ b/browser/extensions/webcompat/injections/css/bug1800000-www.honda.co.uk-choose-dealer-button-fix.css
@@ -0,0 +1,17 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * www.honda.co.uk- "choose dealer" buttons cannot be interacted-with
+ * Bug #1800000 - https://bugzilla.mozilla.org/show_bug.cgi?id=1800000
+ * WebCompat issue #109242 - https://webcompat.com/issues/109242
+ *
+ * The breakage is actually correct behavior, but because of Chrome
+ * bug https://bugs.chromium.org/p/chromium/issues/detail?id=606208
+ * it is currently not breaking on Chrome. We can work around it by
+ * bumping the z-index of the filter options.
+ */
+.cta-button {
+ z-index: 2;
+}
diff --git a/browser/extensions/webcompat/injections/css/bug1819678-nppes.cms.hhs.gov-unsupported-banner.css b/browser/extensions/webcompat/injections/css/bug1819678-nppes.cms.hhs.gov-unsupported-banner.css
new file mode 100644
index 0000000000..ab9788ddc1
--- /dev/null
+++ b/browser/extensions/webcompat/injections/css/bug1819678-nppes.cms.hhs.gov-unsupported-banner.css
@@ -0,0 +1,15 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * nppes.cms.hhs.gov - Firefox is an unsupported browser
+ * WebCompat issue #119017 - https://github.com/webcompat/web-bugs/issues/119017
+ *
+ * As everything seems to work just fine, this intervention simply hides the
+ * banner.
+ */
+
+#unsupportedDiv {
+ display: none !important;
+}
diff --git a/browser/extensions/webcompat/injections/css/bug1829949-tomshardware.com-scrollbar-width.css b/browser/extensions/webcompat/injections/css/bug1829949-tomshardware.com-scrollbar-width.css
new file mode 100644
index 0000000000..f6bee8c878
--- /dev/null
+++ b/browser/extensions/webcompat/injections/css/bug1829949-tomshardware.com-scrollbar-width.css
@@ -0,0 +1,18 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * tomshardware.com - a scrollbar covering navigation menu
+ * Bug #1829949 - https://bugzilla.mozilla.org/show_bug.cgi?id=1829949
+ * WebCompat issue #121170 - https://github.com/webcompat/web-bugs/issues/121170
+ *
+ * The scrollbar is covering navigation items and that makes them half hidden.
+ * There are some ::-webkit-scrollbar css rules applied to the scrollbar,
+ * making it thinner. Adding similar rules for Firefox fixes the issue.
+ */
+
+.trending__list {
+ scrollbar-width: thin;
+ scrollbar-color: #000 #f5f5f5;
+}
diff --git a/browser/extensions/webcompat/injections/css/bug1829952-eventer.co.il-button-height.css b/browser/extensions/webcompat/injections/css/bug1829952-eventer.co.il-button-height.css
new file mode 100644
index 0000000000..54de51589a
--- /dev/null
+++ b/browser/extensions/webcompat/injections/css/bug1829952-eventer.co.il-button-height.css
@@ -0,0 +1,18 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * eventer.co.il - a button is covering entire page
+ * Bug #1829952 - https://bugzilla.mozilla.org/show_bug.cgi?id=1829952
+ * WebCompat issue #121296 - https://github.com/webcompat/web-bugs/issues/121296
+ *
+ * The button is covering the page only in Firefox on mobile
+ * because of additional styles applied via @-moz-document url-prefix.
+ * Resetting the height makes the button normal size
+ */
+
+#purchasePageRedesignContainer .mobileStripButton {
+ height: auto;
+ min-height: auto;
+}
diff --git a/browser/extensions/webcompat/injections/css/bug1830747-babbel.com-page-height.css b/browser/extensions/webcompat/injections/css/bug1830747-babbel.com-page-height.css
new file mode 100644
index 0000000000..1f7585e637
--- /dev/null
+++ b/browser/extensions/webcompat/injections/css/bug1830747-babbel.com-page-height.css
@@ -0,0 +1,17 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * my.babbel.com - "Next" button is not visible
+ * Bug #1830747 - https://bugzilla.mozilla.org/show_bug.cgi?id=1830747
+ * WebCompat issue #119212 - https://github.com/webcompat/web-bugs/issues/119212
+ *
+ * The next button on the bottom of the page is not visible in Firefox,
+ * but visible in Chrome since the site is using -webkit-fill-available rule.
+ * Adding height: 100% to the page wrapper allows to see the button.
+ */
+
+[data-main] {
+ height: 100%;
+}
diff --git a/browser/extensions/webcompat/injections/css/bug1830752-afisha.ru-slider-pointer-events.css b/browser/extensions/webcompat/injections/css/bug1830752-afisha.ru-slider-pointer-events.css
new file mode 100644
index 0000000000..64974c46e4
--- /dev/null
+++ b/browser/extensions/webcompat/injections/css/bug1830752-afisha.ru-slider-pointer-events.css
@@ -0,0 +1,23 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * afisha.ru - Slider not working
+ * Bug #1830752 - https://bugzilla.mozilla.org/show_bug.cgi?id=1830752
+ * WebCompat issue #120455 - https://github.com/webcompat/web-bugs/issues/120455
+ *
+ * The range slider for price filtering is not working because of pointer-events:none applied
+ * on the slider element. It's working in Chrome because of webkit specific rules
+ * set with -moz-range-thumb that override the pointer events on the slider thumb to auto.
+ * Setting the same rule with -moz-range-thumb makes the slider to work.
+ */
+
+.gNPvK::-moz-range-thumb,
+.y5iHc::-moz-range-thumb {
+ background-color: #0050ff;
+ border-color: #0050ff;
+ border-radius: 50%;
+ cursor: pointer;
+ pointer-events: auto;
+}
diff --git a/browser/extensions/webcompat/injections/css/bug1830761-91mobiles.com-content-height.css b/browser/extensions/webcompat/injections/css/bug1830761-91mobiles.com-content-height.css
new file mode 100644
index 0000000000..f2e24346e1
--- /dev/null
+++ b/browser/extensions/webcompat/injections/css/bug1830761-91mobiles.com-content-height.css
@@ -0,0 +1,18 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * 91mobiles.com - Text overlapping
+ * Bug #1830761 - https://bugzilla.mozilla.org/show_bug.cgi?id=1830761
+ * WebCompat issue #117029 - https://github.com/webcompat/web-bugs/issues/117029
+ *
+ * The content overlaps dedicated space since Firefox honors small heights on <td>
+ * due to https://bugzilla.mozilla.org/show_bug.cgi?id=1461852. Setting the height to
+ * fit-content makes it work as expected.
+ */
+
+#fixed-table tr td .cmp-summary-box,
+.cmpr-table .textpanel {
+ height: fit-content;
+}
diff --git a/browser/extensions/webcompat/injections/css/bug1830796-copyleaks.com-hide-unsupported.css b/browser/extensions/webcompat/injections/css/bug1830796-copyleaks.com-hide-unsupported.css
new file mode 100644
index 0000000000..753835de6a
--- /dev/null
+++ b/browser/extensions/webcompat/injections/css/bug1830796-copyleaks.com-hide-unsupported.css
@@ -0,0 +1,13 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * copyleaks.com - Unsupported message
+ * Bug #1830796 - https://bugzilla.mozilla.org/show_bug.cgi?id=1830796
+ * WebCompat issue #121395 - https://github.com/webcompat/web-bugs/issues/121395
+ */
+
+#outdated {
+ display: none !important;
+}
diff --git a/browser/extensions/webcompat/injections/css/bug1830810-interceramic.com-hide-unsupported.css b/browser/extensions/webcompat/injections/css/bug1830810-interceramic.com-hide-unsupported.css
new file mode 100644
index 0000000000..7726140d0e
--- /dev/null
+++ b/browser/extensions/webcompat/injections/css/bug1830810-interceramic.com-hide-unsupported.css
@@ -0,0 +1,13 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * interceramic.com - Unsupported message
+ * Bug #1830810 - https://bugzilla.mozilla.org/show_bug.cgi?id=1830810
+ * WebCompat issue #117807 - https://github.com/webcompat/web-bugs/issues/117807
+ */
+
+#ff-modal {
+ display: none !important;
+}
diff --git a/browser/extensions/webcompat/injections/css/bug1830813-page.onstove.com-hide-unsupported.css b/browser/extensions/webcompat/injections/css/bug1830813-page.onstove.com-hide-unsupported.css
new file mode 100644
index 0000000000..707e75765e
--- /dev/null
+++ b/browser/extensions/webcompat/injections/css/bug1830813-page.onstove.com-hide-unsupported.css
@@ -0,0 +1,18 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * onstove.com - Unsupported message
+ * Bug #1830813 - https://bugzilla.mozilla.org/show_bug.cgi?id=1830813
+ * WebCompat issue #116760 - https://github.com/webcompat/web-bugs/issues/116760
+ */
+
+.gnb-alerts.gnb-old-browser {
+ height: 0;
+}
+
+.isCampaign .gnb-stove.gnb-default-fixed,
+.isCampaign .layout.layout-base .layout-header {
+ height: 68px;
+}
diff --git a/browser/extensions/webcompat/injections/css/bug1836103-autostar-novoross.ru-make-map-taller.css b/browser/extensions/webcompat/injections/css/bug1836103-autostar-novoross.ru-make-map-taller.css
new file mode 100644
index 0000000000..70fc01b86f
--- /dev/null
+++ b/browser/extensions/webcompat/injections/css/bug1836103-autostar-novoross.ru-make-map-taller.css
@@ -0,0 +1,13 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * autostar-novoross.ru - Map is not as tall as expected
+ * Bug #1836103 - https://bugzilla.mozilla.org/show_bug.cgi?id=1836103
+ * WebCompat issue #80763 - https://github.com/webcompat/web-bugs/issues/80763
+ */
+
+.t396 .tn-atom {
+ height: 100%;
+}
diff --git a/browser/extensions/webcompat/injections/css/bug1836105-cnn.com-fix-blank-pages-when-printing.css b/browser/extensions/webcompat/injections/css/bug1836105-cnn.com-fix-blank-pages-when-printing.css
new file mode 100644
index 0000000000..db6f018619
--- /dev/null
+++ b/browser/extensions/webcompat/injections/css/bug1836105-cnn.com-fix-blank-pages-when-printing.css
@@ -0,0 +1,19 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * cnn.com - Printing in portrait mode results in blank pages
+ * Bug #1836105 - https://bugzilla.mozilla.org/show_bug.cgi?id=1836105
+ *
+ * Disable some of CNN's giant styles for height, top and margin-bottom,
+ * since they break print layout in Firefox (as per bug 1830307)
+ */
+
+@media print {
+ .header__wrapper-outer {
+ height: initial !important;
+ top: initial !important;
+ margin-bottom: initial !important;
+ }
+}
diff --git a/browser/extensions/webcompat/injections/css/bug1836177-clalit.co.il-hide-number-input-spinners.css b/browser/extensions/webcompat/injections/css/bug1836177-clalit.co.il-hide-number-input-spinners.css
new file mode 100644
index 0000000000..ef255c7b89
--- /dev/null
+++ b/browser/extensions/webcompat/injections/css/bug1836177-clalit.co.il-hide-number-input-spinners.css
@@ -0,0 +1,13 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * clalit.co.il - Hide number input spinners as page intends
+ * Bug #1836177 - https://bugzilla.mozilla.org/show_bug.cgi?id=1836177
+ * WebCompat issue #109468 - https://github.com/webcompat/web-bugs/issues/109468
+ */
+
+input[type="number"] {
+ -moz-appearance: textfield;
+}
diff --git a/browser/extensions/webcompat/injections/js/bug0000000-testbed-js-injection.js b/browser/extensions/webcompat/injections/js/bug0000000-testbed-js-injection.js
new file mode 100644
index 0000000000..7a192d6c41
--- /dev/null
+++ b/browser/extensions/webcompat/injections/js/bug0000000-testbed-js-injection.js
@@ -0,0 +1,15 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* globals exportFunction */
+
+Object.defineProperty(window.wrappedJSObject, "isTestFeatureSupported", {
+ get: exportFunction(function () {
+ return true;
+ }, window),
+
+ set: exportFunction(function () {}, window),
+});
diff --git a/browser/extensions/webcompat/injections/js/bug1448747-fastclick-shim.js b/browser/extensions/webcompat/injections/js/bug1448747-fastclick-shim.js
new file mode 100644
index 0000000000..7a8e85f538
--- /dev/null
+++ b/browser/extensions/webcompat/injections/js/bug1448747-fastclick-shim.js
@@ -0,0 +1,35 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1448747 - Neutralize FastClick
+ *
+ * The patch is applied on sites using FastClick library
+ * to make sure `FastClick.notNeeded` returns `true`.
+ * This allows to disable FastClick and fix various breakage caused
+ * by the library (mainly non-functioning drop-down lists).
+ */
+
+/* globals exportFunction */
+
+(function () {
+ const proto = CSS2Properties.prototype.wrappedJSObject;
+ const descriptor = Object.getOwnPropertyDescriptor(proto, "touchAction");
+ const { get } = descriptor;
+
+ descriptor.get = exportFunction(function () {
+ try {
+ throw Error();
+ } catch (e) {
+ if (e.stack?.includes("notNeeded")) {
+ return "none";
+ }
+ }
+ return get.call(this);
+ }, window);
+
+ Object.defineProperty(proto, "touchAction", descriptor);
+})();
diff --git a/browser/extensions/webcompat/injections/js/bug1452707-window.controllers-shim-ib.absa.co.za.js b/browser/extensions/webcompat/injections/js/bug1452707-window.controllers-shim-ib.absa.co.za.js
new file mode 100644
index 0000000000..40e17b4a36
--- /dev/null
+++ b/browser/extensions/webcompat/injections/js/bug1452707-window.controllers-shim-ib.absa.co.za.js
@@ -0,0 +1,33 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1452707 - Build site patch for ib.absa.co.za
+ * WebCompat issue #16401 - https://webcompat.com/issues/16401
+ *
+ * The online banking at ib.absa.co.za detect if window.controllers is a
+ * non-falsy value to detect if the current browser is Firefox or something
+ * else. In bug 1448045, this shim has been disabled for Firefox Nightly 61+,
+ * which breaks the UA detection on this site and results in a "Browser
+ * unsuppored" error message.
+ *
+ * This site patch simply sets window.controllers to a string, resulting in
+ * their check to work again.
+ */
+
+/* globals exportFunction */
+
+console.info(
+ "window.controllers has been shimmed for compatibility reasons. See https://webcompat.com/issues/16401 for details."
+);
+
+Object.defineProperty(window.wrappedJSObject, "controllers", {
+ get: exportFunction(function () {
+ return true;
+ }, window),
+
+ set: exportFunction(function () {}, window),
+});
diff --git a/browser/extensions/webcompat/injections/js/bug1457335-histography.io-ua-change.js b/browser/extensions/webcompat/injections/js/bug1457335-histography.io-ua-change.js
new file mode 100644
index 0000000000..06085acc5a
--- /dev/null
+++ b/browser/extensions/webcompat/injections/js/bug1457335-histography.io-ua-change.js
@@ -0,0 +1,38 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1457335 - histography.io - Override UA & navigator.vendor
+ * WebCompat issue #1804 - https://webcompat.com/issues/1804
+ *
+ * This site is using a strict matching of navigator.userAgent and
+ * navigator.vendor to allow access for Safari or Chrome. Here, we set the
+ * values appropriately so we get recognized as Chrome.
+ */
+
+/* globals exportFunction */
+
+console.info(
+ "The user agent has been overridden for compatibility reasons. See https://webcompat.com/issues/1804 for details."
+);
+
+const CHROME_UA = navigator.userAgent + " Chrome for WebCompat";
+
+Object.defineProperty(window.navigator.wrappedJSObject, "userAgent", {
+ get: exportFunction(function () {
+ return CHROME_UA;
+ }, window),
+
+ set: exportFunction(function () {}, window),
+});
+
+Object.defineProperty(window.navigator.wrappedJSObject, "vendor", {
+ get: exportFunction(function () {
+ return "Google Inc.";
+ }, window),
+
+ set: exportFunction(function () {}, window),
+});
diff --git a/browser/extensions/webcompat/injections/js/bug1472075-bankofamerica.com-ua-change.js b/browser/extensions/webcompat/injections/js/bug1472075-bankofamerica.com-ua-change.js
new file mode 100644
index 0000000000..5aa72e75ae
--- /dev/null
+++ b/browser/extensions/webcompat/injections/js/bug1472075-bankofamerica.com-ua-change.js
@@ -0,0 +1,52 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1472075 - Build UA override for Bank of America for OSX & Linux
+ * WebCompat issue #2787 - https://webcompat.com/issues/2787
+ *
+ * BoA is showing a red warning to Linux and macOS users, while accepting
+ * Windows users without warning. From our side, there is no difference here
+ * and we receive a lot of user complains about the warnings, so we spoof
+ * as Firefox on Windows in those cases.
+ */
+
+/* globals exportFunction */
+
+if (!navigator.platform.includes("Win")) {
+ console.info(
+ "The user agent has been overridden for compatibility reasons. See https://webcompat.com/issues/2787 for details."
+ );
+
+ const WINDOWS_UA = navigator.userAgent.replace(
+ /\(.*; rv:/i,
+ "(Windows NT 10.0; Win64; x64; rv:"
+ );
+
+ Object.defineProperty(window.navigator.wrappedJSObject, "userAgent", {
+ get: exportFunction(function () {
+ return WINDOWS_UA;
+ }, window),
+
+ set: exportFunction(function () {}, window),
+ });
+
+ Object.defineProperty(window.navigator.wrappedJSObject, "appVersion", {
+ get: exportFunction(function () {
+ return "appVersion";
+ }, window),
+
+ set: exportFunction(function () {}, window),
+ });
+
+ Object.defineProperty(window.navigator.wrappedJSObject, "platform", {
+ get: exportFunction(function () {
+ return "Win64";
+ }, window),
+
+ set: exportFunction(function () {}, window),
+ });
+}
diff --git a/browser/extensions/webcompat/injections/js/bug1579159-m.tailieu.vn-pdfjs-worker-disable.js b/browser/extensions/webcompat/injections/js/bug1579159-m.tailieu.vn-pdfjs-worker-disable.js
new file mode 100644
index 0000000000..5c757466c6
--- /dev/null
+++ b/browser/extensions/webcompat/injections/js/bug1579159-m.tailieu.vn-pdfjs-worker-disable.js
@@ -0,0 +1,32 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * m.tailieu.vn - Override PDFJS.disableWorker to be true
+ * WebCompat issue #39057 - https://webcompat.com/issues/39057
+ *
+ * Custom viewer built with PDF.js is not working in Firefox for Android
+ * Disabling worker to match Chrome behavior fixes the issue
+ */
+
+/* globals exportFunction */
+
+console.info(
+ "window.PDFJS.disableWorker has been set to true for compatibility reasons. See https://webcompat.com/issues/39057 for details."
+);
+
+let globals = {};
+
+Object.defineProperty(window.wrappedJSObject, "PDFJS", {
+ get: exportFunction(function () {
+ return globals;
+ }, window),
+
+ set: exportFunction(function (value = {}) {
+ globals = value;
+ globals.disableWorker = true;
+ }, window),
+});
diff --git a/browser/extensions/webcompat/injections/js/bug1605611-maps.google.com-directions-time.js b/browser/extensions/webcompat/injections/js/bug1605611-maps.google.com-directions-time.js
new file mode 100644
index 0000000000..a068e8dcd5
--- /dev/null
+++ b/browser/extensions/webcompat/injections/js/bug1605611-maps.google.com-directions-time.js
@@ -0,0 +1,38 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1605611 - Cannot change Departure/arrival dates in Google Maps on Android
+ *
+ * This patch re-enables the disabled "Leave now" button.
+ *
+ * See https://bugzilla.mozilla.org/show_bug.cgi?id=1800498 and
+ * https://bugzilla.mozilla.org/show_bug.cgi?id=1605611 for details.
+ */
+
+const selector =
+ ".ml-directions-searchbox-parent [aria-haspopup=dialog][disabled=true]";
+
+document.addEventListener("DOMContentLoaded", () => {
+ // In case the element appeared before the MutationObserver was activated.
+ for (const elem of document.querySelectorAll(selector)) {
+ elem.disabled = false;
+ }
+ // Start watching for the insertion of the "Leave now" button.
+ const moOptions = {
+ attributeFilter: ["disabled"],
+ attributes: true,
+ subtree: true,
+ };
+ const mo = new MutationObserver(function (records) {
+ for (const { target } of records) {
+ if (target.matches(selector)) {
+ target.disabled = false;
+ }
+ }
+ });
+ mo.observe(document.body, moOptions);
+});
diff --git a/browser/extensions/webcompat/injections/js/bug1631811-datastudio.google.com-indexedDB.js b/browser/extensions/webcompat/injections/js/bug1631811-datastudio.google.com-indexedDB.js
new file mode 100644
index 0000000000..fb9be74039
--- /dev/null
+++ b/browser/extensions/webcompat/injections/js/bug1631811-datastudio.google.com-indexedDB.js
@@ -0,0 +1,22 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1631811 - disable indexedDB for datastudio.google.com iframes
+ *
+ * Indexed DB is disabled already for these iframes due to cookie blocking.
+ * This intervention changes the functionality from throwing a SecurityError
+ * when indexedDB is accessed to removing it from the window object
+ */
+
+console.info(
+ "window.indexedDB has been overwritten for compatibility reasons. See https://bugzilla.mozilla.org/show_bug.cgi?id=1631811 for details."
+);
+
+Object.defineProperty(window.wrappedJSObject, "indexedDB", {
+ get: undefined,
+ set: undefined,
+});
diff --git a/browser/extensions/webcompat/injections/js/bug1722955-frontgate.com-ua-override.js b/browser/extensions/webcompat/injections/js/bug1722955-frontgate.com-ua-override.js
new file mode 100644
index 0000000000..577a55450a
--- /dev/null
+++ b/browser/extensions/webcompat/injections/js/bug1722955-frontgate.com-ua-override.js
@@ -0,0 +1,21 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/*
+ * Bug 1722955 - Add UA override for frontgate.com
+ * Webcompat issue #36277 - https://github.com/webcompat/web-bugs/issues/36277
+ *
+ * The website is sending the desktop version to Firefox on mobile devices
+ * based on UA sniffing. Spoofing as Chrome fixes this.
+ */
+
+/* globals exportFunction, UAHelpers */
+
+console.info(
+ "The user agent has been overridden for compatibility reasons. See https://webcompat.com/issues/36277 for details."
+);
+
+UAHelpers.overrideWithDeviceAppropriateChromeUA();
diff --git a/browser/extensions/webcompat/injections/js/bug1724764-window-print.js b/browser/extensions/webcompat/injections/js/bug1724764-window-print.js
new file mode 100644
index 0000000000..20e9ecf22d
--- /dev/null
+++ b/browser/extensions/webcompat/injections/js/bug1724764-window-print.js
@@ -0,0 +1,28 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Generic window.print shim
+ *
+ * Issues related to an error caused by missing window.print() method on Android.
+ * Adding print to the window object allows to unbreak the sites.
+ */
+
+/* globals exportFunction */
+
+if (typeof window.print === "undefined") {
+ console.info(
+ "window.print has been shimmed for compatibility reasons. See https://bugzilla.mozilla.org/show_bug.cgi?id=1659818 for details."
+ );
+
+ Object.defineProperty(window.wrappedJSObject, "print", {
+ get: exportFunction(function () {
+ return true;
+ }, window),
+
+ set: exportFunction(function () {}, window),
+ });
+}
diff --git a/browser/extensions/webcompat/injections/js/bug1724868-news.yahoo.co.jp-ua-override.js b/browser/extensions/webcompat/injections/js/bug1724868-news.yahoo.co.jp-ua-override.js
new file mode 100644
index 0000000000..ab7b76c799
--- /dev/null
+++ b/browser/extensions/webcompat/injections/js/bug1724868-news.yahoo.co.jp-ua-override.js
@@ -0,0 +1,29 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1724868 - news.yahoo.co.jp - Override UA
+ * WebCompat issue #82605 - https://webcompat.com/issues/82605
+ *
+ * Yahoo Japan news doesn't allow playing video in Firefox on Android
+ * as they don't have it in their support matrix. They check UA override twice
+ * and display different ui with the same error. Changing UA to Chrome via
+ * content script allows playing the videos.
+ */
+
+/* globals exportFunction */
+
+console.info(
+ "The user agent has been overridden for compatibility reasons. See https://webcompat.com/issues/82605 for details."
+);
+
+Object.defineProperty(window.navigator.wrappedJSObject, "userAgent", {
+ get: exportFunction(function () {
+ return "Mozilla/5.0 (Linux; Android 11; Pixel 4a) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Mobile Safari/537.36";
+ }, window),
+
+ set: exportFunction(function () {}, window),
+});
diff --git a/browser/extensions/webcompat/injections/js/bug1731825-office365-email-handling-prompt-autohide.js b/browser/extensions/webcompat/injections/js/bug1731825-office365-email-handling-prompt-autohide.js
new file mode 100644
index 0000000000..d1823aec74
--- /dev/null
+++ b/browser/extensions/webcompat/injections/js/bug1731825-office365-email-handling-prompt-autohide.js
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1731825 - Office 365 email handling prompt autohide
+ *
+ * This site patch prevents the notification bar on Office 365
+ * apps from popping up on each page-load, offering to handle
+ * email with Outlook.
+ */
+
+/* globals exportFunction */
+
+const warning =
+ "Office 365 Outlook email handling prompt has been hidden. See https://bugzilla.mozilla.org/show_bug.cgi?id=1731825 for details.";
+
+const localStorageKey = "mailProtocolHandlerAlreadyOffered";
+
+const nav = navigator.wrappedJSObject;
+const { registerProtocolHandler } = nav;
+const { localStorage } = window.wrappedJSObject;
+
+Object.defineProperty(navigator.wrappedJSObject, "registerProtocolHandler", {
+ value: exportFunction(function (scheme, url, title) {
+ if (localStorage.getItem(localStorageKey)) {
+ console.info(warning);
+ return undefined;
+ }
+ registerProtocolHandler.call(nav, scheme, url, title);
+ localStorage.setItem(localStorageKey, true);
+ return undefined;
+ }, window),
+});
diff --git a/browser/extensions/webcompat/injections/js/bug1739489-draftjs-beforeinput.js b/browser/extensions/webcompat/injections/js/bug1739489-draftjs-beforeinput.js
new file mode 100644
index 0000000000..5ae55ec6f3
--- /dev/null
+++ b/browser/extensions/webcompat/injections/js/bug1739489-draftjs-beforeinput.js
@@ -0,0 +1,116 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1739489 - Entering an emoji using the MacOS IME "crashes" Draft.js editors.
+ */
+
+/* globals exportFunction */
+
+console.info(
+ "textInput event has been remapped to beforeinput for compatibility reasons. See https://bugzilla.mozilla.org/show_bug.cgi?id=1739489 for details."
+);
+
+window.wrappedJSObject.TextEvent = window.wrappedJSObject.InputEvent;
+
+const { CustomEvent, Event, EventTarget } = window.wrappedJSObject;
+var Remapped = [
+ [CustomEvent, "constructor"],
+ [Event, "constructor"],
+ [Event, "initEvent"],
+ [EventTarget, "addEventListener"],
+ [EventTarget, "removeEventListener"],
+];
+
+for (const [obj, name] of Remapped) {
+ const { prototype } = obj;
+ const orig = prototype[name];
+ Object.defineProperty(prototype, name, {
+ value: exportFunction(function (type, b, c, d) {
+ if (type?.toLowerCase() === "textinput") {
+ type = "beforeinput";
+ }
+ return orig.call(this, type, b, c, d);
+ }, window),
+ });
+}
+
+if (location.host === "www.reddit.com") {
+ (function () {
+ const EditorCSS = ".public-DraftEditor-content[contenteditable=true]";
+ let obsEditor, obsStart, obsText, obsKey, observer;
+ const obsConfig = { characterData: true, childList: true, subtree: true };
+ const obsHandler = () => {
+ observer.disconnect();
+ const finalTextNode = obsEditor.querySelector(
+ `[data-offset-key="${obsKey}"] [data-text='true']`
+ ).firstChild;
+ const end = obsStart + obsText.length;
+ window
+ .getSelection()
+ .setBaseAndExtent(finalTextNode, end, finalTextNode, end);
+ };
+ observer = new MutationObserver(obsHandler);
+
+ document.documentElement.addEventListener(
+ "beforeinput",
+ e => {
+ if (e.inputType != "insertFromPaste") {
+ return;
+ }
+ const { target } = e;
+ obsEditor = target.closest(EditorCSS);
+ if (!obsEditor) {
+ return;
+ }
+ const items = e?.dataTransfer.items;
+ for (let item of items) {
+ if (item.type === "text/plain") {
+ e.preventDefault();
+ item.getAsString(text => {
+ obsText = text;
+
+ // find the editor-managed <span> which contains the text node the
+ // cursor starts on, and the cursor's location (or the selection start)
+ const sel = window.getSelection();
+ obsStart = sel.anchorOffset;
+ let anchor = sel.anchorNode;
+ if (!anchor.closest) {
+ anchor = anchor.parentElement;
+ }
+ anchor = anchor.closest("[data-offset-key]");
+ obsKey = anchor.getAttribute("data-offset-key");
+
+ // set us up to wait for the editor to either update or replace the
+ // <span> with that key (the one containing the text to be changed).
+ // we will then make sure the cursor is after the pasted text, as if
+ // the editor recreates the node, the cursor position is lost
+ observer.observe(obsEditor, obsConfig);
+
+ // force the editor to "paste". sending paste or other events will not
+ // work, nor using execCommand (adding HTML will screw up the DOM that
+ // the editor expects, and adding plain text will make it ignore newlines).
+ target.dispatchEvent(
+ new InputEvent("beforeinput", {
+ inputType: "insertText",
+ data: text,
+ bubbles: true,
+ cancelable: true,
+ })
+ );
+
+ // blur the editor to force it to update/flush its state, because otherwise
+ // the paste works, but the editor doesn't show it (until it is re-focused).
+ obsEditor.blur();
+ });
+ break;
+ }
+ }
+ },
+ true
+ );
+ })();
+}
diff --git a/browser/extensions/webcompat/injections/js/bug1769762-tiktok.com-plugins-shim.js b/browser/extensions/webcompat/injections/js/bug1769762-tiktok.com-plugins-shim.js
new file mode 100644
index 0000000000..7383a4e567
--- /dev/null
+++ b/browser/extensions/webcompat/injections/js/bug1769762-tiktok.com-plugins-shim.js
@@ -0,0 +1,35 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1769762 - Empty out navigator.plugins
+ * WebCompat issue #103612 - https://webcompat.com/issues/103612
+ *
+ * Certain features of the site are breaking if navigator.plugins array is not empty:
+ *
+ * 1. "Likes" on the comments are not saved
+ * 2. Can't reply to other people's comments
+ * 3. "Likes" on the videos are not saved
+ * 4. Can't follow an account (after refreshing "Follow" button is visible again)
+ *
+ * (note that the first 2 are still broken if you open devtools even with this intervention)
+ */
+
+/* globals exportFunction */
+
+console.info(
+ "The PluginArray has been overridden for compatibility reasons. See https://bugzilla.mozilla.org/show_bug.cgi?id=1753874 for details."
+);
+
+const pluginsArray = new window.wrappedJSObject.Array();
+Object.setPrototypeOf(pluginsArray, PluginArray.prototype);
+
+Object.defineProperty(navigator.wrappedJSObject, "plugins", {
+ get: exportFunction(function () {
+ return pluginsArray;
+ }, window),
+ set: exportFunction(function (val) {}, window),
+});
diff --git a/browser/extensions/webcompat/injections/js/bug1774005-installtrigger-shim.js b/browser/extensions/webcompat/injections/js/bug1774005-installtrigger-shim.js
new file mode 100644
index 0000000000..ca7ef5b6c5
--- /dev/null
+++ b/browser/extensions/webcompat/injections/js/bug1774005-installtrigger-shim.js
@@ -0,0 +1,26 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1774005 - Generic window.InstallTrigger shim
+ *
+ * This interventions shims window.InstallTrigger to a string, which evaluates
+ * as `true` in web developers browser sniffing code. This intervention will
+ * be applied to multiple domains, see bug 1774005 for more information.
+ */
+
+/* globals exportFunction */
+
+console.info(
+ "The InstallTrigger has been shimmed for compatibility reasons. See https://bugzilla.mozilla.org/show_bug.cgi?id=1774005 for details."
+);
+
+Object.defineProperty(window.wrappedJSObject, "InstallTrigger", {
+ get: exportFunction(function () {
+ return "This property has been shimed for Web Compatibility reasons.";
+ }, window),
+ set: exportFunction(function (_) {}, window),
+});
diff --git a/browser/extensions/webcompat/injections/js/bug1784302-effectiveType-shim.js b/browser/extensions/webcompat/injections/js/bug1784302-effectiveType-shim.js
new file mode 100644
index 0000000000..7bde4a7d82
--- /dev/null
+++ b/browser/extensions/webcompat/injections/js/bug1784302-effectiveType-shim.js
@@ -0,0 +1,27 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1784302 - Issues due to missing navigator.connection after
+ * https://bugzilla.mozilla.org/show_bug.cgi?id=1637922 landed.
+ * Webcompat issue #104838 - https://github.com/webcompat/web-bugs/issues/104838
+ */
+
+/* globals cloneInto, exportFunction */
+
+console.info(
+ "navigator.connection has been shimmed for compatibility reasons. See https://bugzilla.mozilla.org/show_bug.cgi?id=1756692 for details."
+);
+
+var connection = {
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ effectiveType: "4g",
+};
+
+window.navigator.wrappedJSObject.connection = cloneInto(connection, window, {
+ cloneFunctions: true,
+});
diff --git a/browser/extensions/webcompat/injections/js/bug1795490-www.china-airlines.com-undisable-date-fields-on-mobile.js b/browser/extensions/webcompat/injections/js/bug1795490-www.china-airlines.com-undisable-date-fields-on-mobile.js
new file mode 100644
index 0000000000..f3c0e03513
--- /dev/null
+++ b/browser/extensions/webcompat/injections/js/bug1795490-www.china-airlines.com-undisable-date-fields-on-mobile.js
@@ -0,0 +1,40 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1795490 - Cannot use date fields on China Airlines mobile page
+ *
+ * This patch ensures that the search input never has the [disabled]
+ * attribute, so that users may tap/click on it to search.
+ *
+ * See https://bugzilla.mozilla.org/show_bug.cgi?id=1795490 for details.
+ */
+
+const SELECTOR = `#departureDateMobile[disabled], #returnDateMobile[disabled]`;
+
+function check(target) {
+ if (target.nodeName === "INPUT" && target.matches(SELECTOR)) {
+ target.removeAttribute("disabled");
+ return true;
+ }
+ return false;
+}
+
+new MutationObserver(mutations => {
+ for (const { addedNodes, target, attributeName } of mutations) {
+ if (attributeName === "disabled") {
+ check(target);
+ } else {
+ addedNodes?.forEach(node => {
+ if (!check(node)) {
+ node
+ .querySelectorAll?.(SELECTOR)
+ ?.forEach(n => n.removeAttribute("disabled"));
+ }
+ });
+ }
+ }
+}).observe(document, { attributes: true, childList: true, subtree: true });
diff --git a/browser/extensions/webcompat/injections/js/bug1799968-www.samsung.com-appVersion-linux-fix.js b/browser/extensions/webcompat/injections/js/bug1799968-www.samsung.com-appVersion-linux-fix.js
new file mode 100644
index 0000000000..941f071e2c
--- /dev/null
+++ b/browser/extensions/webcompat/injections/js/bug1799968-www.samsung.com-appVersion-linux-fix.js
@@ -0,0 +1,31 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1799968 - Build site patch for www.samsung.com
+ * WebCompat issue #108993 - https://webcompat.com/issues/108993
+ *
+ * Samsung's Watch pages try to detect the OS via navigator.appVersion,
+ * but fail with Linux because they expect it to contain the literal
+ * string "linux", and their JS breaks.
+ *
+ * As such this site patch sets appVersion to "5.0 (Linux)", and is
+ * only meant to be applied on Linux.
+ */
+
+/* globals exportFunction */
+
+console.info(
+ "navigator.appVersion has been shimmed for compatibility reasons. See https://webcompat.com/issues/108993 for details."
+);
+
+Object.defineProperty(navigator.wrappedJSObject, "appVersion", {
+ get: exportFunction(function () {
+ return "5.0 (Linux)";
+ }, window),
+
+ set: exportFunction(function () {}, window),
+});
diff --git a/browser/extensions/webcompat/injections/js/bug1799980-healow.com-infinite-loop-fix.js b/browser/extensions/webcompat/injections/js/bug1799980-healow.com-infinite-loop-fix.js
new file mode 100644
index 0000000000..191e97dec1
--- /dev/null
+++ b/browser/extensions/webcompat/injections/js/bug1799980-healow.com-infinite-loop-fix.js
@@ -0,0 +1,37 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1799980 - Healow gets stuck in an infinite loop while pages load
+ *
+ * This patch keeps Healow's localization scripts from getting stuck in
+ * an infinite loop while their pages are loading.
+ *
+ * This happens because they use synchronous XMLHttpRequests to fetch a
+ * JSON file with their localized text on the first call to their i18n
+ * function, and then force subsequent calls to wait for it by waiting
+ * in an infinite loop.
+ *
+ * But since they're in an infinite loop, the code after the syncXHR will
+ * never be able to run, so this ultimately triggers a slow script warning.
+ *
+ * We can improve this by just preventing the infinite loop from happening,
+ * though since they disable caching on their JSON files it means that more
+ * XHRs may happen. But since those files are small, this seems like a
+ * reasonable compromise until they migrate to a better i18n solution.
+ *
+ * See https://bugzilla.mozilla.org/show_bug.cgi?id=1799980 for details.
+ */
+
+/* globals exportFunction */
+
+Object.defineProperty(window.wrappedJSObject, "ajaxRequestProcessing", {
+ get: exportFunction(function () {
+ return false;
+ }, window),
+
+ set: exportFunction(function () {}, window),
+});
diff --git a/browser/extensions/webcompat/injections/js/bug1818818-fastclick-legacy-shim.js b/browser/extensions/webcompat/injections/js/bug1818818-fastclick-legacy-shim.js
new file mode 100644
index 0000000000..91f1c1a19a
--- /dev/null
+++ b/browser/extensions/webcompat/injections/js/bug1818818-fastclick-legacy-shim.js
@@ -0,0 +1,24 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1818818 - Neutralize FastClick
+ *
+ * The patch is applied on sites using older version of FastClick library.
+ * This allows to disable FastClick and fix various breakage caused
+ * by the library.
+ */
+
+/* globals exportFunction */
+
+const proto = CSS2Properties.prototype.wrappedJSObject;
+Object.defineProperty(proto, "msTouchAction", {
+ get: exportFunction(function () {
+ return "none";
+ }, window),
+
+ set: exportFunction(function () {}, window),
+});
diff --git a/browser/extensions/webcompat/injections/js/bug1819450-cmbchina.com-ua-change.js b/browser/extensions/webcompat/injections/js/bug1819450-cmbchina.com-ua-change.js
new file mode 100644
index 0000000000..bbe76c465f
--- /dev/null
+++ b/browser/extensions/webcompat/injections/js/bug1819450-cmbchina.com-ua-change.js
@@ -0,0 +1,29 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1819450 - cmbchina.com - Override UA
+ *
+ * The site is using UA detection to redirect to
+ * m.cmbchina.com (mobile version of the site). Adding `SAMSUNG` allows
+ * to bypass the detection of mobile browser.
+ */
+
+/* globals exportFunction */
+
+console.info(
+ "The user agent has been overridden for compatibility reasons. See https://bugzilla.mozilla.org/show_bug.cgi?id=1081239 for details."
+);
+
+const MODIFIED_UA = navigator.userAgent + " SAMSUNG";
+
+Object.defineProperty(window.navigator.wrappedJSObject, "userAgent", {
+ get: exportFunction(function () {
+ return MODIFIED_UA;
+ }, window),
+
+ set: exportFunction(function () {}, window),
+});
diff --git a/browser/extensions/webcompat/injections/js/bug1819476-axisbank.com-webkitSpeechRecognition-shim.js b/browser/extensions/webcompat/injections/js/bug1819476-axisbank.com-webkitSpeechRecognition-shim.js
new file mode 100644
index 0000000000..a72e938e4f
--- /dev/null
+++ b/browser/extensions/webcompat/injections/js/bug1819476-axisbank.com-webkitSpeechRecognition-shim.js
@@ -0,0 +1,26 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * axisbank.com - Shim webkitSpeechRecognition
+ * WebCompat issue #117770 - https://webcompat.com/issues/117770
+ *
+ * The page with bank offerings is not loading options due to the
+ * site relying on webkitSpeechRecognition, which is undefined in Firefox.
+ * Shimming it to `class {}` makes the pages work.
+ */
+
+/* globals exportFunction */
+
+console.info(
+ "webkitSpeechRecognition was shimmed for compatibility reasons. See https://webcompat.com/issues/117770 for details."
+);
+
+Object.defineProperty(window.wrappedJSObject, "webkitSpeechRecognition", {
+ value: exportFunction(function () {
+ return class {};
+ }, window),
+});
diff --git a/browser/extensions/webcompat/injections/js/bug1819678-cnki.net-undisable-search-field.js b/browser/extensions/webcompat/injections/js/bug1819678-cnki.net-undisable-search-field.js
new file mode 100644
index 0000000000..c230feb43d
--- /dev/null
+++ b/browser/extensions/webcompat/injections/js/bug1819678-cnki.net-undisable-search-field.js
@@ -0,0 +1,45 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1819678 - cnki.net - Cannot use search field
+ * WebCompat issue #115777 - https://webcompat.com/issues/115777
+ *
+ * This patch ensures that the search input never has the [disabled]
+ * attribute, so that users may tap/click on it to search.
+ *
+ * See https://bugzilla.mozilla.org/show_bug.cgi?id=1819678 for details.
+ */
+
+console.info(
+ "search input disabled attribute was removed for compatibility reasons. See https://webcompat.com/issues/115777 for details."
+);
+
+const SELECTOR = `.searchimg[disabled]`;
+
+function check(target) {
+ if (target.nodeName === "INPUT" && target.matches(SELECTOR)) {
+ target.removeAttribute("disabled");
+ return true;
+ }
+ return false;
+}
+
+new MutationObserver(mutations => {
+ for (const { addedNodes, target, attributeName } of mutations) {
+ if (attributeName === "disabled") {
+ check(target);
+ } else {
+ addedNodes?.forEach(node => {
+ if (!check(node)) {
+ node
+ .querySelectorAll?.(SELECTOR)
+ ?.forEach(n => n.removeAttribute("disabled"));
+ }
+ });
+ }
+ }
+}).observe(document, { attributes: true, childList: true, subtree: true });
diff --git a/browser/extensions/webcompat/injections/js/bug1819678-free4talk.com-window-chrome-shim.js b/browser/extensions/webcompat/injections/js/bug1819678-free4talk.com-window-chrome-shim.js
new file mode 100644
index 0000000000..6e6b5823cb
--- /dev/null
+++ b/browser/extensions/webcompat/injections/js/bug1819678-free4talk.com-window-chrome-shim.js
@@ -0,0 +1,25 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1827678 - UA spoof for www.free4talk.com
+ *
+ * This site is checking for window.chrome, so let's spoof that.
+ */
+
+/* globals exportFunction */
+
+console.info(
+ "window.chrome has been shimmed for compatibility reasons. See https://github.com/webcompat/web-bugs/issues/77727 for details."
+);
+
+Object.defineProperty(window.wrappedJSObject, "chrome", {
+ get: exportFunction(function () {
+ return true;
+ }, window),
+
+ set: exportFunction(function () {}, window),
+});
diff --git a/browser/extensions/webcompat/injections/js/bug1830776-blueshieldca.com-unsupported.js b/browser/extensions/webcompat/injections/js/bug1830776-blueshieldca.com-unsupported.js
new file mode 100644
index 0000000000..2b1eb11baf
--- /dev/null
+++ b/browser/extensions/webcompat/injections/js/bug1830776-blueshieldca.com-unsupported.js
@@ -0,0 +1,24 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1830776 - blueshieldca.com
+ * WebCompat issue #112630 - https://webcompat.com/issues/112630
+ *
+ * The site is showing unsupported message in Firefox.
+ * They're also checking for "browserCollapsed" item in sessionStorage
+ * before showing the message, to only show it once. Adding this
+ * item to sessionStorage will make sure the message is not shown
+ * on the initial load.
+ */
+
+console.info(
+ "browserCollapsed in sessionStorage has been shimmed for compatibility reasons. See https://bugzilla.mozilla.org/show_bug.cgi?id=1830776 for details."
+);
+
+if (!sessionStorage.getItem("browserCollapsed")) {
+ sessionStorage.setItem("browserCollapsed", "true");
+}
diff --git a/browser/extensions/webcompat/injections/js/bug1831007-nintendo-window-OnetrustActiveGroups.js b/browser/extensions/webcompat/injections/js/bug1831007-nintendo-window-OnetrustActiveGroups.js
new file mode 100644
index 0000000000..433c416770
--- /dev/null
+++ b/browser/extensions/webcompat/injections/js/bug1831007-nintendo-window-OnetrustActiveGroups.js
@@ -0,0 +1,27 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1831007 - Shim window.OnetrustActiveGroups for Nintendo sites
+ *
+ * Nintendo relies on `window.OnetrustActiveGroups` being defined. If it's not,
+ * users may have intermittent issues signing into their account, as they're
+ * then trying to call `.split()` on `undefined`.
+ *
+ * This intervention sets a default value (an empty string), but still allows
+ * the value to be overwritten at any time.
+ */
+
+/* globals exportFunction */
+
+console.info(
+ "The window.OnetrustActiveGroups property has been shimmed for compatibility reasons. See https://bugzilla.mozilla.org/show_bug.cgi?id=1831007 for details."
+);
+
+Object.defineProperty(window.wrappedJSObject, "OnetrustActiveGroups", {
+ value: "",
+ writable: true,
+});
diff --git a/browser/extensions/webcompat/injections/js/bug1836157-thai-masszazs-niceScroll-disable.js b/browser/extensions/webcompat/injections/js/bug1836157-thai-masszazs-niceScroll-disable.js
new file mode 100644
index 0000000000..719267748b
--- /dev/null
+++ b/browser/extensions/webcompat/injections/js/bug1836157-thai-masszazs-niceScroll-disable.js
@@ -0,0 +1,23 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Bug 1836157 - Shim navigator.platform on www.thai-massaszs.net/en/
+ *
+ * This page adds niceScroll on Android, which breaks scrolling and
+ * zooming on Firefox. Adding ` Mac` to `navigator.platform` makes
+ * the page avoid adding niceScroll entirely, unbreaking the page.
+ */
+
+var plat = navigator.platform;
+if (!plat.includes("Mac")) {
+ console.info(
+ "The navigator.platform property has been shimmed to include 'Mac' for compatibility reasons. See https://bugzilla.mozilla.org/show_bug.cgi?id=1836157 for details."
+ );
+
+ Object.defineProperty(navigator.__proto__.wrappedJSObject, "platform", {
+ value: plat + " Mac",
+ writable: true,
+ });
+}
diff --git a/browser/extensions/webcompat/injections/js/bug1842437-www.youtube.com-performance-now-precision.js b/browser/extensions/webcompat/injections/js/bug1842437-www.youtube.com-performance-now-precision.js
new file mode 100644
index 0000000000..2d328de108
--- /dev/null
+++ b/browser/extensions/webcompat/injections/js/bug1842437-www.youtube.com-performance-now-precision.js
@@ -0,0 +1,39 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1842437 - When attempting to go back on youtube.com, the content remains the same
+ *
+ * If consecutive session history entries had history.state.entryTime set to same value,
+ * back button doesn't work as expected. The entryTime value is coming from performance.now()
+ * and modifying its return value slightly to make sure two close consecutive calls don't
+ * get the same result helped with resolving the issue.
+ */
+
+/* globals exportFunction */
+
+console.info(
+ "performance.now precision has been modified for compatibility reasons. See https://bugzilla.mozilla.org/show_bug.cgi?id=1756970 for details."
+);
+
+const origPerf = performance.wrappedJSObject;
+const origNow = origPerf.now;
+
+let counter = 0;
+let previousVal = 0;
+
+Object.defineProperty(window.performance.wrappedJSObject, "now", {
+ value: exportFunction(function () {
+ let originalVal = origNow.call(origPerf);
+ if (originalVal === previousVal) {
+ originalVal += 0.00000003 * ++counter;
+ } else {
+ previousVal = originalVal;
+ counter = 0;
+ }
+ return originalVal;
+ }, window),
+});
diff --git a/browser/extensions/webcompat/lib/about_compat_broker.js b/browser/extensions/webcompat/lib/about_compat_broker.js
new file mode 100644
index 0000000000..faaa56a38e
--- /dev/null
+++ b/browser/extensions/webcompat/lib/about_compat_broker.js
@@ -0,0 +1,141 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* global browser, module, onMessageFromTab */
+
+class AboutCompatBroker {
+ constructor(bindings) {
+ this._injections = bindings.injections;
+ this._uaOverrides = bindings.uaOverrides;
+ this._shims = bindings.shims;
+
+ if (!this._injections && !this._uaOverrides && !this._shims) {
+ throw new Error("No interventions; about:compat broker is not needed");
+ }
+
+ this.portsToAboutCompatTabs = this.buildPorts();
+ this._injections?.bindAboutCompatBroker(this);
+ this._uaOverrides?.bindAboutCompatBroker(this);
+ this._shims?.bindAboutCompatBroker(this);
+ }
+
+ buildPorts() {
+ const ports = new Set();
+
+ browser.runtime.onConnect.addListener(port => {
+ ports.add(port);
+ port.onDisconnect.addListener(function () {
+ ports.delete(port);
+ });
+ });
+
+ async function broadcast(message) {
+ for (const port of ports) {
+ port.postMessage(message);
+ }
+ }
+
+ return { broadcast };
+ }
+
+ filterOverrides(overrides) {
+ return overrides
+ .filter(override => override.availableOnPlatform)
+ .map(override => {
+ const { id, active, bug, domain, hidden } = override;
+ return { id, active, bug, domain, hidden };
+ });
+ }
+
+ getInterventionById(id) {
+ for (const [type, things] of Object.entries({
+ overrides: this._uaOverrides?.getAvailableOverrides() || [],
+ interventions: this._injections?.getAvailableInjections() || [],
+ shims: this._shims?.getAvailableShims() || [],
+ })) {
+ for (const what of things) {
+ if (what.id === id) {
+ return { type, what };
+ }
+ }
+ }
+ return {};
+ }
+
+ bootup() {
+ onMessageFromTab(msg => {
+ switch (msg.command || msg) {
+ case "toggle": {
+ const id = msg.id;
+ const { type, what } = this.getInterventionById(id);
+ if (!what) {
+ return Promise.reject(
+ `No such override or intervention to toggle: ${id}`
+ );
+ }
+ const active = type === "shims" ? !what.disabledReason : what.active;
+ this.portsToAboutCompatTabs
+ .broadcast({ toggling: id, active })
+ .then(async () => {
+ switch (type) {
+ case "interventions": {
+ if (active) {
+ await this._injections?.disableInjection(what);
+ } else {
+ await this._injections?.enableInjection(what);
+ }
+ break;
+ }
+ case "overrides": {
+ if (active) {
+ await this._uaOverrides?.disableOverride(what);
+ } else {
+ await this._uaOverrides?.enableOverride(what);
+ }
+ break;
+ }
+ case "shims": {
+ if (active) {
+ await this._shims?.disableShimForSession(id);
+ } else {
+ await this._shims?.enableShimForSession(id);
+ }
+ // no need to broadcast the "toggled" signal for shims, as
+ // they send a shimsUpdated message themselves instead
+ return;
+ }
+ }
+ this.portsToAboutCompatTabs.broadcast({
+ toggled: id,
+ active: !active,
+ });
+ });
+ break;
+ }
+ case "getAllInterventions": {
+ return Promise.resolve({
+ overrides:
+ (this._uaOverrides?.isEnabled() &&
+ this.filterOverrides(
+ this._uaOverrides?.getAvailableOverrides()
+ )) ||
+ false,
+ interventions:
+ (this._injections?.isEnabled() &&
+ this.filterOverrides(
+ this._injections?.getAvailableInjections()
+ )) ||
+ false,
+ shims: this._shims?.getAvailableShims() || false,
+ });
+ }
+ }
+ return undefined;
+ });
+ }
+}
+
+module.exports = AboutCompatBroker;
diff --git a/browser/extensions/webcompat/lib/custom_functions.js b/browser/extensions/webcompat/lib/custom_functions.js
new file mode 100644
index 0000000000..97603e0424
--- /dev/null
+++ b/browser/extensions/webcompat/lib/custom_functions.js
@@ -0,0 +1,109 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* globals browser, module */
+
+const replaceStringInRequest = (
+ requestId,
+ inString,
+ outString,
+ inEncoding = "utf-8"
+) => {
+ const filter = browser.webRequest.filterResponseData(requestId);
+ const decoder = new TextDecoder(inEncoding);
+ const encoder = new TextEncoder();
+ const RE = new RegExp(inString, "g");
+ const carryoverLength = inString.length;
+ let carryover = "";
+
+ filter.ondata = event => {
+ const replaced = (
+ carryover + decoder.decode(event.data, { stream: true })
+ ).replace(RE, outString);
+ filter.write(encoder.encode(replaced.slice(0, -carryoverLength)));
+ carryover = replaced.slice(-carryoverLength);
+ };
+
+ filter.onstop = event => {
+ if (carryover.length) {
+ filter.write(encoder.encode(carryover));
+ }
+ filter.close();
+ };
+};
+
+const CUSTOM_FUNCTIONS = {
+ detectSwipeFix: injection => {
+ const { urls, types } = injection.data;
+ const listener = (injection.data.listener = ({ requestId }) => {
+ replaceStringInRequest(
+ requestId,
+ "preventDefault:true",
+ "preventDefault:false"
+ );
+ return {};
+ });
+ browser.webRequest.onBeforeRequest.addListener(listener, { urls, types }, [
+ "blocking",
+ ]);
+ },
+ detectSwipeFixDisable: injection => {
+ const { listener } = injection.data;
+ browser.webRequest.onBeforeRequest.removeListener(listener);
+ delete injection.data.listener;
+ },
+ noSniffFix: injection => {
+ const { urls, contentType } = injection.data;
+ const listener = (injection.data.listener = e => {
+ e.responseHeaders.push(contentType);
+ return { responseHeaders: e.responseHeaders };
+ });
+
+ browser.webRequest.onHeadersReceived.addListener(listener, { urls }, [
+ "blocking",
+ "responseHeaders",
+ ]);
+ },
+ noSniffFixDisable: injection => {
+ const { listener } = injection.data;
+ browser.webRequest.onHeadersReceived.removeListener(listener);
+ delete injection.data.listener;
+ },
+ runScriptBeforeRequest: injection => {
+ const { bug, message, request, script, types } = injection;
+ const warning = `${message} See https://bugzilla.mozilla.org/show_bug.cgi?id=${bug} for details.`;
+
+ const listener = (injection.listener = e => {
+ const { tabId, frameId } = e;
+ return browser.tabs
+ .executeScript(tabId, {
+ file: script,
+ frameId,
+ runAt: "document_start",
+ })
+ .then(() => {
+ browser.tabs.executeScript(tabId, {
+ code: `console.warn(${JSON.stringify(warning)})`,
+ runAt: "document_start",
+ });
+ })
+ .catch(_ => {});
+ });
+
+ browser.webRequest.onBeforeRequest.addListener(
+ listener,
+ { urls: request, types: types || ["script"] },
+ ["blocking"]
+ );
+ },
+ runScriptBeforeRequestDisable: injection => {
+ const { listener } = injection;
+ browser.webRequest.onBeforeRequest.removeListener(listener);
+ delete injection.data.listener;
+ },
+};
+
+module.exports = CUSTOM_FUNCTIONS;
diff --git a/browser/extensions/webcompat/lib/injections.js b/browser/extensions/webcompat/lib/injections.js
new file mode 100644
index 0000000000..8760f551c7
--- /dev/null
+++ b/browser/extensions/webcompat/lib/injections.js
@@ -0,0 +1,165 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* globals browser, module */
+
+class Injections {
+ constructor(availableInjections, customFunctions) {
+ this.INJECTION_PREF = "perform_injections";
+
+ this._injectionsEnabled = true;
+
+ this._availableInjections = availableInjections;
+ this._activeInjections = new Map();
+ this._customFunctions = customFunctions;
+ }
+
+ bindAboutCompatBroker(broker) {
+ this._aboutCompatBroker = broker;
+ }
+
+ bootup() {
+ browser.aboutConfigPrefs.onPrefChange.addListener(() => {
+ this.checkInjectionPref();
+ }, this.INJECTION_PREF);
+ this.checkInjectionPref();
+ }
+
+ checkInjectionPref() {
+ browser.aboutConfigPrefs.getPref(this.INJECTION_PREF).then(value => {
+ if (value === undefined) {
+ browser.aboutConfigPrefs.setPref(this.INJECTION_PREF, true);
+ } else if (value === false) {
+ this.unregisterContentScripts();
+ } else {
+ this.registerContentScripts();
+ }
+ });
+ }
+
+ getAvailableInjections() {
+ return this._availableInjections;
+ }
+
+ isEnabled() {
+ return this._injectionsEnabled;
+ }
+
+ async registerContentScripts() {
+ const platformInfo = await browser.runtime.getPlatformInfo();
+ const platformMatches = [
+ "all",
+ platformInfo.os,
+ platformInfo.os == "android" ? "android" : "desktop",
+ ];
+ for (const injection of this._availableInjections) {
+ if (platformMatches.includes(injection.platform)) {
+ injection.availableOnPlatform = true;
+ await this.enableInjection(injection);
+ }
+ }
+
+ this._injectionsEnabled = true;
+ this._aboutCompatBroker.portsToAboutCompatTabs.broadcast({
+ interventionsChanged: this._aboutCompatBroker.filterOverrides(
+ this._availableInjections
+ ),
+ });
+ }
+
+ assignContentScriptDefaults(contentScripts) {
+ let finalConfig = Object.assign({}, contentScripts);
+
+ if (!finalConfig.runAt) {
+ finalConfig.runAt = "document_start";
+ }
+
+ return finalConfig;
+ }
+
+ async enableInjection(injection) {
+ if (injection.active) {
+ return undefined;
+ }
+
+ if (injection.customFunc) {
+ return this.enableCustomInjection(injection);
+ }
+
+ return this.enableContentScripts(injection);
+ }
+
+ enableCustomInjection(injection) {
+ if (injection.customFunc in this._customFunctions) {
+ this._customFunctions[injection.customFunc](injection);
+ injection.active = true;
+ } else {
+ console.error(
+ `Provided function ${injection.customFunc} wasn't found in functions list`
+ );
+ }
+ }
+
+ async enableContentScripts(injection) {
+ try {
+ const handle = await browser.contentScripts.register(
+ this.assignContentScriptDefaults(injection.contentScripts)
+ );
+ this._activeInjections.set(injection, handle);
+ injection.active = true;
+ } catch (ex) {
+ console.error(
+ "Registering WebCompat GoFaster content scripts failed: ",
+ ex
+ );
+ }
+ }
+
+ unregisterContentScripts() {
+ for (const injection of this._availableInjections) {
+ this.disableInjection(injection);
+ }
+
+ this._injectionsEnabled = false;
+ this._aboutCompatBroker.portsToAboutCompatTabs.broadcast({
+ interventionsChanged: false,
+ });
+ }
+
+ async disableInjection(injection) {
+ if (!injection.active) {
+ return undefined;
+ }
+
+ if (injection.customFunc) {
+ return this.disableCustomInjections(injection);
+ }
+
+ return this.disableContentScripts(injection);
+ }
+
+ disableCustomInjections(injection) {
+ const disableFunc = injection.customFunc + "Disable";
+
+ if (disableFunc in this._customFunctions) {
+ this._customFunctions[disableFunc](injection);
+ injection.active = false;
+ } else {
+ console.error(
+ `Provided function ${disableFunc} for disabling injection wasn't found in functions list`
+ );
+ }
+ }
+
+ async disableContentScripts(injection) {
+ const contentScript = this._activeInjections.get(injection);
+ await contentScript.unregister();
+ this._activeInjections.delete(injection);
+ injection.active = false;
+ }
+}
+
+module.exports = Injections;
diff --git a/browser/extensions/webcompat/lib/intervention_helpers.js b/browser/extensions/webcompat/lib/intervention_helpers.js
new file mode 100644
index 0000000000..16ea6572f2
--- /dev/null
+++ b/browser/extensions/webcompat/lib/intervention_helpers.js
@@ -0,0 +1,233 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* globals module */
+
+const GOOGLE_TLDS = [
+ "com",
+ "ac",
+ "ad",
+ "ae",
+ "com.af",
+ "com.ag",
+ "com.ai",
+ "al",
+ "am",
+ "co.ao",
+ "com.ar",
+ "as",
+ "at",
+ "com.au",
+ "az",
+ "ba",
+ "com.bd",
+ "be",
+ "bf",
+ "bg",
+ "com.bh",
+ "bi",
+ "bj",
+ "com.bn",
+ "com.bo",
+ "com.br",
+ "bs",
+ "bt",
+ "co.bw",
+ "by",
+ "com.bz",
+ "ca",
+ "com.kh",
+ "cc",
+ "cd",
+ "cf",
+ "cat",
+ "cg",
+ "ch",
+ "ci",
+ "co.ck",
+ "cl",
+ "cm",
+ "cn",
+ "com.co",
+ "co.cr",
+ "com.cu",
+ "cv",
+ "com.cy",
+ "cz",
+ "de",
+ "dj",
+ "dk",
+ "dm",
+ "com.do",
+ "dz",
+ "com.ec",
+ "ee",
+ "com.eg",
+ "es",
+ "com.et",
+ "fi",
+ "com.fj",
+ "fm",
+ "fr",
+ "ga",
+ "ge",
+ "gf",
+ "gg",
+ "com.gh",
+ "com.gi",
+ "gl",
+ "gm",
+ "gp",
+ "gr",
+ "com.gt",
+ "gy",
+ "com.hk",
+ "hn",
+ "hr",
+ "ht",
+ "hu",
+ "co.id",
+ "iq",
+ "ie",
+ "co.il",
+ "im",
+ "co.in",
+ "io",
+ "is",
+ "it",
+ "je",
+ "com.jm",
+ "jo",
+ "co.jp",
+ "co.ke",
+ "ki",
+ "kg",
+ "co.kr",
+ "com.kw",
+ "kz",
+ "la",
+ "com.lb",
+ "com.lc",
+ "li",
+ "lk",
+ "co.ls",
+ "lt",
+ "lu",
+ "lv",
+ "com.ly",
+ "co.ma",
+ "md",
+ "me",
+ "mg",
+ "mk",
+ "ml",
+ "com.mm",
+ "mn",
+ "ms",
+ "com.mt",
+ "mu",
+ "mv",
+ "mw",
+ "com.mx",
+ "com.my",
+ "co.mz",
+ "com.na",
+ "ne",
+ "com.nf",
+ "com.ng",
+ "com.ni",
+ "nl",
+ "no",
+ "com.np",
+ "nr",
+ "nu",
+ "co.nz",
+ "com.om",
+ "com.pk",
+ "com.pa",
+ "com.pe",
+ "com.ph",
+ "pl",
+ "com.pg",
+ "pn",
+ "com.pr",
+ "ps",
+ "pt",
+ "com.py",
+ "com.qa",
+ "ro",
+ "rs",
+ "ru",
+ "rw",
+ "com.sa",
+ "com.sb",
+ "sc",
+ "se",
+ "com.sg",
+ "sh",
+ "si",
+ "sk",
+ "com.sl",
+ "sn",
+ "sm",
+ "so",
+ "st",
+ "sr",
+ "com.sv",
+ "td",
+ "tg",
+ "co.th",
+ "com.tj",
+ "tk",
+ "tl",
+ "tm",
+ "to",
+ "tn",
+ "com.tr",
+ "tt",
+ "com.tw",
+ "co.tz",
+ "com.ua",
+ "co.ug",
+ "co.uk",
+ "com",
+ "com.uy",
+ "co.uz",
+ "com.vc",
+ "co.ve",
+ "vg",
+ "co.vi",
+ "com.vn",
+ "vu",
+ "ws",
+ "co.za",
+ "co.zm",
+ "co.zw",
+];
+
+var InterventionHelpers = {
+ /**
+ * Useful helper to generate a list of domains with a fixed base domain and
+ * multiple country-TLDs or other cases with various TLDs.
+ *
+ * Example:
+ * matchPatternsForTLDs("*://mozilla.", "/*", ["com", "org"])
+ * => ["*://mozilla.com/*", "*://mozilla.org/*"]
+ */
+ matchPatternsForTLDs(base, suffix, tlds) {
+ return tlds.map(tld => base + tld + suffix);
+ },
+
+ /**
+ * A modified version of matchPatternsForTLDs that always returns the match
+ * list for all known Google country TLDs.
+ */
+ matchPatternsForGoogle(base, suffix = "/*") {
+ return InterventionHelpers.matchPatternsForTLDs(base, suffix, GOOGLE_TLDS);
+ },
+};
+
+module.exports = InterventionHelpers;
diff --git a/browser/extensions/webcompat/lib/messaging_helper.js b/browser/extensions/webcompat/lib/messaging_helper.js
new file mode 100644
index 0000000000..d978ed384f
--- /dev/null
+++ b/browser/extensions/webcompat/lib/messaging_helper.js
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* globals browser */
+
+// By default, only the first handler for browser.runtime.onMessage which
+// returns a value will get to return one. As such, we need to let them all
+// receive the message, and all have a chance to return a response (with the
+// first non-undefined result being the one that is ultimately returned).
+// This way, about:compat and the shims library can both get a chance to
+// process a message, and just return undefined if they wish to ignore it.
+
+const onMessageFromTab = (function () {
+ const handlers = new Set();
+
+ browser.runtime.onMessage.addListener((msg, sender) => {
+ const promises = [...handlers.values()].map(fn => fn(msg, sender));
+ return Promise.allSettled(promises).then(results => {
+ for (const { reason, value } of results) {
+ if (reason) {
+ console.error(reason);
+ } else if (value !== undefined) {
+ return value;
+ }
+ }
+ return undefined;
+ });
+ });
+
+ return function (handler) {
+ handlers.add(handler);
+ };
+})();
diff --git a/browser/extensions/webcompat/lib/module_shim.js b/browser/extensions/webcompat/lib/module_shim.js
new file mode 100644
index 0000000000..2fd39fdbbd
--- /dev/null
+++ b/browser/extensions/webcompat/lib/module_shim.js
@@ -0,0 +1,24 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * We cannot yet use proper JS modules within webextensions, as support for them
+ * is highly experimental and highly instable. So we end up just including all
+ * the JS files we need as separate background scripts, and since they all are
+ * executed within the same context, this works for our in-browser deployment.
+ *
+ * However, this code is tracked outside of mozilla-central, and we work on
+ * shipping this code in other products, like android-components as well.
+ * Because of that, we have automated tests running within that repository. To
+ * make our lives easier, we add `module.exports` statements to the JS source
+ * files, so we can easily import their contents into our NodeJS-based test
+ * suite.
+ *
+ * This works fine, but obviously, `module` is not defined when running
+ * in-browser. So let's use this empty object as a shim, so we don't run into
+ * runtime exceptions because of that.
+ */
+var module = {};
diff --git a/browser/extensions/webcompat/lib/requestStorageAccess_helper.js b/browser/extensions/webcompat/lib/requestStorageAccess_helper.js
new file mode 100644
index 0000000000..032225bb78
--- /dev/null
+++ b/browser/extensions/webcompat/lib/requestStorageAccess_helper.js
@@ -0,0 +1,30 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals browser */
+
+// Helper for calling the internal requestStorageAccessForOrigin method. The
+// method is called on the first-party document for the third-party which needs
+// first-party storage access.
+browser.runtime.onMessage.addListener(request => {
+ let { requestStorageAccessOrigin, warning } = request;
+ if (!requestStorageAccessOrigin) {
+ return false;
+ }
+
+ // Log a warning to the web console, informing about the shim.
+ console.warn(warning);
+
+ // Call the internal storage access API. Passing false means we don't require
+ // user activation, but will always show the storage access prompt. The user
+ // has to explicitly allow storage access.
+ return document
+ .requestStorageAccessForOrigin(requestStorageAccessOrigin, false)
+ .then(() => {
+ return { success: true };
+ })
+ .catch(() => {
+ return { success: false };
+ });
+});
diff --git a/browser/extensions/webcompat/lib/shim_messaging_helper.js b/browser/extensions/webcompat/lib/shim_messaging_helper.js
new file mode 100644
index 0000000000..ee109713a5
--- /dev/null
+++ b/browser/extensions/webcompat/lib/shim_messaging_helper.js
@@ -0,0 +1,65 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* globals browser */
+
+if (!window.Shims) {
+ window.Shims = new Map();
+}
+
+if (!window.ShimsHelperReady) {
+ window.ShimsHelperReady = true;
+
+ browser.runtime.onMessage.addListener(details => {
+ const { shimId, warning } = details;
+ if (!shimId) {
+ return;
+ }
+ window.Shims.set(shimId, details);
+ if (warning) {
+ console.warn(warning);
+ }
+ });
+
+ async function handleMessage(port, shimId, messageId, message) {
+ let response;
+ const shim = window.Shims.get(shimId);
+ if (shim) {
+ const { needsShimHelpers, origin } = shim;
+ if (origin === location.origin) {
+ if (needsShimHelpers?.includes(message)) {
+ const msg = { shimId, message };
+ try {
+ response = await browser.runtime.sendMessage(msg);
+ } catch (_) {}
+ }
+ }
+ }
+ port.postMessage({ messageId, response });
+ }
+
+ window.addEventListener(
+ "ShimConnects",
+ e => {
+ e.stopPropagation();
+ e.preventDefault();
+ const { port, pendingMessages, shimId } = e.detail;
+ const shim = window.Shims.get(shimId);
+ if (!shim) {
+ return;
+ }
+ port.onmessage = ({ data }) => {
+ handleMessage(port, shimId, data.messageId, data.message);
+ };
+ for (const [messageId, message] of pendingMessages) {
+ handleMessage(port, shimId, messageId, message);
+ }
+ },
+ true
+ );
+
+ window.dispatchEvent(new CustomEvent("ShimHelperReady"));
+}
diff --git a/browser/extensions/webcompat/lib/shims.js b/browser/extensions/webcompat/lib/shims.js
new file mode 100644
index 0000000000..ee33627c57
--- /dev/null
+++ b/browser/extensions/webcompat/lib/shims.js
@@ -0,0 +1,1044 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* globals browser, module, onMessageFromTab */
+
+// To grant shims access to bundled logo images without risking
+// exposing our moz-extension URL, we have the shim request them via
+// nonsense URLs which we then redirect to the actual files (but only
+// on tabs where a shim using a given logo happens to be active).
+const LogosBaseURL = "https://smartblock.firefox.etp/";
+
+const releaseBranchPromise = browser.appConstants.getReleaseBranch();
+
+const platformPromise = browser.runtime.getPlatformInfo().then(info => {
+ return info.os === "android" ? "android" : "desktop";
+});
+
+let debug = async function () {
+ if ((await releaseBranchPromise) !== "release_or_beta") {
+ console.debug.apply(this, arguments);
+ }
+};
+let error = async function () {
+ if ((await releaseBranchPromise) !== "release_or_beta") {
+ console.error.apply(this, arguments);
+ }
+};
+let warn = async function () {
+ if ((await releaseBranchPromise) !== "release_or_beta") {
+ console.warn.apply(this, arguments);
+ }
+};
+
+class Shim {
+ constructor(opts, manager) {
+ this.manager = manager;
+
+ const { contentScripts, matches, unblocksOnOptIn } = opts;
+
+ this.branches = opts.branches;
+ this.bug = opts.bug;
+ this.isGoogleTrendsDFPIFix = opts.custom == "google-trends-dfpi-fix";
+ this.file = opts.file;
+ this.hiddenInAboutCompat = opts.hiddenInAboutCompat;
+ this.hosts = opts.hosts;
+ this.id = opts.id;
+ this.logos = opts.logos || [];
+ this.matches = [];
+ this.name = opts.name;
+ this.notHosts = opts.notHosts;
+ this.onlyIfBlockedByETP = opts.onlyIfBlockedByETP;
+ this.onlyIfDFPIActive = opts.onlyIfDFPIActive;
+ this.onlyIfPrivateBrowsing = opts.onlyIfPrivateBrowsing;
+ this._options = opts.options || {};
+ this.needsShimHelpers = opts.needsShimHelpers;
+ this.platform = opts.platform || "all";
+ this.runFirst = opts.runFirst;
+ this.unblocksOnOptIn = unblocksOnOptIn;
+ this.requestStorageAccessForRedirect = opts.requestStorageAccessForRedirect;
+
+ this._hostOptIns = new Set();
+
+ this._disabledByConfig = opts.disabled;
+ this._disabledGlobally = false;
+ this._disabledForSession = false;
+ this._disabledByPlatform = false;
+ this._disabledByReleaseBranch = false;
+
+ this._activeOnTabs = new Set();
+ this._showedOptInOnTabs = new Set();
+
+ const pref = `disabled_shims.${this.id}`;
+
+ this.redirectsRequests = !!this.file && matches?.length;
+
+ this._contentScriptRegistrations = [];
+ this.contentScripts = contentScripts || [];
+ for (const script of this.contentScripts) {
+ if (typeof script.css === "string") {
+ script.css = [{ file: `/shims/${script.css}` }];
+ }
+ if (typeof script.js === "string") {
+ script.js = [{ file: `/shims/${script.js}` }];
+ }
+ }
+
+ for (const match of matches || []) {
+ if (!match.types) {
+ this.matches.push({ patterns: [match], types: ["script"] });
+ } else {
+ this.matches.push(match);
+ }
+ if (match.target) {
+ this.redirectsRequests = true;
+ }
+ }
+
+ browser.aboutConfigPrefs.onPrefChange.addListener(async () => {
+ const value = await browser.aboutConfigPrefs.getPref(pref);
+ this._disabledPrefValue = value;
+ this._onEnabledStateChanged();
+ }, pref);
+
+ this.ready = Promise.all([
+ browser.aboutConfigPrefs.getPref(pref),
+ platformPromise,
+ releaseBranchPromise,
+ ]).then(([disabledPrefValue, platform, branch]) => {
+ this._disabledPrefValue = disabledPrefValue;
+
+ this._disabledByPlatform =
+ this.platform !== "all" && this.platform !== platform;
+
+ this._disabledByReleaseBranch = false;
+ for (const supportedBranchAndPlatform of this.branches || []) {
+ const [supportedBranch, supportedPlatform] =
+ supportedBranchAndPlatform.split(":");
+ if (
+ (!supportedPlatform || supportedPlatform == platform) &&
+ supportedBranch != branch
+ ) {
+ this._disabledByReleaseBranch = true;
+ }
+ }
+
+ this._preprocessOptions(platform, branch);
+ this._onEnabledStateChanged();
+ });
+ }
+
+ _preprocessOptions(platform, branch) {
+ // options may be any value, but can optionally be gated for specified
+ // platform/branches, if in the format `{value, branches, platform}`
+ this.options = {};
+ for (const [k, v] of Object.entries(this._options)) {
+ if (v?.value) {
+ if (
+ (!v.platform || v.platform === platform) &&
+ (!v.branches || v.branches.includes(branch))
+ ) {
+ this.options[k] = v.value;
+ }
+ } else {
+ this.options[k] = v;
+ }
+ }
+ }
+
+ get enabled() {
+ if (this._disabledGlobally || this._disabledForSession) {
+ return false;
+ }
+
+ if (this._disabledPrefValue !== undefined) {
+ return !this._disabledPrefValue;
+ }
+
+ return (
+ !this._disabledByConfig &&
+ !this._disabledByPlatform &&
+ !this._disabledByReleaseBranch
+ );
+ }
+
+ get disabledReason() {
+ if (this._disabledGlobally) {
+ return "globalPref";
+ }
+
+ if (this._disabledForSession) {
+ return "session";
+ }
+
+ if (this._disabledPrefValue !== undefined) {
+ if (this._disabledPrefValue === true) {
+ return "pref";
+ }
+ return false;
+ }
+
+ if (this._disabledByConfig) {
+ return "config";
+ }
+
+ if (this._disabledByPlatform) {
+ return "platform";
+ }
+
+ if (this._disabledByReleaseBranch) {
+ return "releaseBranch";
+ }
+
+ return false;
+ }
+
+ onAllShimsEnabled() {
+ const wasEnabled = this.enabled;
+ this._disabledGlobally = false;
+ if (!wasEnabled) {
+ this._onEnabledStateChanged();
+ }
+ }
+
+ onAllShimsDisabled() {
+ const wasEnabled = this.enabled;
+ this._disabledGlobally = true;
+ if (wasEnabled) {
+ this._onEnabledStateChanged();
+ }
+ }
+
+ enableForSession() {
+ const wasEnabled = this.enabled;
+ this._disabledForSession = false;
+ if (!wasEnabled) {
+ this._onEnabledStateChanged();
+ }
+ }
+
+ disableForSession() {
+ const wasEnabled = this.enabled;
+ this._disabledForSession = true;
+ if (wasEnabled) {
+ this._onEnabledStateChanged();
+ }
+ }
+
+ async _onEnabledStateChanged() {
+ this.manager?.onShimStateChanged(this.id);
+ if (!this.enabled) {
+ await this._unregisterContentScripts();
+ return this._revokeRequestsInETP();
+ }
+ await this._registerContentScripts();
+ return this._allowRequestsInETP();
+ }
+
+ async _registerContentScripts() {
+ if (
+ this.contentScripts.length &&
+ !this._contentScriptRegistrations.length
+ ) {
+ const matches = [];
+ for (const options of this.contentScripts) {
+ matches.push(options.matches);
+ const reg = await browser.contentScripts.register(options);
+ this._contentScriptRegistrations.push(reg);
+ }
+ const urls = Array.from(new Set(matches.flat()));
+ debug("Enabling content scripts for these URLs:", urls);
+ }
+ }
+
+ async _unregisterContentScripts() {
+ for (const registration of this._contentScriptRegistrations) {
+ registration.unregister();
+ }
+ this._contentScriptRegistrations = [];
+ }
+
+ async _allowRequestsInETP() {
+ const matches = this.matches.map(m => m.patterns).flat();
+ if (matches.length) {
+ await browser.trackingProtection.shim(this.id, matches);
+ }
+
+ if (this._hostOptIns.size) {
+ const optIns = this.getApplicableOptIns();
+ if (optIns.length) {
+ await browser.trackingProtection.allow(
+ this.id,
+ this._optInPatterns,
+ Array.from(this._hostOptIns)
+ );
+ }
+ }
+ }
+
+ _revokeRequestsInETP() {
+ return browser.trackingProtection.revoke(this.id);
+ }
+
+ setActiveOnTab(tabId, active = true) {
+ if (active) {
+ this._activeOnTabs.add(tabId);
+ } else {
+ this._activeOnTabs.delete(tabId);
+ this._showedOptInOnTabs.delete(tabId);
+ }
+ }
+
+ isActiveOnTab(tabId) {
+ return this._activeOnTabs.has(tabId);
+ }
+
+ meantForHost(host) {
+ const { hosts, notHosts } = this;
+ if (hosts || notHosts) {
+ if (
+ (notHosts && notHosts.includes(host)) ||
+ (hosts && !hosts.includes(host))
+ ) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ async unblocksURLOnOptIn(url) {
+ if (!this._optInPatterns) {
+ this._optInPatterns = await this.getApplicableOptIns();
+ }
+
+ if (!this._optInMatcher) {
+ this._optInMatcher = browser.matchPatterns.getMatcher(
+ Array.from(this._optInPatterns)
+ );
+ }
+
+ return this._optInMatcher.matches(url);
+ }
+
+ isTriggeredByURLAndType(url, type) {
+ for (const entry of this.matches || []) {
+ if (!entry.types.includes(type)) {
+ continue;
+ }
+ if (!entry.matcher) {
+ entry.matcher = browser.matchPatterns.getMatcher(
+ Array.from(entry.patterns)
+ );
+ }
+ if (entry.matcher.matches(url)) {
+ return entry;
+ }
+ }
+
+ return undefined;
+ }
+
+ async getApplicableOptIns() {
+ if (this._applicableOptIns) {
+ return this._applicableOptIns;
+ }
+ const optins = [];
+ for (const unblock of this.unblocksOnOptIn || []) {
+ if (typeof unblock === "string") {
+ optins.push(unblock);
+ continue;
+ }
+ const { branches, patterns, platforms } = unblock;
+ if (platforms?.length) {
+ const platform = await platformPromise;
+ if (platform !== "all" && !platforms.includes(platform)) {
+ continue;
+ }
+ }
+ if (branches?.length) {
+ const branch = await releaseBranchPromise;
+ if (!branches.includes(branch)) {
+ continue;
+ }
+ }
+ optins.push.apply(optins, patterns);
+ }
+ this._applicableOptIns = optins;
+ return optins;
+ }
+
+ async onUserOptIn(host) {
+ const optins = await this.getApplicableOptIns();
+ if (optins.length) {
+ this.userHasOptedIn = true;
+ this._hostOptIns.add(host);
+ await browser.trackingProtection.allow(
+ this.id,
+ optins,
+ Array.from(this._hostOptIns)
+ );
+ }
+ }
+
+ hasUserOptedInAlready(host) {
+ return this._hostOptIns.has(host);
+ }
+
+ showOptInWarningOnce(tabId, origin) {
+ if (this._showedOptInOnTabs.has(tabId)) {
+ return Promise.resolve();
+ }
+ this._showedOptInOnTabs.add(tabId);
+
+ const { bug, name } = this;
+ const warning = `${name} is allowed on ${origin} for this browsing session due to user opt-in. See https://bugzilla.mozilla.org/show_bug.cgi?id=${bug} for details.`;
+ return browser.tabs
+ .executeScript(tabId, {
+ code: `console.warn(${JSON.stringify(warning)})`,
+ runAt: "document_start",
+ })
+ .catch(() => {});
+ }
+}
+
+class Shims {
+ constructor(availableShims) {
+ if (!browser.trackingProtection) {
+ console.error("Required experimental add-on APIs for shims unavailable");
+ return;
+ }
+
+ this._registerShims(availableShims);
+
+ onMessageFromTab(this._onMessageFromShim.bind(this));
+
+ this.ENABLED_PREF = "enable_shims";
+ browser.aboutConfigPrefs.onPrefChange.addListener(() => {
+ this._checkEnabledPref();
+ }, this.ENABLED_PREF);
+ this._haveCheckedEnabledPref = this._checkEnabledPref();
+ }
+
+ bindAboutCompatBroker(broker) {
+ this._aboutCompatBroker = broker;
+ }
+
+ getShimInfoForAboutCompat(shim) {
+ const { bug, disabledReason, hiddenInAboutCompat, id, name } = shim;
+ const type = "smartblock";
+ return { bug, disabledReason, hidden: hiddenInAboutCompat, id, name, type };
+ }
+
+ disableShimForSession(id) {
+ const shim = this.shims.get(id);
+ shim?.disableForSession();
+ }
+
+ enableShimForSession(id) {
+ const shim = this.shims.get(id);
+ shim?.enableForSession();
+ }
+
+ onShimStateChanged(id) {
+ if (!this._aboutCompatBroker) {
+ return;
+ }
+
+ const shim = this.shims.get(id);
+ if (!shim) {
+ return;
+ }
+
+ const shimsChanged = [this.getShimInfoForAboutCompat(shim)];
+ this._aboutCompatBroker.portsToAboutCompatTabs.broadcast({ shimsChanged });
+ }
+
+ getAvailableShims() {
+ const shims = Array.from(this.shims.values()).map(
+ this.getShimInfoForAboutCompat
+ );
+ shims.sort((a, b) => a.name.localeCompare(b.name));
+ return shims;
+ }
+
+ _registerShims(shims) {
+ if (this.shims) {
+ throw new Error("_registerShims has already been called");
+ }
+
+ this.shims = new Map();
+ for (const shimOpts of shims) {
+ const { id } = shimOpts;
+ if (!this.shims.has(id)) {
+ this.shims.set(shimOpts.id, new Shim(shimOpts, this));
+ }
+ }
+
+ // Register onBeforeRequest listener which handles storage access requests
+ // on matching redirects.
+ let redirectTargetUrls = Array.from(shims.values())
+ .filter(shim => shim.requestStorageAccessForRedirect)
+ .flatMap(shim => shim.requestStorageAccessForRedirect)
+ .map(([, dstUrl]) => dstUrl);
+
+ // Unique target urls.
+ redirectTargetUrls = Array.from(new Set(redirectTargetUrls));
+
+ if (redirectTargetUrls.length) {
+ debug("Registering redirect listener for requestStorageAccess helper", {
+ redirectTargetUrls,
+ });
+ browser.webRequest.onBeforeRequest.addListener(
+ this._onRequestStorageAccessRedirect.bind(this),
+ { urls: redirectTargetUrls, types: ["main_frame"] },
+ ["blocking"]
+ );
+ }
+
+ function addTypePatterns(type, patterns, set) {
+ if (!set.has(type)) {
+ set.set(type, { patterns: new Set() });
+ }
+ const allSet = set.get(type).patterns;
+ for (const pattern of patterns) {
+ allSet.add(pattern);
+ }
+ }
+
+ const allMatchTypePatterns = new Map();
+ const allHeaderChangingMatchTypePatterns = new Map();
+ const allLogos = [];
+ for (const shim of this.shims.values()) {
+ const { logos, matches } = shim;
+ allLogos.push(...logos);
+ for (const { patterns, target, types } of matches || []) {
+ for (const type of types) {
+ if (shim.isGoogleTrendsDFPIFix) {
+ addTypePatterns(type, patterns, allHeaderChangingMatchTypePatterns);
+ }
+ if (target || shim.file || shim.runFirst) {
+ addTypePatterns(type, patterns, allMatchTypePatterns);
+ }
+ }
+ }
+ }
+
+ if (allLogos.length) {
+ const urls = Array.from(new Set(allLogos)).map(l => {
+ return `${LogosBaseURL}${l}`;
+ });
+ debug("Allowing access to these logos:", urls);
+ const unmarkShimsActive = tabId => {
+ for (const shim of this.shims.values()) {
+ shim.setActiveOnTab(tabId, false);
+ }
+ };
+ browser.tabs.onRemoved.addListener(unmarkShimsActive);
+ browser.tabs.onUpdated.addListener((tabId, changeInfo) => {
+ if (changeInfo.discarded || changeInfo.url) {
+ unmarkShimsActive(tabId);
+ }
+ });
+ browser.webRequest.onBeforeRequest.addListener(
+ this._redirectLogos.bind(this),
+ { urls, types: ["image"] },
+ ["blocking"]
+ );
+ }
+
+ if (allHeaderChangingMatchTypePatterns) {
+ for (const [
+ type,
+ { patterns },
+ ] of allHeaderChangingMatchTypePatterns.entries()) {
+ const urls = Array.from(patterns);
+ debug("Shimming these", type, "URLs:", urls);
+ browser.webRequest.onBeforeSendHeaders.addListener(
+ this._onBeforeSendHeaders.bind(this),
+ { urls, types: [type] },
+ ["blocking", "requestHeaders"]
+ );
+ browser.webRequest.onHeadersReceived.addListener(
+ this._onHeadersReceived.bind(this),
+ { urls, types: [type] },
+ ["blocking", "responseHeaders"]
+ );
+ }
+ }
+
+ if (!allMatchTypePatterns.size) {
+ debug("Skipping shims; none enabled");
+ return;
+ }
+
+ for (const [type, { patterns }] of allMatchTypePatterns.entries()) {
+ const urls = Array.from(patterns);
+ debug("Shimming these", type, "URLs:", urls);
+
+ browser.webRequest.onBeforeRequest.addListener(
+ this._ensureShimForRequestOnTab.bind(this),
+ { urls, types: [type] },
+ ["blocking"]
+ );
+ }
+ }
+
+ async _checkEnabledPref() {
+ await browser.aboutConfigPrefs.getPref(this.ENABLED_PREF).then(value => {
+ if (value === undefined) {
+ browser.aboutConfigPrefs.setPref(this.ENABLED_PREF, true);
+ } else if (value === false) {
+ this.enabled = false;
+ } else {
+ this.enabled = true;
+ }
+ });
+ }
+
+ get enabled() {
+ return this._enabled;
+ }
+
+ set enabled(enabled) {
+ if (enabled === this._enabled) {
+ return;
+ }
+
+ this._enabled = enabled;
+
+ for (const shim of this.shims.values()) {
+ if (enabled) {
+ shim.onAllShimsEnabled();
+ } else {
+ shim.onAllShimsDisabled();
+ }
+ }
+ }
+
+ async _onRequestStorageAccessRedirect({
+ originUrl: srcUrl,
+ url: dstUrl,
+ tabId,
+ }) {
+ debug("Detected redirect", { srcUrl, dstUrl, tabId });
+
+ // Check if a shim needs to request storage access for this redirect. This
+ // handler is called when the *source url* matches a shims redirect pattern,
+ // but we still need to check if the *destination url* matches.
+ const matchingShims = Array.from(this.shims.values()).filter(shim => {
+ const { enabled, requestStorageAccessForRedirect } = shim;
+
+ if (!enabled || !requestStorageAccessForRedirect) {
+ return false;
+ }
+
+ return requestStorageAccessForRedirect.some(
+ ([srcPattern, dstPattern]) =>
+ browser.matchPatterns.getMatcher([srcPattern]).matches(srcUrl) &&
+ browser.matchPatterns.getMatcher([dstPattern]).matches(dstUrl)
+ );
+ });
+
+ // For each matching shim, find out if its enabled in regard to dFPI state.
+ const bugNumbers = new Set();
+ let isDFPIActive = null;
+ await Promise.all(
+ matchingShims.map(async shim => {
+ if (shim.onlyIfDFPIActive) {
+ // Only get the dFPI state for the first shim which requires it.
+ if (isDFPIActive === null) {
+ const tabIsPB = (await browser.tabs.get(tabId)).incognito;
+ isDFPIActive = await browser.trackingProtection.isDFPIActive(
+ tabIsPB
+ );
+ }
+ if (!isDFPIActive) {
+ return;
+ }
+ }
+ bugNumbers.add(shim.bug);
+ })
+ );
+
+ // If there is no shim which needs storage access for this redirect src/dst
+ // pair, resume it.
+ if (!bugNumbers.size) {
+ return;
+ }
+
+ // Inject the helper to call requestStorageAccessForOrigin on the document.
+ await browser.tabs.executeScript(tabId, {
+ file: "/lib/requestStorageAccess_helper.js",
+ runAt: "document_start",
+ });
+
+ const bugUrls = Array.from(bugNumbers)
+ .map(bugNo => `https://bugzilla.mozilla.org/show_bug.cgi?id=${bugNo}`)
+ .join(", ");
+ const warning = `Firefox calls the Storage Access API for ${dstUrl} on behalf of ${srcUrl}. See the following bugs for details: ${bugUrls}`;
+
+ // Request storage access for the origin of the destination url of the
+ // redirect.
+ const { origin: requestStorageAccessOrigin } = new URL(dstUrl);
+
+ // Wait for the requestStorageAccess request to finish before resuming the
+ // redirect.
+ const { success } = await browser.tabs.sendMessage(tabId, {
+ requestStorageAccessOrigin,
+ warning,
+ });
+ debug("requestStorageAccess callback", {
+ success,
+ requestStorageAccessOrigin,
+ srcUrl,
+ dstUrl,
+ bugNumbers,
+ });
+ }
+
+ async _onMessageFromShim(payload, sender, sendResponse) {
+ const { tab, frameId } = sender;
+ const { id, url } = tab;
+ const { shimId, message } = payload;
+
+ // Ignore unknown messages (for instance, from about:compat).
+ if (message !== "getOptions" && message !== "optIn") {
+ return undefined;
+ }
+
+ if (sender.id !== browser.runtime.id || id === -1) {
+ throw new Error("not allowed");
+ }
+
+ // Important! It is entirely possible for sites to spoof
+ // these messages, due to shims allowing web pages to
+ // communicate with the extension.
+
+ const shim = this.shims.get(shimId);
+ if (!shim?.needsShimHelpers?.includes(message)) {
+ throw new Error("not allowed");
+ }
+
+ if (message === "getOptions") {
+ return Object.assign(
+ {
+ platform: await platformPromise,
+ releaseBranch: await releaseBranchPromise,
+ },
+ shim.options
+ );
+ } else if (message === "optIn") {
+ try {
+ await shim.onUserOptIn(new URL(url).hostname);
+ const origin = new URL(tab.url).origin;
+ warn(
+ "** User opted in for",
+ shim.name,
+ "shim on",
+ origin,
+ "on tab",
+ id,
+ "frame",
+ frameId
+ );
+ await shim.showOptInWarningOnce(id, origin);
+ } catch (err) {
+ console.error(err);
+ throw new Error("error");
+ }
+ }
+
+ return undefined;
+ }
+
+ async _redirectLogos(details) {
+ await this._haveCheckedEnabledPref;
+
+ if (!this.enabled) {
+ return { cancel: true };
+ }
+
+ const { tabId, url } = details;
+ const logo = new URL(url).pathname.slice(1);
+
+ for (const shim of this.shims.values()) {
+ await shim.ready;
+
+ if (!shim.enabled) {
+ continue;
+ }
+
+ if (shim.onlyIfDFPIActive) {
+ const isPB = (await browser.tabs.get(details.tabId)).incognito;
+ if (!(await browser.trackingProtection.isDFPIActive(isPB))) {
+ continue;
+ }
+ }
+
+ if (!shim.logos.includes(logo)) {
+ continue;
+ }
+
+ if (shim.isActiveOnTab(tabId)) {
+ return { redirectUrl: browser.runtime.getURL(`shims/${logo}`) };
+ }
+ }
+
+ return { cancel: true };
+ }
+
+ async _onHeadersReceived(details) {
+ await this._haveCheckedEnabledPref;
+
+ for (const shim of this.shims.values()) {
+ await shim.ready;
+
+ if (!shim.enabled) {
+ continue;
+ }
+
+ if (shim.onlyIfDFPIActive) {
+ const isPB = (await browser.tabs.get(details.tabId)).incognito;
+ if (!(await browser.trackingProtection.isDFPIActive(isPB))) {
+ continue;
+ }
+ }
+
+ if (shim.isGoogleTrendsDFPIFix) {
+ if (shim.GoogleNidCookieToUse) {
+ continue;
+ }
+
+ for (const header of details.responseHeaders) {
+ if (header.name == "set-cookie") {
+ shim.GoogleNidCookieToUse = header.value;
+ return { redirectUrl: details.url };
+ }
+ }
+ }
+ }
+
+ return undefined;
+ }
+
+ async _onBeforeSendHeaders(details) {
+ await this._haveCheckedEnabledPref;
+
+ const { frameId, requestHeaders, tabId } = details;
+
+ if (!this.enabled) {
+ return { requestHeaders };
+ }
+
+ for (const shim of this.shims.values()) {
+ await shim.ready;
+
+ if (!shim.enabled) {
+ continue;
+ }
+
+ if (shim.isGoogleTrendsDFPIFix) {
+ const value = shim.GoogleNidCookieToUse;
+
+ if (!value) {
+ continue;
+ }
+
+ let found;
+ for (let header of requestHeaders) {
+ if (header.name.toLowerCase() === "cookie") {
+ header.value = value;
+ found = true;
+ }
+ }
+ if (!found) {
+ requestHeaders.push({ name: "Cookie", value });
+ }
+
+ browser.tabs
+ .get(tabId)
+ .then(({ url }) => {
+ debug(
+ `Google Trends dFPI fix used on tab ${tabId} frame ${frameId} (${url})`
+ );
+ })
+ .catch(() => {});
+
+ const warning = `Working around Google Trends tracking protection breakage. See https://bugzilla.mozilla.org/show_bug.cgi?id=${shim.bug} for details.`;
+ browser.tabs
+ .executeScript(tabId, {
+ code: `console.warn(${JSON.stringify(warning)})`,
+ runAt: "document_start",
+ })
+ .catch(() => {});
+ }
+ }
+
+ return { requestHeaders };
+ }
+
+ async _ensureShimForRequestOnTab(details) {
+ await this._haveCheckedEnabledPref;
+
+ if (!this.enabled) {
+ return undefined;
+ }
+
+ // We only ever reach this point if a request is for a URL which ought to
+ // be shimmed. We never get here if a request is blocked, and we only
+ // unblock requests if at least one shim matches it.
+
+ const { frameId, originUrl, requestId, tabId, type, url } = details;
+
+ // Ignore requests unrelated to tabs
+ if (tabId < 0) {
+ return undefined;
+ }
+
+ // We need to base our checks not on the frame's host, but the tab's.
+ const topHost = new URL((await browser.tabs.get(tabId)).url).hostname;
+ const unblocked = await browser.trackingProtection.wasRequestUnblocked(
+ requestId
+ );
+
+ let match;
+ let shimToApply;
+ for (const shim of this.shims.values()) {
+ await shim.ready;
+
+ if (!shim.enabled || (!shim.redirectsRequests && !shim.runFirst)) {
+ continue;
+ }
+
+ if (shim.onlyIfDFPIActive || shim.onlyIfPrivateBrowsing) {
+ const isPB = (await browser.tabs.get(details.tabId)).incognito;
+ if (!isPB && shim.onlyIfPrivateBrowsing) {
+ continue;
+ }
+ if (
+ shim.onlyIfDFPIActive &&
+ !(await browser.trackingProtection.isDFPIActive(isPB))
+ ) {
+ continue;
+ }
+ }
+
+ // Do not apply the shim if it is only meant to apply when strict mode ETP
+ // (content blocking) was going to block the request.
+ if (!unblocked && shim.onlyIfBlockedByETP) {
+ continue;
+ }
+
+ if (!shim.meantForHost(topHost)) {
+ continue;
+ }
+
+ // If this URL and content type isn't meant for this shim, don't apply it.
+ match = shim.isTriggeredByURLAndType(url, type);
+ if (match) {
+ if (!unblocked && match.onlyIfBlockedByETP) {
+ continue;
+ }
+
+ // If the user has already opted in for this shim, all requests it covers
+ // should be allowed; no need for a shim anymore.
+ if (shim.hasUserOptedInAlready(topHost)) {
+ warn(
+ `Allowing tracking ${type} ${url} on tab ${tabId} frame ${frameId} due to opt-in`
+ );
+ shim.showOptInWarningOnce(tabId, new URL(originUrl).origin);
+ return undefined;
+ }
+ shimToApply = shim;
+ break;
+ }
+ }
+
+ let runFirst = false;
+
+ if (shimToApply) {
+ // Note that sites may request the same shim twice, but because the requests
+ // may differ enough for some to fail (CSP/CORS/etc), we always let the request
+ // complete via local redirect. Shims should gracefully handle this as well.
+
+ const { target } = match;
+ const { bug, file, id, name, needsShimHelpers } = shimToApply;
+ runFirst = shimToApply.runFirst;
+
+ const redirect = target || file;
+
+ warn(
+ `Shimming tracking ${type} ${url} on tab ${tabId} frame ${frameId} with ${
+ redirect || runFirst
+ }`
+ );
+
+ const warning = `${name} is being shimmed by Firefox. See https://bugzilla.mozilla.org/show_bug.cgi?id=${bug} for details.`;
+
+ let needConsoleMessage = true;
+
+ if (runFirst) {
+ try {
+ await browser.tabs.executeScript(tabId, {
+ file: `/shims/${runFirst}`,
+ frameId,
+ runAt: "document_start",
+ });
+ } catch (_) {}
+ }
+
+ // For scripts, we also set up any needed shim helpers.
+ if (type === "script" && needsShimHelpers?.length) {
+ try {
+ await browser.tabs.executeScript(tabId, {
+ file: "/lib/shim_messaging_helper.js",
+ frameId,
+ runAt: "document_start",
+ });
+ const origin = new URL(originUrl).origin;
+ await browser.tabs.sendMessage(
+ tabId,
+ { origin, shimId: id, needsShimHelpers, warning },
+ { frameId }
+ );
+ needConsoleMessage = false;
+ shimToApply.setActiveOnTab(tabId);
+ } catch (_) {}
+ }
+
+ if (needConsoleMessage) {
+ try {
+ await browser.tabs.executeScript(tabId, {
+ code: `console.warn(${JSON.stringify(warning)})`,
+ runAt: "document_start",
+ });
+ } catch (_) {}
+ }
+
+ if (!redirect.indexOf("http://") || !redirect.indexOf("https://")) {
+ return { redirectUrl: redirect };
+ }
+
+ // If any shims matched the request to replace it, then redirect to the local
+ // file bundled with SmartBlock, so the request never hits the network.
+ return { redirectUrl: browser.runtime.getURL(`shims/${redirect}`) };
+ }
+
+ // Sanity check: if no shims end up handling this request,
+ // yet it was meant to be blocked by ETP, then block it now.
+ if (unblocked) {
+ error(`unexpected: ${url} not shimmed on tab ${tabId} frame ${frameId}`);
+ return { cancel: true };
+ }
+
+ if (!runFirst) {
+ debug(`ignoring ${url} on tab ${tabId} frame ${frameId}`);
+ }
+ return undefined;
+ }
+}
+
+module.exports = Shims;
diff --git a/browser/extensions/webcompat/lib/ua_helpers.js b/browser/extensions/webcompat/lib/ua_helpers.js
new file mode 100644
index 0000000000..e2ab29c628
--- /dev/null
+++ b/browser/extensions/webcompat/lib/ua_helpers.js
@@ -0,0 +1,79 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* globals exportFunction, module */
+
+var UAHelpers = {
+ _deviceAppropriateChromeUAs: {},
+ getDeviceAppropriateChromeUA(config = {}) {
+ const { version = "103.0.5060.71", androidDevice, desktopOS } = config;
+ const key = `${version}:${androidDevice}:${desktopOS}`;
+ if (!UAHelpers._deviceAppropriateChromeUAs[key]) {
+ const userAgent =
+ typeof navigator !== "undefined" ? navigator.userAgent : "";
+ const RunningFirefoxVersion = (userAgent.match(/Firefox\/([0-9.]+)/) || [
+ "",
+ "58.0",
+ ])[1];
+
+ if (userAgent.includes("Android")) {
+ const RunningAndroidVersion =
+ userAgent.match(/Android [0-9.]+/) || "Android 6.0";
+ if (androidDevice) {
+ UAHelpers._deviceAppropriateChromeUAs[
+ key
+ ] = `Mozilla/5.0 (Linux; ${RunningAndroidVersion}; ${androidDevice}) FxQuantum/${RunningFirefoxVersion} AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${version} Mobile Safari/537.36`;
+ } else {
+ const ChromePhoneUA = `Mozilla/5.0 (Linux; ${RunningAndroidVersion}; Nexus 5 Build/MRA58N) FxQuantum/${RunningFirefoxVersion} AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${version} Mobile Safari/537.36`;
+ const ChromeTabletUA = `Mozilla/5.0 (Linux; ${RunningAndroidVersion}; Nexus 7 Build/JSS15Q) FxQuantum/${RunningFirefoxVersion} AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${version} Safari/537.36`;
+ const IsPhone = userAgent.includes("Mobile");
+ UAHelpers._deviceAppropriateChromeUAs[key] = IsPhone
+ ? ChromePhoneUA
+ : ChromeTabletUA;
+ }
+ } else {
+ let osSegment = "Windows NT 10.0; Win64; x64";
+ if (desktopOS === "macOS" || userAgent.includes("Macintosh")) {
+ osSegment = "Macintosh; Intel Mac OS X 10_15_7";
+ }
+ if (
+ desktopOS !== "nonLinux" &&
+ (desktopOS === "linux" || userAgent.includes("Linux"))
+ ) {
+ osSegment = "X11; Ubuntu; Linux x86_64";
+ }
+
+ UAHelpers._deviceAppropriateChromeUAs[
+ key
+ ] = `Mozilla/5.0 (${osSegment}) FxQuantum/${RunningFirefoxVersion} AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${version} Safari/537.36`;
+ }
+ }
+ return UAHelpers._deviceAppropriateChromeUAs[key];
+ },
+ getPrefix(originalUA) {
+ return originalUA.substr(0, originalUA.indexOf(")") + 1);
+ },
+ overrideWithDeviceAppropriateChromeUA(config) {
+ const chromeUA = UAHelpers.getDeviceAppropriateChromeUA(config);
+ Object.defineProperty(window.navigator.wrappedJSObject, "userAgent", {
+ get: exportFunction(() => chromeUA, window),
+ set: exportFunction(function () {}, window),
+ });
+ },
+ capVersionTo99(originalUA) {
+ const ver = originalUA.match(/Firefox\/(\d+\.\d+)/);
+ if (!ver || parseFloat(ver[1]) < 100) {
+ return originalUA;
+ }
+ return originalUA
+ .replace(`Firefox/${ver[1]}`, "Firefox/99.0")
+ .replace(`rv:${ver[1]}`, "rv:99.0");
+ },
+};
+
+if (typeof module !== "undefined") {
+ module.exports = UAHelpers;
+}
diff --git a/browser/extensions/webcompat/lib/ua_overrides.js b/browser/extensions/webcompat/lib/ua_overrides.js
new file mode 100644
index 0000000000..2426293f3f
--- /dev/null
+++ b/browser/extensions/webcompat/lib/ua_overrides.js
@@ -0,0 +1,210 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* globals browser, module */
+
+class UAOverrides {
+ constructor(availableOverrides) {
+ this.OVERRIDE_PREF = "perform_ua_overrides";
+
+ this._overridesEnabled = true;
+
+ this._availableOverrides = availableOverrides;
+ this._activeListeners = new Map();
+ }
+
+ bindAboutCompatBroker(broker) {
+ this._aboutCompatBroker = broker;
+ }
+
+ bootup() {
+ browser.aboutConfigPrefs.onPrefChange.addListener(() => {
+ this.checkOverridePref();
+ }, this.OVERRIDE_PREF);
+ this.checkOverridePref();
+ }
+
+ checkOverridePref() {
+ browser.aboutConfigPrefs.getPref(this.OVERRIDE_PREF).then(value => {
+ if (value === undefined) {
+ browser.aboutConfigPrefs.setPref(this.OVERRIDE_PREF, true);
+ } else if (value === false) {
+ this.unregisterUAOverrides();
+ } else {
+ this.registerUAOverrides();
+ }
+ });
+ }
+
+ getAvailableOverrides() {
+ return this._availableOverrides;
+ }
+
+ isEnabled() {
+ return this._overridesEnabled;
+ }
+
+ enableOverride(override) {
+ if (override.active) {
+ return;
+ }
+
+ const { blocks, matches, uaTransformer } = override.config;
+ const listener = details => {
+ // Don't actually override the UA for an experiment if the user is not
+ // part of the experiment (unless they force-enabed the override).
+ if (
+ !override.config.experiment ||
+ override.permanentPrefEnabled === true
+ ) {
+ for (const header of details.requestHeaders) {
+ if (header.name.toLowerCase() === "user-agent") {
+ // Don't override the UA if we're on a mobile device that has the
+ // "Request Desktop Site" mode enabled. The UA for the desktop mode
+ // is set inside Gecko with a simple string replace, so we can use
+ // that as a check, see https://searchfox.org/mozilla-central/rev/89d33e1c3b0a57a9377b4815c2f4b58d933b7c32/mobile/android/chrome/geckoview/GeckoViewSettingsChild.js#23-28
+ let isMobileWithDesktopMode =
+ override.currentPlatform == "android" &&
+ header.value.includes("X11; Linux x86_64");
+
+ if (!isMobileWithDesktopMode) {
+ header.value = uaTransformer(header.value);
+ }
+ }
+ }
+ }
+ return { requestHeaders: details.requestHeaders };
+ };
+
+ browser.webRequest.onBeforeSendHeaders.addListener(
+ listener,
+ { urls: matches },
+ ["blocking", "requestHeaders"]
+ );
+
+ const listeners = { onBeforeSendHeaders: listener };
+ if (blocks) {
+ const blistener = details => {
+ return { cancel: true };
+ };
+
+ browser.webRequest.onBeforeRequest.addListener(
+ blistener,
+ { urls: blocks },
+ ["blocking"]
+ );
+
+ listeners.onBeforeRequest = blistener;
+ }
+ this._activeListeners.set(override, listeners);
+ override.active = true;
+ }
+
+ onOverrideConfigChanged(override) {
+ // Check whether the override should be hidden from about:compat.
+ override.hidden = override.config.hidden;
+
+ // Setting the override's permanent pref overrules whether it is hidden.
+ if (override.permanentPrefEnabled !== undefined) {
+ override.hidden = !override.permanentPrefEnabled;
+ }
+
+ // Also check whether the override should be active.
+ let shouldBeActive = true;
+
+ // Overrides can be force-deactivated by their permanent preference.
+ if (override.permanentPrefEnabled === false) {
+ shouldBeActive = false;
+ }
+
+ // Overrides gated behind an experiment the user is not part of do not
+ // have to be activated, unless they are gathering telemetry, or the
+ // user has force-enabled them with their permanent pref.
+ if (override.config.experiment && override.permanentPrefEnabled !== true) {
+ shouldBeActive = false;
+ }
+
+ if (shouldBeActive) {
+ this.enableOverride(override);
+ } else {
+ this.disableOverride(override);
+ }
+
+ if (this._overridesEnabled) {
+ this._aboutCompatBroker.portsToAboutCompatTabs.broadcast({
+ overridesChanged: this._aboutCompatBroker.filterOverrides(
+ this._availableOverrides
+ ),
+ });
+ }
+ }
+
+ async registerUAOverrides() {
+ const platformMatches = ["all"];
+ let platformInfo = await browser.runtime.getPlatformInfo();
+ platformMatches.push(platformInfo.os == "android" ? "android" : "desktop");
+
+ for (const override of this._availableOverrides) {
+ if (platformMatches.includes(override.platform)) {
+ override.availableOnPlatform = true;
+ override.currentPlatform = platformInfo.os;
+
+ // If there is a specific about:config preference governing
+ // this override, monitor its state.
+ const pref = override.config.permanentPref;
+ override.permanentPrefEnabled =
+ pref && (await browser.aboutConfigPrefs.getPref(pref));
+ if (pref) {
+ const checkOverridePref = () => {
+ browser.aboutConfigPrefs.getPref(pref).then(value => {
+ override.permanentPrefEnabled = value;
+ this.onOverrideConfigChanged(override);
+ });
+ };
+ browser.aboutConfigPrefs.onPrefChange.addListener(
+ checkOverridePref,
+ pref
+ );
+ }
+
+ this.onOverrideConfigChanged(override);
+ }
+ }
+
+ this._overridesEnabled = true;
+ this._aboutCompatBroker.portsToAboutCompatTabs.broadcast({
+ overridesChanged: this._aboutCompatBroker.filterOverrides(
+ this._availableOverrides
+ ),
+ });
+ }
+
+ unregisterUAOverrides() {
+ for (const override of this._availableOverrides) {
+ this.disableOverride(override);
+ }
+
+ this._overridesEnabled = false;
+ this._aboutCompatBroker.portsToAboutCompatTabs.broadcast({
+ overridesChanged: false,
+ });
+ }
+
+ disableOverride(override) {
+ if (!override.active) {
+ return;
+ }
+
+ const listeners = this._activeListeners.get(override);
+ for (const [name, listener] of Object.entries(listeners)) {
+ browser.webRequest[name].removeListener(listener);
+ }
+ override.active = false;
+ this._activeListeners.delete(override);
+ }
+}
+
+module.exports = UAOverrides;
diff --git a/browser/extensions/webcompat/manifest.json b/browser/extensions/webcompat/manifest.json
new file mode 100644
index 0000000000..c4a2592f3d
--- /dev/null
+++ b/browser/extensions/webcompat/manifest.json
@@ -0,0 +1,153 @@
+{
+ "manifest_version": 2,
+ "name": "Web Compatibility Interventions",
+ "description": "Urgent post-release fixes for web compatibility.",
+ "version": "115.1.0",
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "webcompat@mozilla.org",
+ "strict_min_version": "102.0"
+ }
+ },
+
+ "experiment_apis": {
+ "aboutConfigPrefs": {
+ "schema": "experiment-apis/aboutConfigPrefs.json",
+ "parent": {
+ "scopes": ["addon_parent"],
+ "script": "experiment-apis/aboutConfigPrefs.js",
+ "paths": [["aboutConfigPrefs"]]
+ }
+ },
+ "appConstants": {
+ "schema": "experiment-apis/appConstants.json",
+ "parent": {
+ "scopes": ["addon_parent"],
+ "script": "experiment-apis/appConstants.js",
+ "paths": [["appConstants"]]
+ }
+ },
+ "aboutPage": {
+ "schema": "about-compat/aboutPage.json",
+ "parent": {
+ "scopes": ["addon_parent"],
+ "script": "about-compat/aboutPage.js",
+ "events": ["startup"]
+ }
+ },
+ "matchPatterns": {
+ "schema": "experiment-apis/matchPatterns.json",
+ "child": {
+ "scopes": ["addon_child"],
+ "script": "experiment-apis/matchPatterns.js",
+ "paths": [["matchPatterns"]]
+ }
+ },
+ "systemManufacturer": {
+ "schema": "experiment-apis/systemManufacturer.json",
+ "child": {
+ "scopes": ["addon_child"],
+ "script": "experiment-apis/systemManufacturer.js",
+ "paths": [["systemManufacturer"]]
+ }
+ },
+ "trackingProtection": {
+ "schema": "experiment-apis/trackingProtection.json",
+ "parent": {
+ "scopes": ["addon_parent"],
+ "script": "experiment-apis/trackingProtection.js",
+ "paths": [["trackingProtection"]]
+ }
+ }
+ },
+
+ "content_security_policy": "script-src 'self' 'sha256-PeZc2H1vv7M8NXqlFyNbN4y4oM6wXmYEbf73m+Aqpak='; default-src 'self'; base-uri moz-extension://*; object-src 'none'",
+
+ "permissions": [
+ "mozillaAddons",
+ "tabs",
+ "webNavigation",
+ "webRequest",
+ "webRequestBlocking",
+ "<all_urls>"
+ ],
+
+ "background": {
+ "scripts": [
+ "lib/module_shim.js",
+ "lib/messaging_helper.js",
+ "lib/intervention_helpers.js",
+ "lib/requestStorageAccess_helper.js",
+ "lib/ua_helpers.js",
+ "data/injections.js",
+ "data/shims.js",
+ "data/ua_overrides.js",
+ "lib/about_compat_broker.js",
+ "lib/custom_functions.js",
+ "lib/injections.js",
+ "lib/shims.js",
+ "lib/ua_overrides.js",
+ "run.js"
+ ]
+ },
+
+ "web_accessible_resources": [
+ "shims/addthis-angular.js",
+ "shims/adform.js",
+ "shims/adnexus-ast.js",
+ "shims/adnexus-prebid.js",
+ "shims/adsafeprotected-ima.js",
+ "shims/apstag.js",
+ "shims/blogger.js",
+ "shims/bloggerAccount.js",
+ "shims/bmauth.js",
+ "shims/branch.js",
+ "shims/chartbeat.js",
+ "shims/crave-ca.js",
+ "shims/criteo.js",
+ "shims/cxense.js",
+ "shims/doubleverify.js",
+ "shims/eluminate.js",
+ "shims/empty-script.js",
+ "shims/empty-shim.txt",
+ "shims/everest.js",
+ "shims/facebook-sdk.js",
+ "shims/facebook.svg",
+ "shims/fastclick.js",
+ "shims/firebase.js",
+ "shims/google-ads.js",
+ "shims/google-analytics-and-tag-manager.js",
+ "shims/google-analytics-ecommerce-plugin.js",
+ "shims/google-analytics-legacy.js",
+ "shims/google-ima.js",
+ "shims/google-page-ad.js",
+ "shims/google-publisher-tags.js",
+ "shims/google-safeframe.html",
+ "shims/history.js",
+ "shims/iam.js",
+ "shims/iaspet.js",
+ "shims/instagram.js",
+ "shims/kinja.js",
+ "shims/live-test-shim.js",
+ "shims/maxmind-geoip.js",
+ "shims/microsoftLogin.js",
+ "shims/microsoftVirtualAssistant.js",
+ "shims/moat.js",
+ "shims/mochitest-shim-1.js",
+ "shims/mochitest-shim-2.js",
+ "shims/mochitest-shim-3.js",
+ "shims/nielsen.js",
+ "shims/optimizely.js",
+ "shims/play.svg",
+ "shims/private-browsing-web-api-fixes.js",
+ "shims/rambler-authenticator.js",
+ "shims/rich-relevance.js",
+ "shims/spotify-embed.js",
+ "shims/tracking-pixel.png",
+ "shims/vast2.xml",
+ "shims/vast3.xml",
+ "shims/vidible.js",
+ "shims/vmad.xml",
+ "shims/webtrends.js"
+ ]
+}
diff --git a/browser/extensions/webcompat/moz.build b/browser/extensions/webcompat/moz.build
new file mode 100644
index 0000000000..0a75a15c90
--- /dev/null
+++ b/browser/extensions/webcompat/moz.build
@@ -0,0 +1,192 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DEFINES["MOZ_APP_VERSION"] = CONFIG["MOZ_APP_VERSION"]
+DEFINES["MOZ_APP_MAXVERSION"] = CONFIG["MOZ_APP_MAXVERSION"]
+
+FINAL_TARGET_FILES.features["webcompat@mozilla.org"] += [
+ "manifest.json",
+ "run.js",
+]
+
+FINAL_TARGET_FILES.features["webcompat@mozilla.org"]["about-compat"] += [
+ "about-compat/aboutCompat.css",
+ "about-compat/aboutCompat.html",
+ "about-compat/aboutCompat.js",
+ "about-compat/AboutCompat.jsm",
+ "about-compat/aboutPage.js",
+ "about-compat/aboutPage.json",
+ "about-compat/aboutPageProcessScript.js",
+]
+
+FINAL_TARGET_FILES.features["webcompat@mozilla.org"]["data"] += [
+ "data/injections.js",
+ "data/shims.js",
+ "data/ua_overrides.js",
+]
+
+FINAL_TARGET_FILES.features["webcompat@mozilla.org"]["experiment-apis"] += [
+ "experiment-apis/aboutConfigPrefs.js",
+ "experiment-apis/aboutConfigPrefs.json",
+ "experiment-apis/appConstants.js",
+ "experiment-apis/appConstants.json",
+ "experiment-apis/matchPatterns.js",
+ "experiment-apis/matchPatterns.json",
+ "experiment-apis/systemManufacturer.js",
+ "experiment-apis/systemManufacturer.json",
+ "experiment-apis/trackingProtection.js",
+ "experiment-apis/trackingProtection.json",
+]
+
+FINAL_TARGET_FILES.features["webcompat@mozilla.org"]["injections"]["css"] += [
+ "injections/css/bug0000000-testbed-css-injection.css",
+ "injections/css/bug1570328-developer-apple.com-transform-scale.css",
+ "injections/css/bug1575000-apply.lloydsbank.co.uk-radio-buttons-fix.css",
+ "injections/css/bug1605611-maps.google.com-directions-time.css",
+ "injections/css/bug1610344-directv.com.co-hide-unsupported-message.css",
+ "injections/css/bug1644830-missingmail.usps.com-checkboxes-not-visible.css",
+ "injections/css/bug1651917-teletrader.com.body-transform-origin.css",
+ "injections/css/bug1653075-livescience.com-scrollbar-width.css",
+ "injections/css/bug1654877-preev.com-moz-appearance-fix.css",
+ "injections/css/bug1654907-reactine.ca-hide-unsupported.css",
+ "injections/css/bug1694470-myvidster.com-content-not-shown.css",
+ "injections/css/bug1707795-office365-sheets-overscroll-disable.css",
+ "injections/css/bug1712833-buskocchi.desuca.co.jp-fix-map-height.css",
+ "injections/css/bug1741234-patient.alphalabs.ca-height-fix.css",
+ "injections/css/bug1765947-veniceincoming.com-left-fix.css",
+ "injections/css/bug1770962-coldwellbankerhomes.com-image-height.css",
+ "injections/css/bug1774490-rainews.it-gallery-fix.css",
+ "injections/css/bug1784141-aveeno.com-acuvue.com-unsupported.css",
+ "injections/css/bug1784199-entrata-platform-unsupported.css",
+ "injections/css/bug1799994-www.vivobarefoot.com-product-filters-fix.css",
+ "injections/css/bug1800000-www.honda.co.uk-choose-dealer-button-fix.css",
+ "injections/css/bug1819678-nppes.cms.hhs.gov-unsupported-banner.css",
+ "injections/css/bug1829949-tomshardware.com-scrollbar-width.css",
+ "injections/css/bug1829952-eventer.co.il-button-height.css",
+ "injections/css/bug1830747-babbel.com-page-height.css",
+ "injections/css/bug1830752-afisha.ru-slider-pointer-events.css",
+ "injections/css/bug1830761-91mobiles.com-content-height.css",
+ "injections/css/bug1830796-copyleaks.com-hide-unsupported.css",
+ "injections/css/bug1830810-interceramic.com-hide-unsupported.css",
+ "injections/css/bug1830813-page.onstove.com-hide-unsupported.css",
+ "injections/css/bug1836103-autostar-novoross.ru-make-map-taller.css",
+ "injections/css/bug1836105-cnn.com-fix-blank-pages-when-printing.css",
+ "injections/css/bug1836177-clalit.co.il-hide-number-input-spinners.css",
+]
+
+FINAL_TARGET_FILES.features["webcompat@mozilla.org"]["injections"]["js"] += [
+ "injections/js/bug0000000-testbed-js-injection.js",
+ "injections/js/bug1448747-fastclick-shim.js",
+ "injections/js/bug1452707-window.controllers-shim-ib.absa.co.za.js",
+ "injections/js/bug1457335-histography.io-ua-change.js",
+ "injections/js/bug1472075-bankofamerica.com-ua-change.js",
+ "injections/js/bug1579159-m.tailieu.vn-pdfjs-worker-disable.js",
+ "injections/js/bug1605611-maps.google.com-directions-time.js",
+ "injections/js/bug1631811-datastudio.google.com-indexedDB.js",
+ "injections/js/bug1722955-frontgate.com-ua-override.js",
+ "injections/js/bug1724764-window-print.js",
+ "injections/js/bug1724868-news.yahoo.co.jp-ua-override.js",
+ "injections/js/bug1731825-office365-email-handling-prompt-autohide.js",
+ "injections/js/bug1739489-draftjs-beforeinput.js",
+ "injections/js/bug1769762-tiktok.com-plugins-shim.js",
+ "injections/js/bug1774005-installtrigger-shim.js",
+ "injections/js/bug1784302-effectiveType-shim.js",
+ "injections/js/bug1795490-www.china-airlines.com-undisable-date-fields-on-mobile.js",
+ "injections/js/bug1799968-www.samsung.com-appVersion-linux-fix.js",
+ "injections/js/bug1799980-healow.com-infinite-loop-fix.js",
+ "injections/js/bug1818818-fastclick-legacy-shim.js",
+ "injections/js/bug1819450-cmbchina.com-ua-change.js",
+ "injections/js/bug1819476-axisbank.com-webkitSpeechRecognition-shim.js",
+ "injections/js/bug1819678-cnki.net-undisable-search-field.js",
+ "injections/js/bug1819678-free4talk.com-window-chrome-shim.js",
+ "injections/js/bug1830776-blueshieldca.com-unsupported.js",
+ "injections/js/bug1831007-nintendo-window-OnetrustActiveGroups.js",
+ "injections/js/bug1836157-thai-masszazs-niceScroll-disable.js",
+ "injections/js/bug1842437-www.youtube.com-performance-now-precision.js",
+]
+
+FINAL_TARGET_FILES.features["webcompat@mozilla.org"]["shims"] += [
+ "shims/addthis-angular.js",
+ "shims/adform.js",
+ "shims/adnexus-ast.js",
+ "shims/adnexus-prebid.js",
+ "shims/adsafeprotected-ima.js",
+ "shims/apstag.js",
+ "shims/blogger.js",
+ "shims/bloggerAccount.js",
+ "shims/bmauth.js",
+ "shims/branch.js",
+ "shims/chartbeat.js",
+ "shims/crave-ca.js",
+ "shims/criteo.js",
+ "shims/cxense.js",
+ "shims/doubleverify.js",
+ "shims/eluminate.js",
+ "shims/empty-script.js",
+ "shims/empty-shim.txt",
+ "shims/everest.js",
+ "shims/facebook-sdk.js",
+ "shims/facebook.svg",
+ "shims/fastclick.js",
+ "shims/firebase.js",
+ "shims/google-ads.js",
+ "shims/google-analytics-and-tag-manager.js",
+ "shims/google-analytics-ecommerce-plugin.js",
+ "shims/google-analytics-legacy.js",
+ "shims/google-ima.js",
+ "shims/google-page-ad.js",
+ "shims/google-publisher-tags.js",
+ "shims/google-safeframe.html",
+ "shims/history.js",
+ "shims/iam.js",
+ "shims/iaspet.js",
+ "shims/instagram.js",
+ "shims/kinja.js",
+ "shims/live-test-shim.js",
+ "shims/maxmind-geoip.js",
+ "shims/microsoftLogin.js",
+ "shims/microsoftVirtualAssistant.js",
+ "shims/moat.js",
+ "shims/mochitest-shim-1.js",
+ "shims/mochitest-shim-2.js",
+ "shims/mochitest-shim-3.js",
+ "shims/nielsen.js",
+ "shims/optimizely.js",
+ "shims/play.svg",
+ "shims/private-browsing-web-api-fixes.js",
+ "shims/rambler-authenticator.js",
+ "shims/rich-relevance.js",
+ "shims/spotify-embed.js",
+ "shims/tracking-pixel.png",
+ "shims/vast2.xml",
+ "shims/vast3.xml",
+ "shims/vidible.js",
+ "shims/vmad.xml",
+ "shims/webtrends.js",
+]
+
+FINAL_TARGET_FILES.features["webcompat@mozilla.org"]["lib"] += [
+ "lib/about_compat_broker.js",
+ "lib/custom_functions.js",
+ "lib/injections.js",
+ "lib/intervention_helpers.js",
+ "lib/messaging_helper.js",
+ "lib/module_shim.js",
+ "lib/requestStorageAccess_helper.js",
+ "lib/shim_messaging_helper.js",
+ "lib/shims.js",
+ "lib/ua_helpers.js",
+ "lib/ua_overrides.js",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
+
+BROWSER_CHROME_MANIFESTS += ["tests/browser/browser.ini"]
+
+with Files("**"):
+ BUG_COMPONENT = ("Web Compatibility", "Tooling & Investigations")
diff --git a/browser/extensions/webcompat/run.js b/browser/extensions/webcompat/run.js
new file mode 100644
index 0000000000..5822dbb2ca
--- /dev/null
+++ b/browser/extensions/webcompat/run.js
@@ -0,0 +1,45 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* globals AboutCompatBroker, AVAILABLE_INJECTIONS, AVAILABLE_SHIMS,
+ AVAILABLE_PIP_OVERRIDES, AVAILABLE_UA_OVERRIDES, CUSTOM_FUNCTIONS,
+ Injections, Shims, UAOverrides */
+
+let injections, shims, uaOverrides;
+
+try {
+ injections = new Injections(AVAILABLE_INJECTIONS, CUSTOM_FUNCTIONS);
+ injections.bootup();
+} catch (e) {
+ console.error("Injections failed to start", e);
+ injections = undefined;
+}
+
+try {
+ uaOverrides = new UAOverrides(AVAILABLE_UA_OVERRIDES);
+ uaOverrides.bootup();
+} catch (e) {
+ console.error("UA overrides failed to start", e);
+ uaOverrides = undefined;
+}
+
+try {
+ shims = new Shims(AVAILABLE_SHIMS);
+} catch (e) {
+ console.error("Shims failed to start", e);
+ shims = undefined;
+}
+
+try {
+ const aboutCompatBroker = new AboutCompatBroker({
+ injections,
+ shims,
+ uaOverrides,
+ });
+ aboutCompatBroker.bootup();
+} catch (e) {
+ console.error("about:compat broker failed to start", e);
+}
diff --git a/browser/extensions/webcompat/shims/addthis-angular.js b/browser/extensions/webcompat/shims/addthis-angular.js
new file mode 100644
index 0000000000..0f0cdd5029
--- /dev/null
+++ b/browser/extensions/webcompat/shims/addthis-angular.js
@@ -0,0 +1,16 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1713694 - Shim AddThis Angular module
+ *
+ * Sites using Angular with AddThis can break entirely if the module is
+ * blocked. This shim mitigates that breakage by loading an empty module.
+ */
+
+if (!window.addthisModule) {
+ window.addthisModule = window?.angular?.module("addthis", ["ng"]);
+}
diff --git a/browser/extensions/webcompat/shims/adform.js b/browser/extensions/webcompat/shims/adform.js
new file mode 100644
index 0000000000..d6727d500e
--- /dev/null
+++ b/browser/extensions/webcompat/shims/adform.js
@@ -0,0 +1,30 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1713695 - Shim Adform tracking
+ *
+ * Sites such as m.tim.it may gate content behind AdForm's trackpoint,
+ * breaking download links and such if blocked. This shim stubs out the
+ * script and its related tracking pixel, so the content still works.
+ */
+
+if (!window.Adform) {
+ window.Adform = {
+ Opt: {
+ disableRedirect() {},
+ getStatus(clientID, callback) {
+ callback({
+ clientID,
+ errorMessage: undefined,
+ optIn() {},
+ optOut() {},
+ status: "nocookie",
+ });
+ },
+ },
+ };
+}
diff --git a/browser/extensions/webcompat/shims/adnexus-ast.js b/browser/extensions/webcompat/shims/adnexus-ast.js
new file mode 100644
index 0000000000..ae07fa6a03
--- /dev/null
+++ b/browser/extensions/webcompat/shims/adnexus-ast.js
@@ -0,0 +1,210 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1734130 - Shim AdNexus AST
+ *
+ * Some sites expect AST to successfully load, or they break.
+ * This shim mitigates that breakage.
+ */
+
+if (!window.apntag?.loaded) {
+ const anq = window.apntag?.anq || [];
+
+ const gTags = new Map();
+ const gAds = new Map();
+ const gEventHandlers = {};
+
+ const Ad = class {
+ adType = "banner";
+ auctionId = "-";
+ banner = {
+ width: 1,
+ height: 1,
+ content: "",
+ trackers: {
+ impression_urls: [],
+ video_events: {},
+ },
+ };
+ brandCategoryId = 0;
+ buyerMemberId = 0;
+ cpm = 0.1;
+ cpm_publisher_currency = 0.1;
+ creativeId = 0;
+ dealId = undefined;
+ height = 1;
+ mediaSubtypeId = 1;
+ mediaTypeId = 1;
+ publisher_currency_code = "US";
+ source = "-";
+ tagId = -1;
+ targetId = "";
+ width = 1;
+
+ constructor(tagId, targetId) {
+ this.tagId = tagId;
+ this.targetId = targetId;
+ }
+ };
+
+ const fireAdEvent = (type, adObj) => {
+ const { targetId } = adObj;
+ const handlers = gEventHandlers[type]?.[targetId];
+ if (!handlers) {
+ return Promise.resolve();
+ }
+ const evt = { adObj, type };
+ return new Promise(done => {
+ setTimeout(() => {
+ for (const cb of handlers) {
+ try {
+ cb(evt);
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ done();
+ }, 1);
+ });
+ };
+
+ const refreshTag = targetId => {
+ const tag = gTags.get(targetId);
+ if (!tag) {
+ return;
+ }
+ if (!gAds.has(targetId)) {
+ gAds.set(targetId, new Ad(tag.tagId, targetId));
+ }
+ const adObj = gAds.get(targetId);
+ fireAdEvent("adRequested", adObj).then(() => {
+ // TODO: do some sites expect adAvailable+adLoaded instead of adNoBid?
+ fireAdEvent("adNoBid", adObj);
+ });
+ };
+
+ const off = (type, targetId, cb) => {
+ gEventHandlers[type]?.[targetId]?.delete(cb);
+ };
+
+ const on = (type, targetId, cb) => {
+ gEventHandlers[type] = gEventHandlers[type] || {};
+ gEventHandlers[type][targetId] =
+ gEventHandlers[type][targetId] || new Set();
+ gEventHandlers[type][targetId].add(cb);
+ };
+
+ const Tag = class {
+ static #nextId = 0;
+ debug = undefined;
+ displayed = false;
+ initialHeight = 1;
+ initialWidth = 1;
+ keywords = {};
+ member = 0;
+ showTagCalled = false;
+ sizes = [];
+ targetId = "";
+ utCalled = true;
+ utDivId = "";
+ utiframeId = "";
+ uuid = "";
+
+ constructor(raw) {
+ const { keywords, sizes, targetId } = raw;
+ this.tagId = Tag.#nextId++;
+ this.keywords = keywords || {};
+ this.sizes = sizes || [];
+ this.targetId = targetId || "";
+ }
+ modifyTag() {}
+ off(type, cb) {
+ off(type, this.targetId, cb);
+ }
+ on(type, cb) {
+ on(type, this.targetId, cb);
+ }
+ setKeywords(kw) {
+ this.keywords = kw;
+ }
+ };
+
+ window.apntag = {
+ anq,
+ attachClickTrackers() {},
+ checkAdAvailable() {},
+ clearPageTargeting() {},
+ clearRequest() {},
+ collapseAd() {},
+ debug: false,
+ defineTag(dfn) {
+ const { targetId } = dfn;
+ if (!targetId) {
+ return;
+ }
+ gTags.set(targetId, new Tag(dfn));
+ },
+ disableDebug() {},
+ dongle: undefined,
+ emitEvent(adObj, type) {
+ fireAdEvent(type, adObj);
+ },
+ enableCookieSet() {},
+ enableDebug() {},
+ fireImpressionTrackers() {},
+ getAdMarkup: () => "",
+ getAdWrap() {},
+ getAstVersion: () => "0.49.0",
+ getPageTargeting() {},
+ getTag(targetId) {
+ return gTags.get(targetId);
+ },
+ handleCb() {},
+ handleMediationBid() {},
+ highlightAd() {},
+ loaded: true,
+ loadTags() {
+ for (const tagName of gTags.keys()) {
+ refreshTag(tagName);
+ }
+ },
+ modifyTag() {},
+ notify() {},
+ offEvent(type, target, cb) {
+ off(type, target, cb);
+ },
+ onEvent(type, target, cb) {
+ on(type, target, cb);
+ },
+ recordErrorEvent() {},
+ refresh() {},
+ registerRenderer() {},
+ requests: {},
+ resizeAd() {},
+ setEndpoint() {},
+ setKeywords() {},
+ setPageOpts() {},
+ setPageTargeting() {},
+ setSafeFrameConfig() {},
+ setSizes() {},
+ showTag() {},
+ };
+
+ const push = function (fn) {
+ if (typeof fn === "function") {
+ try {
+ fn();
+ } catch (e) {
+ console.trace(e);
+ }
+ }
+ };
+
+ anq.push = push;
+
+ anq.forEach(push);
+}
diff --git a/browser/extensions/webcompat/shims/adnexus-prebid.js b/browser/extensions/webcompat/shims/adnexus-prebid.js
new file mode 100644
index 0000000000..f0f810f0e9
--- /dev/null
+++ b/browser/extensions/webcompat/shims/adnexus-prebid.js
@@ -0,0 +1,68 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1694401 - Shim Prebid.js
+ *
+ * Some sites rely on prebid.js to place content, perhaps in conjunction with
+ * other services like Google Publisher Tags and Amazon TAM. This shim prevents
+ * site breakage like image galleries breaking as the user browsers them, by
+ * allowing the content placement to succeed.
+ */
+
+if (!window.pbjs?.requestBids) {
+ const que = window.pbjs?.que || [];
+ const cmd = window.pbjs?.cmd || [];
+ const adUnits = window.pbjs?.adUnits || [];
+
+ window.pbjs = {
+ adUnits,
+ addAdUnits(arr) {
+ if (!Array.isArray(arr)) {
+ arr = [arr];
+ }
+ adUnits.push(arr);
+ },
+ cmd,
+ offEvent() {},
+ que,
+ refreshAds() {},
+ removeAdUnit(codes) {
+ if (!Array.isArray(codes)) {
+ codes = [codes];
+ }
+ for (const code of codes) {
+ for (let i = adUnits.length - 1; i >= 0; i--) {
+ if (adUnits[i].code === code) {
+ adUnits.splice(i, 1);
+ }
+ }
+ }
+ },
+ renderAd() {},
+ requestBids(params) {
+ params?.bidsBackHandler?.();
+ },
+ setConfig() {},
+ setTargetingForGPTAsync() {},
+ };
+
+ const push = function (fn) {
+ if (typeof fn === "function") {
+ try {
+ fn();
+ } catch (e) {
+ console.trace(e);
+ }
+ }
+ };
+
+ que.push = push;
+ cmd.push = push;
+
+ que.forEach(push);
+ cmd.forEach(push);
+}
diff --git a/browser/extensions/webcompat/shims/adsafeprotected-ima.js b/browser/extensions/webcompat/shims/adsafeprotected-ima.js
new file mode 100644
index 0000000000..93cd8e1eab
--- /dev/null
+++ b/browser/extensions/webcompat/shims/adsafeprotected-ima.js
@@ -0,0 +1,19 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ *
+ * Sites relying on Ad Safe Protected's adapter for Google IMA may
+ * have broken videos when the script is blocked. This shim stubs
+ * out the API to help mitigate major breakage.
+ */
+
+if (!window.googleImaVansAdapter) {
+ window.googleImaVansAdapter = {
+ init() {},
+ dispose() {},
+ };
+}
diff --git a/browser/extensions/webcompat/shims/apstag.js b/browser/extensions/webcompat/shims/apstag.js
new file mode 100644
index 0000000000..55be05916b
--- /dev/null
+++ b/browser/extensions/webcompat/shims/apstag.js
@@ -0,0 +1,73 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1713698 - Shim Amazon Transparent Ad Marketplace's apstag.js
+ *
+ * Some sites such as politico.com rely on Amazon TAM tracker to serve ads,
+ * breaking functionality like galleries if it is blocked. This shim helps
+ * mitigate major breakage in that case.
+ */
+
+if (!window.apstag?._getSlotIdToNameMapping) {
+ const _Q = window.apstag?._Q || [];
+
+ const newBid = config => {
+ return {
+ amznbid: "",
+ amzniid: "",
+ amznp: "",
+ amznsz: "0x0",
+ size: "0x0",
+ slotID: config.slotID,
+ };
+ };
+
+ window.apstag = {
+ _Q,
+ _getSlotIdToNameMapping() {},
+ bids() {},
+ debug() {},
+ deleteId() {},
+ fetchBids(cfg, cb) {
+ if (!Array.isArray(cfg?.slots)) {
+ return;
+ }
+ setTimeout(() => {
+ cb(cfg.slots.map(s => newBid(s)));
+ }, 1);
+ },
+ init() {},
+ punt() {},
+ renderImp() {},
+ renewId() {},
+ setDisplayBids() {},
+ targetingKeys: () => [],
+ thirdPartyData: {},
+ updateId() {},
+ };
+
+ window.apstagLOADED = true;
+
+ _Q.push = function (prefix, args) {
+ try {
+ switch (prefix) {
+ case "f":
+ window.apstag.fetchBids(...args);
+ break;
+ case "i":
+ window.apstag.init(...args);
+ break;
+ }
+ } catch (e) {
+ console.trace(e);
+ }
+ };
+
+ for (const cmd of _Q) {
+ _Q.push(cmd);
+ }
+}
diff --git a/browser/extensions/webcompat/shims/blogger.js b/browser/extensions/webcompat/shims/blogger.js
new file mode 100644
index 0000000000..a474b3c5e9
--- /dev/null
+++ b/browser/extensions/webcompat/shims/blogger.js
@@ -0,0 +1,39 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals exportFunction */
+
+"use strict";
+
+/**
+ * Blogger powered blogs rely on storage access to https://blogger.com to enable
+ * oauth with Google. For dFPI, sites need to use the Storage Access API to gain
+ * first party storage access. This shim calls requestStorageAccess on behalf of
+ * the site when a user wants to log in via oauth.
+ */
+
+console.warn(
+ `When using oauth, Firefox calls the Storage Access API on behalf of the site. See https://bugzilla.mozilla.org/show_bug.cgi?id=1776869 for details.`
+);
+
+const GOOGLE_OAUTH_PATH_PREFIX = "https://accounts.google.com/ServiceLogin";
+
+// Overwrite the window.open method so we can detect oauth related popups.
+const origOpen = window.wrappedJSObject.open;
+Object.defineProperty(window.wrappedJSObject, "open", {
+ value: exportFunction((url, ...args) => {
+ // Filter oauth popups.
+ if (!url.startsWith(GOOGLE_OAUTH_PATH_PREFIX)) {
+ return origOpen(url, ...args);
+ }
+ // Request storage access for the Blogger iframe.
+ document.requestStorageAccess().then(() => {
+ origOpen(url, ...args);
+ });
+ // We don't have the window object yet which window.open returns, since the
+ // sign-in flow is dependent on the async storage access request. This isn't
+ // a problem as long as the website does not consume it.
+ return null;
+ }, window),
+});
diff --git a/browser/extensions/webcompat/shims/bloggerAccount.js b/browser/extensions/webcompat/shims/bloggerAccount.js
new file mode 100644
index 0000000000..19e80dbfbe
--- /dev/null
+++ b/browser/extensions/webcompat/shims/bloggerAccount.js
@@ -0,0 +1,68 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals exportFunction */
+
+"use strict";
+
+/**
+ * Blogger uses Google as the auth provider. The account panel uses a
+ * third-party iframe of https://ogs.google.com, which requires first-party
+ * storage access to authenticate. This shim calls requestStorageAccess on
+ * behalf of the site when the user opens the account panel.
+ */
+
+console.warn(
+ `When logging in with Google, Firefox calls the Storage Access API on behalf of the site. See https://bugzilla.mozilla.org/show_bug.cgi?id=1777690 for details.`
+);
+
+const STORAGE_ACCESS_ORIGIN = "https://ogs.google.com";
+
+document.documentElement.addEventListener(
+ "click",
+ e => {
+ const { target, isTrusted } = e;
+ if (!isTrusted) {
+ return;
+ }
+
+ const anchorEl = target.closest("a");
+ if (!anchorEl) {
+ return;
+ }
+
+ if (
+ !anchorEl.href.startsWith("https://accounts.google.com/SignOutOptions")
+ ) {
+ return;
+ }
+
+ // The storage access request below runs async so the panel won't open
+ // immediately. Mitigate this UX issue by updating the clicked element's
+ // style so the user gets some immediate feedback.
+ anchorEl.style.opacity = 0.5;
+ e.stopPropagation();
+ e.preventDefault();
+
+ document
+ .requestStorageAccessForOrigin(STORAGE_ACCESS_ORIGIN)
+ .then(() => {
+ // Reload all iframes of ogs.google.com so the first-party cookies are
+ // sent to the server.
+ // The reload mechanism here is a bit of a hack, since we don't have
+ // access to the content window of a cross-origin iframe.
+ document
+ .querySelectorAll("iframe[src^='https://ogs.google.com/']")
+ .forEach(frame => (frame.src += ""));
+ })
+ // Show the panel in both success and error state. When the user denies
+ // the storage access prompt they will see an error message in the account
+ // panel.
+ .finally(() => {
+ anchorEl.style.opacity = 1.0;
+ target.click();
+ });
+ },
+ true
+);
diff --git a/browser/extensions/webcompat/shims/bmauth.js b/browser/extensions/webcompat/shims/bmauth.js
new file mode 100644
index 0000000000..944f2100d6
--- /dev/null
+++ b/browser/extensions/webcompat/shims/bmauth.js
@@ -0,0 +1,21 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+if (!window.BmAuth) {
+ window.BmAuth = {
+ init: () => new Promise(() => {}),
+ handleSignIn: () => {
+ // TODO: handle this properly!
+ },
+ isAuthenticated: () => Promise.resolve(false),
+ addListener: () => {},
+ api: {
+ event: {
+ addListener: () => {},
+ },
+ },
+ };
+}
diff --git a/browser/extensions/webcompat/shims/branch.js b/browser/extensions/webcompat/shims/branch.js
new file mode 100644
index 0000000000..31e8f4eeec
--- /dev/null
+++ b/browser/extensions/webcompat/shims/branch.js
@@ -0,0 +1,84 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1716220 - Shim Branch Web SDK
+ *
+ * Sites such as TataPlay may not load properly if Branch Web SDK is
+ * blocked. This shim stubs out its script so the page still loads.
+ */
+
+if (!window?.branch?.b) {
+ const queue = window?.branch?._q || [];
+ window.branch = new (class {
+ V = {};
+ g = 0;
+ X = "web2.62.0";
+ b = {
+ A: {},
+ clear() {},
+ get() {},
+ getAll() {},
+ isEnabled: () => true,
+ remove() {},
+ set() {},
+ ca() {},
+ g: [],
+ l: 0,
+ o: 0,
+ s: null,
+ };
+ addListener() {}
+ applyCode() {}
+ autoAppIndex() {}
+ banner() {}
+ c() {}
+ closeBanner() {}
+ closeJourney() {}
+ constructor() {}
+ creditHistory() {}
+ credits() {}
+ crossPlatformIds() {}
+ data() {}
+ deepview() {}
+ deepviewCta() {}
+ disableTracking() {}
+ first() {}
+ getBrowserFingerprintId() {}
+ getCode() {}
+ init(key, ...args) {
+ const cb = args.pop();
+ if (typeof cb === "function") {
+ cb(undefined, {});
+ }
+ }
+ lastAttributedTouchData() {}
+ link() {}
+ logEvent() {}
+ logout() {}
+ qrCode() {}
+ redeem() {}
+ referrals() {}
+ removeListener() {}
+ renderFinalize() {}
+ renderQueue() {}
+ sendSMS() {}
+ setAPIResponseCallback() {}
+ setBranchViewData() {}
+ setIdentity() {}
+ track() {}
+ trackCommerceEvent() {}
+ validateCode() {}
+ })();
+ const push = ([fn, ...args]) => {
+ try {
+ window.branch[fn].apply(window.branch, args);
+ } catch (e) {
+ console.error(e);
+ }
+ };
+ queue.forEach(push);
+}
diff --git a/browser/extensions/webcompat/shims/chartbeat.js b/browser/extensions/webcompat/shims/chartbeat.js
new file mode 100644
index 0000000000..0e57fc6da1
--- /dev/null
+++ b/browser/extensions/webcompat/shims/chartbeat.js
@@ -0,0 +1,18 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1713699 - Shim ChartBeat tracking
+ *
+ * Sites may rely on chartbeat's tracking as they might with Google Analytics,
+ * expecting it to be present for interactive site content to function. This
+ * shim mitigates related breakage.
+ */
+
+window.pSUPERFLY = {
+ activity() {},
+ virtualPage() {},
+};
diff --git a/browser/extensions/webcompat/shims/crave-ca.js b/browser/extensions/webcompat/shims/crave-ca.js
new file mode 100644
index 0000000000..b4d93ccdfa
--- /dev/null
+++ b/browser/extensions/webcompat/shims/crave-ca.js
@@ -0,0 +1,56 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/*
+ * Bug 1746439 - crave.ca login broken with dFPI enabled
+ *
+ * Crave.ca relies upon a login page that is out-of-origin. That login page
+ * sets a cookie for https://www.crave.ca, which is then used as an proof of
+ * authentication on redirect back to the main site. This shim adds a request
+ * for storage access for https://www.crave.ca when the user tries to log in.
+ */
+
+console.warn(
+ `When logging in, Firefox calls the Storage Access API on behalf of the site. See https://bugzilla.mozilla.org/show_bug.cgi?id=1746439 for details.`
+);
+
+// Third-party origin we need to request storage access for.
+const STORAGE_ACCESS_ORIGIN = "https://www.crave.ca";
+
+document.documentElement.addEventListener(
+ "click",
+ e => {
+ const { target, isTrusted } = e;
+ if (!isTrusted) {
+ return;
+ }
+ const button = target.closest("button");
+ if (!button) {
+ return;
+ }
+ const form = target.closest(".login-form");
+ if (!form) {
+ return;
+ }
+
+ console.warn(
+ "Calling the Storage Access API on behalf of " + STORAGE_ACCESS_ORIGIN
+ );
+ button.disabled = true;
+ e.stopPropagation();
+ e.preventDefault();
+ document
+ .requestStorageAccessForOrigin(STORAGE_ACCESS_ORIGIN)
+ .then(() => {
+ button.disabled = false;
+ target.click();
+ })
+ .catch(() => {
+ button.disabled = false;
+ });
+ },
+ true
+);
diff --git a/browser/extensions/webcompat/shims/criteo.js b/browser/extensions/webcompat/shims/criteo.js
new file mode 100644
index 0000000000..afdc00b888
--- /dev/null
+++ b/browser/extensions/webcompat/shims/criteo.js
@@ -0,0 +1,64 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1713720 - Shim Criteo
+ *
+ * Sites relying on window.Criteo to be loaded can experience
+ * breakage if it is blocked. Stubbing out the API in a shim can
+ * mitigate this breakage.
+ */
+
+if (window.Criteo?.CallRTA === undefined) {
+ window.Criteo = {
+ CallRTA() {},
+ ComputeStandaloneDFPTargeting() {},
+ DisplayAcceptableAdIfAdblocked() {},
+ DisplayAd() {},
+ GetBids() {},
+ GetBidsForAdUnit() {},
+ Passback: {
+ RequestBids() {},
+ RenderAd() {},
+ },
+ PubTag: {
+ Adapters: {
+ AMP() {},
+ Prebid() {},
+ },
+ Context: {
+ GetIdfs() {},
+ SetIdfs() {},
+ },
+ DirectBidding: {
+ DirectBiddingEvent() {},
+ DirectBiddingSlot() {},
+ DirectBiddingUrlBuilder() {},
+ Size() {},
+ },
+ RTA: {
+ DefaultCrtgContentName: "crtg_content",
+ DefaultCrtgRtaCookieName: "crtg_rta",
+ },
+ },
+ RenderAd() {},
+ RequestBids() {},
+ RequestBidsOnGoogleTagSlots() {},
+ SetCCPAExplicitOptOut() {},
+ SetCeh() {},
+ SetDFPKeyValueTargeting() {},
+ SetLineItemRanges() {},
+ SetPublisherExt() {},
+ SetSlotsExt() {},
+ SetTargeting() {},
+ SetUserExt() {},
+ events: {
+ push() {},
+ },
+ passbackEvents: [],
+ usePrebidEvents: true,
+ };
+}
diff --git a/browser/extensions/webcompat/shims/cxense.js b/browser/extensions/webcompat/shims/cxense.js
new file mode 100644
index 0000000000..55862f4fb5
--- /dev/null
+++ b/browser/extensions/webcompat/shims/cxense.js
@@ -0,0 +1,593 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1713721 - Shim Cxense
+ *
+ * Sites relying on window.cX can experience breakage if it is blocked.
+ * Stubbing out the API in a shim can mitigate this breakage. There are
+ * two versions of the API, one including window.cX.CCE, but both appear
+ * to be very similar so we use one shim for both.
+ */
+
+if (window.cX?.getUserSegmentIds === undefined) {
+ const callQueue = window.cX?.callQueue || [];
+ const callQueueCCE = window.cX?.CCE?.callQueue || [];
+
+ function getRandomString(l = 16) {
+ const v = crypto.getRandomValues(new Uint8Array(l));
+ const s = Array.from(v, c => c.toString(16)).join("");
+ return s.slice(0, l);
+ }
+
+ const call = (cb, ...args) => {
+ if (typeof cb !== "function") {
+ return;
+ }
+ try {
+ cb(...args);
+ } catch (e) {
+ console.error(e);
+ }
+ };
+
+ const invokeOn = lib => {
+ return (fn, ...args) => {
+ try {
+ lib[fn](...args);
+ } catch (e) {
+ console.error(e);
+ }
+ };
+ };
+
+ const userId = getRandomString();
+ const cxUserId = `cx:${getRandomString(25)}:${getRandomString(12)}`;
+ const topLeft = { left: 0, top: 0 };
+ const margins = { left: 0, top: 0, right: 0, bottom: 0 };
+ const ccePushUrl =
+ "https://comcluster.cxense.com/cce/push?callback={{callback}}";
+ const displayWidget = (divId, a, ctx, callback) => call(callback, ctx, divId);
+ const getUserSegmentIds = a => call(a?.callback, a?.defaultValue || []);
+ const init = (a, b, c, d, callback) => call(callback);
+ const render = (a, data, ctx, callback) => call(callback, data, ctx);
+ const run = (params, ctx, callback) => call(callback, params, ctx);
+ const runCtrlVersion = (a, b, callback) => call(callback);
+ const runCxVersion = (a, data, b, ctx, callback) => call(callback, data, ctx);
+ const runTest = (a, divId, b, c, ctx, callback) => call(callback, divId, ctx);
+ const sendConversionEvent = (a, options) => call(options?.callback, {});
+ const sendEvent = (a, b, args) => call(args?.callback, {});
+
+ const getDivId = className => {
+ const e = document.querySelector(`.${className}`);
+ if (e) {
+ return `${className}-01`;
+ }
+ return null;
+ };
+
+ const getDocumentSize = () => {
+ const width = document.body.clientWidth;
+ const height = document.body.clientHeight;
+ return { width, height };
+ };
+
+ const getNowSeconds = () => {
+ return Math.round(new Date().getTime() / 1000);
+ };
+
+ const getPageContext = () => {
+ return {
+ location: location.href,
+ pageViewRandom: "",
+ userId,
+ };
+ };
+
+ const getWindowSize = () => {
+ const width = window.innerWidth;
+ const height = window.innerHeight;
+ return { width, height };
+ };
+
+ const isObject = i => {
+ return typeof i === "object" && i !== null && !Array.isArray(i);
+ };
+
+ const runMulti = widgets => {
+ widgets?.forEach(({ widgetParams, widgetContext, widgetCallback }) => {
+ call(widgetCallback, widgetParams, widgetContext);
+ });
+ };
+
+ let testGroup = -1;
+ let snapPoints = [];
+ const startTime = new Date();
+
+ const library = {
+ addCustomerScript() {},
+ addEventListener() {},
+ addExternalId() {},
+ afterInitializePage() {},
+ allUserConsents() {},
+ backends: {
+ production: {
+ baseAdDeliveryUrl: "http://adserver.cxad.cxense.com/adserver/search",
+ secureBaseAdDeliveryUrl:
+ "https://s-adserver.cxad.cxense.com/adserver/search",
+ },
+ sandbox: {
+ baseAdDeliveryUrl:
+ "http://adserver.sandbox.cxad.cxense.com/adserver/search",
+ secureBaseAdDeliveryUrl:
+ "https://s-adserver.sandbox.cxad.cxense.com/adserver/search",
+ },
+ },
+ calculateAdSpaceSize(adCount, adUnitSize, marginA, marginB) {
+ return adCount * (adUnitSize + marginA + marginB);
+ },
+ cdn: {
+ template: {
+ direct: {
+ http: "http://cdn.cxpublic.com/",
+ https: "https://cdn.cxpublic.com/",
+ },
+ mapped: {
+ http: "http://cdn-templates.cxpublic.com/",
+ https: "https://cdn-templates.cxpublic.com/",
+ },
+ },
+ },
+ cint() {},
+ cleanUpGlobalIds: [],
+ clearBaseUrl: "https://scdn.cxense.com/sclear.html",
+ clearCustomParameters() {},
+ clearIdUrl: "https://scomcluster.cxense.com/public/clearid",
+ clearIds() {},
+ clickTracker: (a, b, callback) => call(callback),
+ clientStorageUrl: "https://clientstorage.cxense.com",
+ combineArgs: () => Object.create(),
+ combineKeywordsIntoArray: () => [],
+ consentClasses: ["pv", "segment", "ad", "recs"],
+ consentClassesV2: ["geo", "device"],
+ cookieSyncRUrl: "csyn-r.cxense.com",
+ createDelegate() {},
+ csdUrls: {
+ domainScriptUrl: "//csd.cxpublic.com/d/",
+ customerScriptUrl: "//csd.cxpublic.com/t/",
+ },
+ cxenseGlobalIdIframeUrl: "https://scdn.cxense.com/sglobal.html",
+ cxenseUserIdUrl: "https://id.cxense.com/public/user/id",
+ decodeUrlEncodedNameValuePairs: () => Object.create(),
+ defaultAdRenderer: () => "",
+ deleteCookie() {},
+ denyWithoutConsent: {
+ addExternalId: "pv",
+ getUserSegmentIds: "segment",
+ insertAdSpace: "ad",
+ insertMultipleAdSpaces: "ad",
+ sendEvent: "pv",
+ sendPageViewEvent: "pv",
+ sync: "ad",
+ },
+ dmpPushUrl: "https://comcluster.cxense.com/dmp/push?callback={{callback}}",
+ emptyWidgetUrl: "https://scdn.cxense.com/empty.html",
+ eventReceiverBaseUrl: "https://scomcluster.cxense.com/Repo/rep.html",
+ eventReceiverBaseUrlGif: "https://scomcluster.cxense.com/Repo/rep.gif",
+ getAllText: () => "",
+ getClientStorageVariable() {},
+ getCookie: () => null,
+ getCxenseUserId: () => cxUserId,
+ getDocumentSize,
+ getElementPosition: () => topLeft,
+ getHashFragment: () => location.hash.substr(1),
+ getLocalStats: () => Object.create(),
+ getNodeValue: n => n.nodeValue,
+ getNowSeconds,
+ getPageContext,
+ getRandomString,
+ getScrollPos: () => topLeft,
+ getSessionId: () => "",
+ getSiteId: () => "",
+ getTimezoneOffset: () => new Date().getTimezoneOffset(),
+ getTopLevelDomain: () => location.hostname,
+ getUserId: () => userId,
+ getUserSegmentIds,
+ getWindowSize,
+ hasConsent: () => true,
+ hasHistory: () => true,
+ hasLocalStorage: () => true,
+ hasPassiveEventListeners: () => true,
+ hasPostMessage: () => true,
+ hasSessionStorage() {},
+ initializePage() {},
+ insertAdSpace() {},
+ insertMultipleAdSpaces() {},
+ insertWidget() {},
+ invoke: invokeOn(library),
+ isAmpIFrame() {},
+ isArray() {},
+ isCompatModeActive() {},
+ isConsentRequired() {},
+ isEdge: () => false,
+ isFirefox: () => true,
+ isIE6Or7: () => false,
+ isObject,
+ isRecsDestination: () => false,
+ isSafari: () => false,
+ isTextNode: n => n?.nodeType === 3,
+ isTopWindow: () => window === top,
+ jsonpRequest: () => false,
+ loadScript() {},
+ m_accountId: "0",
+ m_activityEvents: false,
+ m_activityState: {
+ activeTime: startTime,
+ currScrollLeft: 0,
+ currScrollTop: 0,
+ exitLink: "",
+ hadHIDActivity: false,
+ maxViewLeft: 1,
+ maxViewTop: 1,
+ parentMetrics: undefined,
+ prevActivityTime: startTime + 2,
+ prevScreenX: 0,
+ prevScreenY: 0,
+ prevScrollLeft: 0,
+ prevScrollTop: 0,
+ prevTime: startTime + 1,
+ prevWindowHeight: 1,
+ prevWindowWidth: 1,
+ scrollDepthPercentage: 0,
+ scrollDepthPixels: 0,
+ },
+ m_atfr: null,
+ m_c1xTpWait: 0,
+ m_clientStorage: {
+ iframeEl: null,
+ iframeIsLoaded: false,
+ iframeOrigin: "https://clientstorage.cxense.com",
+ iframePath: "/clientstorage_v2.html",
+ messageContexts: {},
+ messageQueue: [],
+ },
+ m_compatMode: {},
+ m_compatModeActive: false,
+ m_compatPvSent: false,
+ m_consentVersion: 1,
+ m_customParameters: [],
+ m_documentSizeRequestedFromChild: false,
+ m_externalUserIds: [],
+ m_globalIdLoading: {
+ globalIdIFrameEl: null,
+ globalIdIFrameElLoaded: false,
+ },
+ m_isSpaRecsDestination: false,
+ m_knownMessageSources: [],
+ m_p1Complete: false,
+ m_prevLocationHash: "",
+ m_previousPageViewReport: null,
+ m_rawCustomParameters: {},
+ m_rnd: getRandomString(),
+ m_scriptStartTime: startTime,
+ m_siteId: "0",
+ m_spaRecsClickUrl: null,
+ m_thirdPartyIds: true,
+ m_usesConsent: false,
+ m_usesIabConsent: false,
+ m_usesSecureCookies: true,
+ m_usesTcf20Consent: false,
+ m_widgetSpecs: {},
+ Object,
+ onClearIds() {},
+ onFFP1() {},
+ onP1() {},
+ p1BaseUrl: "https://scdn.cxense.com/sp1.html",
+ p1JsUrl: "https://p1cluster.cxense.com/p1.js",
+ parseHashArgs: () => Object.create(),
+ parseMargins: () => margins,
+ parseUrlArgs: () => Object.create(),
+ postMessageToParent() {},
+ publicWidgetDataUrl: "https://api.cxense.com/public/widget/data",
+ removeClientStorageVariable() {},
+ removeEventListener() {},
+ renderContainedImage: () => "<div/>",
+ renderTemplate: () => "<div/>",
+ reportActivity() {},
+ requireActivityEvents() {},
+ requireConsent() {},
+ requireOnlyFirstPartyIds() {},
+ requireSecureCookies() {},
+ requireTcf20() {},
+ sendEvent,
+ sendSpaRecsClick: (a, callback) => call(callback),
+ setAccountId() {},
+ setAllConsentsTo() {},
+ setClientStorageVariable() {},
+ setCompatMode() {},
+ setConsent() {},
+ setCookie() {},
+ setCustomParameters() {},
+ setEventAttributes() {},
+ setGeoPosition() {},
+ setNodeValue() {},
+ setRandomId() {},
+ setRestrictionsToConsentClasses() {},
+ setRetargetingParameters() {},
+ setSiteId() {},
+ setUserProfileParameters() {},
+ setupIabCmp() {},
+ setupTcfApi() {},
+ shouldPollActivity() {},
+ startLocalStats() {},
+ startSessionAnnotation() {},
+ stopAllSessionAnnotations() {},
+ stopSessionAnnotation() {},
+ sync() {},
+ trackAmpIFrame() {},
+ trackElement() {},
+ trim: s => s.trim(),
+ tsridUrl: "https://tsrid.cxense.com/lookup?callback={{callback}}",
+ userSegmentUrl:
+ "https://api.cxense.com/profile/user/segment?callback={{callback}}",
+ };
+
+ const libraryCCE = {
+ "__cx-toolkit__": {
+ isShown: true,
+ data: [],
+ },
+ activeSnapPoint: null,
+ activeWidgets: [],
+ ccePushUrl,
+ clickTracker: () => "",
+ displayResult() {},
+ displayWidget,
+ getDivId,
+ getTestGroup: () => testGroup,
+ init,
+ insertMaster() {},
+ instrumentClickLinks() {},
+ invoke: invokeOn(libraryCCE),
+ noCache: false,
+ offerProductId: null,
+ persistedQueryId: null,
+ prefix: null,
+ previewCampaign: null,
+ previewDiv: null,
+ previewId: null,
+ previewTestId: null,
+ processCxResult() {},
+ render,
+ reportTestImpression() {},
+ run,
+ runCtrlVersion,
+ runCxVersion,
+ runMulti,
+ runTest,
+ sendConversionEvent,
+ sendPageViewEvent: (a, b, c, callback) => call(callback),
+ setSnapPoints(x) {
+ snapPoints = x;
+ },
+ setTestGroup(x) {
+ testGroup = x;
+ },
+ setVisibilityField() {},
+ get snapPoints() {
+ return snapPoints;
+ },
+ startTime,
+ get testGroup() {
+ return testGroup;
+ },
+ testVariant: null,
+ trackTime: 0.5,
+ trackVisibility() {},
+ updateRecsClickUrls() {},
+ utmParams: [],
+ version: "2.42",
+ visibilityField: "timeHalf",
+ };
+
+ const CCE = {
+ activeSnapPoint: null,
+ activeWidgets: [],
+ callQueue: callQueueCCE,
+ ccePushUrl,
+ clickTracker: () => "",
+ displayResult() {},
+ displayWidget,
+ getDivId,
+ getTestGroup: () => testGroup,
+ init,
+ insertMaster() {},
+ instrumentClickLinks() {},
+ invoke: invokeOn(libraryCCE),
+ library: libraryCCE,
+ noCache: false,
+ offerProductId: null,
+ persistedQueryId: null,
+ prefix: null,
+ previewCampaign: null,
+ previewDiv: null,
+ previewId: null,
+ previewTestId: null,
+ processCxResult() {},
+ render,
+ reportTestImpression() {},
+ run,
+ runCtrlVersion,
+ runCxVersion,
+ runMulti,
+ runTest,
+ sendConversionEvent,
+ sendPageViewEvent: (a, b, c, callback) => call(callback),
+ setSnapPoints(x) {
+ snapPoints = x;
+ },
+ setTestGroup(x) {
+ testGroup = x;
+ },
+ setVisibilityField() {},
+ get snapPoints() {
+ return snapPoints;
+ },
+ startTime,
+ get testGroup() {
+ return testGroup;
+ },
+ testVariant: null,
+ trackTime: 0.5,
+ trackVisibility() {},
+ updateRecsClickUrls() {},
+ utmParams: [],
+ version: "2.42",
+ visibilityField: "timeHalf",
+ };
+
+ window.cX = {
+ addCustomerScript() {},
+ addEventListener() {},
+ addExternalId() {},
+ afterInitializePage() {},
+ allUserConsents: () => undefined,
+ Array,
+ calculateAdSpaceSize: () => 0,
+ callQueue,
+ CCE,
+ cint: () => undefined,
+ clearCustomParameters() {},
+ clearIds() {},
+ clickTracker: () => "",
+ combineArgs: () => Object.create(),
+ combineKeywordsIntoArray: () => [],
+ createDelegate() {},
+ decodeUrlEncodedNameValuePairs: () => Object.create(),
+ defaultAdRenderer: () => "",
+ deleteCookie() {},
+ getAllText: () => "",
+ getClientStorageVariable() {},
+ getCookie: () => null,
+ getCxenseUserId: () => cxUserId,
+ getDocumentSize,
+ getElementPosition: () => topLeft,
+ getHashFragment: () => location.hash.substr(1),
+ getLocalStats: () => Object.create(),
+ getNodeValue: n => n.nodeValue,
+ getNowSeconds,
+ getPageContext,
+ getRandomString,
+ getScrollPos: () => topLeft,
+ getSessionId: () => "",
+ getSiteId: () => "",
+ getTimezoneOffset: () => new Date().getTimezoneOffset(),
+ getTopLevelDomain: () => location.hostname,
+ getUserId: () => userId,
+ getUserSegmentIds,
+ getWindowSize,
+ hasConsent: () => true,
+ hasHistory: () => true,
+ hasLocalStorage: () => true,
+ hasPassiveEventListeners: () => true,
+ hasPostMessage: () => true,
+ hasSessionStorage() {},
+ initializePage() {},
+ insertAdSpace() {},
+ insertMultipleAdSpaces() {},
+ insertWidget() {},
+ invoke: invokeOn(library),
+ isAmpIFrame() {},
+ isArray() {},
+ isCompatModeActive() {},
+ isConsentRequired() {},
+ isEdge: () => false,
+ isFirefox: () => true,
+ isIE6Or7: () => false,
+ isObject,
+ isRecsDestination: () => false,
+ isSafari: () => false,
+ isTextNode: n => n?.nodeType === 3,
+ isTopWindow: () => window === top,
+ JSON,
+ jsonpRequest: () => false,
+ library,
+ loadScript() {},
+ Object,
+ onClearIds() {},
+ onFFP1() {},
+ onP1() {},
+ parseHashArgs: () => Object.create(),
+ parseMargins: () => margins,
+ parseUrlArgs: () => Object.create(),
+ postMessageToParent() {},
+ removeClientStorageVariable() {},
+ removeEventListener() {},
+ renderContainedImage: () => "<div/>",
+ renderTemplate: () => "<div/>",
+ reportActivity() {},
+ requireActivityEvents() {},
+ requireConsent() {},
+ requireOnlyFirstPartyIds() {},
+ requireSecureCookies() {},
+ requireTcf20() {},
+ sendEvent,
+ sendPageViewEvent: (a, callback) => call(callback, {}),
+ sendSpaRecsClick() {},
+ setAccountId() {},
+ setAllConsentsTo() {},
+ setClientStorageVariable() {},
+ setCompatMode() {},
+ setConsent() {},
+ setCookie() {},
+ setCustomParameters() {},
+ setEventAttributes() {},
+ setGeoPosition() {},
+ setNodeValue() {},
+ setRandomId() {},
+ setRestrictionsToConsentClasses() {},
+ setRetargetingParameters() {},
+ setSiteId() {},
+ setUserProfileParameters() {},
+ setupIabCmp() {},
+ setupTcfApi() {},
+ shouldPollActivity() {},
+ startLocalStats() {},
+ startSessionAnnotation() {},
+ stopAllSessionAnnotations() {},
+ stopSessionAnnotation() {},
+ sync() {},
+ trackAmpIFrame() {},
+ trackElement() {},
+ trim: s => s.trim(),
+ };
+
+ window.cxTest = window.cX;
+
+ window.cx_pollActiveTime = () => undefined;
+ window.cx_pollActivity = () => undefined;
+ window.cx_pollFragmentMessage = () => undefined;
+
+ const execQueue = (lib, queue) => {
+ return () => {
+ const invoke = invokeOn(lib);
+ setTimeout(() => {
+ queue.push = cmd => {
+ setTimeout(() => invoke(...cmd), 1);
+ };
+ for (const cmd of queue) {
+ invoke(...cmd);
+ }
+ }, 25);
+ };
+ };
+
+ window.cx_callQueueExecute = execQueue(library, callQueue);
+ window.cxCCE_callQueueExecute = execQueue(libraryCCE, callQueueCCE);
+
+ window.cx_callQueueExecute();
+ window.cxCCE_callQueueExecute();
+}
diff --git a/browser/extensions/webcompat/shims/doubleverify.js b/browser/extensions/webcompat/shims/doubleverify.js
new file mode 100644
index 0000000000..7eaf945d77
--- /dev/null
+++ b/browser/extensions/webcompat/shims/doubleverify.js
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1771557 - Shim DoubleVerify analytics
+ *
+ * Some sites such as Sports Illustrated expect DoubleVerify's
+ * analytics script to load, otherwise odd breakage may occur.
+ * This shim helps mitigate such breakage.
+ */
+
+if (!window?.PQ?.loaded) {
+ const cmd = [];
+ cmd.push = function (c) {
+ try {
+ c?.();
+ } catch (_) {}
+ };
+
+ window.apntag = {
+ anq: [],
+ };
+
+ window.PQ = {
+ cmd,
+ loaded: true,
+ getTargeting: (_, cb) => cb?.([]),
+ init: () => {},
+ loadSignals: (_, cb) => cb?.(),
+ loadSignalsForSlots: (_, cb) => cb?.(),
+ PTS: {},
+ };
+}
diff --git a/browser/extensions/webcompat/shims/eluminate.js b/browser/extensions/webcompat/shims/eluminate.js
new file mode 100644
index 0000000000..3fa65c048c
--- /dev/null
+++ b/browser/extensions/webcompat/shims/eluminate.js
@@ -0,0 +1,95 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1606448 - Shim CoreMetrics Eluminate analytics
+ *
+ * Sites may rely on eluminate.js tracking in ways which cause breakage,
+ * which has been seen on shopping sites such as Vans.com, where the
+ * search filtering UX is broken. This shim mitigates such breakage.
+ */
+
+if (!window.CM_DDX) {
+ window.CM_DDX = {
+ domReadyFired: false,
+ headScripts: true,
+ dispatcherLoadRequested: false,
+ firstPassFunctionBinding: false,
+ BAD_PAGE_ID_ELAPSED_TIMEOUT: 5000,
+ version: -1,
+ standalone: false,
+ test: {
+ syndicate: true,
+ testCounter: "",
+ doTest: false,
+ newWin: false,
+ process: () => {},
+ },
+ partner: {},
+ invokeFunctionWhenAvailable: a => {
+ a();
+ },
+ gup: d => "",
+ privacy: {
+ isDoNotTrackEnabled: () => false,
+ setDoNotTrack: () => {},
+ getDoNotTrack: () => false,
+ },
+ setSubCookie: () => {},
+ };
+ const noopfn = () => {};
+ const w = window;
+ w.cmAddShared = noopfn;
+ w.cmCalcSKUString = noopfn;
+ w.cmCreateManualImpressionTag = noopfn;
+ w.cmCreateManualLinkClickTag = noopfn;
+ w.cmCreateManualPageviewTag = noopfn;
+ w.cmCreateOrderTag = noopfn;
+ w.cmCreatePageviewTag = noopfn;
+ w.cmExecuteTagQueue = noopfn;
+ w.cmRetrieveUserID = noopfn;
+ w.cmSetClientID = noopfn;
+ w.cmSetCurrencyCode = noopfn;
+ w.cmSetFirstPartyIDs = noopfn;
+ w.cmSetSubCookie = noopfn;
+ w.cmSetupCookieMigration = noopfn;
+ w.cmSetupNormalization = noopfn;
+ w.cmSetupOther = noopfn;
+ w.cmStartTagSet = noopfn;
+ w.cmCreateConversionEventTag = noopfn;
+ w.cmCreateDefaultPageviewTag = noopfn;
+ w.cmCreateElementTag = noopfn;
+ w.cmCreateManualImpressionTag = noopfn;
+ w.cmCreateManualLinkClickTag = noopfn;
+ w.cmCreateManualPageviewTag = noopfn;
+ w.cmCreatePageElementTag = noopfn;
+ w.cmCreatePageviewTag = noopfn;
+ w.cmCreateProductElementTag = noopfn;
+ w.cmCreateProductviewTag = noopfn;
+ w.cmCreateTechPropsTag = noopfn;
+ w.cmLoadIOConfig = noopfn;
+ w.cmSetClientID = noopfn;
+ w.cmSetCurrencyCode = noopfn;
+ w.cmSetFirstPartyIDs = noopfn;
+ w.cmSetupCookieMigration = noopfn;
+ w.cmSetupNormalization = noopfn;
+
+ w.cmSetupOther = b => {
+ for (const a in b) {
+ window[a] = b[a];
+ }
+ };
+
+ const techProps = {};
+
+ w.coremetrics = {
+ cmLastReferencedPageID: "",
+ cmLoad: noopfn,
+ cmUpdateConfig: noopfn,
+ getTechProps: () => techProps,
+ isDef: c => typeof c !== "undefined" && c,
+ };
+}
diff --git a/browser/extensions/webcompat/shims/empty-script.js b/browser/extensions/webcompat/shims/empty-script.js
new file mode 100644
index 0000000000..d01f2ab537
--- /dev/null
+++ b/browser/extensions/webcompat/shims/empty-script.js
@@ -0,0 +1,5 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* This script is intentionally empty */
diff --git a/browser/extensions/webcompat/shims/empty-shim.txt b/browser/extensions/webcompat/shims/empty-shim.txt
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/browser/extensions/webcompat/shims/empty-shim.txt
diff --git a/browser/extensions/webcompat/shims/everest.js b/browser/extensions/webcompat/shims/everest.js
new file mode 100644
index 0000000000..259ab9033e
--- /dev/null
+++ b/browser/extensions/webcompat/shims/everest.js
@@ -0,0 +1,171 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1728114 - Shim Adobe EverestJS
+ *
+ * Sites assuming EverestJS will load can break if it is blocked.
+ * This shim mitigates that breakage.
+ */
+
+if (!window.__ql) {
+ window.__ql = {};
+}
+
+if (!window.EF) {
+ const AdCloudLocalStorage = {
+ get: (_, cb) => cb(),
+ isInitDone: true,
+ isInitSuccess: true,
+ };
+
+ const emptyObj = {};
+
+ const nullSrc = {
+ getHosts: () => [undefined],
+ getProtocols: () => [undefined],
+ hash: {},
+ hashParamsOrder: [],
+ host: undefined,
+ path: [],
+ port: undefined,
+ query: {},
+ queryDelimiter: "&",
+ queryParamsOrder: [],
+ queryPrefix: "?",
+ queryWithoutEncode: {},
+ respectEmptyQueryParamValue: undefined,
+ scheme: undefined,
+ text: "//",
+ userInfo: undefined,
+ };
+
+ const pixelDetailsEvent = {
+ addToDom() {},
+ canAddToDom: () => false,
+ fire() {},
+ getDomElement() {},
+ initializeUri() {},
+ pixelDetailsReceiver() {},
+ scheme: "https:",
+ uri: nullSrc,
+ userid: 0,
+ };
+
+ window.EF = {
+ AdCloudLocalStorage,
+ accessTopUrl: 0,
+ acquireCookieMatchingSlot() {},
+ addListener() {},
+ addPixelDetailsReadyListener() {},
+ addToDom() {},
+ allow3rdPartyPixels: 1,
+ appData: "",
+ appendDictionary() {},
+ checkGlobalSid() {},
+ checkUrlParams() {},
+ cmHost: "cm.everesttech.net",
+ context: {
+ isFbApp: () => 0,
+ isPageview: () => false,
+ isSegmentation: () => false,
+ isTransaction: () => false,
+ },
+ conversionData: "",
+ cookieMatchingSlots: 1,
+ debug: 0,
+ deserializeUrlParams: () => emptyObj,
+ doCookieMatching() {},
+ ef_itp_ls: false,
+ eventType: "",
+ executeAfterLoad() {},
+ executeOnloadCallbacks() {},
+ expectedTrackingParams: ["ev_cl", "ev_sid"],
+ fbIsApp: 0,
+ fbsCM: 0,
+ fbsPixelId: 0,
+ filterList: () => [],
+ getArrayIndex: -1,
+ getConversionData: () => "",
+ getConversionDataFromLocalStorage: cb => cb(),
+ getDisplayClickUri: () => "",
+ getEpochFromEfUniq: () => 0,
+ getFirstLevelObjectCopy: () => emptyObj,
+ getInvisibleIframeElement() {},
+ getInvisibleImageElement() {},
+ getMacroSubstitutedText: () => "",
+ getPixelDetails: cb => cb({}),
+ getScriptElement() {},
+ getScriptSrc: () => "",
+ getServerParams: () => emptyObj,
+ getSortedAttributes: () => [],
+ getTrackingParams: () => emptyObj,
+ getTransactionParams: () => emptyObj,
+ handleConversionData() {},
+ impressionProperties: "",
+ impressionTypes: ["impression", "impression_served"],
+ inFloodlight: 0,
+ init(config) {
+ try {
+ const { userId } = config;
+ window.EF.userId = userId;
+ pixelDetailsEvent.userId = userId;
+ } catch (_) {}
+ },
+ initializeEFVariables() {},
+ isArray: a => Array.isArray(a),
+ isEmptyDictionary: () => true,
+ isITPEnabled: () => false,
+ isPermanentCookieSet: () => false,
+ isSearchClick: () => 0,
+ isXSSReady() {},
+ jsHost: "www.everestjs.net",
+ jsTagAdded: 0,
+ location: nullSrc,
+ locationHref: nullSrc,
+ locationSkipBang: nullSrc,
+ log() {},
+ main() {},
+ main2() {},
+ newCookieMatchingEvent: () => emptyObj,
+ newFbsCookieMatching: () => emptyObj,
+ newImpression: () => emptyObj,
+ newPageview: () => emptyObj,
+ newPixelDetails: () => emptyObj,
+ newPixelEvent: () => emptyObj,
+ newPixelServerDisplayClickRedirectUri: () => emptyObj,
+ newPixelServerGenericRedirectUri: () => emptyObj,
+ newPixelServerUri: () => emptyObj,
+ newProductSegment: () => emptyObj,
+ newSegmentJavascript: () => emptyObj,
+ newTransaction: () => emptyObj,
+ newUri: () => emptyObj,
+ onloadCallbacks: [],
+ pageViewProperties: "",
+ pageviewProperties: "",
+ pixelDetails: {},
+ pixelDetailsAdded: 1,
+ pixelDetailsEvent,
+ pixelDetailsParams: [],
+ pixelDetailsReadyCallbackFns: [],
+ pixelDetailsRecieverCalled: 1,
+ pixelHost: "pixel.everesttech.net",
+ protocol: document?.location?.protocol || "",
+ referrer: nullSrc,
+ removeListener() {},
+ searchSegment: "",
+ segment: "",
+ serverParamsListener() {},
+ sid: 0,
+ sku: "",
+ throttleCookie: "",
+ trackingJavascriptSrc: nullSrc,
+ transactionObjectList: [],
+ transactionProperties: "",
+ userServerParams: {},
+ userid: 0,
+ };
+}
diff --git a/browser/extensions/webcompat/shims/facebook-sdk.js b/browser/extensions/webcompat/shims/facebook-sdk.js
new file mode 100644
index 0000000000..1e995ff047
--- /dev/null
+++ b/browser/extensions/webcompat/shims/facebook-sdk.js
@@ -0,0 +1,554 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1226498 - Shim Facebook SDK
+ *
+ * This shim provides functionality to enable Facebook's authenticator on third
+ * party sites ("continue/log in with Facebook" buttons). This includes rendering
+ * the button as the SDK would, if sites require it. This way, if users wish to
+ * opt into the Facebook login process regardless of the tracking consequences,
+ * they only need to click the button as usual.
+ *
+ * In addition, the shim also attempts to provide placeholders for Facebook
+ * videos, which users may click to opt into seeing the video (also despite
+ * the increased tracking risks). This is an experimental feature enabled
+ * that is only currently enabled on nightly builds.
+ *
+ * Finally, this shim also stubs out as much of the SDK as possible to prevent
+ * breaking on sites which expect that it will always successfully load.
+ */
+
+if (!window.FB) {
+ const FacebookLogoURL = "https://smartblock.firefox.etp/facebook.svg";
+ const PlayIconURL = "https://smartblock.firefox.etp/play.svg";
+
+ const originalUrl = document.currentScript.src;
+
+ let haveUnshimmed;
+ let initInfo;
+ let activeOnloginAttribute;
+ const placeholdersToRemoveOnUnshim = new Set();
+ const loggedGraphApiCalls = [];
+ const eventHandlers = new Map();
+
+ function getGUID() {
+ const v = crypto.getRandomValues(new Uint8Array(20));
+ return Array.from(v, c => c.toString(16)).join("");
+ }
+
+ const sendMessageToAddon = (function () {
+ const shimId = "FacebookSDK";
+ const pendingMessages = new Map();
+ const channel = new MessageChannel();
+ channel.port1.onerror = console.error;
+ channel.port1.onmessage = event => {
+ const { messageId, response } = event.data;
+ const resolve = pendingMessages.get(messageId);
+ if (resolve) {
+ pendingMessages.delete(messageId);
+ resolve(response);
+ }
+ };
+ function reconnect() {
+ const detail = {
+ pendingMessages: [...pendingMessages.values()],
+ port: channel.port2,
+ shimId,
+ };
+ window.dispatchEvent(new CustomEvent("ShimConnects", { detail }));
+ }
+ window.addEventListener("ShimHelperReady", reconnect);
+ reconnect();
+ return function (message) {
+ const messageId = getGUID();
+ return new Promise(resolve => {
+ const payload = { message, messageId, shimId };
+ pendingMessages.set(messageId, resolve);
+ channel.port1.postMessage(payload);
+ });
+ };
+ })();
+
+ const isNightly = sendMessageToAddon("getOptions").then(opts => {
+ return opts.releaseBranch === "nightly";
+ });
+
+ function makeLoginPlaceholder(target) {
+ // Sites may provide their own login buttons, or rely on the Facebook SDK
+ // to render one for them. For the latter case, we provide placeholders
+ // which try to match the examples and documentation here:
+ // https://developers.facebook.com/docs/facebook-login/web/login-button/
+
+ if (target.textContent || target.hasAttribute("fb-xfbml-state")) {
+ return;
+ }
+ target.setAttribute("fb-xfbml-state", "");
+
+ const size = target.getAttribute("data-size") || "large";
+
+ let font, margin, minWidth, maxWidth, height, iconHeight;
+ if (size === "small") {
+ font = 11;
+ margin = 8;
+ minWidth = maxWidth = 200;
+ height = 20;
+ iconHeight = 12;
+ } else if (size === "medium") {
+ font = 13;
+ margin = 8;
+ minWidth = 200;
+ maxWidth = 320;
+ height = 28;
+ iconHeight = 16;
+ } else {
+ font = 16;
+ minWidth = 240;
+ maxWidth = 400;
+ margin = 12;
+ height = 40;
+ iconHeight = 24;
+ }
+
+ const wattr = target.getAttribute("data-width") || "";
+ const width =
+ wattr === "100%" ? wattr : `${parseFloat(wattr) || minWidth}px`;
+
+ const round = target.getAttribute("data-layout") === "rounded" ? 20 : 4;
+
+ const text =
+ target.getAttribute("data-button-type") === "continue_with"
+ ? "Continue with Facebook"
+ : "Log in with Facebook";
+
+ const button = document.createElement("div");
+ button.style = `
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding-left: ${margin + iconHeight}px;
+ ${width};
+ min-width: ${minWidth}px;
+ max-width: ${maxWidth}px;
+ height: ${height}px;
+ border-radius: ${round}px;
+ -moz-text-size-adjust: none;
+ -moz-user-select: none;
+ color: #fff;
+ font-size: ${font}px;
+ font-weight: bold;
+ font-family: Helvetica, Arial, sans-serif;
+ letter-spacing: .25px;
+ background-color: #1877f2;
+ background-repeat: no-repeat;
+ background-position: ${margin}px 50%;
+ background-size: ${iconHeight}px ${iconHeight}px;
+ background-image: url(${FacebookLogoURL});
+ `;
+ button.textContent = text;
+ target.appendChild(button);
+ target.addEventListener("click", () => {
+ activeOnloginAttribute = target.getAttribute("onlogin");
+ });
+ }
+
+ async function makeVideoPlaceholder(target) {
+ // For videos, we provide a more generic placeholder of roughly the
+ // expected size with a play button, as well as a Facebook logo.
+ if (!(await isNightly) || target.hasAttribute("fb-xfbml-state")) {
+ return;
+ }
+ target.setAttribute("fb-xfbml-state", "");
+
+ let width = parseInt(target.getAttribute("data-width"));
+ let height = parseInt(target.getAttribute("data-height"));
+ if (height) {
+ height = `${width * 0.6}px`;
+ } else {
+ height = `100%; min-height:${width * 0.75}px`;
+ }
+ if (width) {
+ width = `${width}px`;
+ } else {
+ width = `100%; min-width:200px`;
+ }
+
+ const placeholder = document.createElement("div");
+ placeholdersToRemoveOnUnshim.add(placeholder);
+ placeholder.style = `
+ width: ${width};
+ height: ${height};
+ top: 0px;
+ left: 0px;
+ background: #000;
+ color: #fff;
+ text-align: center;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background-image: url(${FacebookLogoURL}), url(${PlayIconURL});
+ background-position: calc(100% - 24px) 24px, 50% 47.5%;
+ background-repeat: no-repeat, no-repeat;
+ background-size: 43px 42px, 25% 25%;
+ -moz-text-size-adjust: none;
+ -moz-user-select: none;
+ color: #fff;
+ align-items: center;
+ padding-top: 200px;
+ font-size: 14pt;
+ `;
+ placeholder.textContent = "Click to allow blocked Facebook content";
+ placeholder.addEventListener("click", evt => {
+ if (!evt.isTrusted) {
+ return;
+ }
+ allowFacebookSDK(() => {
+ placeholdersToRemoveOnUnshim.forEach(p => p.remove());
+ });
+ });
+
+ target.innerHTML = "";
+ target.appendChild(placeholder);
+ }
+
+ // We monitor for XFBML objects as Facebook SDK does, so we
+ // can provide placeholders for dynamically-added ones.
+ const xfbmlObserver = new MutationObserver(mutations => {
+ for (let { addedNodes, target, type } of mutations) {
+ const nodes = type === "attributes" ? [target] : addedNodes;
+ for (const node of nodes) {
+ if (node?.classList?.contains("fb-login-button")) {
+ makeLoginPlaceholder(node);
+ }
+ if (node?.classList?.contains("fb-video")) {
+ makeVideoPlaceholder(node);
+ }
+ }
+ }
+ });
+
+ xfbmlObserver.observe(document.documentElement, {
+ childList: true,
+ subtree: true,
+ attributes: true,
+ attributeFilter: ["class"],
+ });
+
+ const needPopup =
+ !/app_runner/.test(window.name) && !/iframe_canvas/.test(window.name);
+ const popupName = getGUID();
+ let activePopup;
+
+ if (needPopup) {
+ const oldWindowOpen = window.open;
+ window.open = function (href, name, params) {
+ try {
+ const url = new URL(href, window.location.href);
+ if (
+ url.protocol === "https:" &&
+ (url.hostname === "m.facebook.com" ||
+ url.hostname === "www.facebook.com") &&
+ url.pathname.endsWith("/oauth")
+ ) {
+ name = popupName;
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ return oldWindowOpen.call(window, href, name, params);
+ };
+ }
+
+ let allowingFacebookPromise;
+
+ async function allowFacebookSDK(postInitCallback) {
+ if (allowingFacebookPromise) {
+ return allowingFacebookPromise;
+ }
+
+ let resolve, reject;
+ allowingFacebookPromise = new Promise((_resolve, _reject) => {
+ resolve = _resolve;
+ reject = _reject;
+ });
+
+ await sendMessageToAddon("optIn");
+
+ xfbmlObserver.disconnect();
+
+ const shim = window.FB;
+ window.FB = undefined;
+
+ // We need to pass the site's initialization info to the real
+ // SDK as it loads, so we use the fbAsyncInit mechanism to
+ // do so, also ensuring our own post-init callbacks are called.
+ const oldInit = window.fbAsyncInit;
+ window.fbAsyncInit = () => {
+ try {
+ if (typeof initInfo !== "undefined") {
+ window.FB.init(initInfo);
+ } else if (oldInit) {
+ oldInit();
+ }
+ } catch (e) {
+ console.error(e);
+ }
+
+ // Also re-subscribe any SDK event listeners as early as possible.
+ for (const [name, fns] of eventHandlers.entries()) {
+ for (const fn of fns) {
+ window.FB.Event.subscribe(name, fn);
+ }
+ }
+
+ // Allow the shim to do any post-init work early as well, while the
+ // SDK script finishes loading and we ask it to re-parse XFBML etc.
+ postInitCallback?.();
+ };
+
+ const script = document.createElement("script");
+ script.src = originalUrl;
+
+ script.addEventListener("error", () => {
+ allowingFacebookPromise = null;
+ script.remove();
+ activePopup?.close();
+ window.FB = shim;
+ reject();
+ alert("Failed to load Facebook SDK; please try again");
+ });
+
+ script.addEventListener("load", () => {
+ haveUnshimmed = true;
+
+ // After the real SDK has fully loaded we re-issue any Graph API
+ // calls the page is waiting on, as well as requesting for it to
+ // re-parse any XBFML elements (including ones with placeholders).
+
+ for (const args of loggedGraphApiCalls) {
+ try {
+ window.FB.api.apply(window.FB, args);
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ window.FB.XFBML.parse(document.body, resolve);
+ });
+
+ document.head.appendChild(script);
+
+ return allowingFacebookPromise;
+ }
+
+ function buildPopupParams() {
+ // We try to match Facebook's popup size reasonably closely.
+ const { outerWidth, outerHeight, screenX, screenY } = window;
+ const { width, height } = window.screen;
+ const w = Math.min(width, 400);
+ const h = Math.min(height, 400);
+ const ua = navigator.userAgent;
+ const isMobile = ua.includes("Mobile") || ua.includes("Tablet");
+ const left = screenX + (screenX < 0 ? width : 0) + (outerWidth - w) / 2;
+ const top = screenY + (screenY < 0 ? height : 0) + (outerHeight - h) / 2.5;
+ let params = `left=${left},top=${top},width=${w},height=${h},scrollbars=1,toolbar=0,location=1`;
+ if (!isMobile) {
+ params = `${params},width=${w},height=${h}`;
+ }
+ return params;
+ }
+
+ // If a page stores the window.FB reference of the shim, then we
+ // want to have it proxy calls to the real SDK once we've unshimmed.
+ function ensureProxiedToUnshimmed(obj) {
+ const shim = {};
+ for (const key in obj) {
+ const value = obj[key];
+ if (typeof value === "function") {
+ shim[key] = function () {
+ if (haveUnshimmed) {
+ return window.FB[key].apply(window.FB, arguments);
+ }
+ return value.apply(this, arguments);
+ };
+ } else if (typeof value !== "object" || value === null) {
+ shim[key] = value;
+ } else {
+ shim[key] = ensureProxiedToUnshimmed(value);
+ }
+ }
+ return new Proxy(shim, {
+ get: (shimmed, key) => (haveUnshimmed ? window.FB : shimmed)[key],
+ });
+ }
+
+ window.FB = ensureProxiedToUnshimmed({
+ api() {
+ loggedGraphApiCalls.push(arguments);
+ },
+ AppEvents: {
+ activateApp() {},
+ clearAppVersion() {},
+ clearUserID() {},
+ EventNames: {
+ ACHIEVED_LEVEL: "fb_mobile_level_achieved",
+ ADDED_PAYMENT_INFO: "fb_mobile_add_payment_info",
+ ADDED_TO_CART: "fb_mobile_add_to_cart",
+ ADDED_TO_WISHLIST: "fb_mobile_add_to_wishlist",
+ COMPLETED_REGISTRATION: "fb_mobile_complete_registration",
+ COMPLETED_TUTORIAL: "fb_mobile_tutorial_completion",
+ INITIATED_CHECKOUT: "fb_mobile_initiated_checkout",
+ PAGE_VIEW: "fb_page_view",
+ RATED: "fb_mobile_rate",
+ SEARCHED: "fb_mobile_search",
+ SPENT_CREDITS: "fb_mobile_spent_credits",
+ UNLOCKED_ACHIEVEMENT: "fb_mobile_achievement_unlocked",
+ VIEWED_CONTENT: "fb_mobile_content_view",
+ },
+ getAppVersion: () => "",
+ getUserID: () => "",
+ logEvent() {},
+ logPageView() {},
+ logPurchase() {},
+ ParameterNames: {
+ APP_USER_ID: "_app_user_id",
+ APP_VERSION: "_appVersion",
+ CONTENT_ID: "fb_content_id",
+ CONTENT_TYPE: "fb_content_type",
+ CURRENCY: "fb_currency",
+ DESCRIPTION: "fb_description",
+ LEVEL: "fb_level",
+ MAX_RATING_VALUE: "fb_max_rating_value",
+ NUM_ITEMS: "fb_num_items",
+ PAYMENT_INFO_AVAILABLE: "fb_payment_info_available",
+ REGISTRATION_METHOD: "fb_registration_method",
+ SEARCH_STRING: "fb_search_string",
+ SUCCESS: "fb_success",
+ },
+ setAppVersion() {},
+ setUserID() {},
+ updateUserProperties() {},
+ },
+ Canvas: {
+ getHash: () => "",
+ getPageInfo(cb) {
+ cb?.call(this, {
+ clientHeight: 1,
+ clientWidth: 1,
+ offsetLeft: 0,
+ offsetTop: 0,
+ scrollLeft: 0,
+ scrollTop: 0,
+ });
+ },
+ Plugin: {
+ hidePluginElement() {},
+ showPluginElement() {},
+ },
+ Prefetcher: {
+ COLLECT_AUTOMATIC: 0,
+ COLLECT_MANUAL: 1,
+ addStaticResource() {},
+ setCollectionMode() {},
+ },
+ scrollTo() {},
+ setAutoGrow() {},
+ setDoneLoading() {},
+ setHash() {},
+ setSize() {},
+ setUrlHandler() {},
+ startTimer() {},
+ stopTimer() {},
+ },
+ Event: {
+ subscribe(e, f) {
+ if (!eventHandlers.has(e)) {
+ eventHandlers.set(e, new Set());
+ }
+ eventHandlers.get(e).add(f);
+ },
+ unsubscribe(e, f) {
+ eventHandlers.get(e)?.delete(f);
+ },
+ },
+ frictionless: {
+ init() {},
+ isAllowed: () => false,
+ },
+ gamingservices: {
+ friendFinder() {},
+ uploadImageToMediaLibrary() {},
+ },
+ getAccessToken: () => null,
+ getAuthResponse() {
+ return { status: "" };
+ },
+ getLoginStatus(cb) {
+ cb?.call(this, { status: "unknown" });
+ },
+ getUserID() {},
+ init(_initInfo) {
+ initInfo = _initInfo; // in case the site is not using fbAsyncInit
+ },
+ login(cb, opts) {
+ // We have to load Facebook's script, and then wait for it to call
+ // window.open. By that time, the popup blocker will likely trigger.
+ // So we open a popup now with about:blank, and then make sure FB
+ // will re-use that same popup later.
+ if (needPopup) {
+ activePopup = window.open("about:blank", popupName, buildPopupParams());
+ }
+ allowFacebookSDK(() => {
+ activePopup = undefined;
+ function runPostLoginCallbacks() {
+ try {
+ cb?.apply(this, arguments);
+ } catch (e) {
+ console.error(e);
+ }
+ if (activeOnloginAttribute) {
+ setTimeout(activeOnloginAttribute, 1);
+ activeOnloginAttribute = undefined;
+ }
+ }
+ window.FB.login(runPostLoginCallbacks, opts);
+ }).catch(() => {
+ activePopup = undefined;
+ activeOnloginAttribute = undefined;
+ try {
+ cb?.({});
+ } catch (e) {
+ console.error(e);
+ }
+ });
+ },
+ logout(cb) {
+ cb?.call(this);
+ },
+ ui(params, fn) {
+ if (params.method === "permissions.oauth") {
+ window.FB.login(fn, params);
+ }
+ },
+ XFBML: {
+ parse(node, cb) {
+ node = node || document;
+ node.querySelectorAll(".fb-login-button").forEach(makeLoginPlaceholder);
+ node.querySelectorAll(".fb-video").forEach(makeVideoPlaceholder);
+ try {
+ cb?.call(this);
+ } catch (e) {
+ console.error(e);
+ }
+ },
+ },
+ });
+
+ window.FB.XFBML.parse();
+
+ window?.fbAsyncInit?.();
+}
diff --git a/browser/extensions/webcompat/shims/facebook.svg b/browser/extensions/webcompat/shims/facebook.svg
new file mode 100644
index 0000000000..df63700a9e
--- /dev/null
+++ b/browser/extensions/webcompat/shims/facebook.svg
@@ -0,0 +1,3 @@
+<!-- copyright is dedicated to the Public Domain.
+ https://en.wikipedia.org/wiki/File:Facebook_f_logo_(2019).svg -->
+<svg xmlns="http://www.w3.org/2000/svg" width="1365.12" height="1365.12" viewBox="0 0 14222 14222"><circle cx="7111" cy="7112" r="7111" fill="#fff"/><path d="M9879 9168l315-2056H8222V5778c0-562 275-1111 1159-1111h897V2917s-814-139-1592-139c-1624 0-2686 984-2686 2767v1567H4194v2056h1806v4969c362 57 733 86 1111 86s749-30 1111-86V9168z" fill="#1977f3"/></svg>
diff --git a/browser/extensions/webcompat/shims/fastclick.js b/browser/extensions/webcompat/shims/fastclick.js
new file mode 100644
index 0000000000..ad6814c995
--- /dev/null
+++ b/browser/extensions/webcompat/shims/fastclick.js
@@ -0,0 +1,75 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1738220 - Shim Conversant FastClick
+ *
+ * Sites assuming FastClick will load can break if it is blocked.
+ * This shim mitigates that breakage.
+ */
+
+// FastClick bundles nodeJS packages/core-js/internals/dom-iterables.js
+// which is known to be needed by at least one site.
+if (!HTMLCollection.prototype.forEach) {
+ const DOMIterables = [
+ "CSSRuleList",
+ "CSSStyleDeclaration",
+ "CSSValueList",
+ "ClientRectList",
+ "DOMRectList",
+ "DOMStringList",
+ "DOMTokenList",
+ "DataTransferItemList",
+ "FileList",
+ "HTMLAllCollection",
+ "HTMLCollection",
+ "HTMLFormElement",
+ "HTMLSelectElement",
+ "MediaList",
+ "MimeTypeArray",
+ "NamedNodeMap",
+ "NodeList",
+ "PaintRequestList",
+ "Plugin",
+ "PluginArray",
+ "SVGLengthList",
+ "SVGNumberList",
+ "SVGPathSegList",
+ "SVGPointList",
+ "SVGStringList",
+ "SVGTransformList",
+ "SourceBufferList",
+ "StyleSheetList",
+ "TextTrackCueList",
+ "TextTrackList",
+ "TouchList",
+ ];
+
+ const forEach = Array.prototype.forEach;
+
+ const handlePrototype = proto => {
+ if (!proto || proto.forEach === forEach) {
+ return;
+ }
+ try {
+ Object.defineProperty(proto, "forEach", {
+ enumerable: false,
+ get: () => forEach,
+ });
+ } catch (_) {
+ proto.forEach = forEach;
+ }
+ };
+
+ for (const name of DOMIterables) {
+ handlePrototype(window[name]?.prototype);
+ }
+}
+
+if (!window.conversant?.launch) {
+ const c = (window.conversant = window.conversant || {});
+ c.launch = () => {};
+}
diff --git a/browser/extensions/webcompat/shims/firebase.js b/browser/extensions/webcompat/shims/firebase.js
new file mode 100644
index 0000000000..8ac049c5e4
--- /dev/null
+++ b/browser/extensions/webcompat/shims/firebase.js
@@ -0,0 +1,95 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1767407 - Shim Firebase
+ *
+ * Sites relying on firebase-messaging.js will break in Private
+ * browsing mode because it assumes that they require service
+ * workers and indexedDB, when they generally do not.
+ */
+
+/* globals cloneInto */
+
+(function () {
+ const win = window.wrappedJSObject;
+ const emptyObj = new win.Object();
+ const emptyArr = new win.Array();
+ const emptyMsg = cloneInto({ message: "" }, window);
+ const noOpFn = cloneInto(function () {}, window, { cloneFunctions: true });
+
+ if (!win.indexedDB) {
+ const idb = {
+ open: () => win.Promise.reject(emptyMsg),
+ };
+
+ Object.defineProperty(win, "indexedDB", {
+ value: cloneInto(idb, window, { cloneFunctions: true }),
+ });
+ }
+
+ // bug 1778993
+ for (const name of [
+ "IDBCursor",
+ "IDBDatabase",
+ "IDBIndex",
+ "IDBOpenDBRequest",
+ "IDBRequest",
+ "IDBTransaction",
+ ]) {
+ if (!win[name]) {
+ Object.defineProperty(win, name, { value: emptyObj });
+ }
+ }
+
+ if (!win.serviceWorker) {
+ const sw = {
+ addEventListener() {},
+ getRegistrations: () => win.Promise.resolve(emptyArr),
+ register: () => win.Promise.reject(emptyMsg),
+ };
+
+ Object.defineProperty(navigator.wrappedJSObject, "serviceWorker", {
+ value: cloneInto(sw, window, { cloneFunctions: true }),
+ });
+
+ // bug 1779536
+ Object.defineProperty(navigator.wrappedJSObject.serviceWorker, "ready", {
+ value: new win.Promise(noOpFn),
+ });
+ }
+
+ // bug 1750699
+ if (!win.PushManager) {
+ Object.defineProperty(win, "PushManager", { value: emptyObj });
+ }
+
+ // bug 1750699
+ if (!win.PushSubscription) {
+ const ps = {
+ prototype: {
+ getKey() {},
+ },
+ };
+
+ Object.defineProperty(win, "PushSubscription", {
+ value: cloneInto(ps, window, { cloneFunctions: true }),
+ });
+ }
+
+ // bug 1750699
+ if (!win.ServiceWorkerRegistration) {
+ const swr = {
+ prototype: {
+ showNotification() {},
+ },
+ };
+
+ Object.defineProperty(win, "ServiceWorkerRegistration", {
+ value: cloneInto(swr, window, { cloneFunctions: true }),
+ });
+ }
+})();
diff --git a/browser/extensions/webcompat/shims/google-ads.js b/browser/extensions/webcompat/shims/google-ads.js
new file mode 100644
index 0000000000..a432186f43
--- /dev/null
+++ b/browser/extensions/webcompat/shims/google-ads.js
@@ -0,0 +1,77 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1713726 - Shim Ads by Google
+ *
+ * Sites relying on window.adsbygoogle may encounter breakage if it is blocked.
+ * This shim provides a stub for that API to mitigate that breakage.
+ */
+
+if (window.adsbygoogle?.loaded === undefined) {
+ window.adsbygoogle = {
+ loaded: true,
+ push() {},
+ };
+}
+
+if (window.gapi?._pl === undefined) {
+ const stub = {
+ go() {},
+ render: () => "",
+ };
+ window.gapi = {
+ _pl: true,
+ additnow: stub,
+ autocomplete: stub,
+ backdrop: stub,
+ blogger: stub,
+ commentcount: stub,
+ comments: stub,
+ community: stub,
+ donation: stub,
+ family_creation: stub,
+ follow: stub,
+ hangout: stub,
+ health: stub,
+ interactivepost: stub,
+ load() {},
+ logutil: {
+ enableDebugLogging() {},
+ },
+ page: stub,
+ partnersbadge: stub,
+ person: stub,
+ platform: {
+ go() {},
+ },
+ playemm: stub,
+ playreview: stub,
+ plus: stub,
+ plusone: stub,
+ post: stub,
+ profile: stub,
+ ratingbadge: stub,
+ recobar: stub,
+ savetoandroidpay: stub,
+ savetodrive: stub,
+ savetowallet: stub,
+ share: stub,
+ sharetoclassroom: stub,
+ shortlists: stub,
+ signin: stub,
+ signin2: stub,
+ surveyoptin: stub,
+ visibility: stub,
+ youtube: stub,
+ ytsubscribe: stub,
+ zoomableimage: stub,
+ };
+}
+
+for (const e of document.querySelectorAll("ins.adsbygoogle")) {
+ e.style.maxWidth = "0px";
+}
diff --git a/browser/extensions/webcompat/shims/google-analytics-and-tag-manager.js b/browser/extensions/webcompat/shims/google-analytics-and-tag-manager.js
new file mode 100644
index 0000000000..8809fca8ec
--- /dev/null
+++ b/browser/extensions/webcompat/shims/google-analytics-and-tag-manager.js
@@ -0,0 +1,187 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1713687 - Shim Google Analytics and Tag Manager
+ *
+ * Sites often rely on the Google Analytics window object and will
+ * break if it fails to load or is blocked. This shim works around
+ * such breakage.
+ *
+ * Sites also often use the Google Optimizer (asynchide) code snippet,
+ * only for it to cause multi-second delays if Google Analytics does
+ * not load. This shim also avoids such delays.
+ *
+ * They also rely on Google Tag Manager, which often goes hand-in-
+ * hand with Analytics, but is not always blocked by anti-tracking
+ * lists. Handling both in the same shim handles both cases.
+ */
+
+if (window[window.GoogleAnalyticsObject || "ga"]?.loaded === undefined) {
+ const DEFAULT_TRACKER_NAME = "t0";
+
+ const trackers = new Map();
+
+ const run = function (fn, ...args) {
+ if (typeof fn === "function") {
+ try {
+ fn(...args);
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ };
+
+ const create = (id, cookie, name, opts) => {
+ id = id || opts?.trackerId;
+ if (!id) {
+ return undefined;
+ }
+ cookie = cookie || opts?.cookieDomain || "_ga";
+ name = name || opts?.name || DEFAULT_TRACKER_NAME;
+ if (!trackers.has(name)) {
+ let props;
+ try {
+ props = new Map(Object.entries(opts));
+ } catch (_) {
+ props = new Map();
+ }
+ trackers.set(name, {
+ get(p) {
+ if (p === "name") {
+ return name;
+ } else if (p === "trackingId") {
+ return id;
+ } else if (p === "cookieDomain") {
+ return cookie;
+ }
+ return props.get(p);
+ },
+ ma() {},
+ requireSync() {},
+ send() {},
+ set(p, v) {
+ if (typeof p !== "object") {
+ p = Object.fromEntries([[p, v]]);
+ }
+ for (const k in p) {
+ props.set(k, p[k]);
+ if (k === "hitCallback") {
+ run(p[k]);
+ }
+ }
+ },
+ });
+ }
+ return trackers.get(name);
+ };
+
+ const cmdRE = /((?<name>.*?)\.)?((?<plugin>.*?):)?(?<method>.*)/;
+
+ function ga(cmd, ...args) {
+ if (arguments.length === 1 && typeof cmd === "function") {
+ run(cmd, trackers.get(DEFAULT_TRACKER_NAME));
+ return undefined;
+ }
+
+ if (typeof cmd !== "string") {
+ return undefined;
+ }
+
+ const groups = cmdRE.exec(cmd)?.groups;
+ if (!groups) {
+ console.error("Could not parse GA command", cmd);
+ return undefined;
+ }
+
+ let { name, plugin, method } = groups;
+
+ if (plugin) {
+ return undefined;
+ }
+
+ if (cmd === "set") {
+ trackers.get(name)?.set(args[0], args[1]);
+ }
+
+ if (method === "remove") {
+ trackers.delete(name);
+ return undefined;
+ }
+
+ if (cmd === "send") {
+ run(args.at(-1)?.hitCallback);
+ return undefined;
+ }
+
+ if (method === "create") {
+ let id, cookie, fields;
+ for (const param of args.slice(0, 4)) {
+ if (typeof param === "object") {
+ fields = param;
+ break;
+ }
+ if (id === undefined) {
+ id = param;
+ } else if (cookie === undefined) {
+ cookie = param;
+ } else {
+ name = param;
+ }
+ }
+ return create(id, cookie, name, fields);
+ }
+
+ return undefined;
+ }
+
+ Object.assign(ga, {
+ create: (a, b, c, d) => ga("create", a, b, c, d),
+ getAll: () => Array.from(trackers.values()),
+ getByName: name => trackers.get(name),
+ loaded: true,
+ remove: t => ga("remove", t),
+ });
+
+ // Process any GA command queue the site pre-declares (bug 1736850)
+ const q = window[window.GoogleAnalyticsObject || "ga"]?.q;
+ window[window.GoogleAnalyticsObject || "ga"] = ga;
+
+ if (Array.isArray(q)) {
+ const push = o => {
+ ga(...o);
+ return true;
+ };
+ q.push = push;
+ q.forEach(o => push(o));
+ }
+
+ // Also process the Google Tag Manager dataLayer (bug 1713688)
+ const dl = window.dataLayer;
+
+ if (Array.isArray(dl) && !dl.find(e => e["gtm.start"])) {
+ const push = function (o) {
+ setTimeout(() => run(o?.eventCallback), 1);
+ return true;
+ };
+ dl.push = push;
+ dl.forEach(o => push(o));
+ }
+
+ // Run dataLayer.hide.end to handle asynchide (bug 1628151)
+ run(window.dataLayer?.hide?.end);
+}
+
+if (!window?.gaplugins?.Linker) {
+ window.gaplugins = window.gaplugins || {};
+ window.gaplugins.Linker = class {
+ autoLink() {}
+ decorate(url) {
+ return url;
+ }
+ passthrough() {}
+ };
+}
diff --git a/browser/extensions/webcompat/shims/google-analytics-ecommerce-plugin.js b/browser/extensions/webcompat/shims/google-analytics-ecommerce-plugin.js
new file mode 100644
index 0000000000..60b49df120
--- /dev/null
+++ b/browser/extensions/webcompat/shims/google-analytics-ecommerce-plugin.js
@@ -0,0 +1,13 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+if (!window.gaplugins) {
+ window.gaplugins = {};
+}
+
+if (!window.gaplugins.EC) {
+ window.gaplugins.EC = () => {};
+}
diff --git a/browser/extensions/webcompat/shims/google-analytics-legacy.js b/browser/extensions/webcompat/shims/google-analytics-legacy.js
new file mode 100644
index 0000000000..da1a638e12
--- /dev/null
+++ b/browser/extensions/webcompat/shims/google-analytics-legacy.js
@@ -0,0 +1,137 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// based on https://github.com/gorhill/uBlock/blob/6f49e079db0262e669b70f4169924f796ac8db7c/src/web_accessible_resources/google-analytics_ga.js
+
+"use strict";
+
+if (!window._gaq) {
+ function noopfn() {}
+
+ const gaq = {
+ Na: noopfn,
+ O: noopfn,
+ Sa: noopfn,
+ Ta: noopfn,
+ Va: noopfn,
+ _createAsyncTracker: noopfn,
+ _getAsyncTracker: noopfn,
+ _getPlugin: noopfn,
+ push: a => {
+ if (typeof a === "function") {
+ a();
+ return;
+ }
+ if (!Array.isArray(a)) {
+ return;
+ }
+ if (
+ typeof a[0] === "string" &&
+ /(^|\.)_link$/.test(a[0]) &&
+ typeof a[1] === "string"
+ ) {
+ window.location.assign(a[1]);
+ }
+ if (
+ a[0] === "_set" &&
+ a[1] === "hitCallback" &&
+ typeof a[2] === "function"
+ ) {
+ a[2]();
+ }
+ },
+ };
+
+ const tracker = {
+ _addIgnoredOrganic: noopfn,
+ _addIgnoredRef: noopfn,
+ _addItem: noopfn,
+ _addOrganic: noopfn,
+ _addTrans: noopfn,
+ _clearIgnoredOrganic: noopfn,
+ _clearIgnoredRef: noopfn,
+ _clearOrganic: noopfn,
+ _cookiePathCopy: noopfn,
+ _deleteCustomVar: noopfn,
+ _getName: noopfn,
+ _setAccount: noopfn,
+ _getAccount: noopfn,
+ _getClientInfo: noopfn,
+ _getDetectFlash: noopfn,
+ _getDetectTitle: noopfn,
+ _getLinkerUrl: a => a,
+ _getLocalGifPath: noopfn,
+ _getServiceMode: noopfn,
+ _getVersion: noopfn,
+ _getVisitorCustomVar: noopfn,
+ _initData: noopfn,
+ _link: noopfn,
+ _linkByPost: noopfn,
+ _setAllowAnchor: noopfn,
+ _setAllowHash: noopfn,
+ _setAllowLinker: noopfn,
+ _setCampContentKey: noopfn,
+ _setCampMediumKey: noopfn,
+ _setCampNameKey: noopfn,
+ _setCampNOKey: noopfn,
+ _setCampSourceKey: noopfn,
+ _setCampTermKey: noopfn,
+ _setCampaignCookieTimeout: noopfn,
+ _setCampaignTrack: noopfn,
+ _setClientInfo: noopfn,
+ _setCookiePath: noopfn,
+ _setCookiePersistence: noopfn,
+ _setCookieTimeout: noopfn,
+ _setCustomVar: noopfn,
+ _setDetectFlash: noopfn,
+ _setDetectTitle: noopfn,
+ _setDomainName: noopfn,
+ _setLocalGifPath: noopfn,
+ _setLocalRemoteServerMode: noopfn,
+ _setLocalServerMode: noopfn,
+ _setReferrerOverride: noopfn,
+ _setRemoteServerMode: noopfn,
+ _setSampleRate: noopfn,
+ _setSessionTimeout: noopfn,
+ _setSiteSpeedSampleRate: noopfn,
+ _setSessionCookieTimeout: noopfn,
+ _setVar: noopfn,
+ _setVisitorCookieTimeout: noopfn,
+ _trackEvent: noopfn,
+ _trackPageLoadTime: noopfn,
+ _trackPageview: noopfn,
+ _trackSocial: noopfn,
+ _trackTiming: noopfn,
+ _trackTrans: noopfn,
+ _visitCode: noopfn,
+ };
+
+ const gat = {
+ _anonymizeIP: noopfn,
+ _createTracker: noopfn,
+ _forceSSL: noopfn,
+ _getPlugin: noopfn,
+ _getTracker: () => tracker,
+ _getTrackerByName: () => tracker,
+ _getTrackers: noopfn,
+ aa: noopfn,
+ ab: noopfn,
+ hb: noopfn,
+ la: noopfn,
+ oa: noopfn,
+ pa: noopfn,
+ u: noopfn,
+ };
+
+ window._gat = gat;
+
+ const aa = window._gaq || [];
+ if (Array.isArray(aa)) {
+ while (aa[0]) {
+ gaq.push(aa.shift());
+ }
+ }
+
+ window._gaq = gaq.qf = gaq;
+}
diff --git a/browser/extensions/webcompat/shims/google-ima.js b/browser/extensions/webcompat/shims/google-ima.js
new file mode 100644
index 0000000000..1f5e56239d
--- /dev/null
+++ b/browser/extensions/webcompat/shims/google-ima.js
@@ -0,0 +1,620 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Bug 1713690 - Shim Google Interactive Media Ads ima3.js
+ *
+ * Many sites use ima3.js for ad bidding and placement, often in conjunction
+ * with Google Publisher Tags, Prebid.js and/or other scripts. This shim
+ * provides a stubbed-out version of the API which helps work around related
+ * site breakage, such as black bxoes where videos ought to be placed.
+ */
+
+if (!window.google?.ima?.VERSION) {
+ const VERSION = "3.517.2";
+
+ const CheckCanAutoplay = (function () {
+ // Sourced from: https://searchfox.org/mozilla-central/source/dom/media/gtest/negative_duration.mp4
+ const TEST_VIDEO = new Blob(
+ [
+ new Uint32Array([
+ 469762048, 1887007846, 1752392036, 0, 913273705, 1717987696,
+ 828601953, -1878917120, 1987014509, 1811939328, 1684567661, 0, 0, 0,
+ -402456576, 0, 256, 1, 0, 0, 256, 0, 0, 0, 256, 0, 0, 0, 64, 0, 0, 0,
+ 0, 0, 0, 33554432, -201261056, 1801548404, 1744830464, 1684564852,
+ 251658241, 0, 0, 0, 0, 16777216, 0, -1, -1, 0, 0, 0, 0, 256, 0, 0, 0,
+ 256, 0, 0, 0, 64, 5, 53250, -2080309248, 1634296941, 738197504,
+ 1684563053, 1, 0, 0, 0, 0, -2137614336, -1, -1, 50261, 754974720,
+ 1919706216, 0, 0, 1701079414, 0, 0, 0, 1701079382, 1851869295,
+ 1919249508, 16777216, 1852402979, 102, 1752004116, 100, 1, 0, 0,
+ 1852400676, 102, 1701995548, 102, 0, 1, 1819440396, 32, 1, 1651799011,
+ 108, 1937011607, 100, 0, 1, 1668702599, 49, 0, 1, 0, 0, 0, 33555712,
+ 4718800, 4718592, 0, 65536, 0, 0, 0, 0, 0, 0, 0, 0, 16776984,
+ 1630601216, 21193590, -14745500, 1729626337, -1407254428, 89161945,
+ 1049019, 9453056, -251611125, 27269507, -379058688, -1329024392,
+ 268435456, 1937011827, 0, 0, 268435456, 1668510835, 0, 0, 335544320,
+ 2054386803, 0, 0, 0, 268435456, 1868788851, 0, 0, 671088640,
+ 2019915373, 536870912, 2019914356, 0, 16777216, 16777216, 0, 0, 0,
+ ]),
+ ],
+ { type: "video/mp4" }
+ );
+
+ let testVideo = undefined;
+
+ return function () {
+ if (!testVideo) {
+ testVideo = document.createElement("video");
+ testVideo.style =
+ "position:absolute; width:0; height:0; left:0; right:0; z-index:-1; border:0";
+ testVideo.setAttribute("muted", "muted");
+ testVideo.setAttribute("playsinline", "playsinline");
+ testVideo.src = URL.createObjectURL(TEST_VIDEO);
+ document.body.appendChild(testVideo);
+ }
+ return testVideo.play();
+ };
+ })();
+
+ let ima = {};
+
+ class AdDisplayContainer {
+ destroy() {}
+ initialize() {}
+ }
+
+ class ImaSdkSettings {
+ #c = true;
+ #f = {};
+ #i = false;
+ #l = "";
+ #p = "";
+ #r = 0;
+ #t = "";
+ #v = "";
+ getCompanionBackfill() {}
+ getDisableCustomPlaybackForIOS10Plus() {
+ return this.#i;
+ }
+ getFeatureFlags() {
+ return this.#f;
+ }
+ getLocale() {
+ return this.#l;
+ }
+ getNumRedirects() {
+ return this.#r;
+ }
+ getPlayerType() {
+ return this.#t;
+ }
+ getPlayerVersion() {
+ return this.#v;
+ }
+ getPpid() {
+ return this.#p;
+ }
+ isCookiesEnabled() {
+ return this.#c;
+ }
+ setAutoPlayAdBreaks() {}
+ setCompanionBackfill() {}
+ setCookiesEnabled(c) {
+ this.#c = !!c;
+ }
+ setDisableCustomPlaybackForIOS10Plus(i) {
+ this.#i = !!i;
+ }
+ setFeatureFlags(f) {
+ this.#f = f;
+ }
+ setLocale(l) {
+ this.#l = l;
+ }
+ setNumRedirects(r) {
+ this.#r = r;
+ }
+ setPlayerType(t) {
+ this.#t = t;
+ }
+ setPlayerVersion(v) {
+ this.#v = v;
+ }
+ setPpid(p) {
+ this.#p = p;
+ }
+ setSessionId(s) {}
+ setVpaidAllowed(a) {}
+ setVpaidMode(m) {}
+ }
+ ImaSdkSettings.CompanionBackfillMode = {
+ ALWAYS: "always",
+ ON_MASTER_AD: "on_master_ad",
+ };
+ ImaSdkSettings.VpaidMode = {
+ DISABLED: 0,
+ ENABLED: 1,
+ INSECURE: 2,
+ };
+
+ class EventHandler {
+ #listeners = new Map();
+
+ _dispatch(e) {
+ const listeners = this.#listeners.get(e.type) || [];
+ for (const listener of Array.from(listeners)) {
+ try {
+ listener(e);
+ } catch (r) {
+ console.error(r);
+ }
+ }
+ }
+
+ addEventListener(t, c) {
+ if (!this.#listeners.has(t)) {
+ this.#listeners.set(t, new Set());
+ }
+ this.#listeners.get(t).add(c);
+ }
+
+ removeEventListener(t, c) {
+ this.#listeners.get(t)?.delete(c);
+ }
+ }
+
+ class AdsLoader extends EventHandler {
+ #settings = new ImaSdkSettings();
+ contentComplete() {}
+ destroy() {}
+ getSettings() {
+ return this.#settings;
+ }
+ getVersion() {
+ return VERSION;
+ }
+ requestAds(r, c) {
+ // If autoplay is disabled and the page is trying to autoplay a tracking
+ // ad, then IMA fails with an error, and the page is expected to request
+ // ads again later when the user clicks to play.
+ CheckCanAutoplay().then(
+ () => {
+ const { ADS_MANAGER_LOADED } = AdsManagerLoadedEvent.Type;
+ this._dispatch(new ima.AdsManagerLoadedEvent(ADS_MANAGER_LOADED));
+ },
+ () => {
+ const e = new ima.AdError(
+ "adPlayError",
+ 1205,
+ 1205,
+ "The browser prevented playback initiated without user interaction."
+ );
+ this._dispatch(new ima.AdErrorEvent(e));
+ }
+ );
+ }
+ }
+
+ class AdsManager extends EventHandler {
+ #volume = 1;
+ collapse() {}
+ configureAdsManager() {}
+ destroy() {}
+ discardAdBreak() {}
+ expand() {}
+ focus() {}
+ getAdSkippableState() {
+ return false;
+ }
+ getCuePoints() {
+ return [0];
+ }
+ getCurrentAd() {
+ return currentAd;
+ }
+ getCurrentAdCuePoints() {
+ return [];
+ }
+ getRemainingTime() {
+ return 0;
+ }
+ getVolume() {
+ return this.#volume;
+ }
+ init(w, h, m, e) {}
+ isCustomClickTrackingUsed() {
+ return false;
+ }
+ isCustomPlaybackUsed() {
+ return false;
+ }
+ pause() {}
+ requestNextAdBreak() {}
+ resize(w, h, m) {}
+ resume() {}
+ setVolume(v) {
+ this.#volume = v;
+ }
+ skip() {}
+ start() {
+ requestAnimationFrame(() => {
+ for (const type of [
+ AdEvent.Type.LOADED,
+ AdEvent.Type.STARTED,
+ AdEvent.Type.CONTENT_RESUME_REQUESTED,
+ AdEvent.Type.AD_BUFFERING,
+ AdEvent.Type.FIRST_QUARTILE,
+ AdEvent.Type.MIDPOINT,
+ AdEvent.Type.THIRD_QUARTILE,
+ AdEvent.Type.COMPLETE,
+ AdEvent.Type.ALL_ADS_COMPLETED,
+ ]) {
+ try {
+ this._dispatch(new ima.AdEvent(type));
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ });
+ }
+ stop() {}
+ updateAdsRenderingSettings(s) {}
+ }
+
+ class AdsRenderingSettings {}
+
+ class AdsRequest {
+ setAdWillAutoPlay() {}
+ setAdWillPlayMuted() {}
+ setContinuousPlayback() {}
+ }
+
+ class AdPodInfo {
+ getAdPosition() {
+ return 1;
+ }
+ getIsBumper() {
+ return false;
+ }
+ getMaxDuration() {
+ return -1;
+ }
+ getPodIndex() {
+ return 1;
+ }
+ getTimeOffset() {
+ return 0;
+ }
+ getTotalAds() {
+ return 1;
+ }
+ }
+
+ class Ad {
+ _pi = new AdPodInfo();
+ getAdId() {
+ return "";
+ }
+ getAdPodInfo() {
+ return this._pi;
+ }
+ getAdSystem() {
+ return "";
+ }
+ getAdvertiserName() {
+ return "";
+ }
+ getApiFramework() {
+ return null;
+ }
+ getCompanionAds() {
+ return [];
+ }
+ getContentType() {
+ return "";
+ }
+ getCreativeAdId() {
+ return "";
+ }
+ getCreativeId() {
+ return "";
+ }
+ getDealId() {
+ return "";
+ }
+ getDescription() {
+ return "";
+ }
+ getDuration() {
+ return 8.5;
+ }
+ getHeight() {
+ return 0;
+ }
+ getMediaUrl() {
+ return null;
+ }
+ getMinSuggestedDuration() {
+ return -2;
+ }
+ getSkipTimeOffset() {
+ return -1;
+ }
+ getSurveyUrl() {
+ return null;
+ }
+ getTitle() {
+ return "";
+ }
+ getTraffickingParameters() {
+ return {};
+ }
+ getTraffickingParametersString() {
+ return "";
+ }
+ getUiElements() {
+ return [""];
+ }
+ getUniversalAdIdRegistry() {
+ return "unknown";
+ }
+ getUniversalAdIds() {
+ return [""];
+ }
+ getUniversalAdIdValue() {
+ return "unknown";
+ }
+ getVastMediaBitrate() {
+ return 0;
+ }
+ getVastMediaHeight() {
+ return 0;
+ }
+ getVastMediaWidth() {
+ return 0;
+ }
+ getWidth() {
+ return 0;
+ }
+ getWrapperAdIds() {
+ return [""];
+ }
+ getWrapperAdSystems() {
+ return [""];
+ }
+ getWrapperCreativeIds() {
+ return [""];
+ }
+ isLinear() {
+ return true;
+ }
+ isSkippable() {
+ return true;
+ }
+ }
+
+ class CompanionAd {
+ getAdSlotId() {
+ return "";
+ }
+ getContent() {
+ return "";
+ }
+ getContentType() {
+ return "";
+ }
+ getHeight() {
+ return 1;
+ }
+ getWidth() {
+ return 1;
+ }
+ }
+
+ class AdError {
+ #errorCode = -1;
+ #message = "";
+ #type = "";
+ #vastErrorCode = -1;
+ constructor(type, code, vast, message) {
+ this.#errorCode = code;
+ this.#message = message;
+ this.#type = type;
+ this.#vastErrorCode = vast;
+ }
+ getErrorCode() {
+ return this.#errorCode;
+ }
+ getInnerError() {}
+ getMessage() {
+ return this.#message;
+ }
+ getType() {
+ return this.#type;
+ }
+ getVastErrorCode() {
+ return this.#vastErrorCode;
+ }
+ toString() {
+ return `AdError ${this.#errorCode}: ${this.#message}`;
+ }
+ }
+ AdError.ErrorCode = {};
+ AdError.Type = {};
+
+ const isEngadget = () => {
+ try {
+ for (const ctx of Object.values(window.vidible._getContexts())) {
+ if (ctx.getPlayer()?.div?.innerHTML.includes("www.engadget.com")) {
+ return true;
+ }
+ }
+ } catch (_) {}
+ return false;
+ };
+
+ const currentAd = isEngadget() ? undefined : new Ad();
+
+ class AdEvent {
+ constructor(type) {
+ this.type = type;
+ }
+ getAd() {
+ return currentAd;
+ }
+ getAdData() {
+ return {};
+ }
+ }
+ AdEvent.Type = {
+ AD_BREAK_READY: "adBreakReady",
+ AD_BUFFERING: "adBuffering",
+ AD_CAN_PLAY: "adCanPlay",
+ AD_METADATA: "adMetadata",
+ AD_PROGRESS: "adProgress",
+ ALL_ADS_COMPLETED: "allAdsCompleted",
+ CLICK: "click",
+ COMPLETE: "complete",
+ CONTENT_PAUSE_REQUESTED: "contentPauseRequested",
+ CONTENT_RESUME_REQUESTED: "contentResumeRequested",
+ DURATION_CHANGE: "durationChange",
+ EXPANDED_CHANGED: "expandedChanged",
+ FIRST_QUARTILE: "firstQuartile",
+ IMPRESSION: "impression",
+ INTERACTION: "interaction",
+ LINEAR_CHANGE: "linearChange",
+ LINEAR_CHANGED: "linearChanged",
+ LOADED: "loaded",
+ LOG: "log",
+ MIDPOINT: "midpoint",
+ PAUSED: "pause",
+ RESUMED: "resume",
+ SKIPPABLE_STATE_CHANGED: "skippableStateChanged",
+ SKIPPED: "skip",
+ STARTED: "start",
+ THIRD_QUARTILE: "thirdQuartile",
+ USER_CLOSE: "userClose",
+ VIDEO_CLICKED: "videoClicked",
+ VIDEO_ICON_CLICKED: "videoIconClicked",
+ VIEWABLE_IMPRESSION: "viewable_impression",
+ VOLUME_CHANGED: "volumeChange",
+ VOLUME_MUTED: "mute",
+ };
+
+ class AdErrorEvent {
+ type = "adError";
+ #error = "";
+ constructor(error) {
+ this.#error = error;
+ }
+ getError() {
+ return this.#error;
+ }
+ getUserRequestContext() {
+ return {};
+ }
+ }
+ AdErrorEvent.Type = {
+ AD_ERROR: "adError",
+ };
+
+ const manager = new AdsManager();
+
+ class AdsManagerLoadedEvent {
+ constructor(type) {
+ this.type = type;
+ }
+ getAdsManager() {
+ return manager;
+ }
+ getUserRequestContext() {
+ return {};
+ }
+ }
+ AdsManagerLoadedEvent.Type = {
+ ADS_MANAGER_LOADED: "adsManagerLoaded",
+ };
+
+ class CustomContentLoadedEvent {}
+ CustomContentLoadedEvent.Type = {
+ CUSTOM_CONTENT_LOADED: "deprecated-event",
+ };
+
+ class CompanionAdSelectionSettings {}
+ CompanionAdSelectionSettings.CreativeType = {
+ ALL: "All",
+ FLASH: "Flash",
+ IMAGE: "Image",
+ };
+ CompanionAdSelectionSettings.ResourceType = {
+ ALL: "All",
+ HTML: "Html",
+ IFRAME: "IFrame",
+ STATIC: "Static",
+ };
+ CompanionAdSelectionSettings.SizeCriteria = {
+ IGNORE: "IgnoreSize",
+ SELECT_EXACT_MATCH: "SelectExactMatch",
+ SELECT_NEAR_MATCH: "SelectNearMatch",
+ };
+
+ class AdCuePoints {
+ getCuePoints() {
+ return [];
+ }
+ }
+
+ class AdProgressData {}
+
+ class UniversalAdIdInfo {
+ getAdIdRegistry() {
+ return "";
+ }
+ getAdIsValue() {
+ return "";
+ }
+ }
+
+ Object.assign(ima, {
+ AdCuePoints,
+ AdDisplayContainer,
+ AdError,
+ AdErrorEvent,
+ AdEvent,
+ AdPodInfo,
+ AdProgressData,
+ AdsLoader,
+ AdsManager: manager,
+ AdsManagerLoadedEvent,
+ AdsRenderingSettings,
+ AdsRequest,
+ CompanionAd,
+ CompanionAdSelectionSettings,
+ CustomContentLoadedEvent,
+ gptProxyInstance: {},
+ ImaSdkSettings,
+ OmidAccessMode: {
+ DOMAIN: "domain",
+ FULL: "full",
+ LIMITED: "limited",
+ },
+ settings: new ImaSdkSettings(),
+ UiElements: {
+ AD_ATTRIBUTION: "adAttribution",
+ COUNTDOWN: "countdown",
+ },
+ UniversalAdIdInfo,
+ VERSION,
+ ViewMode: {
+ FULLSCREEN: "fullscreen",
+ NORMAL: "normal",
+ },
+ });
+
+ if (!window.google) {
+ window.google = {};
+ }
+
+ window.google.ima = ima;
+}
diff --git a/browser/extensions/webcompat/shims/google-page-ad.js b/browser/extensions/webcompat/shims/google-page-ad.js
new file mode 100644
index 0000000000..42f3a0fca5
--- /dev/null
+++ b/browser/extensions/webcompat/shims/google-page-ad.js
@@ -0,0 +1,17 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1713692 - Shim Google Page Ad conversion tracker
+ *
+ * This shim stubs out the simple API for converstion tracking with
+ * Google Page Ad, mitigating major breakage on pages which presume
+ * the API will always successfully load.
+ */
+
+if (!window.google_trackConversion) {
+ window.google_trackConversion = () => {};
+}
diff --git a/browser/extensions/webcompat/shims/google-publisher-tags.js b/browser/extensions/webcompat/shims/google-publisher-tags.js
new file mode 100644
index 0000000000..e5174d3244
--- /dev/null
+++ b/browser/extensions/webcompat/shims/google-publisher-tags.js
@@ -0,0 +1,509 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1713685 - Shim Google Publisher Tags
+ *
+ * Many sites rely on googletag to place content or drive ad bidding,
+ * and will experience major breakage if it is blocked. This shim provides
+ * enough of the API's frame To mitigate much of that breakage.
+ */
+
+if (window.googletag?.apiReady === undefined) {
+ const version = "2021050601";
+
+ const noopthisfn = function () {
+ return this;
+ };
+
+ const slots = new Map();
+ const slotsById = new Map();
+ const slotsPerPath = new Map();
+ const slotCreatives = new Map();
+ const usedCreatives = new Map();
+ const fetchedSlots = new Set();
+ const eventCallbacks = new Map();
+
+ const fireSlotEvent = (name, slot) => {
+ return new Promise(resolve => {
+ requestAnimationFrame(() => {
+ const size = [0, 0];
+ for (const cb of eventCallbacks.get(name) || []) {
+ cb({ isEmpty: true, size, slot });
+ }
+ resolve();
+ });
+ });
+ };
+
+ const recreateIframeForSlot = slot => {
+ const eid = `google_ads_iframe_${slot.getId()}`;
+ document.getElementById(eid)?.remove();
+ const node = document.getElementById(slot.getSlotElementId());
+ if (node) {
+ const f = document.createElement("iframe");
+ f.id = eid;
+ f.srcdoc = "<body></body>";
+ f.style =
+ "position:absolute; width:0; height:0; left:0; right:0; z-index:-1; border:0";
+ f.setAttribute("width", 0);
+ f.setAttribute("height", 0);
+ node.appendChild(f);
+ }
+ };
+
+ const emptySlotElement = slot => {
+ const node = document.getElementById(slot.getSlotElementId());
+ while (node?.lastChild) {
+ node.lastChild.remove();
+ }
+ };
+
+ const SizeMapping = class extends Array {
+ getCreatives() {
+ const { clientWidth, clientHeight } = document.documentElement;
+ for (const [size, creatives] of this) {
+ if (clientWidth >= size[0] && clientHeight >= size[1]) {
+ return creatives;
+ }
+ }
+ return [];
+ }
+ };
+
+ const fetchSlot = slot => {
+ if (!slot) {
+ return;
+ }
+
+ const id = slot.getSlotElementId();
+
+ const node = document.getElementById(id);
+ if (!node) {
+ return;
+ }
+
+ let creatives = slotCreatives.get(id);
+ if (creatives instanceof SizeMapping) {
+ creatives = creatives.getCreatives();
+ }
+
+ if (!creatives?.length) {
+ return;
+ }
+
+ for (const creative of creatives) {
+ if (usedCreatives.has(creative)) {
+ return;
+ }
+ }
+
+ const creative = creatives[0];
+ usedCreatives.set(creative, slot);
+ fetchedSlots.add(id);
+ };
+
+ const displaySlot = async slot => {
+ if (!slot) {
+ return;
+ }
+
+ const id = slot.getSlotElementId();
+ if (!document.getElementById(id)) {
+ return;
+ }
+
+ if (!fetchedSlots.has(id)) {
+ fetchSlot(slot);
+ }
+
+ const parent = document.getElementById(id);
+ if (parent) {
+ parent.appendChild(document.createElement("div"));
+ }
+
+ emptySlotElement(slot);
+ recreateIframeForSlot(slot);
+ await fireSlotEvent("slotRenderEnded", slot);
+ await fireSlotEvent("slotRequested", slot);
+ await fireSlotEvent("slotResponseReceived", slot);
+ await fireSlotEvent("slotOnload", slot);
+ await fireSlotEvent("impressionViewable", slot);
+ };
+
+ const addEventListener = function (name, listener) {
+ if (!eventCallbacks.has(name)) {
+ eventCallbacks.set(name, new Set());
+ }
+ eventCallbacks.get(name).add(listener);
+ return this;
+ };
+
+ const removeEventListener = function (name, listener) {
+ if (eventCallbacks.has(name)) {
+ return eventCallbacks.get(name).delete(listener);
+ }
+ return false;
+ };
+
+ const companionAdsService = {
+ addEventListener,
+ enable() {},
+ fillSlot() {},
+ getAttributeKeys: () => [],
+ getDisplayAdsCorrelator: () => "",
+ getName: () => "companion_ads",
+ getSlotIdMap: () => {
+ return {};
+ },
+ getSlots: () => [],
+ getVideoStreamCorrelator() {},
+ isRoadblockingSupported: () => false,
+ isSlotAPersistentRoadblock: () => false,
+ notifyUnfilledSlots() {},
+ onImplementationLoaded() {},
+ refreshAllSlots() {
+ for (const slot of slotsById.values()) {
+ fetchSlot(slot);
+ displaySlot(slot);
+ }
+ },
+ removeEventListener,
+ set() {},
+ setRefreshUnfilledSlots() {},
+ setVideoSession() {},
+ slotRenderEnded() {},
+ };
+
+ const contentService = {
+ addEventListener,
+ setContent() {},
+ removeEventListener,
+ };
+
+ const getTargetingValue = v => {
+ if (typeof v === "string") {
+ return [v];
+ }
+ try {
+ return [Array.prototype.flat.call(v)[0]];
+ } catch (_) {}
+ return [];
+ };
+
+ const updateTargeting = (targeting, map) => {
+ if (typeof map === "object") {
+ const entries = Object.entries(map || {});
+ for (const [k, v] of entries) {
+ targeting.set(k, getTargetingValue(v));
+ }
+ }
+ };
+
+ const defineSlot = (adUnitPath, creatives, opt_div) => {
+ if (slotsById.has(opt_div)) {
+ document.getElementById(opt_div)?.remove();
+ return slotsById.get(opt_div);
+ }
+ const attributes = new Map();
+ const targeting = new Map();
+ const exclusions = new Set();
+ const response = {
+ advertiserId: undefined,
+ campaignId: undefined,
+ creativeId: undefined,
+ creativeTemplateId: undefined,
+ lineItemId: undefined,
+ };
+ const sizes = [
+ {
+ getHeight: () => 2,
+ getWidth: () => 2,
+ },
+ ];
+ const num = (slotsPerPath.get(adUnitPath) || 0) + 1;
+ slotsPerPath.set(adUnitPath, num);
+ const id = `${adUnitPath}_${num}`;
+ let clickUrl = "";
+ let collapseEmptyDiv = null;
+ let services = new Set();
+ const slot = {
+ addService(e) {
+ services.add(e);
+ return slot;
+ },
+ clearCategoryExclusions: noopthisfn,
+ clearTargeting(k) {
+ if (k === undefined) {
+ targeting.clear();
+ } else {
+ targeting.delete(k);
+ }
+ },
+ defineSizeMapping(mapping) {
+ slotCreatives.set(opt_div, mapping);
+ return this;
+ },
+ get: k => attributes.get(k),
+ getAdUnitPath: () => adUnitPath,
+ getAttributeKeys: () => Array.from(attributes.keys()),
+ getCategoryExclusions: () => Array.from(exclusions),
+ getClickUrl: () => clickUrl,
+ getCollapseEmptyDiv: () => collapseEmptyDiv,
+ getContentUrl: () => "",
+ getDivStartsCollapsed: () => null,
+ getDomId: () => opt_div,
+ getEscapedQemQueryId: () => "",
+ getFirstLook: () => 0,
+ getId: () => id,
+ getHtml: () => "",
+ getName: () => id,
+ getOutOfPage: () => false,
+ getResponseInformation: () => response,
+ getServices: () => Array.from(services),
+ getSizes: () => sizes,
+ getSlotElementId: () => opt_div,
+ getSlotId: () => slot,
+ getTargeting: k => targeting.get(k) || gTargeting.get(k) || [],
+ getTargetingKeys: () =>
+ Array.from(
+ new Set(Array.of(...gTargeting.keys(), ...targeting.keys()))
+ ),
+ getTargetingMap: () =>
+ Object.assign(
+ Object.fromEntries(gTargeting.entries()),
+ Object.fromEntries(targeting.entries())
+ ),
+ set(k, v) {
+ attributes.set(k, v);
+ return slot;
+ },
+ setCategoryExclusion(e) {
+ exclusions.add(e);
+ return slot;
+ },
+ setClickUrl(u) {
+ clickUrl = u;
+ return slot;
+ },
+ setCollapseEmptyDiv(v) {
+ collapseEmptyDiv = !!v;
+ return slot;
+ },
+ setSafeFrameConfig: noopthisfn,
+ setTagForChildDirectedTreatment: noopthisfn,
+ setTargeting(k, v) {
+ targeting.set(k, getTargetingValue(v));
+ return slot;
+ },
+ toString: () => id,
+ updateTargetingFromMap(map) {
+ updateTargeting(targeting, map);
+ return slot;
+ },
+ };
+ slots.set(adUnitPath, slot);
+ slotsById.set(opt_div, slot);
+ slotCreatives.set(opt_div, creatives);
+ return slot;
+ };
+
+ let initialLoadDisabled = false;
+
+ const gTargeting = new Map();
+ const gAttributes = new Map();
+
+ let imaContent = { vid: "", cmsid: "" };
+ let videoContent = { vid: "", cmsid: "" };
+
+ const pubadsService = {
+ addEventListener,
+ clear() {},
+ clearCategoryExclusions: noopthisfn,
+ clearTagForChildDirectedTreatment: noopthisfn,
+ clearTargeting(k) {
+ if (k === undefined) {
+ gTargeting.clear();
+ } else {
+ gTargeting.delete(k);
+ }
+ },
+ collapseEmptyDivs() {},
+ defineOutOfPagePassback: (a, o) => defineSlot(a, 0, o),
+ definePassback: (a, s, o) => defineSlot(a, s, o),
+ disableInitialLoad() {
+ initialLoadDisabled = true;
+ return this;
+ },
+ display(adUnitPath, sizes, opt_div) {
+ const slot = defineSlot(adUnitPath, sizes, opt_div);
+ displaySlot(slot);
+ },
+ enable() {},
+ enableAsyncRendering() {},
+ enableLazyLoad() {},
+ enableSingleRequest() {},
+ enableSyncRendering() {},
+ enableVideoAds() {},
+ forceExperiment() {},
+ get: k => gAttributes.get(k),
+ getAttributeKeys: () => Array.from(gAttributes.keys()),
+ getCorrelator() {},
+ getImaContent: () => imaContent,
+ getName: () => "publisher_ads",
+ getSlots: () => Array.from(slots.values()),
+ getSlotIdMap() {
+ const map = {};
+ slots.values().forEach(s => {
+ map[s.getId()] = s;
+ });
+ return map;
+ },
+ getTagSessionCorrelator() {},
+ getTargeting: k => gTargeting.get(k) || [],
+ getTargetingKeys: () => Array.from(gTargeting.keys()),
+ getTargetingMap: () => Object.fromEntries(gTargeting.entries()),
+ getVersion: () => version,
+ getVideoContent: () => videoContent,
+ isInitialLoadDisabled: () => initialLoadDisabled,
+ isSRA: () => false,
+ markAsAmp() {},
+ refresh(slts) {
+ if (!slts) {
+ slts = slots.values();
+ } else if (!Array.isArray(slts)) {
+ slts = [slts];
+ }
+ for (const slot of slts) {
+ if (slot) {
+ try {
+ fetchSlot(slot);
+ displaySlot(slot);
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ }
+ },
+ removeEventListener,
+ set(k, v) {
+ gAttributes[k] = v;
+ return this;
+ },
+ setCategoryExclusion: noopthisfn,
+ setCentering() {},
+ setCookieOptions: noopthisfn,
+ setCorrelator: noopthisfn,
+ setForceSafeFrame: noopthisfn,
+ setImaContent(vid, cmsid) {
+ imaContent = { vid, cmsid };
+ return this;
+ },
+ setLocation: noopthisfn,
+ setPrivacySettings: noopthisfn,
+ setPublisherProvidedId: noopthisfn,
+ setRequestNonPersonalizedAds: noopthisfn,
+ setSafeFrameConfig: noopthisfn,
+ setTagForChildDirectedTreatment: noopthisfn,
+ setTagForUnderAgeOfConsent: noopthisfn,
+ setTargeting(k, v) {
+ gTargeting.set(k, getTargetingValue(v));
+ return this;
+ },
+ setVideoContent(vid, cmsid) {
+ videoContent = { vid, cmsid };
+ return this;
+ },
+ updateCorrelator() {},
+ updateTargetingFromMap(map) {
+ updateTargeting(gTargeting, map);
+ return this;
+ },
+ };
+
+ const SizeMappingBuilder = class {
+ #mapping;
+ constructor() {
+ this.#mapping = new SizeMapping();
+ }
+ addSize(size, creatives) {
+ if (
+ size !== "fluid" &&
+ (!Array.isArray(size) || isNaN(size[0]) || isNaN(size[1]))
+ ) {
+ this.#mapping = null;
+ } else {
+ this.#mapping?.push([size, creatives]);
+ }
+ return this;
+ }
+ build() {
+ return this.#mapping;
+ }
+ };
+
+ let gt = window.googletag;
+ if (!gt) {
+ gt = window.googletag = {};
+ }
+
+ Object.assign(gt, {
+ apiReady: true,
+ companionAds: () => companionAdsService,
+ content: () => contentService,
+ defineOutOfPageSlot: (a, o) => defineSlot(a, 0, o),
+ defineSlot: (a, s, o) => defineSlot(a, s, o),
+ destroySlots() {
+ slots.clear();
+ slotsById.clear();
+ },
+ disablePublisherConsole() {},
+ display(arg) {
+ let id;
+ if (arg?.getSlotElementId) {
+ id = arg.getSlotElementId();
+ } else if (arg?.nodeType) {
+ id = arg.id;
+ } else {
+ id = String(arg);
+ }
+ displaySlot(slotsById.get(id));
+ },
+ enableServices() {},
+ enums: {
+ OutOfPageFormat: {
+ BOTTOM_ANCHOR: 3,
+ INTERSTITIAL: 5,
+ REWARDED: 4,
+ TOP_ANCHOR: 2,
+ },
+ },
+ getVersion: () => version,
+ pubads: () => pubadsService,
+ pubadsReady: true,
+ setAdIframeTitle() {},
+ sizeMapping: () => new SizeMappingBuilder(),
+ });
+
+ const run = function (fn) {
+ if (typeof fn === "function") {
+ try {
+ fn.call(window);
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ };
+
+ const cmds = gt.cmd || [];
+ const newCmd = [];
+ newCmd.push = run;
+ gt.cmd = newCmd;
+
+ for (const cmd of cmds) {
+ run(cmd);
+ }
+}
diff --git a/browser/extensions/webcompat/shims/google-safeframe.html b/browser/extensions/webcompat/shims/google-safeframe.html
new file mode 100644
index 0000000000..34bc1d242f
--- /dev/null
+++ b/browser/extensions/webcompat/shims/google-safeframe.html
@@ -0,0 +1,29 @@
+<!DOCTYPE html>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+ Bug 1713691 - Shim Google SafeFrame
+
+ Some sites will break if they cannot load a Google SafeFrame. This
+ shim provides a stand-in for the frame to mitigate that breakage.
+-->
+<html>
+ <head>
+ <meta charset="UTF-8" />
+ <title>SafeFrame Container</title>
+ <script>
+ try {
+ const F = /^([^;]+);(\d+);([\s\S]*)$/.exec(window.name);
+ window.name = "";
+ const P = window.document;
+ P.open("text/html", "replace");
+ P.write(F[3].substr(0, +F[2]));
+ P.close();
+ } catch (e) {
+ console.error(e);
+ }
+ </script>
+ </head>
+ <body></body>
+</html>
diff --git a/browser/extensions/webcompat/shims/history.js b/browser/extensions/webcompat/shims/history.js
new file mode 100644
index 0000000000..6fbd1fdedb
--- /dev/null
+++ b/browser/extensions/webcompat/shims/history.js
@@ -0,0 +1,54 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/*
+ * Bug 1624853 - Shim Storage Access API on history.com
+ *
+ * history.com uses Adobe as a necessary third party to authenticating
+ * with a TV provider. In order to accomodate this, we grant storage access
+ * to the Adobe domain via the Storage Access API when the login or logout
+ * buttons are clicked, then forward the click to continue as normal.
+ */
+
+console.warn(
+ `When using oauth, Firefox calls the Storage Access API on behalf of the site. See https://bugzilla.mozilla.org/show_bug.cgi?id=1624853 for details.`
+);
+
+// Third-party origin we need to request storage access for.
+const STORAGE_ACCESS_ORIGIN = "https://sp.auth.adobe.com";
+
+document.documentElement.addEventListener(
+ "click",
+ e => {
+ const { target, isTrusted } = e;
+ if (!isTrusted) {
+ return;
+ }
+
+ const button = target.closest("a");
+ if (!button) {
+ return;
+ }
+
+ const buttonLink = button.href;
+ if (buttonLink?.startsWith("https://www.history.com/mvpd-auth")) {
+ button.disabled = true;
+ button.style.opacity = 0.5;
+ e.stopPropagation();
+ e.preventDefault();
+ document
+ .requestStorageAccessForOrigin(STORAGE_ACCESS_ORIGIN)
+ .then(() => {
+ target.click();
+ })
+ .catch(() => {
+ button.disabled = false;
+ button.style.opacity = 1.0;
+ });
+ }
+ },
+ true
+);
diff --git a/browser/extensions/webcompat/shims/iam.js b/browser/extensions/webcompat/shims/iam.js
new file mode 100644
index 0000000000..84dee0e484
--- /dev/null
+++ b/browser/extensions/webcompat/shims/iam.js
@@ -0,0 +1,39 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1761774 - Shim INFOnline IAM tracker
+ *
+ * Sites using IAM can break if it is blocked. This shim mitigates that
+ * breakage by loading a stand-in module.
+ */
+
+if (!window.iom?.c) {
+ window.iom = {
+ c: () => {},
+ consent: () => {},
+ count: () => {},
+ ct: () => {},
+ deloptout: () => {},
+ doo: () => {},
+ e: () => {},
+ event: () => {},
+ getInvitation: () => {},
+ getPlus: () => {},
+ gi: () => {},
+ gp: () => {},
+ h: () => {},
+ hybrid: () => {},
+ i: () => {},
+ init: () => {},
+ oi: () => {},
+ optin: () => {},
+ setMultiIdentifier: () => {},
+ setoptout: () => {},
+ smi: () => {},
+ soo: () => {},
+ };
+}
diff --git a/browser/extensions/webcompat/shims/iaspet.js b/browser/extensions/webcompat/shims/iaspet.js
new file mode 100644
index 0000000000..7e19dd52ad
--- /dev/null
+++ b/browser/extensions/webcompat/shims/iaspet.js
@@ -0,0 +1,45 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1713701 - Shim Integral Ad Science iaspet.js
+ *
+ * Some sites use iaspet to place content, often together with Google Publisher
+ * Tags. This shim prevents breakage when the script is blocked.
+ */
+
+if (!window.__iasPET?.VERSION) {
+ let queue = window?.__iasPET?.queue;
+ if (!Array.isArray(queue)) {
+ queue = [];
+ }
+
+ const response = JSON.stringify({
+ brandSafety: {},
+ slots: {},
+ });
+
+ function run(cmd) {
+ try {
+ cmd?.dataHandler?.(response);
+ } catch (_) {}
+ }
+
+ queue.push = run;
+
+ window.__iasPET = {
+ VERSION: "1.16.18",
+ queue,
+ sessionId: "",
+ setTargetingForAppNexus() {},
+ setTargetingForGPT() {},
+ start() {},
+ };
+
+ while (queue.length) {
+ run(queue.shift());
+ }
+}
diff --git a/browser/extensions/webcompat/shims/instagram.js b/browser/extensions/webcompat/shims/instagram.js
new file mode 100644
index 0000000000..5bf5014fdc
--- /dev/null
+++ b/browser/extensions/webcompat/shims/instagram.js
@@ -0,0 +1,55 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/*
+ * Bug 1804445 - instagram login broken with dFPI enabled
+ *
+ * Instagram login with Facebook account requires Facebook to have the storage
+ * access under Instagram. This shim adds a request for storage access for
+ * Facebook when the user tries to log in with a Facebook account.
+ */
+
+console.warn(
+ `When logging in, Firefox calls the Storage Access API on behalf of the site. See https://bugzilla.mozilla.org/show_bug.cgi?id=1804445 for details.`
+);
+
+// Third-party origin we need to request storage access for.
+const STORAGE_ACCESS_ORIGIN = "https://www.facebook.com";
+
+document.documentElement.addEventListener(
+ "click",
+ e => {
+ const { target, isTrusted } = e;
+ if (!isTrusted) {
+ return;
+ }
+ const button = target.closest("button[type=button]");
+ if (!button) {
+ return;
+ }
+ const form = target.closest("#loginForm");
+ if (!form) {
+ return;
+ }
+
+ console.warn(
+ "Calling the Storage Access API on behalf of " + STORAGE_ACCESS_ORIGIN
+ );
+ button.disabled = true;
+ e.stopPropagation();
+ e.preventDefault();
+ document
+ .requestStorageAccessForOrigin(STORAGE_ACCESS_ORIGIN)
+ .then(() => {
+ button.disabled = false;
+ target.click();
+ })
+ .catch(() => {
+ button.disabled = false;
+ });
+ },
+ true
+);
diff --git a/browser/extensions/webcompat/shims/kinja.js b/browser/extensions/webcompat/shims/kinja.js
new file mode 100644
index 0000000000..d30425b42d
--- /dev/null
+++ b/browser/extensions/webcompat/shims/kinja.js
@@ -0,0 +1,44 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals exportFunction */
+
+"use strict";
+
+/**
+ * Kinja powered blogs rely on storage access to https://kinja.com to enable
+ * oauth with external providers. For dFPI, sites need to use the Storage Access
+ * API to gain first party storage access. This shim calls requestStorageAccess
+ * on behalf of the site when a user wants to log in via oauth.
+ */
+
+// Third-party origin we need to request storage access for.
+const STORAGE_ACCESS_ORIGIN = "https://kinja.com";
+
+// Prefix of the path opened in a new window when users click the oauth login
+// buttons.
+const OAUTH_PATH_PREFIX = "/oauthlogin?provider=";
+
+console.warn(
+ `When using oauth, Firefox calls the Storage Access API on behalf of the site. See https://bugzilla.mozilla.org/show_bug.cgi?id=1656171 for details.`
+);
+
+// Overwrite the window.open method so we can detect oauth related popups.
+const origOpen = window.wrappedJSObject.open;
+Object.defineProperty(window.wrappedJSObject, "open", {
+ value: exportFunction((url, ...args) => {
+ // Filter oauth popups.
+ if (!url.startsWith(OAUTH_PATH_PREFIX)) {
+ return origOpen(url, ...args);
+ }
+ // Request storage access for Kinja.
+ document.requestStorageAccessForOrigin(STORAGE_ACCESS_ORIGIN).then(() => {
+ origOpen(url, ...args);
+ });
+ // We don't have the window object yet which window.open returns, since the
+ // sign-in flow is dependent on the async storage access request. This isn't
+ // a problem as long as the website does not consume it.
+ return null;
+ }, window),
+});
diff --git a/browser/extensions/webcompat/shims/live-test-shim.js b/browser/extensions/webcompat/shims/live-test-shim.js
new file mode 100644
index 0000000000..45ab9ba48b
--- /dev/null
+++ b/browser/extensions/webcompat/shims/live-test-shim.js
@@ -0,0 +1,82 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* globals browser */
+
+if (!window.LiveTestShimPromise) {
+ const originalUrl = document.currentScript.src;
+
+ const shimId = "LiveTestShim";
+
+ const sendMessageToAddon = (function () {
+ const pendingMessages = new Map();
+ const channel = new MessageChannel();
+ channel.port1.onerror = console.error;
+ channel.port1.onmessage = event => {
+ const { messageId, response } = event.data;
+ const resolve = pendingMessages.get(messageId);
+ if (resolve) {
+ pendingMessages.delete(messageId);
+ resolve(response);
+ }
+ };
+ function reconnect() {
+ const detail = {
+ pendingMessages: [...pendingMessages.values()],
+ port: channel.port2,
+ shimId,
+ };
+ window.dispatchEvent(new CustomEvent("ShimConnects", { detail }));
+ }
+ window.addEventListener("ShimHelperReady", reconnect);
+ reconnect();
+ return function (message) {
+ const messageId =
+ Math.random().toString(36).substring(2) + Date.now().toString(36);
+ return new Promise(resolve => {
+ const payload = {
+ message,
+ messageId,
+ shimId,
+ };
+ pendingMessages.set(messageId, resolve);
+ channel.port1.postMessage(payload);
+ });
+ };
+ })();
+
+ async function go(options) {
+ try {
+ const o = document.getElementById("shims");
+ const cl = o.classList;
+ cl.remove("red");
+ cl.add("green");
+ o.innerText = JSON.stringify(options || "");
+ } catch (_) {}
+
+ if (window !== top) {
+ return;
+ }
+
+ await sendMessageToAddon("optIn");
+
+ const s = document.createElement("script");
+ s.src = originalUrl;
+ document.head.appendChild(s);
+ }
+
+ window[`${shimId}Promise`] = sendMessageToAddon("getOptions").then(
+ options => {
+ if (document.readyState !== "loading") {
+ go(options);
+ } else {
+ window.addEventListener("DOMContentLoaded", () => {
+ go(options);
+ });
+ }
+ }
+ );
+}
diff --git a/browser/extensions/webcompat/shims/maxmind-geoip.js b/browser/extensions/webcompat/shims/maxmind-geoip.js
new file mode 100644
index 0000000000..e5eb1e45a3
--- /dev/null
+++ b/browser/extensions/webcompat/shims/maxmind-geoip.js
@@ -0,0 +1,69 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1754389 - Shim Maxmind GeoIP library
+ *
+ * Some sites rely on Maxmind's GeoIP library which gets blocked by ETP's
+ * fingerprinter blocking. With the library window global not being defined
+ * functionality may break or the site does not render at all. This shim
+ * has it return the United States as the location for all users.
+ */
+
+if (!window.geoip2) {
+ const continent = {
+ code: "NA",
+ geoname_id: 6255149,
+ names: {
+ de: "Nordamerika",
+ en: "North America",
+ es: "Norteamérica",
+ fr: "Amérique du Nord",
+ ja: "北アメリカ",
+ "pt-BR": "América do Norte",
+ ru: "Северная Америка",
+ "zh-CN": "北美洲",
+ },
+ };
+
+ const country = {
+ geoname_id: 6252001,
+ iso_code: "US",
+ names: {
+ de: "USA",
+ en: "United States",
+ es: "Estados Unidos",
+ fr: "États-Unis",
+ ja: "アメリカ合衆国",
+ "pt-BR": "Estados Unidos",
+ ru: "США",
+ "zh-CN": "美国",
+ },
+ };
+
+ const city = {
+ names: {
+ en: "",
+ },
+ };
+
+ const callback = onSuccess => {
+ requestAnimationFrame(() => {
+ onSuccess({
+ city,
+ continent,
+ country,
+ registered_country: country,
+ });
+ });
+ };
+
+ window.geoip2 = {
+ country: callback,
+ city: callback,
+ insights: callback,
+ };
+}
diff --git a/browser/extensions/webcompat/shims/microsoftLogin.js b/browser/extensions/webcompat/shims/microsoftLogin.js
new file mode 100644
index 0000000000..ebbfb2fbff
--- /dev/null
+++ b/browser/extensions/webcompat/shims/microsoftLogin.js
@@ -0,0 +1,29 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const SANDBOX_ATTR = "allow-storage-access-by-user-activation";
+
+console.warn(
+ "Firefox calls the Storage Access API on behalf of the site. See https://bugzilla.mozilla.org/show_bug.cgi?id=1638383 for details."
+);
+
+// Watches for MS auth iframes and adds missing sandbox attribute. The attribute
+// is required so the third-party iframe can gain access to its first party
+// storage via the Storage Access API.
+function init() {
+ const observer = new MutationObserver(() => {
+ document.body
+ .querySelectorAll("iframe[id^='msalRenewFrame'][sandbox]")
+ .forEach(frame => {
+ frame.sandbox.add(SANDBOX_ATTR);
+ });
+ });
+
+ observer.observe(document.body, {
+ attributes: true,
+ subtree: false,
+ childList: true,
+ });
+}
+window.addEventListener("DOMContentLoaded", init);
diff --git a/browser/extensions/webcompat/shims/microsoftVirtualAssistant.js b/browser/extensions/webcompat/shims/microsoftVirtualAssistant.js
new file mode 100644
index 0000000000..4b4493750c
--- /dev/null
+++ b/browser/extensions/webcompat/shims/microsoftVirtualAssistant.js
@@ -0,0 +1,46 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1801277 - Shim Microsoft virtual assistant.
+ *
+ * The microsoft virtual assistant will break when accessing the indexedDB that
+ * will throw a security error because the virtual assistant is under a
+ * third-party tracking domain 'liveperson.net'. The shim replaces the indexedDB
+ * with a fake interface that won't throw an error.
+ */
+
+/* globals cloneInto */
+
+(function () {
+ const win = window.wrappedJSObject;
+
+ try {
+ // We only replace the indexedDB when liveperson.net is loaded in a
+ // third-party context. Note that this is not strictly correct because
+ // this is a cross-origin check but not a third-party check.
+ if (win.parent == win || win.location.origin == win.top.location.origin) {
+ return;
+ }
+ } catch (e) {
+ // If we get a security error when accessing the top-level origin, this
+ // shows that the window is in a cross-origin context. In this case, we can
+ // proceed to apply the shim.
+ if (e.name != "SecurityError") {
+ throw e;
+ }
+ }
+
+ const emptyMsg = cloneInto({ message: "" }, window);
+
+ const idb = {
+ open: () => win.Promise.reject(emptyMsg),
+ };
+
+ Object.defineProperty(win, "indexedDB", {
+ value: cloneInto(idb, window, { cloneFunctions: true }),
+ });
+})();
diff --git a/browser/extensions/webcompat/shims/moat.js b/browser/extensions/webcompat/shims/moat.js
new file mode 100644
index 0000000000..9957492684
--- /dev/null
+++ b/browser/extensions/webcompat/shims/moat.js
@@ -0,0 +1,46 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1713704 - Shim Moat ad tracker
+ *
+ * Sites such as Forbes may gate content behind Moat ads, resulting in
+ * breakage like black boxes where videos should be placed. This shim
+ * helps mitigate that breakage by allowing the placement to succeed.
+ */
+
+if (!window.moatPrebidAPI?.__A) {
+ const targeting = new Map();
+
+ const slotConfig = {
+ m_categories: ["moat_safe"],
+ m_data: "0",
+ m_safety: "safe",
+ };
+
+ window.moatPrebidApi = {
+ __A() {},
+ disableLogging() {},
+ enableLogging() {},
+ getMoatTargetingForPage: () => slotConfig,
+ getMoatTargetingForSlot(slot) {
+ return targeting.get(slot?.getSlotElementId());
+ },
+ pageDataAvailable: () => true,
+ safetyDataAvailable: () => true,
+ setMoatTargetingForAllSlots() {
+ for (const slot of window.googletag.pubads().getSlots() || []) {
+ targeting.set(slot.getSlotElementId(), slot.getTargeting());
+ }
+ },
+ setMoatTargetingForSlot(slot) {
+ targeting.set(slot?.getSlotElementId(), slotConfig);
+ },
+ slotDataAvailable() {
+ return window.googletag?.pubads().getSlots().length > 0;
+ },
+ };
+}
diff --git a/browser/extensions/webcompat/shims/mochitest-shim-1.js b/browser/extensions/webcompat/shims/mochitest-shim-1.js
new file mode 100644
index 0000000000..d95059cf7a
--- /dev/null
+++ b/browser/extensions/webcompat/shims/mochitest-shim-1.js
@@ -0,0 +1,87 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* globals browser */
+
+if (!window.MochitestShimPromise) {
+ const originalUrl = document.currentScript.src;
+
+ const shimId = "MochitestShim";
+
+ const sendMessageToAddon = (function () {
+ const pendingMessages = new Map();
+ const channel = new MessageChannel();
+ channel.port1.onerror = console.error;
+ channel.port1.onmessage = event => {
+ const { messageId, response } = event.data;
+ const resolve = pendingMessages.get(messageId);
+ if (resolve) {
+ pendingMessages.delete(messageId);
+ resolve(response);
+ }
+ };
+ function reconnect() {
+ const detail = {
+ pendingMessages: [...pendingMessages.values()],
+ port: channel.port2,
+ shimId,
+ };
+ window.dispatchEvent(new CustomEvent("ShimConnects", { detail }));
+ }
+ window.addEventListener("ShimHelperReady", reconnect);
+ reconnect();
+ return function (message) {
+ const messageId =
+ Math.random().toString(36).substring(2) + Date.now().toString(36);
+ return new Promise(resolve => {
+ const payload = {
+ message,
+ messageId,
+ shimId,
+ };
+ pendingMessages.set(messageId, resolve);
+ channel.port1.postMessage(payload);
+ });
+ };
+ })();
+
+ async function go(options) {
+ try {
+ const o = document.getElementById("shims");
+ const cl = o.classList;
+ cl.remove("red");
+ cl.add("green");
+ o.innerText = JSON.stringify(options || "");
+ } catch (_) {}
+
+ window.shimPromiseResolve("shimmed");
+
+ if (window !== top) {
+ window.optInPromiseResolve(false);
+ return;
+ }
+
+ await sendMessageToAddon("optIn");
+
+ window.doingOptIn = true;
+ const s = document.createElement("script");
+ s.src = originalUrl;
+ s.onerror = () => window.optInPromiseResolve("error");
+ document.head.appendChild(s);
+ }
+
+ window[`${shimId}Promise`] = new Promise(resolve => {
+ sendMessageToAddon("getOptions").then(options => {
+ if (document.readyState !== "loading") {
+ resolve(go(options));
+ } else {
+ window.addEventListener("DOMContentLoaded", () => {
+ resolve(go(options));
+ });
+ }
+ });
+ });
+}
diff --git a/browser/extensions/webcompat/shims/mochitest-shim-2.js b/browser/extensions/webcompat/shims/mochitest-shim-2.js
new file mode 100644
index 0000000000..bc5536405e
--- /dev/null
+++ b/browser/extensions/webcompat/shims/mochitest-shim-2.js
@@ -0,0 +1,85 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* globals browser */
+
+if (!window.testPromise) {
+ const originalUrl = document.currentScript.src;
+
+ const shimId = "MochitestShim2";
+
+ const sendMessageToAddon = (function () {
+ const pendingMessages = new Map();
+ const channel = new MessageChannel();
+ channel.port1.onerror = console.error;
+ channel.port1.onmessage = event => {
+ const { messageId, response } = event.data;
+ const resolve = pendingMessages.get(messageId);
+ if (resolve) {
+ pendingMessages.delete(messageId);
+ resolve(response);
+ }
+ };
+ function reconnect() {
+ const detail = {
+ pendingMessages: [...pendingMessages.values()],
+ port: channel.port2,
+ shimId,
+ };
+ window.dispatchEvent(new CustomEvent("ShimConnects", { detail }));
+ }
+ window.addEventListener("ShimHelperReady", reconnect);
+ reconnect();
+ return function (message) {
+ const messageId =
+ Math.random().toString(36).substring(2) + Date.now().toString(36);
+ return new Promise(resolve => {
+ const payload = {
+ message,
+ messageId,
+ shimId,
+ };
+ pendingMessages.set(messageId, resolve);
+ channel.port1.postMessage(payload);
+ });
+ };
+ })();
+
+ async function go(options) {
+ try {
+ const o = document.getElementById("shims");
+ const cl = o.classList;
+ cl.remove("red");
+ cl.add("green");
+ o.innerText = JSON.stringify(options || "");
+ } catch (_) {}
+
+ window.shimPromiseResolve("shimmed");
+
+ if (window !== top) {
+ window.optInPromiseResolve(false);
+ return;
+ }
+
+ await sendMessageToAddon("optIn");
+
+ window.doingOptIn = true;
+ const s = document.createElement("script");
+ s.src = originalUrl;
+ s.onerror = () => window.optInPromiseResolve("error");
+ document.head.appendChild(s);
+ }
+
+ sendMessageToAddon("getOptions").then(options => {
+ if (document.readyState !== "loading") {
+ go(options);
+ } else {
+ window.addEventListener("DOMContentLoaded", () => {
+ go(options);
+ });
+ }
+ });
+}
diff --git a/browser/extensions/webcompat/shims/mochitest-shim-3.js b/browser/extensions/webcompat/shims/mochitest-shim-3.js
new file mode 100644
index 0000000000..dc0a8005f5
--- /dev/null
+++ b/browser/extensions/webcompat/shims/mochitest-shim-3.js
@@ -0,0 +1,7 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+window.shimPromiseResolve("shimmed");
diff --git a/browser/extensions/webcompat/shims/nielsen.js b/browser/extensions/webcompat/shims/nielsen.js
new file mode 100644
index 0000000000..d34528a7c1
--- /dev/null
+++ b/browser/extensions/webcompat/shims/nielsen.js
@@ -0,0 +1,111 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1760754 - Shim Nielsen tracker
+ *
+ * Sites expecting the Nielsen tracker to load properly can break if it
+ * is blocked. This shim mitigates that breakage by loading a stand-in.
+ */
+
+if (!window.nol_t) {
+ const cid = "";
+
+ let domain = "";
+ let schemeHost = "";
+ let scriptName = "";
+ try {
+ const url = document?.currentScript?.src;
+ const { pathname, protocol, host } = new URL(url);
+ domain = host.split(".").slice(0, -2).join(".");
+ schemeHost = `${protocol}//${host}/`;
+ scriptName = pathname.split("/").pop();
+ } catch (_) {}
+
+ const NolTracker = class {
+ CONST = {
+ max_tags: 20,
+ };
+ feat = {};
+ globals = {
+ cid,
+ content: "0",
+ defaultApidFile: "config250",
+ defaultErrorParams: {
+ nol_vcid: "c00",
+ nol_clientid: "",
+ },
+ domain,
+ fpidSfCodeList: [""],
+ init() {},
+ tagCurrRetry: -1,
+ tagMaxRetry: 3,
+ wlCurrRetry: -1,
+ wlMaxRetry: 3,
+ };
+ pmap = [];
+ pvar = {
+ cid,
+ content: "0",
+ cookies_enabled: "n",
+ server: domain,
+ };
+ scriptName = [scriptName];
+ version = "6.0.107";
+
+ addScript() {}
+ catchLinkOverlay() {}
+ clickEvent() {}
+ clickTrack() {}
+ do_sample() {}
+ downloadEvent() {}
+ eventTrack() {}
+ filter() {}
+ fireToUrl() {}
+ getSchemeHost() {
+ return schemeHost;
+ }
+ getVersion() {}
+ iframe() {}
+ in_sample() {
+ return true;
+ }
+ injectBsdk() {}
+ invite() {}
+ linkTrack() {}
+ mergeFeatures() {}
+ pageEvent() {}
+ pause() {}
+ populateWhitelist() {}
+ post() {}
+ postClickTrack() {}
+ postData() {}
+ postEvent() {}
+ postEventTrack() {}
+ postLinkTrack() {}
+ prefix() {
+ return "";
+ }
+ processDdrsSvc() {}
+ random() {}
+ record() {
+ return this;
+ }
+ regLinkOverlay() {}
+ regListen() {}
+ retrieveCiFileViaCors() {}
+ sectionEvent() {}
+ sendALink() {}
+ sendForm() {}
+ sendIt() {}
+ slideEvent() {}
+ whitelistAssigned() {}
+ };
+
+ window.nol_t = () => {
+ return new NolTracker();
+ };
+}
diff --git a/browser/extensions/webcompat/shims/optimizely.js b/browser/extensions/webcompat/shims/optimizely.js
new file mode 100644
index 0000000000..dcda87421d
--- /dev/null
+++ b/browser/extensions/webcompat/shims/optimizely.js
@@ -0,0 +1,205 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Bug 1714431 - Shim Optimizely
+ *
+ * This shim stubs out window.optimizely for those sites which
+ * break when it is not successfully loaded.
+ */
+
+if (!window.optimizely?.state) {
+ const behavior = {
+ query: () => [],
+ };
+
+ const dcp = {
+ getAttributeValue() {},
+ waitForAttributeValue: () => Promise.resolve(),
+ };
+
+ const data = {
+ accountId: "",
+ audiences: {},
+ campaigns: {},
+ clientName: "js",
+ clientVersion: "0.166.0",
+ dcpServiceId: null,
+ events: {},
+ experiments: {},
+ groups: {},
+ pages: {},
+ projectId: "",
+ revision: "",
+ snippetId: null,
+ variations: {},
+ };
+
+ const activationId = String(Date.now());
+
+ const state = {
+ getActivationId() {
+ return activationId;
+ },
+ getActiveExperimentIds() {
+ return [];
+ },
+ getCampaignStateLists() {
+ return {};
+ },
+ getCampaignStates() {
+ return {};
+ },
+ getDecisionObject() {
+ return null;
+ },
+ getDecisionString() {
+ return null;
+ },
+ getExperimentStates() {
+ return {};
+ },
+ getPageStates() {
+ return {};
+ },
+ getRedirectInfo() {
+ return null;
+ },
+ getVariationMap() {
+ return {};
+ },
+ isGlobalHoldback() {
+ return false;
+ },
+ };
+
+ const poll = (fn, to) => {
+ setInterval(() => {
+ try {
+ fn();
+ } catch (_) {}
+ }, to);
+ };
+
+ const waitUntil = test => {
+ let interval, resolve;
+ function check() {
+ try {
+ if (test()) {
+ clearInterval(interval);
+ resolve?.();
+ return true;
+ }
+ } catch (_) {}
+ return false;
+ }
+ return new Promise(r => {
+ resolve = r;
+ if (check()) {
+ resolve();
+ return;
+ }
+ interval = setInterval(check, 250);
+ });
+ };
+
+ const waitForElement = sel => {
+ return waitUntil(() => {
+ document.querySelector(sel);
+ });
+ };
+
+ const observeSelector = (sel, fn, opts) => {
+ let interval;
+ const observed = new Set();
+ function check() {
+ try {
+ for (const e of document.querySelectorAll(sel)) {
+ if (observed.has(e)) {
+ continue;
+ }
+ observed.add(e);
+ try {
+ fn(e);
+ } catch (_) {}
+ if (opts.once) {
+ clearInterval(interval);
+ }
+ }
+ } catch (_) {}
+ }
+ interval = setInterval(check, 250);
+ const timeout = { opts };
+ if (timeout) {
+ setTimeout(() => {
+ clearInterval(interval);
+ }, timeout);
+ }
+ };
+
+ const utils = {
+ Promise: window.Promise,
+ observeSelector,
+ poll,
+ waitForElement,
+ waitUntil,
+ };
+
+ const visitorId = {
+ randomId: "",
+ };
+
+ let browserVersion = "";
+ try {
+ browserVersion = navigator.userAgent.match(/rv:(.*)\)/)[1];
+ } catch (_) {}
+
+ const visitor = {
+ browserId: "ff",
+ browserVersion,
+ currentTimestamp: Date.now(),
+ custom: {},
+ customBehavior: {},
+ device: "desktop",
+ device_type: "desktop_laptop",
+ events: [],
+ first_session: true,
+ offset: 240,
+ referrer: null,
+ source_type: "direct",
+ visitorId,
+ };
+
+ window.optimizely = {
+ data: {
+ note: "Obsolete, use optimizely.get('data') instead",
+ },
+ get(e) {
+ switch (e) {
+ case "behavior":
+ return behavior;
+ case "data":
+ return data;
+ case "dcp":
+ return dcp;
+ case "jquery":
+ throw new Error("jQuery not implemented");
+ case "session":
+ return undefined;
+ case "state":
+ return state;
+ case "utils":
+ return utils;
+ case "visitor":
+ return visitor;
+ case "visitor_id":
+ return visitorId;
+ }
+ return undefined;
+ },
+ initialized: true,
+ push() {},
+ state: {},
+ };
+}
diff --git a/browser/extensions/webcompat/shims/play.svg b/browser/extensions/webcompat/shims/play.svg
new file mode 100644
index 0000000000..df5bbcb4f1
--- /dev/null
+++ b/browser/extensions/webcompat/shims/play.svg
@@ -0,0 +1,7 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ source: https://searchfox.org/mozilla-central/source/devtools/client/themes/images/play.svg -->
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
+ <path fill="#fff" d="M20.436 11.37L5.904 2.116c-.23-.147-.523-.158-.762-.024-.24.132-.39.384-.39.657v18.5c0 .273.15.525.39.657.112.063.236.093.36.093.14 0 .28-.04.402-.117l14.53-9.248c.218-.138.35-.376.35-.633 0-.256-.132-.495-.348-.633z"/>
+</svg>
diff --git a/browser/extensions/webcompat/shims/private-browsing-web-api-fixes.js b/browser/extensions/webcompat/shims/private-browsing-web-api-fixes.js
new file mode 100644
index 0000000000..bc45aeda26
--- /dev/null
+++ b/browser/extensions/webcompat/shims/private-browsing-web-api-fixes.js
@@ -0,0 +1,17 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1714354 - Fix for site issues with web APIs in private browsing
+ *
+ * Some sites expect specific DOM APIs to work in specific ways, which
+ * is not always true, such as in private browsing mode. We work around
+ * related breakage by undefining those APIs entirely in private
+ * browsing mode for those sites.
+ */
+
+delete window.wrappedJSObject.caches;
+delete window.wrappedJSObject.indexedDB;
diff --git a/browser/extensions/webcompat/shims/rambler-authenticator.js b/browser/extensions/webcompat/shims/rambler-authenticator.js
new file mode 100644
index 0000000000..1fe074b660
--- /dev/null
+++ b/browser/extensions/webcompat/shims/rambler-authenticator.js
@@ -0,0 +1,84 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+if (!window.ramblerIdHelper) {
+ const originalScript = document.currentScript.src;
+
+ const sendMessageToAddon = (function () {
+ const shimId = "Rambler";
+ const pendingMessages = new Map();
+ const channel = new MessageChannel();
+ channel.port1.onerror = console.error;
+ channel.port1.onmessage = event => {
+ const { messageId, response } = event.data;
+ const resolve = pendingMessages.get(messageId);
+ if (resolve) {
+ pendingMessages.delete(messageId);
+ resolve(response);
+ }
+ };
+ function reconnect() {
+ const detail = {
+ pendingMessages: [...pendingMessages.values()],
+ port: channel.port2,
+ shimId,
+ };
+ window.dispatchEvent(new CustomEvent("ShimConnects", { detail }));
+ }
+ window.addEventListener("ShimHelperReady", reconnect);
+ reconnect();
+ return function (message) {
+ const messageId =
+ Math.random().toString(36).substring(2) + Date.now().toString(36);
+ return new Promise(resolve => {
+ const payload = {
+ message,
+ messageId,
+ shimId,
+ };
+ pendingMessages.set(messageId, resolve);
+ channel.port1.postMessage(payload);
+ });
+ };
+ })();
+
+ const ramblerIdHelper = {
+ getProfileInfo: (successCallback, errorCallback) => {
+ successCallback({});
+ },
+ openAuth: () => {
+ sendMessageToAddon("optIn").then(function () {
+ const openAuthArgs = arguments;
+ window.ramblerIdHelper = undefined;
+ const s = document.createElement("script");
+ s.src = originalScript;
+ document.head.appendChild(s);
+ s.addEventListener("load", () => {
+ const helper = window.ramblerIdHelper;
+ for (const { fn, args } of callLog) {
+ helper[fn].apply(helper, args);
+ }
+ helper.openAuth.apply(helper, openAuthArgs);
+ });
+ });
+ },
+ };
+
+ const callLog = [];
+ function addLoggedCall(fn) {
+ ramblerIdHelper[fn] = () => {
+ callLog.push({ fn, args: arguments });
+ };
+ }
+
+ addLoggedCall("registerOnFrameCloseCallback");
+ addLoggedCall("registerOnFrameRedirect");
+ addLoggedCall("registerOnPossibleLoginCallback");
+ addLoggedCall("registerOnPossibleLogoutCallback");
+ addLoggedCall("registerOnPossibleOauthLoginCallback");
+
+ window.ramblerIdHelper = ramblerIdHelper;
+}
diff --git a/browser/extensions/webcompat/shims/rich-relevance.js b/browser/extensions/webcompat/shims/rich-relevance.js
new file mode 100644
index 0000000000..aea85c030a
--- /dev/null
+++ b/browser/extensions/webcompat/shims/rich-relevance.js
@@ -0,0 +1,288 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1713725 - Shim Rich Relevance personalized shopping
+ *
+ * Sites may expect the Rich Relevance personalized shopping API to load,
+ * breaking if it is blocked. This shim attempts to limit breakage on those
+ * site to just the personalized shopping aspects, by stubbing out the APIs.
+ */
+
+if (!window.r3_common) {
+ const jsonCallback = window.RR?.jsonCallback;
+ const defaultCallback = window.RR?.defaultCallback;
+
+ const getRandomString = (l = 66) => {
+ const v = crypto.getRandomValues(new Uint8Array(l));
+ const s = Array.from(v, c => c.toString(16)).join("");
+ return s.slice(0, l);
+ };
+
+ const call = (fn, ...args) => {
+ if (typeof fn === "function") {
+ try {
+ fn(...args);
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ };
+
+ class r3_generic {
+ type = "GENERIC";
+ createScript() {}
+ destroy() {}
+ }
+
+ class r3_addtocart extends r3_generic {
+ type = "ADDTOCART";
+ addItemIdToCart() {}
+ }
+
+ class r3_addtoregistry extends r3_generic {
+ type = "ADDTOREGISTRY";
+ addItemIdCentsQuantity() {}
+ }
+
+ class r3_brand extends r3_generic {
+ type = "BRAND";
+ }
+
+ class r3_cart extends r3_generic {
+ type = "CART";
+ addItemId() {}
+ addItemIdCentsQuantity() {}
+ addItemIdDollarsAndCentsQuantity() {}
+ addItemIdPriceQuantity() {}
+ }
+
+ class r3_category extends r3_generic {
+ type = "CATEGORY";
+ addItemId() {}
+ setId() {}
+ setName() {}
+ setParentId() {}
+ setTopName() {}
+ }
+
+ class r3_common extends r3_generic {
+ type = "COMMON";
+ baseUrl = "https://recs.richrelevance.com/rrserver/";
+ devFlags = {};
+ jsFileName = "p13n_generated.js";
+ RICHSORT = {
+ paginate() {},
+ filterPrice() {},
+ filterAttribute() {},
+ };
+ addCategoryHintId() {}
+ addClickthruParams() {}
+ addContext() {}
+ addFilter() {}
+ addFilterBrand() {}
+ addFilterCategory() {}
+ addItemId() {}
+ addItemIdToCart() {}
+ addPlacementType() {}
+ addRefinement() {}
+ addSearchTerm() {}
+ addSegment() {}
+ blockItemId() {}
+ enableCfrad() {}
+ enableRad() {}
+ forceDebugMode() {}
+ forceDevMode() {}
+ forceDisplayMode() {}
+ forceLocale() {}
+ initFromParams() {}
+ setApiKey() {}
+ setBaseUrl() {}
+ setCartValue() {}
+ setChannel() {}
+ setClickthruServer() {}
+ setCurrency() {}
+ setDeviceId() {}
+ setFilterBrandsIncludeMatchingElements() {}
+ setForcedTreatment() {}
+ setImageServer() {}
+ setLanguage() {}
+ setMVTForcedTreatment() {}
+ setNoCookieMode() {}
+ setPageBrand() {}
+ setPrivateMode() {}
+ setRefinementFallback() {}
+ setRegionId() {}
+ setRegistryId() {}
+ setRegistryType() {}
+ setSessionId() {}
+ setUserId() {}
+ useDummyData() {}
+ }
+
+ class r3_error extends r3_generic {
+ type = "ERROR";
+ }
+
+ class r3_home extends r3_generic {
+ type = "HOME";
+ }
+
+ class r3_item extends r3_generic {
+ type = "ITEM";
+ addAttribute() {}
+ addCategory() {}
+ addCategoryId() {}
+ setBrand() {}
+ setEndDate() {}
+ setId() {}
+ setImageId() {}
+ setLinkId() {}
+ setName() {}
+ setPrice() {}
+ setRating() {}
+ setRecommendable() {}
+ setReleaseDate() {}
+ setSalePrice() {}
+ }
+
+ class r3_personal extends r3_generic {
+ type = "PERSONAL";
+ }
+
+ class r3_purchased extends r3_generic {
+ type = "PURCHASED";
+ addItemId() {}
+ addItemIdCentsQuantity() {}
+ addItemIdDollarsAndCentsQuantity() {}
+ addItemIdPriceQuantity() {}
+ setOrderNumber() {}
+ setPromotionCode() {}
+ setShippingCost() {}
+ setTaxes() {}
+ setTotalPrice() {}
+ }
+
+ class r3_search extends r3_generic {
+ type = "SEARCH";
+ addItemId() {}
+ setTerms() {}
+ }
+
+ class r3_wishlist extends r3_generic {
+ type = "WISHLIST";
+ addItemId() {}
+ }
+
+ const RR = {
+ add() {},
+ addItemId() {},
+ addItemIdCentsQuantity() {},
+ addItemIdDollarsAndCentsQuantity() {},
+ addItemIdPriceQuantity() {},
+ addItemIdToCart() {},
+ addObject() {},
+ addSearchTerm() {},
+ c() {},
+ charset: "UTF-8",
+ checkParamCookieValue: () => null,
+ d: document,
+ data: {
+ JSON: {
+ placements: [],
+ },
+ },
+ debugWindow() {},
+ set defaultCallback(fn) {
+ call(fn);
+ },
+ fixName: n => n,
+ genericAddItemPriceQuantity() {},
+ get() {},
+ getDomElement(a) {
+ return typeof a === "string" && a ? document.querySelector(a) : null;
+ },
+ id() {},
+ insert() {},
+ insertDynamicPlacement() {},
+ isArray: a => Array.isArray(a),
+ js() {},
+ set jsonCallback(fn) {
+ call(fn, {});
+ },
+ l: document.location.href,
+ lc() {},
+ noCookieMode: false,
+ ol() {},
+ onloadCalled: true,
+ pq() {},
+ rcsCookieDefaultDuration: 364,
+ registerPageType() {},
+ registeredPageTypes: {
+ ADDTOCART: r3_addtocart,
+ ADDTOREGISTRY: r3_addtoregistry,
+ BRAND: r3_brand,
+ CART: r3_cart,
+ CATEGORY: r3_category,
+ COMMON: r3_common,
+ ERROR: r3_error,
+ GENERIC: r3_generic,
+ HOME: r3_home,
+ ITEM: r3_item,
+ PERSONAL: r3_personal,
+ PURCHASED: r3_purchased,
+ SEARCH: r3_search,
+ WISHLIST: r3_wishlist,
+ },
+ renderDynamicPlacements() {},
+ set() {},
+ setCharset() {},
+ U: "undefined",
+ unregisterAllPageType() {},
+ unregisterPageType() {},
+ };
+
+ Object.assign(window, {
+ r3() {},
+ r3_addtocart,
+ r3_addtoregistry,
+ r3_brand,
+ r3_cart,
+ r3_category,
+ r3_common,
+ r3_error,
+ r3_generic,
+ r3_home,
+ r3_item,
+ r3_personal,
+ r3_placement() {},
+ r3_purchased,
+ r3_search,
+ r3_wishlist,
+ RR,
+ rr_addLoadEvent() {},
+ rr_annotations_array: [undefined],
+ rr_call_after_flush() {},
+ rr_create_script() {},
+ rr_dynamic: {
+ placements: [],
+ },
+ rr_flush() {},
+ rr_flush_onload() {},
+ rr_insert_placement() {},
+ rr_onload_called: true,
+ rr_placement_place_holders: [],
+ rr_placements: [],
+ rr_recs: {
+ placements: [],
+ },
+ rr_remote_data: getRandomString(),
+ rr_v: "1.2.6.20210212",
+ });
+
+ call(jsonCallback);
+ call(defaultCallback, {});
+}
diff --git a/browser/extensions/webcompat/shims/spotify-embed.js b/browser/extensions/webcompat/shims/spotify-embed.js
new file mode 100644
index 0000000000..62ad05b725
--- /dev/null
+++ b/browser/extensions/webcompat/shims/spotify-embed.js
@@ -0,0 +1,133 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals exportFunction */
+
+"use strict";
+
+/**
+ * Spotify embeds default to "track preview mode". They require first-party
+ * storage access in order to detect the login status and allow the user to play
+ * the whole song or add it to their library.
+ * Upon clicking the "play" button in the preview view this shim attempts to get
+ * storage access and on success, reloads the frame and plays the full track.
+ * This only works if the user is already logged in to Spotify in the
+ * first-party context.
+ */
+
+const AUTOPLAY_FLAG = "shimPlayAfterStorageAccess";
+const SELECTOR_PREVIEW_PLAY = 'div[data-testid="preview-play-pause"] > button';
+const SELECTOR_FULL_PLAY = 'button[data-testid="play-pause-button"]';
+
+/**
+ * Promise-wrapper around DOMContentLoaded event.
+ */
+function waitForDOMContentLoaded() {
+ return new Promise(resolve => {
+ window.addEventListener("DOMContentLoaded", resolve, { once: true });
+ });
+}
+
+/**
+ * Listener for the preview playback button which requests storage access and
+ * reloads the page.
+ */
+function previewPlayButtonListener(event) {
+ const { target, isTrusted } = event;
+ if (!isTrusted) {
+ return;
+ }
+
+ const button = target.closest("button");
+ if (!button) {
+ return;
+ }
+
+ // Filter for the preview playback button. This won't match the full
+ // playback button that is shown when the user is logged in.
+ if (!button.matches(SELECTOR_PREVIEW_PLAY)) {
+ return;
+ }
+
+ // The storage access request below runs async so playback won't start
+ // immediately. Mitigate this UX issue by updating the clicked element's
+ // style so the user gets some immediate feedback.
+ button.style.opacity = 0.5;
+ event.stopPropagation();
+ event.preventDefault();
+
+ console.debug("Requesting storage access.", location.origin);
+ document
+ .requestStorageAccess()
+ // When storage access is granted, reload the frame for the embedded
+ // player to detect the login state and give us full playback
+ // capabilities.
+ .then(() => {
+ // Use a flag to indicate that we want to click play after reload.
+ // This is so the user does not have to click play twice.
+ sessionStorage.setItem(AUTOPLAY_FLAG, "true");
+ console.debug("Reloading after storage access grant.");
+ location.reload();
+ })
+ // If the user denies the storage access prompt we can't use the login
+ // state. Attempt start preview playback instead.
+ .catch(() => {
+ button.click();
+ })
+ // Reset button style for both success and error case.
+ .finally(() => {
+ button.style.opacity = 1.0;
+ });
+}
+
+/**
+ * Attempt to start (full) playback. Waits for the play button to appear and
+ * become ready.
+ */
+async function startFullPlayback() {
+ // Wait for DOMContentLoaded before looking for the playback button.
+ await waitForDOMContentLoaded();
+
+ let numTries = 0;
+ let intervalId = setInterval(() => {
+ try {
+ document.querySelector(SELECTOR_FULL_PLAY).click();
+ clearInterval(intervalId);
+ console.debug("Clicked play after storage access grant.");
+ } catch (e) {}
+ numTries++;
+
+ if (numTries >= 50) {
+ console.debug("Can not start playback. Giving up.");
+ clearInterval(intervalId);
+ }
+ }, 200);
+}
+
+(async () => {
+ // Only run the shim for embedded iframes.
+ if (window.top == window) {
+ return;
+ }
+
+ console.warn(
+ `When using the Spotify embedded player, Firefox calls the Storage Access API on behalf of the site. See https://bugzilla.mozilla.org/show_bug.cgi?id=1792395 for details.`
+ );
+
+ // Already requested storage access before the reload, trigger playback.
+ if (sessionStorage.getItem(AUTOPLAY_FLAG) == "true") {
+ sessionStorage.removeItem(AUTOPLAY_FLAG);
+
+ await startFullPlayback();
+ return;
+ }
+
+ // Wait for the user to click the preview play button. If the player has
+ // already loaded the full version, this method will do nothing.
+ document.documentElement.addEventListener(
+ "click",
+ previewPlayButtonListener,
+ { capture: true }
+ );
+})();
diff --git a/browser/extensions/webcompat/shims/tracking-pixel.png b/browser/extensions/webcompat/shims/tracking-pixel.png
new file mode 100644
index 0000000000..52c591798e
--- /dev/null
+++ b/browser/extensions/webcompat/shims/tracking-pixel.png
Binary files differ
diff --git a/browser/extensions/webcompat/shims/vast2.xml b/browser/extensions/webcompat/shims/vast2.xml
new file mode 100644
index 0000000000..3536ccfc0f
--- /dev/null
+++ b/browser/extensions/webcompat/shims/vast2.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+ Bug 1713693 - Shim Doubleclick
+
+ Some sites rely on an XML VAST Ad response from Doubleclick, or will
+ break (showing black boxes instead of videos, etc). This shim mitigates
+ such breakage.
+-->
+<VAST version="2.0"></VAST>
diff --git a/browser/extensions/webcompat/shims/vast3.xml b/browser/extensions/webcompat/shims/vast3.xml
new file mode 100644
index 0000000000..ae03f0dc14
--- /dev/null
+++ b/browser/extensions/webcompat/shims/vast3.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+ Bug 1713693 - Shim Doubleclick
+
+ Some sites rely on an XML VAST Ad response from Doubleclick, or will
+ break (showing black boxes instead of videos, etc). This shim mitigates
+ such breakage.
+-->
+<VAST version="3.0"></VAST>
diff --git a/browser/extensions/webcompat/shims/vidible.js b/browser/extensions/webcompat/shims/vidible.js
new file mode 100644
index 0000000000..1d45bc0f7e
--- /dev/null
+++ b/browser/extensions/webcompat/shims/vidible.js
@@ -0,0 +1,424 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1713710 - Shim Vidible video player
+ *
+ * Sites relying on Vidible's video player may experience broken videos if that
+ * script is blocked. This shim allows users to opt into viewing those videos
+ * regardless of any tracking consequences, by providing placeholders for each.
+ */
+
+if (!window.vidible?.version) {
+ const PlayIconURL = "https://smartblock.firefox.etp/play.svg";
+
+ const originalScript = document.currentScript.src;
+
+ const getGUID = () => {
+ const v = crypto.getRandomValues(new Uint8Array(20));
+ return Array.from(v, c => c.toString(16)).join("");
+ };
+
+ const sendMessageToAddon = (function () {
+ const shimId = "Vidible";
+ const pendingMessages = new Map();
+ const channel = new MessageChannel();
+ channel.port1.onerror = console.error;
+ channel.port1.onmessage = event => {
+ const { messageId, response } = event.data;
+ const resolve = pendingMessages.get(messageId);
+ if (resolve) {
+ pendingMessages.delete(messageId);
+ resolve(response);
+ }
+ };
+ function reconnect() {
+ const detail = {
+ pendingMessages: [...pendingMessages.values()],
+ port: channel.port2,
+ shimId,
+ };
+ window.dispatchEvent(new CustomEvent("ShimConnects", { detail }));
+ }
+ window.addEventListener("ShimHelperReady", reconnect);
+ reconnect();
+ return function (message) {
+ const messageId = getGUID();
+ return new Promise(resolve => {
+ const payload = { message, messageId, shimId };
+ pendingMessages.set(messageId, resolve);
+ channel.port1.postMessage(payload);
+ });
+ };
+ })();
+
+ const Shimmer = (function () {
+ // If a page might store references to an object before we replace it,
+ // ensure that it only receives proxies to that object created by
+ // `Shimmer.proxy(obj)`. Later when the unshimmed object is created,
+ // call `Shimmer.unshim(proxy, unshimmed)`. This way the references
+ // will automatically "become" the unshimmed object when appropriate.
+
+ const shimmedObjects = new WeakMap();
+ const unshimmedObjects = new Map();
+
+ function proxy(shim) {
+ if (shimmedObjects.has(shim)) {
+ return shimmedObjects.get(shim);
+ }
+
+ const prox = new Proxy(shim, {
+ get: (target, k) => {
+ if (unshimmedObjects.has(prox)) {
+ return unshimmedObjects.get(prox)[k];
+ }
+ return target[k];
+ },
+ apply: (target, thisArg, args) => {
+ if (unshimmedObjects.has(prox)) {
+ return unshimmedObjects.get(prox)(...args);
+ }
+ return target.apply(thisArg, args);
+ },
+ construct: (target, args) => {
+ if (unshimmedObjects.has(prox)) {
+ return new unshimmedObjects.get(prox)(...args);
+ }
+ return new target(...args);
+ },
+ });
+ shimmedObjects.set(shim, prox);
+ shimmedObjects.set(prox, prox);
+
+ for (const key in shim) {
+ const value = shim[key];
+ if (typeof value === "function") {
+ shim[key] = function () {
+ const unshimmed = unshimmedObjects.get(prox);
+ if (unshimmed) {
+ return unshimmed[key].apply(unshimmed, arguments);
+ }
+ return value.apply(this, arguments);
+ };
+ } else if (typeof value !== "object" || value === null) {
+ shim[key] = value;
+ } else {
+ shim[key] = Shimmer.proxy(value);
+ }
+ }
+
+ return prox;
+ }
+
+ function unshim(shim, unshimmed) {
+ unshimmedObjects.set(shim, unshimmed);
+
+ for (const prop in shim) {
+ if (prop in unshimmed) {
+ const un = unshimmed[prop];
+ if (typeof un === "object" && un !== null) {
+ unshim(shim[prop], un);
+ }
+ } else {
+ unshimmedObjects.set(shim[prop], undefined);
+ }
+ }
+ }
+
+ return { proxy, unshim };
+ })();
+
+ const extras = [];
+ const playersByNode = new WeakMap();
+ const playerData = new Map();
+
+ const getJSONPVideoPlacements = () => {
+ return document.querySelectorAll(
+ `script[src*="delivery.vidible.tv/jsonp"]`
+ );
+ };
+
+ const allowVidible = () => {
+ if (allowVidible.promise) {
+ return allowVidible.promise;
+ }
+
+ const shim = window.vidible;
+ window.vidible = undefined;
+
+ allowVidible.promise = sendMessageToAddon("optIn")
+ .then(() => {
+ return new Promise((resolve, reject) => {
+ const script = document.createElement("script");
+ script.src = originalScript;
+ script.addEventListener("load", () => {
+ Shimmer.unshim(shim, window.vidible);
+
+ for (const args of extras) {
+ window.visible.registerExtra(...args);
+ }
+
+ for (const jsonp of getJSONPVideoPlacements()) {
+ const { src } = jsonp;
+ const jscript = document.createElement("script");
+ jscript.onload = resolve;
+ jscript.src = src;
+ jsonp.replaceWith(jscript);
+ }
+
+ for (const [playerShim, data] of playerData.entries()) {
+ const { loadCalled, on, parent, placeholder, setup } = data;
+
+ placeholder?.remove();
+
+ const player = window.vidible.player(parent);
+ Shimmer.unshim(playerShim, player);
+
+ for (const [type, fns] of on.entries()) {
+ for (const fn of fns) {
+ try {
+ player.on(type, fn);
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ }
+
+ if (setup) {
+ player.setup(setup);
+ }
+
+ if (loadCalled) {
+ player.load();
+ }
+ }
+
+ resolve();
+ });
+
+ script.addEventListener("error", () => {
+ script.remove();
+ reject();
+ });
+
+ document.head.appendChild(script);
+ });
+ })
+ .catch(() => {
+ window.vidible = shim;
+ delete allowVidible.promise;
+ });
+
+ return allowVidible.promise;
+ };
+
+ const createVideoPlaceholder = (service, callback) => {
+ const placeholder = document.createElement("div");
+ placeholder.style = `
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ min-width: 160px;
+ min-height: 100px;
+ top: 0px;
+ left: 0px;
+ background: #000;
+ color: #fff;
+ text-align: center;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background-image: url(${PlayIconURL});
+ background-position: 50% 47.5%;
+ background-repeat: no-repeat;
+ background-size: 25% 25%;
+ -moz-text-size-adjust: none;
+ -moz-user-select: none;
+ color: #fff;
+ align-items: center;
+ padding-top: 200px;
+ font-size: 14pt;
+ `;
+ placeholder.textContent = `Click to allow blocked ${service} content`;
+ placeholder.addEventListener("click", evt => {
+ evt.isTrusted && callback();
+ });
+ return placeholder;
+ };
+
+ const Player = function (parent) {
+ const existing = playersByNode.get(parent);
+ if (existing) {
+ return existing;
+ }
+
+ const player = Shimmer.proxy(this);
+ playersByNode.set(parent, player);
+
+ const placeholder = createVideoPlaceholder("Vidible", allowVidible);
+ parent.parentNode.insertBefore(placeholder, parent);
+
+ playerData.set(player, {
+ on: new Map(),
+ parent,
+ placeholder,
+ });
+ return player;
+ };
+
+ const changeData = function (fn) {
+ const data = playerData.get(this);
+ if (data) {
+ fn(data);
+ playerData.set(this, data);
+ }
+ };
+
+ Player.prototype = {
+ addEventListener() {},
+ destroy() {
+ const { placeholder } = playerData.get(this);
+ placeholder?.remove();
+ playerData.delete(this);
+ },
+ dispatchEvent() {},
+ getAdsPassedTime() {},
+ getAllMacros() {},
+ getCurrentTime() {},
+ getDuration() {},
+ getHeight() {},
+ getPixelsLog() {},
+ getPlayerContainer() {},
+ getPlayerInfo() {},
+ getPlayerStatus() {},
+ getRequestsLog() {},
+ getStripUrl() {},
+ getVolume() {},
+ getWidth() {},
+ hidePlayReplayControls() {},
+ isMuted() {},
+ isPlaying() {},
+ load() {
+ changeData(data => (data.loadCalled = true));
+ },
+ mute() {},
+ on(type, fn) {
+ changeData(({ on }) => {
+ if (!on.has(type)) {
+ on.set(type, new Set());
+ }
+ on.get(type).add(fn);
+ });
+ },
+ off(type, fn) {
+ changeData(({ on }) => {
+ on.get(type)?.delete(fn);
+ });
+ },
+ overrideMacro() {},
+ pause() {},
+ play() {},
+ playVideoByIndex() {},
+ removeEventListener() {},
+ seekTo() {},
+ sendBirthDate() {},
+ sendKey() {},
+ setup(s) {
+ changeData(data => (data.setup = s));
+ return this;
+ },
+ setVideosToPlay() {},
+ setVolume() {},
+ showPlayReplayControls() {},
+ toggleFullscreen() {},
+ toggleMute() {},
+ togglePlay() {},
+ updateBid() {},
+ version() {},
+ volume() {},
+ };
+
+ const vidible = {
+ ADVERT_CLOSED: "advertClosed",
+ AD_END: "adend",
+ AD_META: "admeta",
+ AD_PAUSED: "adpaused",
+ AD_PLAY: "adplay",
+ AD_START: "adstart",
+ AD_TIMEUPDATE: "adtimeupdate",
+ AD_WAITING: "adwaiting",
+ AGE_GATE_DISPLAYED: "agegatedisplayed",
+ BID_UPDATED: "BidUpdated",
+ CAROUSEL_CLICK: "CarouselClick",
+ CONTEXT_ENDED: "contextended",
+ CONTEXT_STARTED: "contextstarted",
+ ENTER_FULLSCREEN: "playerenterfullscreen",
+ EXIT_FULLSCREEN: "playerexitfullscreen",
+ FALLBACK: "fallback",
+ FLOAT_END_ACTION: "floatended",
+ FLOAT_START_ACTION: "floatstarted",
+ HIDE_PLAY_REPLAY_BUTTON: "hideplayreplaybutton",
+ LIGHTBOX_ACTIVATED: "lightboxactivated",
+ LIGHTBOX_DEACTIVATED: "lightboxdeactivated",
+ MUTE: "Mute",
+ PLAYER_CONTROLS_STATE_CHANGE: "playercontrolsstatechaned",
+ PLAYER_DOCKED: "playerDocked",
+ PLAYER_ERROR: "playererror",
+ PLAYER_FLOATING: "playerFloating",
+ PLAYER_READY: "playerready",
+ PLAYER_RESIZE: "playerresize",
+ PLAYLIST_END: "playlistend",
+ SEEK_END: "SeekEnd",
+ SEEK_START: "SeekStart",
+ SHARE_SCREEN_CLOSED: "sharescreenclosed",
+ SHARE_SCREEN_OPENED: "sharescreenopened",
+ SHOW_PLAY_REPLAY_BUTTON: "showplayreplaybutton",
+ SUBTITLES_DISABLED: "subtitlesdisabled",
+ SUBTITLES_ENABLED: "subtitlesenabled",
+ SUBTITLES_READY: "subtitlesready",
+ UNMUTE: "Unmute",
+ VIDEO_DATA_LOADED: "videodataloaded",
+ VIDEO_END: "videoend",
+ VIDEO_META: "videometadata",
+ VIDEO_MODULE_CREATED: "videomodulecreated",
+ VIDEO_PAUSE: "videopause",
+ VIDEO_PLAY: "videoplay",
+ VIDEO_SEEKEND: "videoseekend",
+ VIDEO_SELECTED: "videoselected",
+ VIDEO_START: "videostart",
+ VIDEO_TIMEUPDATE: "videotimeupdate",
+ VIDEO_VOLUME_CHANGED: "videovolumechanged",
+ VOLUME: "Volume",
+ _getContexts: () => [],
+ "content.CLICK": "content.click",
+ "content.IMPRESSION": "content.impression",
+ "content.QUARTILE": "content.quartile",
+ "content.VIEW": "content.view",
+ createPlayer: parent => new Player(parent),
+ createPlayerAsync: parent => new Player(parent),
+ createVPAIDPlayer: parent => new Player(parent),
+ destroyAll() {},
+ extension() {},
+ getContext() {},
+ player: parent => new Player(parent),
+ playerInceptionTime() {
+ return { undefined: 1620149827713 };
+ },
+ registerExtra(a, b, c) {
+ extras.push([a, b, c]);
+ },
+ version: () => "21.1.313",
+ };
+
+ window.vidible = Shimmer.proxy(vidible);
+
+ for (const jsonp of getJSONPVideoPlacements()) {
+ const player = new Player(jsonp);
+ const { placeholder } = playerData.get(player);
+ jsonp.parentNode.insertBefore(placeholder, jsonp);
+ }
+}
diff --git a/browser/extensions/webcompat/shims/vmad.xml b/browser/extensions/webcompat/shims/vmad.xml
new file mode 100644
index 0000000000..5bb9a5a5d5
--- /dev/null
+++ b/browser/extensions/webcompat/shims/vmad.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+ Bug 1713693 - Shim Doubleclick
+
+ Some sites rely on an XML VMAD Ad response from Doubleclick, or will
+ break (showing black boxes instead of videos, etc). This shim mitigates
+ such breakage.
+-->
+<vmap:AdBreak></vmap:AdBreak>
diff --git a/browser/extensions/webcompat/shims/webtrends.js b/browser/extensions/webcompat/shims/webtrends.js
new file mode 100644
index 0000000000..c7ef0069da
--- /dev/null
+++ b/browser/extensions/webcompat/shims/webtrends.js
@@ -0,0 +1,46 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Bug 1766414 - Shim WebTrends Core Tag and Advanced Link Tracking
+ *
+ * Sites using WebTrends Core Tag or Link Tracking can break if they are
+ * are blocked. This shim mitigates that breakage by loading an empty module.
+ */
+
+if (!window.dcsMultiTrack) {
+ window.dcsMultiTrack = o => {
+ o?.callback?.({});
+ };
+}
+
+if (!window.WebTrends) {
+ class dcs {
+ addSelector() {
+ return this;
+ }
+ addTransform() {
+ return this;
+ }
+ DCSext = {};
+ init(obj) {
+ return this;
+ }
+ track() {
+ return this;
+ }
+ }
+
+ window.Webtrends = window.WebTrends = {
+ dcs,
+ multiTrack: window.dcsMultiTrack,
+ };
+
+ window.requestAnimationFrame(() => {
+ window.webtrendsAsyncLoad?.(dcs);
+ window.webtrendsAsyncInit?.();
+ });
+}
diff --git a/browser/extensions/webcompat/tests/browser/browser.ini b/browser/extensions/webcompat/tests/browser/browser.ini
new file mode 100644
index 0000000000..f15b901240
--- /dev/null
+++ b/browser/extensions/webcompat/tests/browser/browser.ini
@@ -0,0 +1,15 @@
+[DEFAULT]
+support-files =
+ head.js
+ shims_test.js
+ shims_test_2.js
+ shims_test_3.js
+ iframe_test.html
+ shims_test.html
+ shims_test_2.html
+ shims_test_3.html
+
+[browser_aboutcompat.js]
+[browser_shims.js]
+https_first_disabled = true
+skip-if = verify
diff --git a/browser/extensions/webcompat/tests/browser/browser_aboutcompat.js b/browser/extensions/webcompat/tests/browser/browser_aboutcompat.js
new file mode 100644
index 0000000000..a448269294
--- /dev/null
+++ b/browser/extensions/webcompat/tests/browser/browser_aboutcompat.js
@@ -0,0 +1,27 @@
+"use strict";
+
+add_task(async function test_about_compat_loads_properly() {
+ const tab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: "about:compat",
+ waitForLoad: true,
+ });
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async function () {
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector("#overrides tr[data-id]"),
+ "UA overrides are listed"
+ );
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector("#interventions tr[data-id]"),
+ "interventions are listed"
+ );
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector("#smartblock tr[data-id]"),
+ "SmartBlock shims are listed"
+ );
+ ok(true, "Interventions are listed");
+ });
+
+ await BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/extensions/webcompat/tests/browser/browser_shims.js b/browser/extensions/webcompat/tests/browser/browser_shims.js
new file mode 100644
index 0000000000..4de900a4c6
--- /dev/null
+++ b/browser/extensions/webcompat/tests/browser/browser_shims.js
@@ -0,0 +1,73 @@
+"use strict";
+
+registerCleanupFunction(() => {
+ UrlClassifierTestUtils.cleanupTestTrackers();
+ Services.prefs.clearUserPref(TRACKING_PREF);
+});
+
+add_setup(async function () {
+ await UrlClassifierTestUtils.addTestTrackers();
+});
+
+add_task(async function test_shim_disabled_by_own_pref() {
+ // Test that a shim will not apply if disabled in about:config
+
+ Services.prefs.setBoolPref(DISABLE_SHIM1_PREF, true);
+ Services.prefs.setBoolPref(TRACKING_PREF, true);
+
+ await testShimDoesNotRun();
+
+ Services.prefs.clearUserPref(DISABLE_SHIM1_PREF);
+ Services.prefs.clearUserPref(TRACKING_PREF);
+});
+
+add_task(async function test_shim_disabled_by_global_pref() {
+ // Test that a shim will not apply if disabled in about:config
+
+ Services.prefs.setBoolPref(GLOBAL_PREF, false);
+ Services.prefs.setBoolPref(DISABLE_SHIM1_PREF, false);
+ Services.prefs.setBoolPref(TRACKING_PREF, true);
+
+ await testShimDoesNotRun();
+
+ Services.prefs.clearUserPref(GLOBAL_PREF);
+ Services.prefs.clearUserPref(DISABLE_SHIM1_PREF);
+ Services.prefs.clearUserPref(TRACKING_PREF);
+});
+
+add_task(async function test_shim_disabled_hosts_notHosts() {
+ Services.prefs.setBoolPref(TRACKING_PREF, true);
+
+ await testShimDoesNotRun(false, SHIMMABLE_TEST_PAGE_3);
+
+ Services.prefs.clearUserPref(TRACKING_PREF);
+});
+
+add_task(async function test_shim_disabled_overridden_by_pref() {
+ Services.prefs.setBoolPref(TRACKING_PREF, true);
+
+ await testShimDoesNotRun(false, SHIMMABLE_TEST_PAGE_2);
+
+ Services.prefs.setBoolPref(DISABLE_SHIM2_PREF, false);
+
+ await testShimRuns(SHIMMABLE_TEST_PAGE_2);
+
+ Services.prefs.clearUserPref(TRACKING_PREF);
+ Services.prefs.clearUserPref(DISABLE_SHIM2_PREF);
+});
+
+add_task(async function test_shim() {
+ // Test that a shim which only runs in strict mode works, and that it
+ // is permitted to opt into showing normally-blocked tracking content.
+
+ Services.prefs.setBoolPref(TRACKING_PREF, true);
+
+ await testShimRuns(SHIMMABLE_TEST_PAGE);
+
+ // test that if the user opts in on one domain, they will still have to opt
+ // in on another domain which embeds an iframe to the first one.
+
+ await testShimRuns(EMBEDDING_TEST_PAGE, 0, false, false);
+
+ Services.prefs.clearUserPref(TRACKING_PREF);
+});
diff --git a/browser/extensions/webcompat/tests/browser/head.js b/browser/extensions/webcompat/tests/browser/head.js
new file mode 100644
index 0000000000..7fe8c3c171
--- /dev/null
+++ b/browser/extensions/webcompat/tests/browser/head.js
@@ -0,0 +1,140 @@
+"use strict";
+
+const TEST_ROOT = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "http://example.com"
+);
+
+const THIRD_PARTY_ROOT = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "http://example.net"
+);
+
+const SHIMMABLE_TEST_PAGE = `${TEST_ROOT}shims_test.html`;
+const SHIMMABLE_TEST_PAGE_2 = `${TEST_ROOT}shims_test_2.html`;
+const SHIMMABLE_TEST_PAGE_3 = `${TEST_ROOT}shims_test_3.html`;
+const EMBEDDING_TEST_PAGE = `${THIRD_PARTY_ROOT}iframe_test.html`;
+
+const BLOCKED_TRACKER_URL =
+ "//trackertest.org/tests/toolkit/components/url-classifier/tests/mochitest/evil.js";
+
+const DISABLE_SHIM1_PREF = "extensions.webcompat.disabled_shims.MochitestShim";
+const DISABLE_SHIM2_PREF = "extensions.webcompat.disabled_shims.MochitestShim2";
+const DISABLE_SHIM3_PREF = "extensions.webcompat.disabled_shims.MochitestShim3";
+const DISABLE_SHIM4_PREF = "extensions.webcompat.disabled_shims.MochitestShim4";
+const GLOBAL_PREF = "extensions.webcompat.enable_shims";
+const TRACKING_PREF = "privacy.trackingprotection.enabled";
+
+const { UrlClassifierTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/UrlClassifierTestUtils.sys.mjs"
+);
+
+async function testShimRuns(
+ testPage,
+ frame,
+ trackersAllowed = true,
+ expectOptIn = true
+) {
+ const tab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: testPage,
+ waitForLoad: true,
+ });
+
+ const TrackingProtection =
+ tab.ownerGlobal.gProtectionsHandler.blockers.TrackingProtection;
+ ok(TrackingProtection, "TP is attached to the tab");
+ ok(TrackingProtection.enabled, "TP is enabled");
+
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [[trackersAllowed, BLOCKED_TRACKER_URL, expectOptIn], frame],
+ async (args, _frame) => {
+ const window = _frame === undefined ? content : content.frames[_frame];
+
+ await SpecialPowers.spawn(
+ window,
+ args,
+ async (_trackersAllowed, trackerUrl, _expectOptIn) => {
+ const shimResult = await content.wrappedJSObject.shimPromise;
+ is("shimmed", shimResult, "Shim activated");
+
+ const optInResult = await content.wrappedJSObject.optInPromise;
+ is(_expectOptIn, optInResult, "Shim allowed opt in if appropriate");
+
+ const o = content.document.getElementById("shims");
+ const cl = o.classList;
+ const opts = JSON.parse(o.innerText);
+ is(
+ undefined,
+ opts.branchValue,
+ "Shim script did not receive option for other branch"
+ );
+ is(
+ undefined,
+ opts.platformValue,
+ "Shim script did not receive option for other platform"
+ );
+ is(
+ true,
+ opts.simpleOption,
+ "Shim script received simple option correctly"
+ );
+ ok(opts.complexOption, "Shim script received complex option");
+ is(
+ 1,
+ opts.complexOption.a,
+ "Shim script received complex options correctly #1"
+ );
+ is(
+ "test",
+ opts.complexOption.b,
+ "Shim script received complex options correctly #2"
+ );
+ ok(cl.contains("green"), "Shim affected page correctly");
+ }
+ );
+ }
+ );
+
+ await BrowserTestUtils.removeTab(tab);
+}
+
+async function testShimDoesNotRun(
+ trackersAllowed = false,
+ testPage = SHIMMABLE_TEST_PAGE
+) {
+ const tab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: testPage,
+ waitForLoad: true,
+ });
+
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [trackersAllowed, BLOCKED_TRACKER_URL],
+ async (_trackersAllowed, trackerUrl) => {
+ const shimResult = await content.wrappedJSObject.shimPromise;
+ is("did not shim", shimResult, "Shim did not activate");
+
+ ok(
+ !content.document.getElementById("shims").classList.contains("green"),
+ "Shim script did not run"
+ );
+
+ is(
+ _trackersAllowed ? "ALLOWED" : "BLOCKED",
+ await new Promise(resolve => {
+ const s = content.document.createElement("script");
+ s.src = trackerUrl;
+ s.onload = () => resolve("ALLOWED");
+ s.onerror = () => resolve("BLOCKED");
+ content.document.head.appendChild(s);
+ }),
+ "Normally-blocked resources blocked if appropriate"
+ );
+ }
+ );
+
+ await BrowserTestUtils.removeTab(tab);
+}
diff --git a/browser/extensions/webcompat/tests/browser/iframe_test.html b/browser/extensions/webcompat/tests/browser/iframe_test.html
new file mode 100644
index 0000000000..baf1ee9024
--- /dev/null
+++ b/browser/extensions/webcompat/tests/browser/iframe_test.html
@@ -0,0 +1,19 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf8" />
+ <script>
+ window.shimPromise = new Promise(resolve => {
+ window.shimPromiseResolve = resolve;
+ });
+ window.optInPromise = new Promise(resolve => {
+ window.optInPromiseResolve = resolve;
+ });
+ </script>
+ </head>
+ <body>
+ <iframe
+ src="http://example.com/browser/browser/extensions/webcompat/tests/browser/shims_test.html"
+ ></iframe>
+ </body>
+</html>
diff --git a/browser/extensions/webcompat/tests/browser/shims_test.html b/browser/extensions/webcompat/tests/browser/shims_test.html
new file mode 100644
index 0000000000..ebe877316d
--- /dev/null
+++ b/browser/extensions/webcompat/tests/browser/shims_test.html
@@ -0,0 +1,21 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf8" />
+ <script>
+ window.shimPromise = new Promise(resolve => {
+ window.shimPromiseResolve = resolve;
+ });
+ window.optInPromise = new Promise(resolve => {
+ window.optInPromiseResolve = resolve;
+ });
+ </script>
+ <script
+ onerror="window.shimPromiseResolve('error')"
+ src="shims_test.js"
+ ></script>
+ </head>
+ <body>
+ <div id="shims"></div>
+ </body>
+</html>
diff --git a/browser/extensions/webcompat/tests/browser/shims_test.js b/browser/extensions/webcompat/tests/browser/shims_test.js
new file mode 100644
index 0000000000..4a55bee7ed
--- /dev/null
+++ b/browser/extensions/webcompat/tests/browser/shims_test.js
@@ -0,0 +1,11 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+if (window.doingOptIn) {
+ window.optInPromiseResolve(true);
+} else {
+ window.shimPromiseResolve("did not shim");
+}
diff --git a/browser/extensions/webcompat/tests/browser/shims_test_2.html b/browser/extensions/webcompat/tests/browser/shims_test_2.html
new file mode 100644
index 0000000000..b080f74f6e
--- /dev/null
+++ b/browser/extensions/webcompat/tests/browser/shims_test_2.html
@@ -0,0 +1,21 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf8" />
+ <script>
+ window.shimPromise = new Promise(resolve => {
+ window.shimPromiseResolve = resolve;
+ });
+ window.optInPromise = new Promise(resolve => {
+ window.optInPromiseResolve = resolve;
+ });
+ </script>
+ <script
+ onerror="window.shimPromiseResolve('error')"
+ src="shims_test_2.js"
+ ></script>
+ </head>
+ <body>
+ <div id="shims"></div>
+ </body>
+</html>
diff --git a/browser/extensions/webcompat/tests/browser/shims_test_2.js b/browser/extensions/webcompat/tests/browser/shims_test_2.js
new file mode 100644
index 0000000000..4a55bee7ed
--- /dev/null
+++ b/browser/extensions/webcompat/tests/browser/shims_test_2.js
@@ -0,0 +1,11 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+if (window.doingOptIn) {
+ window.optInPromiseResolve(true);
+} else {
+ window.shimPromiseResolve("did not shim");
+}
diff --git a/browser/extensions/webcompat/tests/browser/shims_test_3.html b/browser/extensions/webcompat/tests/browser/shims_test_3.html
new file mode 100644
index 0000000000..bcb6f12043
--- /dev/null
+++ b/browser/extensions/webcompat/tests/browser/shims_test_3.html
@@ -0,0 +1,21 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf8" />
+ <script>
+ window.shimPromise = new Promise(resolve => {
+ window.shimPromiseResolve = resolve;
+ });
+ window.optInPromise = new Promise(resolve => {
+ window.optInPromiseResolve = resolve;
+ });
+ </script>
+ <script
+ onerror="window.shimPromiseResolve('error')"
+ src="shims_test_3.js"
+ ></script>
+ </head>
+ <body>
+ <div id="shims"></div>
+ </body>
+</html>
diff --git a/browser/extensions/webcompat/tests/browser/shims_test_3.js b/browser/extensions/webcompat/tests/browser/shims_test_3.js
new file mode 100644
index 0000000000..9acb6cdcf1
--- /dev/null
+++ b/browser/extensions/webcompat/tests/browser/shims_test_3.js
@@ -0,0 +1,7 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+window.shimPromiseResolve("did not shim");