From 8de65ea7915ded00297037c6a85103390176b5ca Mon Sep 17 00:00:00 2001 From: Cesar Gonzalez Date: Thu, 7 Sep 2023 15:33:04 -0500 Subject: [PATCH] [PM-3285] Autofill v2 Feature Branch (#5939) * [PM-3285] Autofill v2 Feature Branch * [PM-2130] - Audit, Modularize, and Refactor Core autofill.js File (#5453) * split up autofill.ts, first pass * remove modification tracking comments * lessen and localize eslint disables * additional typing and formatting * update autofill v2 with PR #5364 changes (update/i18n confirm dialogs) * update autofill v2 with PR #4155 changes (add autofill support for textarea) Co-Authored-By: Manuel * move commonly used string values to constants * ts cleanup * [PM-2130] Starting work to re-architect autofillv2.ts * [PM-2130] Starting work to re-architect autofillv2.ts * [PM-2130] Working through autofill collect method * [PM-2130] Marking Removal of documentUUID as dead code * [PM-2130] Refining the implementation of collect and moving broken out utils back into class implementation * [PM-2130] Applying small refactors to AutofillCollect * [PM-2130] Refining the implementation of getAutofillFieldLabelTag to help with readability of the method * [PM-2130] Implementing jest tests for AutofillCollect methods * [PM-2130] Refining implementation for AutofillCollect * [PM-2200] Unit tests for autofill content script utilities with slight refactors (#5544) * add unit tests for urlNotSecure * add test coverage command * add unit tests for canSeeElementToStyle * canSeeElementToStyle should not return true if `animateTheFilling` or `currentEl` is false * add tests for selectAllFromDoc and getElementByOpId * clean up getElementByOpId * address some typing issues * add tests for setValueForElementByEvent, setValueForElement, and doSimpleSetByQuery * clean up setValueForElement and setValueForElementByEvent * more typescript cleanup * add tests for doClickByOpId and touchAllPasswordFields * add tests for doFocusByOpId and doClickByQuery * misc fill cleanup * move functions between collect and fill utils and replace getElementForOPID for duplicate getElementByOpId * add tests for isKnownTag and isElementVisible * rename addProp and remove redundant focusElement in favor of doFocusElement * cleanup * fix checkNodeType * add tests for shiftForLeftLabel * clean up and rename checkNodeType, isKnownTag, and shiftForLeftLabel * add tests for getFormElements * clean up getFormElements * add tests for getElementAttrValue, getElementValue, getSelectElementOptions, getLabelTop, and queryDoc * clean up and rename queryDoc to queryDocument * misc cleanup and rename getElementAttrValue to getPropertyOrAttribute * rebase cleanup * prettier formatting * [PM-2130] Fixing linting issues * [PM-2130] Fixing linting issues * [PM-2130] Migrating implementation for collect methods and tests for those methods into AutofillCollect context * [PM-2130] Migrating getPropertyOrAttribute method from utils to AutofillCollect * [PM-2130] Continuing migration of methods from collect utils into AutofillCollect * [PM-2130] Rework of isViewable method to better handle behavior for how we identify if an element is currently within the viewport * [PM-2130] Filling out implementation of autofill-insert * [PM-2130] Refining AutofillInsert * [PM-2130] Implementing jest tests for AutofillCollect methods and breaking out visibility related logic to a separate service * [PM-2130] Fixing jest tests for AutofillCollect * [PM-2130] Fixing jest tests for AutofillInit * [PM-2130] Adjusting how the AutofillFieldVisibilityService class is used in AutofillCollect * [PM-2130] Working through AutofillInsert implementation * [PM-2130] Migrating methods from fill.ts to AutofillInsert * [PM-2130] Migrating methods from fill.ts to AutofillInsert * [PM-2130] Applying fix for IntersectionObserver when triggering behavior in Safari and fixing issue with how we trigger an input event shortly after filling in a field * [PM-2130] Refactoring AutofillCollect to service CollectAutofillContentService * [PM-2130] Refactoring AutofillInsert to service InsertAutofillContentService * [PM-2130] Further organization of implementation * [PM-2130] Filling out missing jest test for AutofillInit.fillForm method * [PM-2130] Migrating the last of the collect jest tests to InsertAutofillContentService * [PM-2130] Further refactoring of elements including typing information * [PM-2130] Implementing jest tests for InsertAutofillContentService * [PM-2130] Implementing jest tests for InsertAutofillContentService * [PM-2130] Organization and refactoring of methods within InsertAutofillContent * [PM-2130] Implementation of jest tests for InsertAutofillContentService * [PM-2130] Implementation of Jest Test for IntertAutofillContentService * [PM-2130] Finalizing migration of methods and jest tests from util files into Autofill serivces * [PM-2130] Cleaning up dead code comments * [PM-2130] Removing unnecessary constants * [PM-2130] Finalizing jest tests for InsertAutofillContentService * [PM-2130] Refactoring FieldVisibiltyService to DomElementVisibilityService to allow service to act in a more general manner * [PM-2130] Implementing jest tests for DomElementVisibilityService * [PM-2130] Implementing jest tests for DomElementVisibilityService * [PM-2130] Implementing jest tests for DomElementVisibilityService * [PM-2130] Implementing jest tests for DomElementVisibilityService * [PM-2130] Breaking out the callback method used to resolve the IntersectionObserver promise * [PM-2130] Adding a comment explaining a fix for Safari * [PM-2130] Adding a comment explaining a fix for Safari * [PM-2130] Applying changes required for PM-2762 to implementation, and ensuring jest tests exist to validate the behavior * [PM-2130] Removing usage of IntersectionObserver when identifying element visibility due to broken interactions with React Components * [PM-2130] Fixing issue found when attempting to capture the elementAtCenterPoint in determining file visibility * [PM-2100] Create Unit Test Suite for autofill.service.ts (#5371) * [PM-2100] Create Unit Test Suite for Autofill.service.ts * [PM-2100] Finishing out tests for the getFormsWithPasswordFields method * [PM-2100] Implementing tests for the doAutofill method within the autofill service * [PM-2100] Working through implementation of doAutofill method * [PM-2100] Working through implementation of doAutofill method * [PM-2100] Finishing implementatino of isUntrustedIframe method within autofill service * [PM-2100] Finishing implementation of doAutoFill method within autofill service * [PM-2100] Finishing implementation of doAutoFillOnTab method within autofill service * [PM-2100] Working through tests for generateFillScript * [PM-2100] Finalizing generateFillScript method testing * [PM-2100] Starting implementation of generateLoginFillScript * [PM-2100] Working through tests for generateLoginFillScript * [PM-2100] Finalizing generateLoginFillScript method testing * [PM-2100] Removing unnecessary jest config file * [PM-2100] Fixing jest tests based on changes implemented within PM-2130 * [PM-2100] Fixing autofill mocks * [PM-2100] Fixing AutofillService jest tests * [PM-2100] Handling missing tests within coverage of AutofillService * [PM-2100] Handling missing tests within coverage of AutofillService.generateLoginFillScript * [PM-2100] Writing tests for AutofillService.generateCardFillScript * [PM-2100] Finalizing tests for AutofillService.generateCardFillScript * [PM-2100] Adding additional tests to cover changes introduced by TOTOP autofill PR * [PM-2100] Adding jest tests for Autofill.generateIdentityFillScript * [PM-2100] Finalizing tests for AutofillService.generateIdentityFillScript * [PM-2100] Implementing tests for AutofillService * [PM-2100] Implementing tests for AutofillService.loadPasswordFields * [PM-2100] Implementing tests for AutofillService.findUsernameField * [PM-2100] Implementing tests for AutofillService.findTotpField * [PM-2100] Implementing tests for AutofillService.fieldPropertyIsPrefixMatch * [PM-2100] Finalizing tests for AutofillService * [PM-2100] Modyfing placement of autofill-mocks * [PM-2100] Modyfing placement of autofill-mocks * [PM-2100] Removal of jest transform declaration * [PM-2130] Fixing issue with autofill service unit tests * [PM-2130] Fixing issue with autofill service unit tests * [PM-2130] Fixing test test for when we need to handle a password reprompt --------- Co-authored-by: Manuel Co-authored-by: Cesar Gonzalez Co-authored-by: Cesar Gonzalez * [PM-3285] Migrating Changes from PM-1407 into autofill v2 refactor implementation * [PM-2747] Add Support for Feature Flag of Autofill Version (#5695) * [PM-2100] Create Unit Test Suite for Autofill.service.ts * [PM-2100] Finishing out tests for the getFormsWithPasswordFields method * [PM-2100] Implementing tests for the doAutofill method within the autofill service * [PM-2100] Working through implementation of doAutofill method * [PM-2100] Working through implementation of doAutofill method * [PM-2100] Finishing implementatino of isUntrustedIframe method within autofill service * [PM-2100] Finishing implementation of doAutoFill method within autofill service * [PM-2100] Finishing implementation of doAutoFillOnTab method within autofill service * [PM-2100] Working through tests for generateFillScript * split up autofill.ts, first pass * remove modification tracking comments * lessen and localize eslint disables * additional typing and formatting * update autofill v2 with PR #5364 changes (update/i18n confirm dialogs) * update autofill v2 with PR #4155 changes (add autofill support for textarea) Co-Authored-By: Manuel * move commonly used string values to constants * ts cleanup * [PM-2100] Finalizing generateFillScript method testing * [PM-2100] Starting implementation of generateLoginFillScript * [PM-2100] Working through tests for generateLoginFillScript * [PM-2100] Finalizing generateLoginFillScript method testing * [PM-2130] Starting work to re-architect autofillv2.ts * [PM-2130] Starting work to re-architect autofillv2.ts * [PM-2130] Working through autofill collect method * [PM-2130] Marking Removal of documentUUID as dead code * [PM-2130] Refining the implementation of collect and moving broken out utils back into class implementation * [PM-2130] Applying small refactors to AutofillCollect * [PM-2130] Refining the implementation of getAutofillFieldLabelTag to help with readability of the method * [PM-2130] Implementing jest tests for AutofillCollect methods * [PM-2130] Refining implementation for AutofillCollect * [PM-2200] Unit tests for autofill content script utilities with slight refactors (#5544) * add unit tests for urlNotSecure * add test coverage command * add unit tests for canSeeElementToStyle * canSeeElementToStyle should not return true if `animateTheFilling` or `currentEl` is false * add tests for selectAllFromDoc and getElementByOpId * clean up getElementByOpId * address some typing issues * add tests for setValueForElementByEvent, setValueForElement, and doSimpleSetByQuery * clean up setValueForElement and setValueForElementByEvent * more typescript cleanup * add tests for doClickByOpId and touchAllPasswordFields * add tests for doFocusByOpId and doClickByQuery * misc fill cleanup * move functions between collect and fill utils and replace getElementForOPID for duplicate getElementByOpId * add tests for isKnownTag and isElementVisible * rename addProp and remove redundant focusElement in favor of doFocusElement * cleanup * fix checkNodeType * add tests for shiftForLeftLabel * clean up and rename checkNodeType, isKnownTag, and shiftForLeftLabel * add tests for getFormElements * clean up getFormElements * add tests for getElementAttrValue, getElementValue, getSelectElementOptions, getLabelTop, and queryDoc * clean up and rename queryDoc to queryDocument * misc cleanup and rename getElementAttrValue to getPropertyOrAttribute * rebase cleanup * prettier formatting * [PM-2130] Fixing linting issues * [PM-2130] Fixing linting issues * [PM-2130] Migrating implementation for collect methods and tests for those methods into AutofillCollect context * [PM-2130] Migrating getPropertyOrAttribute method from utils to AutofillCollect * [PM-2130] Continuing migration of methods from collect utils into AutofillCollect * [PM-2130] Rework of isViewable method to better handle behavior for how we identify if an element is currently within the viewport * [PM-2130] Filling out implementation of autofill-insert * [PM-2130] Refining AutofillInsert * [PM-2130] Implementing jest tests for AutofillCollect methods and breaking out visibility related logic to a separate service * [PM-2130] Fixing jest tests for AutofillCollect * [PM-2130] Fixing jest tests for AutofillInit * [PM-2130] Adjusting how the AutofillFieldVisibilityService class is used in AutofillCollect * [PM-2130] Working through AutofillInsert implementation * [PM-2130] Migrating methods from fill.ts to AutofillInsert * [PM-2130] Migrating methods from fill.ts to AutofillInsert * [PM-2130] Applying fix for IntersectionObserver when triggering behavior in Safari and fixing issue with how we trigger an input event shortly after filling in a field * [PM-2130] Refactoring AutofillCollect to service CollectAutofillContentService * [PM-2130] Refactoring AutofillInsert to service InsertAutofillContentService * [PM-2130] Further organization of implementation * [PM-2130] Filling out missing jest test for AutofillInit.fillForm method * [PM-2130] Migrating the last of the collect jest tests to InsertAutofillContentService * [PM-2130] Further refactoring of elements including typing information * [PM-2130] Implementing jest tests for InsertAutofillContentService * [PM-2130] Implementing jest tests for InsertAutofillContentService * [PM-2130] Organization and refactoring of methods within InsertAutofillContent * [PM-2130] Implementation of jest tests for InsertAutofillContentService * [PM-2130] Implementation of Jest Test for IntertAutofillContentService * [PM-2130] Finalizing migration of methods and jest tests from util files into Autofill serivces * [PM-2130] Cleaning up dead code comments * [PM-2130] Removing unnecessary constants * [PM-2130] Finalizing jest tests for InsertAutofillContentService * [PM-2130] Refactoring FieldVisibiltyService to DomElementVisibilityService to allow service to act in a more general manner * [PM-2130] Implementing jest tests for DomElementVisibilityService * [PM-2130] Implementing jest tests for DomElementVisibilityService * [PM-2130] Implementing jest tests for DomElementVisibilityService * [PM-2130] Implementing jest tests for DomElementVisibilityService * [PM-2130] Breaking out the callback method used to resolve the IntersectionObserver promise * [PM-2100] Removing unnecessary jest config file * [PM-2100] Fixing jest tests based on changes implemented within PM-2130 * [PM-2100] Fixing autofill mocks * [PM-2100] Fixing AutofillService jest tests * [PM-2100] Handling missing tests within coverage of AutofillService * [PM-2100] Handling missing tests within coverage of AutofillService.generateLoginFillScript * [PM-2100] Writing tests for AutofillService.generateCardFillScript * [PM-2100] Finalizing tests for AutofillService.generateCardFillScript * [PM-2100] Adding additional tests to cover changes introduced by TOTOP autofill PR * [PM-2100] Adding jest tests for Autofill.generateIdentityFillScript * [PM-2100] Finalizing tests for AutofillService.generateIdentityFillScript * [PM-2100] Implementing tests for AutofillService * [PM-2130] Adding a comment explaining a fix for Safari * [PM-2130] Adding a comment explaining a fix for Safari * [PM-2100] Implementing tests for AutofillService.loadPasswordFields * [PM-2100] Implementing tests for AutofillService.findUsernameField * [PM-2100] Implementing tests for AutofillService.findTotpField * [PM-2100] Implementing tests for AutofillService.fieldPropertyIsPrefixMatch * [PM-2100] Finalizing tests for AutofillService * [PM-2747] Add Support for Feature Flag of Autofill Version * [PM-2747] Adding Support for Manifest v3 within the implementation * [PM-2747] Modifying how the feature flag for autofill is named * [PM-2747] Modifying main.background.ts to load the ConfigApiService correctly * [PM-2747] Refactoring trigger of autofill scripts to be a simple immediately invoked function * [PM-2100] Modyfing placement of autofill-mocks * [PM-2100] Modyfing placement of autofill-mocks * [PM-2100] Removal of jest transform declaration * [PM-2130] Applying changes required for PM-2762 to implementation, and ensuring jest tests exist to validate the behavior * [PM-2747] Modifying how we inject the autofill scripts to ensure we are injecting into all frames within a page * [PM-2130] Removing usage of IntersectionObserver when identifying element visibility due to broken interactions with React Components * [PM-2130] Fixing issue found when attempting to capture the elementAtCenterPoint in determining file visibility * [PM-2100] Create Unit Test Suite for autofill.service.ts (#5371) * [PM-2100] Create Unit Test Suite for Autofill.service.ts * [PM-2100] Finishing out tests for the getFormsWithPasswordFields method * [PM-2100] Implementing tests for the doAutofill method within the autofill service * [PM-2100] Working through implementation of doAutofill method * [PM-2100] Working through implementation of doAutofill method * [PM-2100] Finishing implementatino of isUntrustedIframe method within autofill service * [PM-2100] Finishing implementation of doAutoFill method within autofill service * [PM-2100] Finishing implementation of doAutoFillOnTab method within autofill service * [PM-2100] Working through tests for generateFillScript * [PM-2100] Finalizing generateFillScript method testing * [PM-2100] Starting implementation of generateLoginFillScript * [PM-2100] Working through tests for generateLoginFillScript * [PM-2100] Finalizing generateLoginFillScript method testing * [PM-2100] Removing unnecessary jest config file * [PM-2100] Fixing jest tests based on changes implemented within PM-2130 * [PM-2100] Fixing autofill mocks * [PM-2100] Fixing AutofillService jest tests * [PM-2100] Handling missing tests within coverage of AutofillService * [PM-2100] Handling missing tests within coverage of AutofillService.generateLoginFillScript * [PM-2100] Writing tests for AutofillService.generateCardFillScript * [PM-2100] Finalizing tests for AutofillService.generateCardFillScript * [PM-2100] Adding additional tests to cover changes introduced by TOTOP autofill PR * [PM-2100] Adding jest tests for Autofill.generateIdentityFillScript * [PM-2100] Finalizing tests for AutofillService.generateIdentityFillScript * [PM-2100] Implementing tests for AutofillService * [PM-2100] Implementing tests for AutofillService.loadPasswordFields * [PM-2100] Implementing tests for AutofillService.findUsernameField * [PM-2100] Implementing tests for AutofillService.findTotpField * [PM-2100] Implementing tests for AutofillService.fieldPropertyIsPrefixMatch * [PM-2100] Finalizing tests for AutofillService * [PM-2100] Modyfing placement of autofill-mocks * [PM-2100] Modyfing placement of autofill-mocks * [PM-2100] Removal of jest transform declaration * [PM-2747] Applying a fix for a race condition that can occur when loading the notification bar and autofiller script login * [PM-2747] Reverting removal of autofill npm action. Now this will force usage of autofill-v2 regardless of whether a feature flag is set or not * [PM-2747] Fixing logic error incorporated when merging in master * [PM-2130] Fixing issue with autofill service unit tests * [PM-2130] Fixing issue with autofill service unit tests * [PM-2747] Fixing issue present with notification bar merge * [PM-2130] Fixing test test for when we need to handle a password reprompt * [PM-2747] Fixing wording for webpack script * [PM-2747] Addressing stylistic changes requested from code review * [PM-2747] Addressing stylistic changes requested from code review --------- Co-authored-by: Jonathan Prusik Co-authored-by: Manuel Co-authored-by: Jonathan Prusik * [PM-3285] Applying stylistic changes suggested by code review for the feature flag implementation * [PM-3285] Adding temporary console log to validate which version is being used * [PM-3285] Removing temporary console log indicating which version of autofill the user is currently loading --------- Co-authored-by: Jonathan Prusik Co-authored-by: Manuel Co-authored-by: Jonathan Prusik --- apps/browser/package.json | 2 +- apps/browser/src/autofill/constants.ts | 13 + .../content/abstractions/autofill-init.ts | 21 + .../autofill/content/autofill-init.spec.ts | 175 + .../src/autofill/content/autofill-init.ts | 130 + .../src/autofill/content/autofiller.ts | 10 +- .../src/autofill/content/autofillv2.ts | 1399 ------ .../src/autofill/content/notification-bar.ts | 121 +- .../trigger-autofill-script-injection.spec.ts | 16 + .../trigger-autofill-script-injection.ts | 3 + apps/browser/src/autofill/globals.d.ts | 7 + .../src/autofill/jest/autofill-mocks.ts | 131 + .../src/autofill/jest/testing-utils.ts | 5 + .../src/autofill/models/autofill-field.ts | 150 +- .../autofill/models/autofill-page-details.ts | 4 - .../src/autofill/models/autofill-script.ts | 21 +- .../services/abstractions/autofill.service.ts | 22 +- .../collect-autofill-content.service.ts | 8 + .../dom-element-visibility.service.ts | 6 + .../insert-autofill-content.service.ts | 7 + .../services/autofill.service.spec.ts | 4226 +++++++++++++++++ .../src/autofill/services/autofill.service.ts | 303 +- .../collect-autofill-content.service.spec.ts | 1588 +++++++ .../collect-autofill-content.service.ts | 578 +++ .../dom-element-visibility.service.spec.ts | 409 ++ .../dom-element-visibility.service.ts | 199 + .../insert-autofill-content.service.spec.ts | 1047 ++++ .../insert-autofill-content.service.ts | 349 ++ apps/browser/src/autofill/types/index.ts | 19 + .../browser/src/background/main.background.ts | 2 + .../src/background/runtime.background.ts | 7 + apps/browser/src/manifest.json | 7 +- .../src/platform/browser/browser-api.spec.ts | 56 + .../src/platform/browser/browser-api.ts | 27 + apps/browser/test.setup.ts | 16 + apps/browser/tsconfig.spec.json | 5 +- apps/browser/webpack.config.js | 15 +- libs/common/src/enums/feature-flag.enum.ts | 1 + 38 files changed, 9490 insertions(+), 1615 deletions(-) create mode 100644 apps/browser/src/autofill/constants.ts create mode 100644 apps/browser/src/autofill/content/abstractions/autofill-init.ts create mode 100644 apps/browser/src/autofill/content/autofill-init.spec.ts create mode 100644 apps/browser/src/autofill/content/autofill-init.ts delete mode 100644 apps/browser/src/autofill/content/autofillv2.ts create mode 100644 apps/browser/src/autofill/content/trigger-autofill-script-injection.spec.ts create mode 100644 apps/browser/src/autofill/content/trigger-autofill-script-injection.ts create mode 100644 apps/browser/src/autofill/globals.d.ts create mode 100644 apps/browser/src/autofill/jest/autofill-mocks.ts create mode 100644 apps/browser/src/autofill/jest/testing-utils.ts create mode 100644 apps/browser/src/autofill/services/abstractions/collect-autofill-content.service.ts create mode 100644 apps/browser/src/autofill/services/abstractions/dom-element-visibility.service.ts create mode 100644 apps/browser/src/autofill/services/abstractions/insert-autofill-content.service.ts create mode 100644 apps/browser/src/autofill/services/autofill.service.spec.ts create mode 100644 apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts create mode 100644 apps/browser/src/autofill/services/collect-autofill-content.service.ts create mode 100644 apps/browser/src/autofill/services/dom-element-visibility.service.spec.ts create mode 100644 apps/browser/src/autofill/services/dom-element-visibility.service.ts create mode 100644 apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts create mode 100644 apps/browser/src/autofill/services/insert-autofill-content.service.ts create mode 100644 apps/browser/src/platform/browser/browser-api.spec.ts diff --git a/apps/browser/package.json b/apps/browser/package.json index 980eb3258a..cec3a9caab 100644 --- a/apps/browser/package.json +++ b/apps/browser/package.json @@ -6,7 +6,6 @@ "build:mv3": "cross-env MANIFEST_VERSION=3 webpack", "build:watch": "webpack --watch", "build:watch:mv3": "cross-env MANIFEST_VERSION=3 webpack --watch", - "build:watch:autofill": "cross-env AUTOFILL_VERSION=2 webpack --watch", "build:prod": "cross-env NODE_ENV=production webpack", "build:prod:watch": "cross-env NODE_ENV=production webpack --watch", "dist": "npm run build:prod && gulp dist", @@ -19,6 +18,7 @@ "dist:safari:masdev": "npm run build:prod && gulp dist:safari:masdev", "dist:safari:dmg": "npm run build:prod && gulp dist:safari:dmg", "test": "jest", + "test:coverage": "jest --coverage --coverageDirectory=coverage", "test:watch": "jest --watch", "test:watch:all": "jest --watchAll" } diff --git a/apps/browser/src/autofill/constants.ts b/apps/browser/src/autofill/constants.ts new file mode 100644 index 0000000000..7f3637180b --- /dev/null +++ b/apps/browser/src/autofill/constants.ts @@ -0,0 +1,13 @@ +export const TYPE_CHECK = { + FUNCTION: "function", + NUMBER: "number", + STRING: "string", +} as const; + +export const EVENTS = { + CHANGE: "change", + INPUT: "input", + KEYDOWN: "keydown", + KEYPRESS: "keypress", + KEYUP: "keyup", +} as const; diff --git a/apps/browser/src/autofill/content/abstractions/autofill-init.ts b/apps/browser/src/autofill/content/abstractions/autofill-init.ts new file mode 100644 index 0000000000..706c6da4ee --- /dev/null +++ b/apps/browser/src/autofill/content/abstractions/autofill-init.ts @@ -0,0 +1,21 @@ +import AutofillScript from "../../models/autofill-script"; + +type AutofillExtensionMessage = { + command: string; + tab?: chrome.tabs.Tab; + sender?: string; + fillScript?: AutofillScript; +}; + +type AutofillExtensionMessageHandlers = { + [key: string]: CallableFunction; + collectPageDetails: (message: { message: AutofillExtensionMessage }) => void; + collectPageDetailsImmediately: (message: { message: AutofillExtensionMessage }) => void; + fillForm: (message: { message: AutofillExtensionMessage }) => void; +}; + +interface AutofillInit { + init(): void; +} + +export { AutofillExtensionMessage, AutofillExtensionMessageHandlers, AutofillInit }; diff --git a/apps/browser/src/autofill/content/autofill-init.spec.ts b/apps/browser/src/autofill/content/autofill-init.spec.ts new file mode 100644 index 0000000000..447fe31a8a --- /dev/null +++ b/apps/browser/src/autofill/content/autofill-init.spec.ts @@ -0,0 +1,175 @@ +import { mock } from "jest-mock-extended"; + +import AutofillPageDetails from "../models/autofill-page-details"; +import AutofillScript from "../models/autofill-script"; + +import { AutofillExtensionMessage } from "./abstractions/autofill-init"; + +describe("AutofillInit", () => { + let bitwardenAutofillInit: any; + + beforeEach(() => { + require("../content/autofill-init"); + bitwardenAutofillInit = window.bitwardenAutofillInit; + }); + + afterEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + }); + + describe("init", () => { + it("sets up the extension message listeners", () => { + jest.spyOn(bitwardenAutofillInit, "setupExtensionMessageListeners"); + + bitwardenAutofillInit.init(); + + expect(bitwardenAutofillInit.setupExtensionMessageListeners).toHaveBeenCalled(); + }); + }); + + describe("collectPageDetails", () => { + let extensionMessage: AutofillExtensionMessage; + let pageDetails: AutofillPageDetails; + + beforeEach(() => { + extensionMessage = { + command: "collectPageDetails", + tab: mock(), + sender: "sender", + }; + pageDetails = { + title: "title", + url: "http://example.com", + documentUrl: "documentUrl", + forms: {}, + fields: [], + collectedTimestamp: 0, + }; + jest + .spyOn(bitwardenAutofillInit.collectAutofillContentService, "getPageDetails") + .mockReturnValue(pageDetails); + }); + + it("returns collected page details for autofill if set to send the details in the response", async () => { + const response = await bitwardenAutofillInit["collectPageDetails"](extensionMessage, true); + + expect(bitwardenAutofillInit.collectAutofillContentService.getPageDetails).toHaveBeenCalled(); + expect(response).toEqual(pageDetails); + }); + + it("sends the collected page details for autofill using a background script message", async () => { + jest.spyOn(chrome.runtime, "sendMessage"); + + await bitwardenAutofillInit["collectPageDetails"](extensionMessage); + + expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({ + command: "collectPageDetailsResponse", + tab: extensionMessage.tab, + details: pageDetails, + sender: extensionMessage.sender, + }); + }); + }); + + describe("fillForm", () => { + it("will call the InsertAutofillContentService to fill the form", () => { + const fillScript = mock(); + jest + .spyOn(bitwardenAutofillInit.insertAutofillContentService, "fillForm") + .mockImplementation(); + + bitwardenAutofillInit.fillForm(fillScript); + + expect(bitwardenAutofillInit.insertAutofillContentService.fillForm).toHaveBeenCalledWith( + fillScript + ); + }); + }); + + describe("setupExtensionMessageListeners", () => { + it("sets up a chrome runtime on message listener", () => { + jest.spyOn(chrome.runtime.onMessage, "addListener"); + + bitwardenAutofillInit["setupExtensionMessageListeners"](); + + expect(chrome.runtime.onMessage.addListener).toHaveBeenCalledWith( + bitwardenAutofillInit["handleExtensionMessage"] + ); + }); + }); + + describe("handleExtensionMessage", () => { + let message: AutofillExtensionMessage; + let sender: chrome.runtime.MessageSender; + const sendResponse = jest.fn(); + + beforeEach(() => { + message = { + command: "collectPageDetails", + tab: mock(), + sender: "sender", + }; + sender = mock(); + }); + + it("returns a false value if a extension message handler is not found with the given message command", () => { + message.command = "unknownCommand"; + + const response = bitwardenAutofillInit["handleExtensionMessage"]( + message, + sender, + sendResponse + ); + + expect(response).toBe(false); + }); + + it("returns a false value if the message handler does not return a response", async () => { + const response1 = await bitwardenAutofillInit["handleExtensionMessage"]( + message, + sender, + sendResponse + ); + await Promise.resolve(response1); + + expect(response1).not.toBe(false); + + message.command = "fillForm"; + message.fillScript = mock(); + + const response2 = await bitwardenAutofillInit["handleExtensionMessage"]( + message, + sender, + sendResponse + ); + + expect(response2).toBe(false); + }); + + it("returns a true value and calls sendResponse if the message handler returns a response", async () => { + message.command = "collectPageDetailsImmediately"; + const pageDetails: AutofillPageDetails = { + title: "title", + url: "http://example.com", + documentUrl: "documentUrl", + forms: {}, + fields: [], + collectedTimestamp: 0, + }; + jest + .spyOn(bitwardenAutofillInit.collectAutofillContentService, "getPageDetails") + .mockReturnValue(pageDetails); + + const response = await bitwardenAutofillInit["handleExtensionMessage"]( + message, + sender, + sendResponse + ); + await Promise.resolve(response); + + expect(response).toBe(true); + expect(sendResponse).toHaveBeenCalledWith(pageDetails); + }); + }); +}); diff --git a/apps/browser/src/autofill/content/autofill-init.ts b/apps/browser/src/autofill/content/autofill-init.ts new file mode 100644 index 0000000000..8b441ae0e2 --- /dev/null +++ b/apps/browser/src/autofill/content/autofill-init.ts @@ -0,0 +1,130 @@ +import AutofillPageDetails from "../models/autofill-page-details"; +import AutofillScript from "../models/autofill-script"; +import CollectAutofillContentService from "../services/collect-autofill-content.service"; +import DomElementVisibilityService from "../services/dom-element-visibility.service"; +import InsertAutofillContentService from "../services/insert-autofill-content.service"; + +import { + AutofillExtensionMessage, + AutofillExtensionMessageHandlers, + AutofillInit as AutofillInitInterface, +} from "./abstractions/autofill-init"; + +class AutofillInit implements AutofillInitInterface { + private readonly domElementVisibilityService: DomElementVisibilityService; + private readonly collectAutofillContentService: CollectAutofillContentService; + private readonly insertAutofillContentService: InsertAutofillContentService; + private readonly extensionMessageHandlers: AutofillExtensionMessageHandlers = { + collectPageDetails: ({ message }) => this.collectPageDetails(message), + collectPageDetailsImmediately: ({ message }) => this.collectPageDetails(message, true), + fillForm: ({ message }) => this.fillForm(message.fillScript), + }; + + /** + * AutofillInit constructor. Initializes the DomElementVisibilityService, + * CollectAutofillContentService and InsertAutofillContentService classes. + */ + constructor() { + this.domElementVisibilityService = new DomElementVisibilityService(); + this.collectAutofillContentService = new CollectAutofillContentService( + this.domElementVisibilityService + ); + this.insertAutofillContentService = new InsertAutofillContentService( + this.domElementVisibilityService, + this.collectAutofillContentService + ); + } + + /** + * Initializes the autofill content script, setting up + * the extension message listeners. This method should + * be called once when the content script is loaded. + * @public + */ + init() { + this.setupExtensionMessageListeners(); + } + + /** + * Collects the page details and sends them to the + * extension background script. If the `sendDetailsInResponse` + * parameter is set to true, the page details will be + * returned to facilitate sending the details in the + * response to the extension message. + * @param {AutofillExtensionMessage} message + * @param {boolean} sendDetailsInResponse + * @returns {AutofillPageDetails | void} + * @private + */ + private async collectPageDetails( + message: AutofillExtensionMessage, + sendDetailsInResponse = false + ): Promise { + const pageDetails: AutofillPageDetails = + await this.collectAutofillContentService.getPageDetails(); + if (sendDetailsInResponse) { + return pageDetails; + } + + chrome.runtime.sendMessage({ + command: "collectPageDetailsResponse", + tab: message.tab, + details: pageDetails, + sender: message.sender, + }); + } + + /** + * Fills the form with the given fill script. + * @param {AutofillScript} fillScript + * @private + */ + private fillForm(fillScript: AutofillScript) { + this.insertAutofillContentService.fillForm(fillScript); + } + + /** + * Sets up the extension message listeners + * for the content script. + * @private + */ + private setupExtensionMessageListeners() { + chrome.runtime.onMessage.addListener(this.handleExtensionMessage); + } + + /** + * Handles the extension messages + * sent to the content script. + * @param {AutofillExtensionMessage} message + * @param {chrome.runtime.MessageSender} sender + * @param {(response?: any) => void} sendResponse + * @returns {boolean} + * @private + */ + private handleExtensionMessage = ( + message: AutofillExtensionMessage, + sender: chrome.runtime.MessageSender, + sendResponse: (response?: any) => void + ): boolean => { + const command: string = message.command; + const handler: CallableFunction | undefined = this.extensionMessageHandlers[command]; + if (!handler) { + return false; + } + + const messageResponse = handler({ message, sender }); + if (!messageResponse) { + return false; + } + + Promise.resolve(messageResponse).then((response) => sendResponse(response)); + return true; + }; +} + +(function () { + if (!window.bitwardenAutofillInit) { + window.bitwardenAutofillInit = new AutofillInit(); + window.bitwardenAutofillInit.init(); + } +})(); diff --git a/apps/browser/src/autofill/content/autofiller.ts b/apps/browser/src/autofill/content/autofiller.ts index 7fe9e5514a..7f58e72c7d 100644 --- a/apps/browser/src/autofill/content/autofiller.ts +++ b/apps/browser/src/autofill/content/autofiller.ts @@ -1,4 +1,10 @@ -document.addEventListener("DOMContentLoaded", (event) => { +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", loadAutofiller); +} else { + loadAutofiller(); +} + +function loadAutofiller() { let pageHref: string = null; let filledThisHref = false; let delayFillTimeout: number; @@ -49,4 +55,4 @@ document.addEventListener("DOMContentLoaded", (event) => { chrome.runtime.sendMessage(msg); } } -}); +} diff --git a/apps/browser/src/autofill/content/autofillv2.ts b/apps/browser/src/autofill/content/autofillv2.ts deleted file mode 100644 index 65813b3afe..0000000000 --- a/apps/browser/src/autofill/content/autofillv2.ts +++ /dev/null @@ -1,1399 +0,0 @@ -/* eslint-disable no-var, no-console, no-prototype-builtins */ -// These eslint rules are disabled because the original JS was not written with them in mind and we don't want to fix -// them all now - -/* - 1Password Extension - - Lovingly handcrafted by Dave Teare, Michael Fey, Rad Azzouz, and Roustem Karimov. - Copyright (c) 2014 AgileBits. All rights reserved. - - ================================================================================ - - Copyright (c) 2014 AgileBits Inc. - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. - */ - -/* - MODIFICATIONS FROM ORIGINAL - - 1. Populate isFirefox - 2. Remove isChrome and isSafari since they are not used. - 3. Unminify and format to meet Mozilla review requirements. - 4. Remove unnecessary input types from getFormElements query selector and limit number of elements returned. - 5. Remove fakeTested prop. - 6. Rename com.agilebits.* stuff to com.bitwarden.* - 7. Remove "some useful globals" on window - 8. Add ability to autofill span[data-bwautofill] elements - 9. Add new handler, for new command that responds with page details in response callback - 10. Handle sandbox iframe and sandbox rule in CSP - 11. Work on array of saved urls instead of just one to determine if we should autofill non-https sites - 12. Remove setting of attribute com.browser.browser.userEdited on user-inputs - 13. Handle null value URLs in urlNotSecure - 14. Convert to Typescript, add typings and remove dead code (not marked with START/END MODIFICATION) - */ -import AutofillForm from "../models/autofill-form"; -import AutofillPageDetails from "../models/autofill-page-details"; -import AutofillScript, { - AutofillScriptOptions, - FillScript, - FillScriptOp, -} from "../models/autofill-script"; - -/** - * The Document with additional custom properties added by this script - */ -type AutofillDocument = Document & { - elementsByOPID: Record; - elementForOPID: (opId: string) => Element; -}; - -/** - * A HTMLElement (usually a form element) with additional custom properties added by this script - */ -type ElementWithOpId = T & { - opid: string; -}; - -/** - * This script's definition of a Form Element (only a subset of HTML form elements) - * This is defined by getFormElements - */ -type FormElement = HTMLInputElement | HTMLSelectElement | HTMLSpanElement; - -/** - * A Form Element that we can set a value on (fill) - */ -type FillableControl = HTMLInputElement | HTMLSelectElement; - -function collect(document: Document) { - // START MODIFICATION - var isFirefox = - navigator.userAgent.indexOf("Firefox") !== -1 || navigator.userAgent.indexOf("Gecko/") !== -1; - // END MODIFICATION - - (document as AutofillDocument).elementsByOPID = {}; - - function getPageDetails(theDoc: Document, oneShotId: string) { - // start helpers - - /** - * For a given element `el`, returns the value of the attribute `attrName`. - * @param {HTMLElement} el - * @param {string} attrName - * @returns {string} The value of the attribute - */ - function getElementAttrValue(el: any, attrName: string) { - var attrVal = el[attrName]; - if ("string" == typeof attrVal) { - return attrVal; - } - attrVal = el.getAttribute(attrName); - return "string" == typeof attrVal ? attrVal : null; - } - - /** - * Returns the value of the given element. - * @param {HTMLElement} el - * @returns {any} Value of the element - */ - function getElementValue(el: any) { - switch (toLowerString(el.type)) { - case "checkbox": - return el.checked ? "✓" : ""; - - case "hidden": - el = el.value; - if (!el || "number" != typeof el.length) { - return ""; - } - 254 < el.length && (el = el.substr(0, 254) + "...SNIPPED"); - return el; - - default: - // START MODIFICATION - if (!el.type && el.tagName.toLowerCase() === "span") { - return el.innerText; - } - // END MODIFICATION - return el.value; - } - } - - /** - * If `el` is a `` elements, an array of the element's option `text` values - */ - selectInfo: any; - /** - * The `maxLength` attribute for the field - */ - maxLength: number; + htmlClass: string | null; + + tabindex: string | null; + + title: string | null; /** * The `tagName` for the field */ - tagName: string; - [key: string]: any; + tagName?: string | null; + /** + * The concatenated `innerText` or `textContent` of all the elements that are to the "left" of the field in the DOM + */ + "label-left"?: string; + /** + * The concatenated `innerText` or `textContent` of all the elements that are to the "right" of the field in the DOM + */ + "label-right"?: string; + /** + * For fields in a data table, the contents of the table row immediately above the field + */ + "label-top"?: string; + /** + * The concatenated `innerText` or `textContent` of all elements that are HTML labels for the field + */ + "label-tag"?: string; + /** + * The `aria-label` attribute for the field + */ + "label-aria"?: string | null; + + "label-data"?: string | null; + + "aria-hidden"?: boolean; + + "aria-disabled"?: boolean; + + "aria-haspopup"?: boolean; + + "data-stripe"?: string | null; + /** + * The HTML `placeholder` attribute for the field + */ + placeholder?: string | null; + /** + * The HTML `type` attribute for the field + */ + type?: string; + /** + * The HTML `value` for the field + */ + value?: string; + /** + * The `disabled` status of the field + */ + disabled?: boolean; + /** + * The `readonly` status of the field + */ + readonly?: boolean; + /** + * The `opid` attribute value of the form that contains the field + */ + form?: string; + /** + * The `x-autocompletetype`, `autocompletetype`, or `autocomplete` attribute for the field + */ + autoCompleteType?: string | null; + /** + * For ` + + + +`; + +describe("CollectAutofillContentService", () => { + const domElementVisibilityService = new DomElementVisibilityService(); + let collectAutofillContentService: CollectAutofillContentService; + + beforeEach(() => { + document.body.innerHTML = mockLoginForm; + collectAutofillContentService = new CollectAutofillContentService(domElementVisibilityService); + }); + + afterEach(() => { + jest.clearAllMocks(); + document.body.innerHTML = ""; + }); + + describe("getPageDetails", () => { + it("returns an object containing information about the curren page as well as autofill data for the forms and fields of the page", async () => { + const documentTitle = "Test Page"; + const formId = "validFormId"; + const formAction = "https://example.com/"; + const formMethod = "post"; + const formName = "validFormName"; + const usernameFieldId = "usernameField"; + const usernameFieldName = "username"; + const usernameFieldLabel = "User Name"; + const passwordFieldId = "passwordField"; + const passwordFieldName = "password"; + const passwordFieldLabel = "Password"; + document.title = documentTitle; + document.body.innerHTML = ` +
+ + + + +
+ `; + jest.spyOn(collectAutofillContentService as any, "buildAutofillFormsData"); + jest.spyOn(collectAutofillContentService as any, "buildAutofillFieldsData"); + jest + .spyOn(collectAutofillContentService["domElementVisibilityService"], "isFormFieldViewable") + .mockResolvedValue(true); + + const pageDetails = await collectAutofillContentService.getPageDetails(); + + expect(collectAutofillContentService["buildAutofillFormsData"]).toHaveBeenCalled(); + expect(collectAutofillContentService["buildAutofillFieldsData"]).toHaveBeenCalled(); + expect(pageDetails).toStrictEqual({ + title: documentTitle, + url: window.location.href, + documentUrl: document.location.href, + forms: { + __form__0: { + opid: "__form__0", + htmlAction: formAction, + htmlName: formName, + htmlID: formId, + htmlMethod: formMethod, + }, + }, + fields: [ + { + opid: "__0", + elementNumber: 0, + maxLength: 999, + viewable: true, + htmlID: usernameFieldId, + htmlName: usernameFieldName, + htmlClass: null, + tabindex: null, + title: "", + tagName: "input", + "label-tag": usernameFieldLabel, + "label-data": null, + "label-aria": null, + "label-top": null, + "label-right": passwordFieldLabel, + "label-left": usernameFieldLabel, + placeholder: "", + rel: null, + type: "text", + value: "", + checked: false, + autoCompleteType: "", + disabled: false, + readonly: false, + selectInfo: null, + form: "__form__0", + "aria-hidden": false, + "aria-disabled": false, + "aria-haspopup": false, + "data-stripe": null, + }, + { + opid: "__1", + elementNumber: 1, + maxLength: 999, + viewable: true, + htmlID: passwordFieldId, + htmlName: passwordFieldName, + htmlClass: null, + tabindex: null, + title: "", + tagName: "input", + "label-tag": passwordFieldLabel, + "label-data": null, + "label-aria": null, + "label-top": null, + "label-right": "", + "label-left": passwordFieldLabel, + placeholder: "", + rel: null, + type: "password", + value: "", + checked: false, + autoCompleteType: "", + disabled: false, + readonly: false, + selectInfo: null, + form: "__form__0", + "aria-hidden": false, + "aria-disabled": false, + "aria-haspopup": false, + "data-stripe": null, + }, + ], + collectedTimestamp: expect.any(Number), + }); + }); + }); + + describe("getAutofillFieldElementByOpid", () => { + it("returns the element with the opid property value matching the passed value", () => { + const textInput = document.querySelector('input[type="text"]') as FormElementWithAttribute; + const passwordInput = document.querySelector( + 'input[type="password"]' + ) as FormElementWithAttribute; + textInput.opid = "__0"; + passwordInput.opid = "__1"; + + const textInputWithOpid = collectAutofillContentService.getAutofillFieldElementByOpid("__0"); + const passwordInputWithOpid = + collectAutofillContentService.getAutofillFieldElementByOpid("__1"); + + expect(textInputWithOpid).toEqual(textInput); + expect(textInputWithOpid).not.toEqual(passwordInput); + expect(passwordInputWithOpid).toEqual(passwordInput); + }); + + it("returns the first of the element with an `opid` value matching the passed value and emits a console warning if multiple fields contain the same `opid`", () => { + const textInput = document.querySelector('input[type="text"]') as FormElementWithAttribute; + const passwordInput = document.querySelector( + 'input[type="password"]' + ) as FormElementWithAttribute; + jest.spyOn(console, "warn").mockImplementationOnce(jest.fn()); + textInput.opid = "__1"; + passwordInput.opid = "__1"; + + const elementWithOpid0 = collectAutofillContentService.getAutofillFieldElementByOpid("__0"); + const elementWithOpid1 = collectAutofillContentService.getAutofillFieldElementByOpid("__1"); + + expect(elementWithOpid0).toEqual(textInput); + expect(elementWithOpid1).toEqual(textInput); + expect(elementWithOpid1).not.toEqual(passwordInput); + // eslint-disable-next-line no-console + expect(console.warn).toHaveBeenCalledWith("More than one element found with opid __1"); + }); + + it("returns the element at the index position (parsed from passed opid) of all AutofillField elements when the passed opid value cannot be found", () => { + const textInput = document.querySelector('input[type="text"]') as FormElementWithAttribute; + const passwordInput = document.querySelector( + 'input[type="password"]' + ) as FormElementWithAttribute; + textInput.opid = undefined; + passwordInput.opid = "__1"; + + const elementWithOpid0 = collectAutofillContentService.getAutofillFieldElementByOpid("__0"); + const elementWithOpid2 = collectAutofillContentService.getAutofillFieldElementByOpid("__2"); + + expect(textInput.opid).toBeUndefined(); + expect(elementWithOpid0).toEqual(textInput); + expect(elementWithOpid0).not.toEqual(passwordInput); + expect(elementWithOpid2).toBeNull(); + }); + + it("returns null if no element can be found", () => { + const textInput = document.querySelector('input[type="text"]') as FormElementWithAttribute; + textInput.opid = "__0"; + + const foundElementWithOpid = + collectAutofillContentService.getAutofillFieldElementByOpid("__999"); + + expect(foundElementWithOpid).toBeNull(); + }); + }); + + describe("buildAutofillFormsData", () => { + it("returns an object of AutofillForm objects with the form id as a key", () => { + const documentTitle = "Test Page"; + const formId1 = "validFormId"; + const formAction1 = "https://example.com/"; + const formMethod1 = "post"; + const formName1 = "validFormName"; + const formId2 = "validFormId2"; + const formAction2 = "https://example2.com/"; + const formMethod2 = "get"; + const formName2 = "validFormName2"; + document.title = documentTitle; + document.body.innerHTML = ` +
+ + + + +
+
+ + +
+ `; + + const autofillFormsData = collectAutofillContentService["buildAutofillFormsData"](); + + expect(autofillFormsData).toStrictEqual({ + __form__0: { + opid: "__form__0", + htmlAction: formAction1, + htmlName: formName1, + htmlID: formId1, + htmlMethod: formMethod1, + }, + __form__1: { + opid: "__form__1", + htmlAction: formAction2, + htmlName: formName2, + htmlID: formId2, + htmlMethod: formMethod2, + }, + }); + }); + }); + + describe("buildAutofillFieldsData", () => { + it("returns a promise containing an array of AutofillField objects", async () => { + jest.spyOn(collectAutofillContentService as any, "getAutofillFieldElements"); + jest.spyOn(collectAutofillContentService as any, "buildAutofillFieldItem"); + jest + .spyOn(collectAutofillContentService["domElementVisibilityService"], "isFormFieldViewable") + .mockResolvedValue(true); + + const autofillFieldsPromise = collectAutofillContentService["buildAutofillFieldsData"](); + const autofillFieldsData = await Promise.resolve(autofillFieldsPromise); + + expect(collectAutofillContentService["getAutofillFieldElements"]).toHaveBeenCalledWith(50); + expect(collectAutofillContentService["buildAutofillFieldItem"]).toHaveBeenCalledTimes(2); + expect(autofillFieldsPromise).toBeInstanceOf(Promise); + expect(autofillFieldsData).toStrictEqual([ + { + "aria-disabled": false, + "aria-haspopup": false, + "aria-hidden": false, + autoCompleteType: "", + checked: false, + "data-stripe": null, + disabled: false, + elementNumber: 0, + form: null, + htmlClass: null, + htmlID: "username", + htmlName: "", + "label-aria": null, + "label-data": null, + "label-left": "", + "label-right": "", + "label-tag": "", + "label-top": null, + maxLength: 999, + opid: "__0", + placeholder: "", + readonly: false, + rel: null, + selectInfo: null, + tabindex: null, + tagName: "input", + title: "", + type: "text", + value: "", + viewable: true, + }, + { + "aria-disabled": false, + "aria-haspopup": false, + "aria-hidden": false, + autoCompleteType: "", + checked: false, + "data-stripe": null, + disabled: false, + elementNumber: 1, + form: null, + htmlClass: null, + htmlID: "", + htmlName: "", + "label-aria": null, + "label-data": null, + "label-left": "", + "label-right": "", + "label-tag": "", + "label-top": null, + maxLength: 999, + opid: "__1", + placeholder: "", + readonly: false, + rel: null, + selectInfo: null, + tabindex: null, + tagName: "input", + title: "", + type: "password", + value: "", + viewable: true, + }, + ]); + }); + }); + + describe("getAutofillFieldElements", () => { + it("returns all form elements from the targeted document if no limit is set", () => { + document.body.innerHTML = ` +
+
+ + + + + + + + + Span Element +
+
+ `; + const usernameInput = document.getElementById("username"); + const passwordInput = document.querySelector('input[type="password"]'); + const commentsTextarea = document.getElementById("comments"); + const selectElement = document.getElementById("select"); + const spanElement = document.querySelector('span[data-bwautofill="true"]'); + jest.spyOn(document, "querySelectorAll"); + jest.spyOn(collectAutofillContentService as any, "getPropertyOrAttribute"); + + const formElements: FormFieldElement[] = + collectAutofillContentService["getAutofillFieldElements"](); + + expect(document.querySelectorAll).toHaveBeenCalledWith( + 'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"]):not([type="file"]):not([data-bwignore]), textarea:not([data-bwignore]), select:not([data-bwignore]), span[data-bwautofill]' + ); + expect(collectAutofillContentService["getPropertyOrAttribute"]).not.toHaveBeenCalled(); + expect(formElements).toEqual([ + usernameInput, + passwordInput, + commentsTextarea, + selectElement, + spanElement, + ]); + }); + + it("returns up to 2 (passed as `limit`) form elements from the targeted document with more than 2 form elements", () => { + document.body.innerHTML = ` +
+ included span + + ignored span + + + + + another included span +
+ `; + const spanElement = document.querySelector("span[data-bwautofill='true']"); + const textAreaInput = document.querySelector("textarea"); + jest.spyOn(collectAutofillContentService as any, "getPropertyOrAttribute"); + + const formElements: FormFieldElement[] = + collectAutofillContentService["getAutofillFieldElements"](2); + + expect(collectAutofillContentService["getPropertyOrAttribute"]).toHaveBeenNthCalledWith( + 1, + spanElement, + "type" + ); + expect(collectAutofillContentService["getPropertyOrAttribute"]).toHaveBeenNthCalledWith( + 2, + textAreaInput, + "type" + ); + expect(formElements).toEqual([spanElement, textAreaInput]); + }); + + it("returns form elements from the targeted document, ignoring input types `hidden`, `submit`, `reset`, `button`, `image`, `file`, and inputs tagged with `data-bwignore`, while giving lower order priority to `checkbox` and `radio` inputs if the returned list is truncated by `limit", () => { + document.body.innerHTML = ` +
+
+ Select an option: +
+ + +
+
+ + +
+
+ + +
+
+ included span + + ignored span + + + + + + + + + + + + + + another included span +
+ `; + const inputRadioA = document.querySelector('input[type="radio"][value="option-a"]'); + const inputRadioB = document.querySelector('input[type="radio"][value="option-b"]'); + const inputRadioC = document.querySelector('input[type="radio"][value="option-c"]'); + const firstSpan = document.getElementById("first-span"); + const textAreaInput = document.querySelector("textarea"); + const checkboxInput = document.querySelector('input[type="checkbox"]'); + const selectElement = document.querySelector("select"); + const usernameInput = document.getElementById("username"); + const passwordInput = document.querySelector('input[type="password"]'); + const secondSpan = document.getElementById("second-span"); + + const formElements: FormFieldElement[] = + collectAutofillContentService["getAutofillFieldElements"](); + + expect(formElements).toEqual([ + inputRadioA, + inputRadioB, + inputRadioC, + firstSpan, + textAreaInput, + checkboxInput, + selectElement, + usernameInput, + passwordInput, + secondSpan, + ]); + }); + + it("returns form elements from the targeted document while giving lower order priority to `checkbox` and `radio` inputs if the returned list is truncated by `limit`", () => { + document.body.innerHTML = ` +
+ + + + ignored span +
+ Select an option: +
+ + +
+
+ + +
+
+ + +
+
+ + + + + another included span +
+ `; + const textAreaInput = document.querySelector("textarea"); + const selectElement = document.querySelector("select"); + const usernameInput = document.getElementById("username"); + const passwordInput = document.querySelector('input[type="password"]'); + const includedSpan = document.querySelector('span[data-bwautofill="true"]'); + const checkboxInput = document.querySelector('input[type="checkbox"]'); + const inputRadioA = document.querySelector('input[type="radio"][value="option-a"]'); + const inputRadioB = document.querySelector('input[type="radio"][value="option-b"]'); + + const truncatedFormElements: FormFieldElement[] = + collectAutofillContentService["getAutofillFieldElements"](8); + + expect(truncatedFormElements).toEqual([ + textAreaInput, + selectElement, + usernameInput, + passwordInput, + includedSpan, + checkboxInput, + inputRadioA, + inputRadioB, + ]); + }); + }); + + describe("buildAutofillFieldItem", () => { + it("returns the AutofillField base data values without the field labels or input values if the passed element is a span element", async () => { + const index = 0; + const spanElementId = "span-element"; + const spanElementClasses = "span element classes"; + const spanElementTabIndex = 0; + const spanElementTitle = "Span Element Title"; + document.body.innerHTML = ` + Span Element + `; + const spanElement = document.getElementById( + spanElementId + ) as ElementWithOpId; + jest.spyOn(collectAutofillContentService as any, "getAutofillFieldMaxLength"); + jest + .spyOn(collectAutofillContentService["domElementVisibilityService"], "isFormFieldViewable") + .mockResolvedValue(true); + jest.spyOn(collectAutofillContentService as any, "getPropertyOrAttribute"); + jest.spyOn(collectAutofillContentService as any, "getElementValue"); + + const autofillFieldItem = await collectAutofillContentService["buildAutofillFieldItem"]( + spanElement, + index + ); + + expect(collectAutofillContentService["getAutofillFieldMaxLength"]).toHaveBeenCalledWith( + spanElement + ); + expect( + collectAutofillContentService["domElementVisibilityService"].isFormFieldViewable + ).toHaveBeenCalledWith(spanElement); + expect(collectAutofillContentService["getPropertyOrAttribute"]).toHaveBeenNthCalledWith( + 1, + spanElement, + "id" + ); + expect(collectAutofillContentService["getPropertyOrAttribute"]).toHaveBeenNthCalledWith( + 2, + spanElement, + "name" + ); + expect(collectAutofillContentService["getPropertyOrAttribute"]).toHaveBeenNthCalledWith( + 3, + spanElement, + "class" + ); + expect(collectAutofillContentService["getPropertyOrAttribute"]).toHaveBeenNthCalledWith( + 4, + spanElement, + "tabindex" + ); + expect(collectAutofillContentService["getPropertyOrAttribute"]).toHaveBeenNthCalledWith( + 5, + spanElement, + "title" + ); + expect(collectAutofillContentService["getPropertyOrAttribute"]).toHaveBeenNthCalledWith( + 6, + spanElement, + "tagName" + ); + expect(collectAutofillContentService["getElementValue"]).not.toHaveBeenCalled(); + expect(autofillFieldItem).toEqual({ + elementNumber: index, + htmlClass: spanElementClasses, + htmlID: spanElementId, + htmlName: null, + maxLength: null, + opid: `__${index}`, + tabindex: String(spanElementTabIndex), + tagName: spanElement.tagName.toLowerCase(), + title: spanElementTitle, + viewable: true, + }); + }); + + it("returns the AutofillField base data, label data, and input element data", async () => { + const index = 0; + const usernameField = { + labelText: "Username", + id: "username-id", + classes: "username input classes", + name: "username", + type: "text", + maxLength: 42, + tabIndex: 0, + title: "Username Input Title", + autocomplete: "username-autocomplete", + dataLabel: "username-data-label", + ariaLabel: "username-aria-label", + placeholder: "username-placeholder", + rel: "username-rel", + value: "username-value", + dataStripe: "data-stripe", + }; + document.body.innerHTML = ` +
+ + +
+ `; + const formElement = document.querySelector("form"); + formElement.opid = "form-opid"; + const usernameInput = document.getElementById( + usernameField.id + ) as ElementWithOpId; + jest.spyOn(collectAutofillContentService as any, "getAutofillFieldMaxLength"); + jest + .spyOn(collectAutofillContentService["domElementVisibilityService"], "isFormFieldViewable") + .mockResolvedValue(true); + jest.spyOn(collectAutofillContentService as any, "getPropertyOrAttribute"); + jest.spyOn(collectAutofillContentService as any, "getElementValue"); + + const autofillFieldItem = await collectAutofillContentService["buildAutofillFieldItem"]( + usernameInput, + index + ); + + expect(autofillFieldItem).toEqual({ + "aria-disabled": false, + "aria-haspopup": false, + "aria-hidden": false, + autoCompleteType: usernameField.autocomplete, + checked: false, + "data-stripe": usernameField.dataStripe, + disabled: false, + elementNumber: index, + form: formElement.opid, + htmlClass: usernameField.classes, + htmlID: usernameField.id, + htmlName: usernameField.name, + "label-aria": usernameField.ariaLabel, + "label-data": usernameField.dataLabel, + "label-left": usernameField.labelText, + "label-right": "", + "label-tag": usernameField.labelText, + "label-top": null, + maxLength: usernameField.maxLength, + opid: `__${index}`, + placeholder: usernameField.placeholder, + readonly: false, + rel: usernameField.rel, + selectInfo: null, + tabindex: String(usernameField.tabIndex), + tagName: usernameInput.tagName.toLowerCase(), + title: usernameField.title, + type: usernameField.type, + value: usernameField.value, + viewable: true, + }); + }); + + it("returns the AutofillField base data and input element data, but not the label data if the input element is of type `hidden`", async () => { + const index = 0; + const hiddenField = { + labelText: "Hidden Field", + id: "hidden-id", + classes: "hidden input classes", + name: "hidden", + type: "hidden", + maxLength: 42, + tabIndex: 0, + title: "Hidden Input Title", + autocomplete: "off", + rel: "hidden-rel", + value: "hidden-value", + dataStripe: "data-stripe", + }; + document.body.innerHTML = ` +
+ + +
+ `; + const formElement = document.querySelector("form"); + formElement.opid = "form-opid"; + const hiddenInput = document.getElementById( + hiddenField.id + ) as ElementWithOpId; + jest.spyOn(collectAutofillContentService as any, "getAutofillFieldMaxLength"); + jest + .spyOn(collectAutofillContentService["domElementVisibilityService"], "isFormFieldViewable") + .mockResolvedValue(true); + jest.spyOn(collectAutofillContentService as any, "getPropertyOrAttribute"); + jest.spyOn(collectAutofillContentService as any, "getElementValue"); + + const autofillFieldItem = await collectAutofillContentService["buildAutofillFieldItem"]( + hiddenInput, + index + ); + + expect(autofillFieldItem).toEqual({ + "aria-disabled": false, + "aria-haspopup": false, + "aria-hidden": false, + autoCompleteType: null, + checked: false, + "data-stripe": hiddenField.dataStripe, + disabled: false, + elementNumber: index, + form: formElement.opid, + htmlClass: hiddenField.classes, + htmlID: hiddenField.id, + htmlName: hiddenField.name, + maxLength: hiddenField.maxLength, + opid: `__${index}`, + readonly: false, + rel: hiddenField.rel, + selectInfo: null, + tabindex: String(hiddenField.tabIndex), + tagName: hiddenInput.tagName.toLowerCase(), + title: hiddenField.title, + type: hiddenField.type, + value: hiddenField.value, + viewable: true, + }); + }); + }); + + describe("createAutofillFieldLabelTag", () => { + beforeEach(() => { + jest.spyOn(collectAutofillContentService as any, "createLabelElementsTag"); + jest.spyOn(document, "querySelectorAll"); + }); + + it("returns the label tag early if the passed element contains any labels", () => { + document.body.innerHTML = ` + + + + `; + const element = document.querySelector("#username-id") as FillableFormFieldElement; + + const labelTag = collectAutofillContentService["createAutofillFieldLabelTag"](element); + + expect(collectAutofillContentService["createLabelElementsTag"]).toHaveBeenCalledWith( + new Set(element.labels) + ); + expect(document.querySelectorAll).not.toHaveBeenCalled(); + expect(labelTag).toEqual("Username"); + }); + + it("queries all labels associated with the element's id", () => { + document.body.innerHTML = ` + + + `; + const element = document.querySelector("#country-id") as FillableFormFieldElement; + const elementLabel = document.querySelector("label[for='country-id']"); + + const labelTag = collectAutofillContentService["createAutofillFieldLabelTag"](element); + + expect(document.querySelectorAll).toHaveBeenCalledWith(`label[for="${element.id}"]`); + expect(collectAutofillContentService["createLabelElementsTag"]).toHaveBeenCalledWith( + new Set([elementLabel]) + ); + expect(labelTag).toEqual("Country"); + }); + + it("queries all labels associated with the element's name", () => { + document.body.innerHTML = ` + + + `; + const element = document.querySelector("select") as FillableFormFieldElement; + const elementLabel = document.querySelector("label[for='country-name']"); + + const labelTag = collectAutofillContentService["createAutofillFieldLabelTag"](element); + + expect(document.querySelectorAll).not.toHaveBeenCalledWith(`label[for="${element.id}"]`); + expect(document.querySelectorAll).toHaveBeenCalledWith(`label[for="${element.name}"]`); + expect(collectAutofillContentService["createLabelElementsTag"]).toHaveBeenCalledWith( + new Set([elementLabel]) + ); + expect(labelTag).toEqual("Country"); + }); + + it("will not add duplicate labels that are found to the label tag", () => { + document.body.innerHTML = ` + +
+ `; + const element = document.querySelector("#country-name") as FillableFormFieldElement; + element.name = "country-name"; + const elementLabel = document.querySelector("label[for='country-name']"); + + const labelTag = collectAutofillContentService["createAutofillFieldLabelTag"](element); + + expect(document.querySelectorAll).toHaveBeenCalledWith( + `label[for="${element.id}"], label[for="${element.name}"]` + ); + expect(collectAutofillContentService["createLabelElementsTag"]).toHaveBeenCalledWith( + new Set([elementLabel]) + ); + expect(labelTag).toEqual("Country"); + }); + + it("will attempt to identify the label of an element from its parent element", () => { + document.body.innerHTML = ``; + const element = document.querySelector("#username-id") as FillableFormFieldElement; + const elementLabel = element.parentElement; + + const labelTag = collectAutofillContentService["createAutofillFieldLabelTag"](element); + + expect(collectAutofillContentService["createLabelElementsTag"]).toHaveBeenCalledWith( + new Set([elementLabel]) + ); + expect(labelTag).toEqual("Username"); + }); + + it("will attempt to identify the label of an element from a `dt` element associated with the element's parent", () => { + document.body.innerHTML = ` +
+
Username
+
+ +
+
+ `; + const element = document.querySelector("#username-id") as FillableFormFieldElement; + const elementLabel = document.querySelector("#label-element"); + + const labelTag = collectAutofillContentService["createAutofillFieldLabelTag"](element); + + expect(collectAutofillContentService["createLabelElementsTag"]).toHaveBeenCalledWith( + new Set([elementLabel]) + ); + expect(labelTag).toEqual("Username"); + }); + + it("will return an empty string value if no labels can be found for an element", () => { + document.body.innerHTML = ` + + `; + const element = document.querySelector("#username-id") as FillableFormFieldElement; + + const labelTag = collectAutofillContentService["createAutofillFieldLabelTag"](element); + + expect(labelTag).toEqual(""); + }); + }); + + describe("queryElementLabels", () => { + it("returns null if the passed element has no id or name", () => { + document.body.innerHTML = ` + + `; + const element = document.querySelector("input") as FillableFormFieldElement; + + const labels = collectAutofillContentService["queryElementLabels"](element); + + expect(labels).toBeNull(); + }); + + it("returns an empty NodeList if the passed element has no label", () => { + document.body.innerHTML = ` + + `; + const element = document.querySelector("input") as FillableFormFieldElement; + + const labels = collectAutofillContentService["queryElementLabels"](element); + + expect(labels).toEqual(document.querySelectorAll("label")); + }); + + it("returns the label of an element associated with its ID value", () => { + document.body.innerHTML = ` + + + `; + const element = document.querySelector("input") as FillableFormFieldElement; + + const labels = collectAutofillContentService["queryElementLabels"](element); + + expect(labels).toEqual(document.querySelectorAll("label[for='username-id']")); + }); + + it("returns the label of an element associated with its name value", () => { + document.body.innerHTML = ` + + + `; + const element = document.querySelector("input") as FillableFormFieldElement; + + const labels = collectAutofillContentService["queryElementLabels"](element); + + expect(labels).toEqual(document.querySelectorAll("label[for='username']")); + }); + }); + + describe("createLabelElementsTag", () => { + it("returns a string containing all the labels associated with a given input element", () => { + const firstLabelText = "Username by name"; + const secondLabelText = "Username by ID"; + document.body.innerHTML = ` + + + + `; + const labels = document.querySelectorAll("label"); + jest.spyOn(collectAutofillContentService as any, "trimAndRemoveNonPrintableText"); + + const labelTag = collectAutofillContentService["createLabelElementsTag"](new Set(labels)); + + expect( + collectAutofillContentService["trimAndRemoveNonPrintableText"] + ).toHaveBeenNthCalledWith(1, firstLabelText); + expect( + collectAutofillContentService["trimAndRemoveNonPrintableText"] + ).toHaveBeenNthCalledWith(2, secondLabelText); + expect(labelTag).toEqual(`${firstLabelText}${secondLabelText}`); + }); + }); + + describe("getAutofillFieldMaxLength", () => { + it("returns null if the passed FormFieldElement is not an element type that has a max length property", () => { + document.body.innerHTML = ` + + `; + const element = document.querySelector("select") as FillableFormFieldElement; + + const maxLength = collectAutofillContentService["getAutofillFieldMaxLength"](element); + + expect(maxLength).toBeNull(); + }); + + it("returns a value of 999 if the passed FormFieldElement has no set maxLength value", () => { + document.body.innerHTML = ` + + `; + const element = document.querySelector("input") as FillableFormFieldElement; + + const maxLength = collectAutofillContentService["getAutofillFieldMaxLength"](element); + + expect(maxLength).toEqual(999); + }); + + it("returns a value of 999 if the passed FormFieldElement has a maxLength value higher than 999", () => { + document.body.innerHTML = ` + + `; + const element = document.querySelector("input") as FillableFormFieldElement; + + const maxLength = collectAutofillContentService["getAutofillFieldMaxLength"](element); + + expect(maxLength).toEqual(999); + }); + + it("returns the maxLength property of a passed FormFieldElement", () => { + document.body.innerHTML = ` + + `; + const element = document.querySelector("input") as FillableFormFieldElement; + + const maxLength = collectAutofillContentService["getAutofillFieldMaxLength"](element); + + expect(maxLength).toEqual(10); + }); + }); + + describe("createAutofillFieldRightLabel", () => { + it("returns an empty string if no siblings are found", () => { + document.body.innerHTML = ` + + `; + const element = document.querySelector("input") as FillableFormFieldElement; + + const labelTag = collectAutofillContentService["createAutofillFieldRightLabel"](element); + + expect(labelTag).toEqual(""); + }); + + it("returns the text content of the element's next sibling element", () => { + document.body.innerHTML = ` + + + `; + const element = document.querySelector("input") as FillableFormFieldElement; + + const labelTag = collectAutofillContentService["createAutofillFieldRightLabel"](element); + + expect(labelTag).toEqual("Username"); + }); + + it("returns the text content of the element's next sibling textNode", () => { + document.body.innerHTML = ` + + Username + `; + const element = document.querySelector("input") as FillableFormFieldElement; + + const labelTag = collectAutofillContentService["createAutofillFieldRightLabel"](element); + + expect(labelTag).toEqual("Username"); + }); + }); + + describe("createAutofillFieldLeftLabel", () => { + it("returns a string value of the text content associated with the previous siblings of the passed element", () => { + document.body.innerHTML = ` +
+ Text Content + + +
+ `; + const element = document.querySelector("input") as FillableFormFieldElement; + + const labelTag = collectAutofillContentService["createAutofillFieldLeftLabel"](element); + + expect(labelTag).toEqual("Text ContentUsername"); + }); + }); + + describe("createAutofillFieldTopLabel", () => { + it("returns the table column header value for the passed table element", () => { + document.body.innerHTML = ` + + + + + + + + + + + + + +
UsernamePasswordLogin code
+ `; + const targetTableCellInput = document.querySelector( + 'input[name="password"]' + ) as HTMLInputElement; + + const targetTableCellLabel = + collectAutofillContentService["createAutofillFieldTopLabel"](targetTableCellInput); + + expect(targetTableCellLabel).toEqual("Password"); + }); + + it("will attempt to return the value for the previous sibling row as the label if a `th` cell is not found", () => { + document.body.innerHTML = ` + + + + + + + + + + + + + +
UsernamePasswordLogin code
+ `; + const targetTableCellInput = document.querySelector( + 'input[name="auth-code"]' + ) as HTMLInputElement; + + const targetTableCellLabel = + collectAutofillContentService["createAutofillFieldTopLabel"](targetTableCellInput); + + expect(targetTableCellLabel).toEqual("Login code"); + }); + + it("returns null for the passed table element it's parent row has no previous sibling row", () => { + document.body.innerHTML = ` + + + + + + + + +
+ `; + const targetTableCellInput = document.querySelector( + 'input[name="password"]' + ) as HTMLInputElement; + + const targetTableCellLabel = + collectAutofillContentService["createAutofillFieldTopLabel"](targetTableCellInput); + + expect(targetTableCellLabel).toEqual(null); + }); + + it("returns null if the input element is not structured within a `td` element", () => { + document.body.innerHTML = ` + + + + + + + + + +
+ + + +
UsernamePasswordLogin code
+ `; + const targetTableCellInput = document.querySelector( + 'input[name="password"]' + ) as HTMLInputElement; + + const targetTableCellLabel = + collectAutofillContentService["createAutofillFieldTopLabel"](targetTableCellInput); + + expect(targetTableCellLabel).toEqual(null); + }); + + it("returns null if the index of the `td` element is larger than the length of cells in the sibling row", () => { + document.body.innerHTML = ` + + + + + + + + + + + + +
UsernamePassword
+ `; + const targetTableCellInput = document.querySelector( + 'input[name="auth-code"]' + ) as HTMLInputElement; + + const targetTableCellLabel = + collectAutofillContentService["createAutofillFieldTopLabel"](targetTableCellInput); + + expect(targetTableCellLabel).toEqual(null); + }); + }); + + describe("isNewSectionElement", () => { + const validElementTags = [ + "html", + "body", + "button", + "form", + "head", + "iframe", + "input", + "option", + "script", + "select", + "table", + "textarea", + ]; + const invalidElementTags = ["div", "span"]; + + describe("given a transitional element", () => { + validElementTags.forEach((tag) => { + const element = document.createElement(tag); + + it(`returns true if the element tag is a ${tag}`, () => { + expect(collectAutofillContentService["isNewSectionElement"](element)).toEqual(true); + }); + }); + }); + + describe("given an non-transitional element", () => { + invalidElementTags.forEach((tag) => { + const element = document.createElement(tag); + + it(`returns false if the element tag is a ${tag}`, () => { + expect(collectAutofillContentService["isNewSectionElement"](element)).toEqual(false); + }); + }); + }); + + it(`returns true if the provided element is falsy`, () => { + expect(collectAutofillContentService["isNewSectionElement"](undefined)).toEqual(true); + }); + }); + + describe("getTextContentFromElement", () => { + it("returns the node value for a text node", () => { + document.body.innerHTML = ` +
+ +
+ `; + const element = document.querySelector("#username-id"); + const textNode = element.previousSibling; + const parsedTextContent = collectAutofillContentService["trimAndRemoveNonPrintableText"]( + textNode.nodeValue + ); + jest.spyOn(collectAutofillContentService as any, "trimAndRemoveNonPrintableText"); + + const textContent = collectAutofillContentService["getTextContentFromElement"](textNode); + + expect(textNode.nodeType).toEqual(Node.TEXT_NODE); + expect(collectAutofillContentService["trimAndRemoveNonPrintableText"]).toHaveBeenCalledWith( + textNode.nodeValue + ); + expect(textContent).toEqual(parsedTextContent); + }); + + it("returns the text content for an element node", () => { + document.body.innerHTML = ` +
+ + +
+ `; + const element = document.querySelector('label[for="username-id"]'); + jest.spyOn(collectAutofillContentService as any, "trimAndRemoveNonPrintableText"); + + const textContent = collectAutofillContentService["getTextContentFromElement"](element); + + expect(element.nodeType).toEqual(Node.ELEMENT_NODE); + expect(collectAutofillContentService["trimAndRemoveNonPrintableText"]).toHaveBeenCalledWith( + element.textContent + ); + expect(textContent).toEqual(element.textContent); + }); + }); + + describe("trimAndRemoveNonPrintableText", () => { + it("returns an empty string if no text content is passed", () => { + const textContent = collectAutofillContentService["trimAndRemoveNonPrintableText"](undefined); + + expect(textContent).toEqual(""); + }); + + it("returns a trimmed string with all non-printable text removed", () => { + const nonParsedText = `Hello!\nThis is a \t + test string.\x0B\x08`; + + const parsedText = + collectAutofillContentService["trimAndRemoveNonPrintableText"](nonParsedText); + + expect(parsedText).toEqual("Hello! This is a test string."); + }); + }); + + describe("recursivelyGetTextFromPreviousSiblings", () => { + it("should find text adjacent to the target element likely to be a label", () => { + document.body.innerHTML = ` +
+ Text about things +
some things
+
+

Stuff Section Header

+ Other things which are also stuff +
Not visible text
+ + +
+
+ `; + const textInput = document.querySelector("#input-tag") as FormElementWithAttribute; + + const elementList: string[] = + collectAutofillContentService["recursivelyGetTextFromPreviousSiblings"](textInput); + + expect(elementList).toEqual([ + "something else", + "Not visible text", + "Other things which are also stuff", + "Stuff Section Header", + ]); + }); + + it("should stop looking at siblings for label values when a 'new section' element is seen", () => { + document.body.innerHTML = ` +
+ Text about things +
some things
+
+

Stuff Section Header

+ Other things which are also stuff +
Not a label
+ + + +
+
+ `; + + const textInput = document.querySelector("#input-tag") as FormElementWithAttribute; + const elementList: string[] = + collectAutofillContentService["recursivelyGetTextFromPreviousSiblings"](textInput); + + expect(elementList).toEqual(["something else"]); + }); + + it("should keep looking for labels in parents when there are no siblings of the target element", () => { + document.body.innerHTML = ` +
+ Text about things + +
some things
+
+ +
+
+ `; + + const textInput = document.querySelector("#input-tag") as FormElementWithAttribute; + const elementList: string[] = + collectAutofillContentService["recursivelyGetTextFromPreviousSiblings"](textInput); + + expect(elementList).toEqual(["some things"]); + }); + + it("should find label in parent sibling last child if no other label candidates have been encountered and there are no text nodes along the way", () => { + document.body.innerHTML = ` +
+
+
not the most relevant things
+
some nested things
+
+ +
+
+
+ `; + + const textInput = document.querySelector("#input-tag") as FormElementWithAttribute; + const elementList: string[] = + collectAutofillContentService["recursivelyGetTextFromPreviousSiblings"](textInput); + + expect(elementList).toEqual(["some nested things"]); + }); + + it("should exit early if the target element has no parent element/node", () => { + const textInput = document.querySelector("html") as HTMLHtmlElement; + + const elementList: string[] = + collectAutofillContentService["recursivelyGetTextFromPreviousSiblings"](textInput); + + expect(elementList).toEqual([]); + }); + }); + + describe("getPropertyOrAttribute", () => { + it("returns the value of the named property of the target element if the property exists within the element", () => { + document.body.innerHTML += ''; + const textInput = document.querySelector("#username") as HTMLInputElement; + textInput.setAttribute("value", "jsmith"); + const checkboxInput = document.querySelector('input[type="checkbox"]') as HTMLInputElement; + jest.spyOn(textInput, "getAttribute"); + jest.spyOn(checkboxInput, "getAttribute"); + + const textInputValue = collectAutofillContentService["getPropertyOrAttribute"]( + textInput, + "value" + ); + const textInputId = collectAutofillContentService["getPropertyOrAttribute"](textInput, "id"); + const textInputBaseURI = collectAutofillContentService["getPropertyOrAttribute"]( + textInput, + "baseURI" + ); + const textInputAutofocus = collectAutofillContentService["getPropertyOrAttribute"]( + textInput, + "autofocus" + ); + const checkboxInputChecked = collectAutofillContentService["getPropertyOrAttribute"]( + checkboxInput, + "checked" + ); + + expect(textInput.getAttribute).not.toHaveBeenCalled(); + expect(checkboxInput.getAttribute).not.toHaveBeenCalled(); + expect(textInputValue).toEqual("jsmith"); + expect(textInputId).toEqual("username"); + expect(textInputBaseURI).toEqual("http://localhost/"); + expect(textInputAutofocus).toEqual(false); + expect(checkboxInputChecked).toEqual(true); + }); + + it("returns the value of the named attribute of the element if it does not exist as a property within the element", () => { + const textInput = document.querySelector("#username") as HTMLInputElement; + textInput.setAttribute("data-unique-attribute", "unique-value"); + jest.spyOn(textInput, "getAttribute"); + + const textInputUniqueAttribute = collectAutofillContentService["getPropertyOrAttribute"]( + textInput, + "data-unique-attribute" + ); + + expect(textInputUniqueAttribute).toEqual("unique-value"); + expect(textInput.getAttribute).toHaveBeenCalledWith("data-unique-attribute"); + }); + + it("returns a null value if the element does not contain the passed attribute name as either a property or attribute value", () => { + const textInput = document.querySelector("#username") as HTMLInputElement; + jest.spyOn(textInput, "getAttribute"); + + const textInputNonExistentAttribute = collectAutofillContentService["getPropertyOrAttribute"]( + textInput, + "non-existent-attribute" + ); + + expect(textInputNonExistentAttribute).toEqual(null); + expect(textInput.getAttribute).toHaveBeenCalledWith("non-existent-attribute"); + }); + }); + + describe("getElementValue", () => { + it("returns an empty string of passed input elements whose value is not set", () => { + document.body.innerHTML += ` + + + + `; + const textInput = document.querySelector("#username") as HTMLInputElement; + const checkboxInput = document.querySelector('input[type="checkbox"]') as HTMLInputElement; + const hiddenInput = document.querySelector("#hidden-input") as HTMLInputElement; + const spanInput = document.querySelector("#span-input") as HTMLInputElement; + + const textInputValue = collectAutofillContentService["getElementValue"](textInput); + const checkboxInputValue = collectAutofillContentService["getElementValue"](checkboxInput); + const hiddenInputValue = collectAutofillContentService["getElementValue"](hiddenInput); + const spanInputValue = collectAutofillContentService["getElementValue"](spanInput); + + expect(textInputValue).toEqual(""); + expect(checkboxInputValue).toEqual(""); + expect(hiddenInputValue).toEqual(""); + expect(spanInputValue).toEqual(""); + }); + + it("returns the value of the passed input element", () => { + document.body.innerHTML += ` + + + A span input value + `; + const textInput = document.querySelector("#username") as HTMLInputElement; + textInput.value = "jsmith"; + const checkboxInput = document.querySelector('input[type="checkbox"]') as HTMLInputElement; + checkboxInput.checked = true; + const hiddenInput = document.querySelector("#hidden-input") as HTMLInputElement; + hiddenInput.value = "aHiddenInputValue"; + const spanInput = document.querySelector("#span-input") as HTMLInputElement; + + const textInputValue = collectAutofillContentService["getElementValue"](textInput); + const checkboxInputValue = collectAutofillContentService["getElementValue"](checkboxInput); + const hiddenInputValue = collectAutofillContentService["getElementValue"](hiddenInput); + const spanInputValue = collectAutofillContentService["getElementValue"](spanInput); + + expect(textInputValue).toEqual("jsmith"); + expect(checkboxInputValue).toEqual("✓"); + expect(hiddenInputValue).toEqual("aHiddenInputValue"); + expect(spanInputValue).toEqual("A span input value"); + }); + + it("return the truncated value of the passed hidden input type if the value length exceeds 256 characters", () => { + document.body.innerHTML += ` + + `; + const longValueHiddenInput = document.querySelector( + "#long-value-hidden-input" + ) as HTMLInputElement; + + const longHiddenValue = + collectAutofillContentService["getElementValue"](longValueHiddenInput); + + expect(longHiddenValue).toEqual( + "’Twas brillig, and the slithy toves | Did gyre and gimble in the wabe: | All mimsy were the borogoves, | And the mome raths outgrabe. | “Beware the Jabberwock, my son! | The jaws that bite, the claws that catch! | Beware the Jubjub bird, and shun | The f...SNIPPED" + ); + }); + }); + + describe("getSelectElementOptions", () => { + it("returns the inner text and values of each `option` within the passed `select`", () => { + document.body.innerHTML = ` + + + `; + const selectWithOptions = document.querySelector("#select-with-options") as HTMLSelectElement; + const selectWithoutOptions = document.querySelector( + "#select-without-options" + ) as HTMLSelectElement; + + const selectWithOptionsOptions = + collectAutofillContentService["getSelectElementOptions"](selectWithOptions); + const selectWithoutOptionsOptions = + collectAutofillContentService["getSelectElementOptions"](selectWithoutOptions); + + expect(selectWithOptionsOptions).toEqual({ + options: [ + ["option1", "1"], + ["optionb", "b"], + ["optioniii", "iii"], + [null, "four"], + ], + }); + expect(selectWithoutOptionsOptions).toEqual({ options: [] }); + }); + }); +}); diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.ts new file mode 100644 index 0000000000..ec7658c986 --- /dev/null +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.ts @@ -0,0 +1,578 @@ +import AutofillField from "../models/autofill-field"; +import AutofillForm from "../models/autofill-form"; +import AutofillPageDetails from "../models/autofill-page-details"; +import { + ElementWithOpId, + FillableFormFieldElement, + FormFieldElement, + FormElementWithAttribute, +} from "../types"; + +import { CollectAutofillContentService as CollectAutofillContentServiceInterface } from "./abstractions/collect-autofill-content.service"; +import DomElementVisibilityService from "./dom-element-visibility.service"; + +class CollectAutofillContentService implements CollectAutofillContentServiceInterface { + private readonly domElementVisibilityService: DomElementVisibilityService; + + constructor(domElementVisibilityService: DomElementVisibilityService) { + this.domElementVisibilityService = domElementVisibilityService; + } + + /** + * Builds the data for all the forms and fields + * that are found within the page DOM. + * @returns {Promise} + * @public + */ + async getPageDetails(): Promise { + const autofillFormsData: Record = this.buildAutofillFormsData(); + const autofillFieldsData: AutofillField[] = await this.buildAutofillFieldsData(); + + return { + title: document.title, + url: (document.defaultView || window).location.href, + documentUrl: document.location.href, + forms: autofillFormsData, + fields: autofillFieldsData, + collectedTimestamp: Date.now(), + }; + } + + /** + * Find an AutofillField element by its opid, will only return the first + * element if there are multiple elements with the same opid. If no + * element is found, null will be returned. + * @param {string} opid + * @returns {FormFieldElement | null} + */ + getAutofillFieldElementByOpid(opid: string): FormFieldElement | null { + const fieldElements = this.getAutofillFieldElements(); + const fieldElementsWithOpid = fieldElements.filter( + (fieldElement) => (fieldElement as ElementWithOpId).opid === opid + ) as ElementWithOpId[]; + + if (!fieldElementsWithOpid.length) { + const elementIndex = parseInt(opid.split("__")[1], 10); + + return fieldElements[elementIndex] || null; + } + + if (fieldElementsWithOpid.length > 1) { + // eslint-disable-next-line no-console + console.warn(`More than one element found with opid ${opid}`); + } + + return fieldElementsWithOpid[0]; + } + + /** + * Queries the DOM for all the forms elements and + * returns a collection of AutofillForm objects. + * @returns {Record} + * @private + */ + private buildAutofillFormsData(): Record { + const autofillForms: Record = {}; + const documentFormElements = document.querySelectorAll("form"); + + documentFormElements.forEach((formElement: HTMLFormElement, index: number) => { + formElement.opid = `__form__${index}`; + + autofillForms[formElement.opid] = { + opid: formElement.opid, + htmlAction: new URL( + this.getPropertyOrAttribute(formElement, "action"), + window.location.href + ).href, + htmlName: this.getPropertyOrAttribute(formElement, "name"), + htmlID: this.getPropertyOrAttribute(formElement, "id"), + htmlMethod: this.getPropertyOrAttribute(formElement, "method"), + }; + }); + + return autofillForms; + } + + /** + * Queries the DOM for all the field elements and + * returns a list of AutofillField objects. + * @returns {Promise} + * @private + */ + private async buildAutofillFieldsData(): Promise { + const autofillFieldElements = this.getAutofillFieldElements(50); + const autofillFieldDataPromises = autofillFieldElements.map(this.buildAutofillFieldItem); + + return Promise.all(autofillFieldDataPromises); + } + + /** + * Queries the DOM for all the field elements that can be autofilled, + * and returns a list limited to the given `fieldsLimit` number that + * is ordered by priority. + * @param {number} fieldsLimit - The maximum number of fields to return + * @returns {FormFieldElement[]} + * @private + */ + private getAutofillFieldElements(fieldsLimit?: number): FormFieldElement[] { + const formFieldElements: FormFieldElement[] = [ + ...(document.querySelectorAll( + 'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"]):not([type="file"]):not([data-bwignore]), ' + + "textarea:not([data-bwignore]), " + + "select:not([data-bwignore]), " + + "span[data-bwautofill]" + ) as NodeListOf), + ]; + + if (!fieldsLimit || formFieldElements.length <= fieldsLimit) { + return formFieldElements; + } + + const priorityFormFields: FormFieldElement[] = []; + const unimportantFormFields: FormFieldElement[] = []; + const unimportantFieldTypesSet = new Set(["checkbox", "radio"]); + for (const element of formFieldElements) { + if (priorityFormFields.length >= fieldsLimit) { + return priorityFormFields; + } + + const fieldType = this.getPropertyOrAttribute(element, "type")?.toLowerCase(); + if (unimportantFieldTypesSet.has(fieldType)) { + unimportantFormFields.push(element); + continue; + } + + priorityFormFields.push(element); + } + + const numberUnimportantFieldsToInclude = fieldsLimit - priorityFormFields.length; + for (let index = 0; index < numberUnimportantFieldsToInclude; index++) { + priorityFormFields.push(unimportantFormFields[index]); + } + + return priorityFormFields; + } + + /** + * Builds an AutofillField object from the given form element. Will only return + * shared field values if the element is a span element. Will not return any label + * values if the element is a hidden input element. + * @param {ElementWithOpId} element + * @param {number} index + * @returns {Promise} + * @private + */ + private buildAutofillFieldItem = async ( + element: ElementWithOpId, + index: number + ): Promise => { + element.opid = `__${index}`; + + const autofillFieldBase = { + opid: element.opid, + elementNumber: index, + maxLength: this.getAutofillFieldMaxLength(element), + viewable: await this.domElementVisibilityService.isFormFieldViewable(element), + htmlID: this.getPropertyOrAttribute(element, "id"), + htmlName: this.getPropertyOrAttribute(element, "name"), + htmlClass: this.getPropertyOrAttribute(element, "class"), + tabindex: this.getPropertyOrAttribute(element, "tabindex"), + title: this.getPropertyOrAttribute(element, "title"), + tagName: this.getPropertyOrAttribute(element, "tagName")?.toLowerCase(), + }; + + if (element instanceof HTMLSpanElement) { + return autofillFieldBase; + } + + let autofillFieldLabels = {}; + const autoCompleteType = + this.getPropertyOrAttribute(element, "x-autocompletetype") || + this.getPropertyOrAttribute(element, "autocompletetype") || + this.getPropertyOrAttribute(element, "autocomplete"); + const elementType = this.getPropertyOrAttribute(element, "type")?.toLowerCase(); + if (elementType !== "hidden") { + autofillFieldLabels = { + "label-tag": this.createAutofillFieldLabelTag(element), + "label-data": this.getPropertyOrAttribute(element, "data-label"), + "label-aria": this.getPropertyOrAttribute(element, "aria-label"), + "label-top": this.createAutofillFieldTopLabel(element), + "label-right": this.createAutofillFieldRightLabel(element), + "label-left": this.createAutofillFieldLeftLabel(element), + placeholder: this.getPropertyOrAttribute(element, "placeholder"), + }; + } + + return { + ...autofillFieldBase, + ...autofillFieldLabels, + rel: this.getPropertyOrAttribute(element, "rel"), + type: elementType, + value: this.getElementValue(element), + checked: Boolean(this.getPropertyOrAttribute(element, "checked")), + autoCompleteType: autoCompleteType !== "off" ? autoCompleteType : null, + disabled: Boolean(this.getPropertyOrAttribute(element, "disabled")), + readonly: Boolean(this.getPropertyOrAttribute(element, "readOnly")), + selectInfo: + element instanceof HTMLSelectElement ? this.getSelectElementOptions(element) : null, + form: element.form ? this.getPropertyOrAttribute(element.form, "opid") : null, + "aria-hidden": this.getPropertyOrAttribute(element, "aria-hidden") === "true", + "aria-disabled": this.getPropertyOrAttribute(element, "aria-disabled") === "true", + "aria-haspopup": this.getPropertyOrAttribute(element, "aria-haspopup") === "true", + "data-stripe": this.getPropertyOrAttribute(element, "data-stripe"), + }; + }; + + /** + * Creates a label tag used to autofill the element pulled from a label + * associated with the element's id, name, parent element or from an + * associated description term element if no other labels can be found. + * Returns a string containing all the `textContent` or `innerText` + * values of the label elements. + * @param {FillableFormFieldElement} element + * @returns {string} + * @private + */ + private createAutofillFieldLabelTag(element: FillableFormFieldElement): string { + const labelElementsSet: Set = new Set(element.labels); + + if (labelElementsSet.size) { + return this.createLabelElementsTag(labelElementsSet); + } + + const labelElements: NodeListOf | null = this.queryElementLabels(element); + labelElements?.forEach((labelElement) => labelElementsSet.add(labelElement)); + + let currentElement: HTMLElement | null = element; + while (currentElement && currentElement !== document.documentElement) { + if (currentElement instanceof HTMLLabelElement) { + labelElementsSet.add(currentElement); + } + + currentElement = currentElement.parentElement.closest("label"); + } + + if ( + !labelElementsSet.size && + element.parentElement?.tagName.toLowerCase() === "dd" && + element.parentElement.previousElementSibling?.tagName.toLowerCase() === "dt" + ) { + labelElementsSet.add(element.parentElement.previousElementSibling as HTMLElement); + } + + return this.createLabelElementsTag(labelElementsSet); + } + + /** + * Queries the DOM for label elements associated with the given element + * by id or name. Returns a NodeList of label elements or null if none + * are found. + * @param {FillableFormFieldElement} element + * @returns {NodeListOf | null} + * @private + */ + private queryElementLabels( + element: FillableFormFieldElement + ): NodeListOf | null { + let labelQuerySelectors = element.id ? `label[for="${element.id}"]` : ""; + if (element.name) { + const forElementNameSelector = `label[for="${element.name}"]`; + labelQuerySelectors = labelQuerySelectors + ? `${labelQuerySelectors}, ${forElementNameSelector}` + : forElementNameSelector; + } + + if (!labelQuerySelectors) { + return null; + } + + return document.querySelectorAll(labelQuerySelectors); + } + + /** + * Map over all the label elements and creates a + * string of the text content of each label element. + * @param {Set} labelElementsSet + * @returns {string} + * @private + */ + private createLabelElementsTag = (labelElementsSet: Set): string => { + return [...labelElementsSet] + .map((labelElement) => { + const textContent: string | null = labelElement + ? labelElement.textContent || labelElement.innerText + : null; + + return this.trimAndRemoveNonPrintableText(textContent || ""); + }) + .join(""); + }; + + /** + * Gets the maxLength property of the passed FormFieldElement and + * returns the value or null if the element does not have a + * maxLength property. If the element has a maxLength property + * greater than 999, it will return 999. + * @param {FormFieldElement} element + * @returns {number | null} + * @private + */ + private getAutofillFieldMaxLength(element: FormFieldElement): number | null { + const elementHasMaxLengthProperty = + element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement; + const elementMaxLength = + elementHasMaxLengthProperty && element.maxLength > -1 ? element.maxLength : 999; + + return elementHasMaxLengthProperty ? Math.min(elementMaxLength, 999) : null; + } + + /** + * Iterates over the next siblings of the passed element and + * returns a string of the text content of each element. Will + * stop iterating if it encounters a new section element. + * @param {FormFieldElement} element + * @returns {string} + * @private + */ + private createAutofillFieldRightLabel(element: FormFieldElement): string { + const labelTextContent: string[] = []; + let currentElement: ChildNode = element; + + while (currentElement && currentElement.nextSibling) { + currentElement = currentElement.nextSibling; + if (this.isNewSectionElement(currentElement)) { + break; + } + + const textContent = this.getTextContentFromElement(currentElement); + if (textContent) { + labelTextContent.push(textContent); + } + } + + return labelTextContent.join(""); + } + + /** + * Recursively gets the text content from an element's previous siblings + * and returns a string of the text content of each element. + * @param {FormFieldElement} element + * @returns {string} + * @private + */ + private createAutofillFieldLeftLabel(element: FormFieldElement): string { + const labelTextContent: string[] = this.recursivelyGetTextFromPreviousSiblings(element); + + return labelTextContent.reverse().join(""); + } + + /** + * Assumes that the input elements that are to be autofilled are within a + * table structure. Queries the previous sibling of the parent row that + * the input element is in and returns the text content of the cell that + * is in the same column as the input element. + * @param {FormFieldElement} element + * @returns {string | null} + * @private + */ + private createAutofillFieldTopLabel(element: FormFieldElement): string | null { + const tableDataElement = element.closest("td"); + if (!tableDataElement) { + return null; + } + + const tableDataElementIndex = tableDataElement.cellIndex; + const parentSiblingTableRowElement = tableDataElement.closest("tr") + ?.previousElementSibling as HTMLTableRowElement; + + return parentSiblingTableRowElement?.cells?.length > tableDataElementIndex + ? this.getTextContentFromElement(parentSiblingTableRowElement.cells[tableDataElementIndex]) + : null; + } + + /** + * Check if the element's tag indicates that a transition to a new section of the + * page is occurring. If so, we should not use the element or its children in order + * to get autofill context for the previous element. + * @param {HTMLElement} currentElement + * @returns {boolean} + * @private + */ + private isNewSectionElement(currentElement: HTMLElement | Node): boolean { + if (!currentElement) { + return true; + } + + const transitionalElementTagsSet = new Set([ + "html", + "body", + "button", + "form", + "head", + "iframe", + "input", + "option", + "script", + "select", + "table", + "textarea", + ]); + return ( + "tagName" in currentElement && + transitionalElementTagsSet.has(currentElement.tagName.toLowerCase()) + ); + } + + /** + * Gets the text content from a passed element, regardless of whether it is a + * text node, an element node or an HTMLElement. + * @param {Node | HTMLElement} element + * @returns {string} + * @private + */ + private getTextContentFromElement(element: Node | HTMLElement): string { + if (element.nodeType === Node.TEXT_NODE) { + return this.trimAndRemoveNonPrintableText(element.nodeValue); + } + + return this.trimAndRemoveNonPrintableText( + element.textContent || (element as HTMLElement).innerText + ); + } + + /** + * Removes non-printable characters from the passed text + * content and trims leading and trailing whitespace. + * @param {string} textContent + * @returns {string} + * @private + */ + private trimAndRemoveNonPrintableText(textContent: string): string { + return (textContent || "") + .replace(/[^\x20-\x7E]+|\s+/g, " ") // Strip out non-primitive characters and replace multiple spaces with a single space + .trim(); // Trim leading and trailing whitespace + } + + /** + * Get the text content from the previous siblings of the element. If + * no text content is found, recursively get the text content from the + * previous siblings of the parent element. + * @param {FormFieldElement} element + * @returns {string[]} + * @private + */ + private recursivelyGetTextFromPreviousSiblings(element: Node | HTMLElement): string[] { + const textContentItems: string[] = []; + let currentElement = element; + while (currentElement && currentElement.previousSibling) { + // Ensure we are capturing text content from nodes and elements. + currentElement = currentElement.previousSibling; + + if (this.isNewSectionElement(currentElement)) { + return textContentItems; + } + + const textContent = this.getTextContentFromElement(currentElement); + if (textContent) { + textContentItems.push(textContent); + } + } + + if (!currentElement || textContentItems.length) { + return textContentItems; + } + + // Prioritize capturing text content from elements rather than nodes. + currentElement = currentElement.parentElement || currentElement.parentNode; + + let siblingElement = + currentElement instanceof HTMLElement + ? currentElement.previousElementSibling + : currentElement.previousSibling; + while (siblingElement?.lastChild && !this.isNewSectionElement(siblingElement)) { + siblingElement = siblingElement.lastChild; + } + + if (this.isNewSectionElement(siblingElement)) { + return textContentItems; + } + + const textContent = this.getTextContentFromElement(siblingElement); + if (textContent) { + textContentItems.push(textContent); + return textContentItems; + } + + return this.recursivelyGetTextFromPreviousSiblings(siblingElement); + } + + /** + * Get the value of a property or attribute from a FormFieldElement. + * @param {HTMLElement} element + * @param {string} attributeName + * @returns {string | null} + * @private + */ + private getPropertyOrAttribute(element: HTMLElement, attributeName: string): string | null { + if (attributeName in element) { + return (element as FormElementWithAttribute)[attributeName]; + } + + return element.getAttribute(attributeName); + } + + /** + * Gets the value of the element. If the element is a checkbox, returns a checkmark if the + * checkbox is checked, or an empty string if it is not checked. If the element is a hidden + * input, returns the value of the input if it is less than 254 characters, or a truncated + * value if it is longer than 254 characters. + * @param {FormFieldElement} element + * @returns {string} + * @private + */ + private getElementValue(element: FormFieldElement): string { + if (element instanceof HTMLSpanElement) { + const spanTextContent = element.textContent || element.innerText; + return spanTextContent || ""; + } + + const elementValue = element.value || ""; + const elementType = String(element.type).toLowerCase(); + if ("checked" in element && elementType === "checkbox") { + return element.checked ? "✓" : ""; + } + + if (elementType === "hidden") { + const inputValueMaxLength = 254; + + return elementValue.length > inputValueMaxLength + ? `${elementValue.substring(0, inputValueMaxLength)}...SNIPPED` + : elementValue; + } + + return elementValue; + } + + /** + * Get the options from a select element and return them as an array + * of arrays indicating the select element option text and value. + * @param {HTMLSelectElement} element + * @returns {{options: (string | null)[][]}} + * @private + */ + private getSelectElementOptions(element: HTMLSelectElement): { options: (string | null)[][] } { + const options = [...element.options].map((option) => { + const optionText = option.text + ? String(option.text) + .toLowerCase() + .replace(/[\s~`!@$%^&#*()\-_+=:;'"[\]|\\,<.>?]/gm, "") // Remove whitespace and punctuation + : null; + + return [optionText, option.value]; + }); + + return { options }; + } +} + +export default CollectAutofillContentService; diff --git a/apps/browser/src/autofill/services/dom-element-visibility.service.spec.ts b/apps/browser/src/autofill/services/dom-element-visibility.service.spec.ts new file mode 100644 index 0000000000..e17783b7a6 --- /dev/null +++ b/apps/browser/src/autofill/services/dom-element-visibility.service.spec.ts @@ -0,0 +1,409 @@ +import { FormFieldElement } from "../types"; + +import DomElementVisibilityService from "./dom-element-visibility.service"; + +function createBoundingClientRectMock(customProperties: Partial = {}): DOMRectReadOnly { + return { + top: 0, + bottom: 0, + left: 0, + right: 0, + width: 500, + height: 500, + x: 0, + y: 0, + toJSON: jest.fn(), + ...customProperties, + }; +} + +describe("DomElementVisibilityService", () => { + let domElementVisibilityService: DomElementVisibilityService; + + beforeEach(() => { + document.body.innerHTML = ` +
+ + + + +
+ `; + domElementVisibilityService = new DomElementVisibilityService(); + }); + + afterEach(() => { + jest.clearAllMocks(); + document.body.innerHTML = ""; + }); + + describe("isFormFieldViewable", () => { + it("returns false if the element is outside viewport bounds", async () => { + const usernameElement = document.querySelector("input[name='username']") as FormFieldElement; + jest.spyOn(usernameElement, "getBoundingClientRect"); + jest + .spyOn(domElementVisibilityService as any, "isElementOutsideViewportBounds") + .mockResolvedValueOnce(true); + jest.spyOn(domElementVisibilityService, "isElementHiddenByCss"); + jest.spyOn(domElementVisibilityService as any, "formFieldIsNotHiddenBehindAnotherElement"); + + const isFormFieldViewable = await domElementVisibilityService.isFormFieldViewable( + usernameElement + ); + + expect(isFormFieldViewable).toEqual(false); + expect(usernameElement.getBoundingClientRect).toHaveBeenCalled(); + expect(domElementVisibilityService["isElementOutsideViewportBounds"]).toHaveBeenCalledWith( + usernameElement, + usernameElement.getBoundingClientRect() + ); + expect(domElementVisibilityService["isElementHiddenByCss"]).not.toHaveBeenCalled(); + expect( + domElementVisibilityService["formFieldIsNotHiddenBehindAnotherElement"] + ).not.toHaveBeenCalled(); + }); + + it("returns false if the element is hidden by CSS", async () => { + const usernameElement = document.querySelector("input[name='username']") as FormFieldElement; + jest.spyOn(usernameElement, "getBoundingClientRect"); + jest + .spyOn(domElementVisibilityService as any, "isElementOutsideViewportBounds") + .mockReturnValueOnce(false); + jest.spyOn(domElementVisibilityService, "isElementHiddenByCss").mockReturnValueOnce(true); + jest.spyOn(domElementVisibilityService as any, "formFieldIsNotHiddenBehindAnotherElement"); + + const isFormFieldViewable = await domElementVisibilityService.isFormFieldViewable( + usernameElement + ); + + expect(isFormFieldViewable).toEqual(false); + expect(usernameElement.getBoundingClientRect).toHaveBeenCalled(); + expect(domElementVisibilityService["isElementOutsideViewportBounds"]).toHaveBeenCalledWith( + usernameElement, + usernameElement.getBoundingClientRect() + ); + expect(domElementVisibilityService["isElementHiddenByCss"]).toHaveBeenCalledWith( + usernameElement + ); + expect( + domElementVisibilityService["formFieldIsNotHiddenBehindAnotherElement"] + ).not.toHaveBeenCalled(); + }); + + it("returns false if the element is hidden behind another element", async () => { + const usernameElement = document.querySelector("input[name='username']") as FormFieldElement; + jest.spyOn(usernameElement, "getBoundingClientRect"); + jest + .spyOn(domElementVisibilityService as any, "isElementOutsideViewportBounds") + .mockReturnValueOnce(false); + jest.spyOn(domElementVisibilityService, "isElementHiddenByCss").mockReturnValueOnce(false); + jest + .spyOn(domElementVisibilityService as any, "formFieldIsNotHiddenBehindAnotherElement") + .mockReturnValueOnce(false); + + const isFormFieldViewable = await domElementVisibilityService.isFormFieldViewable( + usernameElement + ); + + expect(isFormFieldViewable).toEqual(false); + expect(usernameElement.getBoundingClientRect).toHaveBeenCalled(); + expect(domElementVisibilityService["isElementOutsideViewportBounds"]).toHaveBeenCalledWith( + usernameElement, + usernameElement.getBoundingClientRect() + ); + expect(domElementVisibilityService["isElementHiddenByCss"]).toHaveBeenCalledWith( + usernameElement + ); + expect( + domElementVisibilityService["formFieldIsNotHiddenBehindAnotherElement"] + ).toHaveBeenCalledWith(usernameElement, usernameElement.getBoundingClientRect()); + }); + + it("returns true if the form field is viewable", async () => { + const usernameElement = document.querySelector("input[name='username']") as FormFieldElement; + jest.spyOn(usernameElement, "getBoundingClientRect"); + jest + .spyOn(domElementVisibilityService as any, "isElementOutsideViewportBounds") + .mockReturnValueOnce(false); + jest.spyOn(domElementVisibilityService, "isElementHiddenByCss").mockReturnValueOnce(false); + jest + .spyOn(domElementVisibilityService as any, "formFieldIsNotHiddenBehindAnotherElement") + .mockReturnValueOnce(true); + + const isFormFieldViewable = await domElementVisibilityService.isFormFieldViewable( + usernameElement + ); + + expect(isFormFieldViewable).toEqual(true); + expect(usernameElement.getBoundingClientRect).toHaveBeenCalled(); + expect(domElementVisibilityService["isElementOutsideViewportBounds"]).toHaveBeenCalledWith( + usernameElement, + usernameElement.getBoundingClientRect() + ); + expect(domElementVisibilityService["isElementHiddenByCss"]).toHaveBeenCalledWith( + usernameElement + ); + expect( + domElementVisibilityService["formFieldIsNotHiddenBehindAnotherElement"] + ).toHaveBeenCalledWith(usernameElement, usernameElement.getBoundingClientRect()); + }); + }); + + describe("isElementHiddenByCss", () => { + it("returns true when a non-hidden element is passed", () => { + document.body.innerHTML = ` + + `; + const usernameElement = document.getElementById("username"); + + const isElementHidden = domElementVisibilityService["isElementHiddenByCss"](usernameElement); + + expect(isElementHidden).toEqual(false); + }); + + it("returns true when the element has a `visibility: hidden;` CSS rule applied to it either inline or in a computed style", () => { + document.body.innerHTML = ` + + + + `; + const usernameElement = document.getElementById("username"); + const passwordElement = document.getElementById("password"); + jest.spyOn(usernameElement.style, "getPropertyValue"); + jest.spyOn(usernameElement.ownerDocument.defaultView, "getComputedStyle"); + jest.spyOn(passwordElement.style, "getPropertyValue"); + jest.spyOn(passwordElement.ownerDocument.defaultView, "getComputedStyle"); + + const isUsernameElementHidden = + domElementVisibilityService["isElementHiddenByCss"](usernameElement); + const isPasswordElementHidden = + domElementVisibilityService["isElementHiddenByCss"](passwordElement); + + expect(isUsernameElementHidden).toEqual(true); + expect(usernameElement.style.getPropertyValue).toHaveBeenCalled(); + expect(usernameElement.ownerDocument.defaultView.getComputedStyle).toHaveBeenCalledWith( + usernameElement + ); + expect(isPasswordElementHidden).toEqual(true); + expect(passwordElement.style.getPropertyValue).toHaveBeenCalled(); + expect(passwordElement.ownerDocument.defaultView.getComputedStyle).toHaveBeenCalledWith( + passwordElement + ); + }); + + it("returns true when the element has a `display: none;` CSS rule applied to it either inline or in a computed style", () => { + document.body.innerHTML = ` + + + + `; + const usernameElement = document.getElementById("username"); + const passwordElement = document.getElementById("password"); + + const isUsernameElementHidden = + domElementVisibilityService["isElementHiddenByCss"](usernameElement); + const isPasswordElementHidden = + domElementVisibilityService["isElementHiddenByCss"](passwordElement); + + expect(isUsernameElementHidden).toEqual(true); + expect(isPasswordElementHidden).toEqual(true); + }); + + it("returns true when the element has a `opacity: 0;` CSS rule applied to it either inline or in a computed style", () => { + document.body.innerHTML = ` + + + + `; + const usernameElement = document.getElementById("username"); + const passwordElement = document.getElementById("password"); + + const isUsernameElementHidden = + domElementVisibilityService["isElementHiddenByCss"](usernameElement); + const isPasswordElementHidden = + domElementVisibilityService["isElementHiddenByCss"](passwordElement); + + expect(isUsernameElementHidden).toEqual(true); + expect(isPasswordElementHidden).toEqual(true); + }); + + it("returns true when the element has a `clip-path` CSS rule applied to it that hides the element either inline or in a computed style", () => { + document.body.innerHTML = ` + + + + + `; + }); + }); + + describe("isElementOutsideViewportBounds", () => { + const mockViewportWidth = 1920; + const mockViewportHeight = 1080; + + beforeEach(() => { + Object.defineProperty(document.documentElement, "scrollWidth", { + writable: true, + value: mockViewportWidth, + }); + Object.defineProperty(document.documentElement, "scrollHeight", { + writable: true, + value: mockViewportHeight, + }); + }); + + it("returns true if the passed element's size is not sufficient for visibility", () => { + const usernameElement = document.querySelector("input[name='username']") as FormFieldElement; + const elementBoundingClientRect = createBoundingClientRectMock({ + width: 9, + height: 9, + }); + + const isElementOutsideViewportBounds = domElementVisibilityService[ + "isElementOutsideViewportBounds" + ](usernameElement, elementBoundingClientRect); + + expect(isElementOutsideViewportBounds).toEqual(true); + }); + + it("returns true if the passed element is overflowing the left viewport", () => { + const usernameElement = document.querySelector("input[name='username']") as FormFieldElement; + const elementBoundingClientRect = createBoundingClientRectMock({ + left: -1, + }); + + const isElementOutsideViewportBounds = domElementVisibilityService[ + "isElementOutsideViewportBounds" + ](usernameElement, elementBoundingClientRect); + + expect(isElementOutsideViewportBounds).toEqual(true); + }); + + it("returns true if the passed element is overflowing the right viewport", () => { + const usernameElement = document.querySelector("input[name='username']") as FormFieldElement; + const elementBoundingClientRect = createBoundingClientRectMock({ + left: mockViewportWidth + 1, + }); + + const isElementOutsideViewportBounds = domElementVisibilityService[ + "isElementOutsideViewportBounds" + ](usernameElement, elementBoundingClientRect); + + expect(isElementOutsideViewportBounds).toEqual(true); + }); + + it("returns true if the passed element is overflowing the top viewport", () => { + const usernameElement = document.querySelector("input[name='username']") as FormFieldElement; + const elementBoundingClientRect = createBoundingClientRectMock({ + top: -1, + }); + + const isElementOutsideViewportBounds = domElementVisibilityService[ + "isElementOutsideViewportBounds" + ](usernameElement, elementBoundingClientRect); + + expect(isElementOutsideViewportBounds).toEqual(true); + }); + + it("returns true if the passed element is overflowing the bottom viewport", () => { + const usernameElement = document.querySelector("input[name='username']") as FormFieldElement; + const elementBoundingClientRect = createBoundingClientRectMock({ + top: mockViewportHeight + 1, + }); + + const isElementOutsideViewportBounds = domElementVisibilityService[ + "isElementOutsideViewportBounds" + ](usernameElement, elementBoundingClientRect); + + expect(isElementOutsideViewportBounds).toEqual(true); + }); + + it("returns false if the passed element is not outside of the viewport bounds", () => { + const usernameElement = document.querySelector("input[name='username']") as FormFieldElement; + const elementBoundingClientRect = createBoundingClientRectMock({}); + + const isElementOutsideViewportBounds = domElementVisibilityService[ + "isElementOutsideViewportBounds" + ](usernameElement, elementBoundingClientRect); + + expect(isElementOutsideViewportBounds).toEqual(false); + }); + }); + + describe("formFieldIsNotHiddenBehindAnotherElement", () => { + it("returns true if the element found at the center point of the passed targetElement is the targetElement itself", () => { + const usernameElement = document.querySelector("input[name='username']") as FormFieldElement; + jest.spyOn(usernameElement, "getBoundingClientRect"); + document.elementFromPoint = jest.fn(() => usernameElement); + + const formFieldIsNotHiddenBehindAnotherElement = + domElementVisibilityService["formFieldIsNotHiddenBehindAnotherElement"](usernameElement); + + expect(formFieldIsNotHiddenBehindAnotherElement).toEqual(true); + expect(document.elementFromPoint).toHaveBeenCalled(); + expect(usernameElement.getBoundingClientRect).toHaveBeenCalled(); + }); + + it("returns true if the element found at the center point of the passed targetElement is an implicit label of the element", () => { + document.body.innerHTML = ` + + `; + const usernameElement = document.querySelector("input[name='username']") as FormFieldElement; + const labelTextElement = document.querySelector("span"); + document.elementFromPoint = jest.fn(() => labelTextElement); + + const formFieldIsNotHiddenBehindAnotherElement = + domElementVisibilityService["formFieldIsNotHiddenBehindAnotherElement"](usernameElement); + + expect(formFieldIsNotHiddenBehindAnotherElement).toEqual(true); + }); + + it("returns true if the element found at the center point of the passed targetElement is a label of the targetElement", () => { + const usernameElement = document.querySelector("input[name='username']") as FormFieldElement; + const labelElement = document.querySelector("label[for='username']") as FormFieldElement; + const mockBoundingRect = createBoundingClientRectMock({}); + jest.spyOn(usernameElement, "getBoundingClientRect"); + document.elementFromPoint = jest.fn(() => labelElement); + + const formFieldIsNotHiddenBehindAnotherElement = domElementVisibilityService[ + "formFieldIsNotHiddenBehindAnotherElement" + ](usernameElement, mockBoundingRect); + + expect(formFieldIsNotHiddenBehindAnotherElement).toEqual(true); + expect(document.elementFromPoint).toHaveBeenCalledWith( + mockBoundingRect.left + mockBoundingRect.width / 2, + mockBoundingRect.top + mockBoundingRect.height / 2 + ); + expect(usernameElement.getBoundingClientRect).not.toHaveBeenCalled(); + }); + + it("returns false if the element found at the center point is not the passed targetElement or a label of that element", () => { + const usernameElement = document.querySelector("input[name='username']") as FormFieldElement; + document.elementFromPoint = jest.fn(() => document.createElement("div")); + + const formFieldIsNotHiddenBehindAnotherElement = + domElementVisibilityService["formFieldIsNotHiddenBehindAnotherElement"](usernameElement); + + expect(formFieldIsNotHiddenBehindAnotherElement).toEqual(false); + }); + }); +}); diff --git a/apps/browser/src/autofill/services/dom-element-visibility.service.ts b/apps/browser/src/autofill/services/dom-element-visibility.service.ts new file mode 100644 index 0000000000..4be59d7f27 --- /dev/null +++ b/apps/browser/src/autofill/services/dom-element-visibility.service.ts @@ -0,0 +1,199 @@ +import { FillableFormFieldElement, FormFieldElement } from "../types"; + +import { DomElementVisibilityService as domElementVisibilityServiceInterface } from "./abstractions/dom-element-visibility.service"; + +class DomElementVisibilityService implements domElementVisibilityServiceInterface { + private cachedComputedStyle: CSSStyleDeclaration | null = null; + + /** + * Checks if a form field is viewable. This is done by checking if the element is within the + * viewport bounds, not hidden by CSS, and not hidden behind another element. + * @param {FormFieldElement} element + * @returns {Promise} + */ + async isFormFieldViewable(element: FormFieldElement): Promise { + const elementBoundingClientRect = element.getBoundingClientRect(); + + if ( + this.isElementOutsideViewportBounds(element, elementBoundingClientRect) || + this.isElementHiddenByCss(element) + ) { + return false; + } + + return this.formFieldIsNotHiddenBehindAnotherElement(element, elementBoundingClientRect); + } + + /** + * Check if the target element is hidden using CSS. This is done by checking the opacity, display, + * visibility, and clip-path CSS properties of the element. We also check the opacity of all + * parent elements to ensure that the target element is not hidden by a parent element. + * @param {HTMLElement} element + * @returns {boolean} + * @public + */ + isElementHiddenByCss(element: HTMLElement): boolean { + this.cachedComputedStyle = null; + + if ( + this.isElementInvisible(element) || + this.isElementNotDisplayed(element) || + this.isElementNotVisible(element) || + this.isElementClipped(element) + ) { + return true; + } + + let parentElement = element.parentElement; + while (parentElement && parentElement !== element.ownerDocument.documentElement) { + this.cachedComputedStyle = null; + if (this.isElementInvisible(parentElement)) { + return true; + } + + parentElement = parentElement.parentElement; + } + + return false; + } + + /** + * Gets the computed style of a given element, will only calculate the computed + * style if the element's style has not been previously cached. + * @param {HTMLElement} element + * @param {string} styleProperty + * @returns {string} + * @private + */ + private getElementStyle(element: HTMLElement, styleProperty: string): string { + if (!this.cachedComputedStyle) { + this.cachedComputedStyle = (element.ownerDocument.defaultView || window).getComputedStyle( + element + ); + } + + return this.cachedComputedStyle.getPropertyValue(styleProperty); + } + + /** + * Checks if the opacity of the target element is less than 0.1. + * @param {HTMLElement} element + * @returns {boolean} + * @private + */ + private isElementInvisible(element: HTMLElement): boolean { + return parseFloat(this.getElementStyle(element, "opacity")) < 0.1; + } + + /** + * Checks if the target element has a display property of none. + * @param {HTMLElement} element + * @returns {boolean} + * @private + */ + private isElementNotDisplayed(element: HTMLElement): boolean { + return this.getElementStyle(element, "display") === "none"; + } + + /** + * Checks if the target element has a visibility property of hidden or collapse. + * @param {HTMLElement} element + * @returns {boolean} + * @private + */ + private isElementNotVisible(element: HTMLElement): boolean { + return new Set(["hidden", "collapse"]).has(this.getElementStyle(element, "visibility")); + } + + /** + * Checks if the target element has a clip-path property that hides the element. + * @param {HTMLElement} element + * @returns {boolean} + * @private + */ + private isElementClipped(element: HTMLElement): boolean { + return new Set([ + "inset(50%)", + "inset(100%)", + "circle(0)", + "circle(0px)", + "circle(0px at 50% 50%)", + "polygon(0 0, 0 0, 0 0, 0 0)", + "polygon(0px 0px, 0px 0px, 0px 0px, 0px 0px)", + ]).has(this.getElementStyle(element, "clipPath")); + } + + /** + * Checks if the target element is outside the viewport bounds. This is done by checking if the + * element is too small or is overflowing the viewport bounds. + * @param {HTMLElement} targetElement + * @param {DOMRectReadOnly | null} targetElementBoundingClientRect + * @returns {boolean} + * @private + */ + private isElementOutsideViewportBounds( + targetElement: HTMLElement, + targetElementBoundingClientRect: DOMRectReadOnly | null = null + ): boolean { + const documentElement = targetElement.ownerDocument.documentElement; + const documentElementWidth = documentElement.scrollWidth; + const documentElementHeight = documentElement.scrollHeight; + const elementBoundingClientRect = + targetElementBoundingClientRect || targetElement.getBoundingClientRect(); + const elementTopOffset = elementBoundingClientRect.top - documentElement.clientTop; + const elementLeftOffset = elementBoundingClientRect.left - documentElement.clientLeft; + + const isElementSizeInsufficient = + elementBoundingClientRect.width < 10 || elementBoundingClientRect.height < 10; + const isElementOverflowingLeftViewport = elementLeftOffset < 0; + const isElementOverflowingRightViewport = + elementLeftOffset + elementBoundingClientRect.width > documentElementWidth; + const isElementOverflowingTopViewport = elementTopOffset < 0; + const isElementOverflowingBottomViewport = + elementTopOffset + elementBoundingClientRect.height > documentElementHeight; + + return ( + isElementSizeInsufficient || + isElementOverflowingLeftViewport || + isElementOverflowingRightViewport || + isElementOverflowingTopViewport || + isElementOverflowingBottomViewport + ); + } + + /** + * Checks if a passed FormField is not hidden behind another element. This is done by + * checking if the element at the center point of the FormField is the FormField itself + * or one of its labels. + * @param {FormFieldElement} targetElement + * @param {DOMRectReadOnly | null} targetElementBoundingClientRect + * @returns {boolean} + * @private + */ + private formFieldIsNotHiddenBehindAnotherElement( + targetElement: FormFieldElement, + targetElementBoundingClientRect: DOMRectReadOnly | null = null + ): boolean { + const elementBoundingClientRect = + targetElementBoundingClientRect || targetElement.getBoundingClientRect(); + const elementAtCenterPoint = targetElement.ownerDocument.elementFromPoint( + elementBoundingClientRect.left + elementBoundingClientRect.width / 2, + elementBoundingClientRect.top + elementBoundingClientRect.height / 2 + ); + + if (elementAtCenterPoint === targetElement) { + return true; + } + + const targetElementLabelsSet = new Set((targetElement as FillableFormFieldElement).labels); + if (targetElementLabelsSet.has(elementAtCenterPoint as HTMLLabelElement)) { + return true; + } + + const closestParentLabel = elementAtCenterPoint?.parentElement?.closest("label"); + + return targetElementLabelsSet.has(closestParentLabel); + } +} + +export default DomElementVisibilityService; diff --git a/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts b/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts new file mode 100644 index 0000000000..828d768ca2 --- /dev/null +++ b/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts @@ -0,0 +1,1047 @@ +import { EVENTS } from "../constants"; +import AutofillScript, { FillScript, FillScriptActions } from "../models/autofill-script"; +import { FillableFormFieldElement, FormElementWithAttribute, FormFieldElement } from "../types"; + +import CollectAutofillContentService from "./collect-autofill-content.service"; +import DomElementVisibilityService from "./dom-element-visibility.service"; +import InsertAutofillContentService from "./insert-autofill-content.service"; + +const mockLoginForm = ` +
+
+ + +
+
+`; + +const eventsToTest = [ + EVENTS.CHANGE, + EVENTS.INPUT, + EVENTS.KEYDOWN, + EVENTS.KEYPRESS, + EVENTS.KEYUP, + "blur", + "click", + "focus", + "focusin", + "focusout", + "mousedown", + "paste", + "select", + "selectionchange", + "touchend", + "touchstart", +]; + +const initEventCount = Object.freeze( + eventsToTest.reduce( + (eventCounts, eventName) => ({ + ...eventCounts, + [eventName]: 0, + }), + {} + ) +); + +let confirmSpy: jest.SpyInstance; +let windowSpy: jest.SpyInstance; +let savedURLs: string[] | null = ["https://bitwarden.com"]; +function setMockWindowLocation({ + protocol, + hostname, +}: { + protocol: "http:" | "https:"; + hostname: string; +}) { + windowSpy.mockImplementation(() => ({ + location: { + protocol, + hostname, + }, + })); +} + +describe("InsertAutofillContentService", () => { + const domElementVisibilityService = new DomElementVisibilityService(); + const collectAutofillContentService = new CollectAutofillContentService( + domElementVisibilityService + ); + let insertAutofillContentService: InsertAutofillContentService; + let fillScript: AutofillScript; + + beforeEach(() => { + document.body.innerHTML = mockLoginForm; + confirmSpy = jest.spyOn(window, "confirm"); + windowSpy = jest.spyOn(window, "window", "get"); + insertAutofillContentService = new InsertAutofillContentService( + domElementVisibilityService, + collectAutofillContentService + ); + fillScript = { + script: [ + ["click_on_opid", "username"], + ["focus_by_opid", "username"], + ["fill_by_opid", "username", "test"], + ], + properties: { + delay_between_operations: 20, + }, + metadata: {}, + autosubmit: null, + savedUrls: ["https://bitwarden.com"], + untrustedIframe: false, + itemType: "login", + }; + }); + + afterEach(() => { + jest.resetAllMocks(); + windowSpy.mockRestore(); + confirmSpy.mockRestore(); + document.body.innerHTML = ""; + }); + + describe("fillForm", () => { + it("returns early if the passed fill script does not have a script property", () => { + fillScript.script = []; + jest.spyOn(insertAutofillContentService as any, "fillingWithinSandboxedIframe"); + jest.spyOn(insertAutofillContentService as any, "userCancelledInsecureUrlAutofill"); + jest.spyOn(insertAutofillContentService as any, "userCancelledUntrustedIframeAutofill"); + jest.spyOn(insertAutofillContentService as any, "runFillScriptAction"); + + insertAutofillContentService.fillForm(fillScript); + + expect(insertAutofillContentService["fillingWithinSandboxedIframe"]).not.toHaveBeenCalled(); + expect( + insertAutofillContentService["userCancelledInsecureUrlAutofill"] + ).not.toHaveBeenCalled(); + expect( + insertAutofillContentService["userCancelledUntrustedIframeAutofill"] + ).not.toHaveBeenCalled(); + expect(insertAutofillContentService["runFillScriptAction"]).not.toHaveBeenCalled(); + }); + + it("returns early if the script is filling within a sand boxed iframe", () => { + jest + .spyOn(insertAutofillContentService as any, "fillingWithinSandboxedIframe") + .mockReturnValue(true); + jest.spyOn(insertAutofillContentService as any, "userCancelledInsecureUrlAutofill"); + jest.spyOn(insertAutofillContentService as any, "userCancelledUntrustedIframeAutofill"); + jest.spyOn(insertAutofillContentService as any, "runFillScriptAction"); + + insertAutofillContentService.fillForm(fillScript); + + expect(insertAutofillContentService["fillingWithinSandboxedIframe"]).toHaveBeenCalled(); + expect( + insertAutofillContentService["userCancelledInsecureUrlAutofill"] + ).not.toHaveBeenCalled(); + expect( + insertAutofillContentService["userCancelledUntrustedIframeAutofill"] + ).not.toHaveBeenCalled(); + expect(insertAutofillContentService["runFillScriptAction"]).not.toHaveBeenCalled(); + }); + + it("returns early if the autofill is occurring on an insecure url and the user cancels the autofill", () => { + jest + .spyOn(insertAutofillContentService as any, "fillingWithinSandboxedIframe") + .mockReturnValue(false); + jest + .spyOn(insertAutofillContentService as any, "userCancelledInsecureUrlAutofill") + .mockReturnValue(true); + jest.spyOn(insertAutofillContentService as any, "userCancelledUntrustedIframeAutofill"); + jest.spyOn(insertAutofillContentService as any, "runFillScriptAction"); + + insertAutofillContentService.fillForm(fillScript); + + expect(insertAutofillContentService["fillingWithinSandboxedIframe"]).toHaveBeenCalled(); + expect(insertAutofillContentService["userCancelledInsecureUrlAutofill"]).toHaveBeenCalled(); + expect( + insertAutofillContentService["userCancelledUntrustedIframeAutofill"] + ).not.toHaveBeenCalled(); + expect(insertAutofillContentService["runFillScriptAction"]).not.toHaveBeenCalled(); + }); + + it("returns early if the iframe is untrusted and the user cancelled the autofill", () => { + jest + .spyOn(insertAutofillContentService as any, "fillingWithinSandboxedIframe") + .mockReturnValue(false); + jest + .spyOn(insertAutofillContentService as any, "userCancelledInsecureUrlAutofill") + .mockReturnValue(false); + jest + .spyOn(insertAutofillContentService as any, "userCancelledUntrustedIframeAutofill") + .mockReturnValue(true); + jest.spyOn(insertAutofillContentService as any, "runFillScriptAction"); + + insertAutofillContentService.fillForm(fillScript); + + expect(insertAutofillContentService["fillingWithinSandboxedIframe"]).toHaveBeenCalled(); + expect(insertAutofillContentService["userCancelledInsecureUrlAutofill"]).toHaveBeenCalled(); + expect( + insertAutofillContentService["userCancelledUntrustedIframeAutofill"] + ).toHaveBeenCalled(); + expect(insertAutofillContentService["runFillScriptAction"]).not.toHaveBeenCalled(); + }); + + it("runs the fill script action for all scripts found within the fill script", () => { + jest + .spyOn(insertAutofillContentService as any, "fillingWithinSandboxedIframe") + .mockReturnValue(false); + jest + .spyOn(insertAutofillContentService as any, "userCancelledInsecureUrlAutofill") + .mockReturnValue(false); + jest + .spyOn(insertAutofillContentService as any, "userCancelledUntrustedIframeAutofill") + .mockReturnValue(false); + jest.spyOn(insertAutofillContentService as any, "runFillScriptAction"); + + insertAutofillContentService.fillForm(fillScript); + + expect(insertAutofillContentService["fillingWithinSandboxedIframe"]).toHaveBeenCalled(); + expect(insertAutofillContentService["userCancelledInsecureUrlAutofill"]).toHaveBeenCalled(); + expect( + insertAutofillContentService["userCancelledUntrustedIframeAutofill"] + ).toHaveBeenCalled(); + expect(insertAutofillContentService["runFillScriptAction"]).toHaveBeenCalledTimes(3); + expect(insertAutofillContentService["runFillScriptAction"]).toHaveBeenNthCalledWith( + 1, + fillScript.script[0], + 0, + fillScript.script + ); + expect(insertAutofillContentService["runFillScriptAction"]).toHaveBeenNthCalledWith( + 2, + fillScript.script[1], + 1, + fillScript.script + ); + expect(insertAutofillContentService["runFillScriptAction"]).toHaveBeenNthCalledWith( + 3, + fillScript.script[2], + 2, + fillScript.script + ); + }); + }); + + describe("fillingWithinSandboxedIframe", () => { + afterEach(() => { + Object.defineProperty(globalThis, "window", { + value: { frameElement: null }, + writable: true, + }); + }); + + it("returns false if the `self.origin` value is not null", () => { + const result = insertAutofillContentService["fillingWithinSandboxedIframe"](); + + expect(result).toBe(false); + expect(self.origin).not.toBeNull(); + }); + + it("returns true if the frameElement has a sandbox attribute", () => { + Object.defineProperty(globalThis, "window", { + value: { frameElement: { hasAttribute: jest.fn(() => true) } }, + writable: true, + }); + + const result = insertAutofillContentService["fillingWithinSandboxedIframe"](); + + expect(result).toBe(true); + }); + + it("returns true if the window location hostname is empty", () => { + setMockWindowLocation({ protocol: "http:", hostname: "" }); + + const result = insertAutofillContentService["fillingWithinSandboxedIframe"](); + + expect(result).toBe(true); + }); + }); + + describe("userCancelledInsecureUrlAutofill", () => { + const currentHostname = "bitwarden.com"; + + beforeEach(() => { + savedURLs = [`https://${currentHostname}`]; + }); + + describe("returns false if Autofill occurring...", () => { + it("when there are no saved URLs", () => { + savedURLs = []; + setMockWindowLocation({ protocol: "http:", hostname: currentHostname }); + + const userCancelledInsecureUrlAutofill = + insertAutofillContentService["userCancelledInsecureUrlAutofill"](savedURLs); + + expect(userCancelledInsecureUrlAutofill).toBe(false); + + savedURLs = null; + + const userCancelledInsecureUrlAutofill2 = + insertAutofillContentService["userCancelledInsecureUrlAutofill"](savedURLs); + + expect(confirmSpy).not.toHaveBeenCalled(); + expect(userCancelledInsecureUrlAutofill2).toBe(false); + }); + + it("on http page and saved URLs contain no https values", () => { + savedURLs = ["http://bitwarden.com"]; + setMockWindowLocation({ protocol: "http:", hostname: currentHostname }); + + const userCancelledInsecureUrlAutofill = + insertAutofillContentService["userCancelledInsecureUrlAutofill"](savedURLs); + + expect(confirmSpy).not.toHaveBeenCalled(); + expect(userCancelledInsecureUrlAutofill).toBe(false); + }); + + it("on https page with saved https URL", () => { + setMockWindowLocation({ protocol: "https:", hostname: currentHostname }); + + const userCancelledInsecureUrlAutofill = + insertAutofillContentService["userCancelledInsecureUrlAutofill"](savedURLs); + + expect(confirmSpy).not.toHaveBeenCalled(); + expect(userCancelledInsecureUrlAutofill).toBe(false); + }); + + it("on page with no password field", () => { + setMockWindowLocation({ protocol: "https:", hostname: currentHostname }); + + document.body.innerHTML = ` +
+
+ +
+
+ `; + + const userCancelledInsecureUrlAutofill = + insertAutofillContentService["userCancelledInsecureUrlAutofill"](savedURLs); + + expect(confirmSpy).not.toHaveBeenCalled(); + expect(userCancelledInsecureUrlAutofill).toBe(false); + }); + + it("on http page with saved https URL and user approval", () => { + setMockWindowLocation({ protocol: "http:", hostname: currentHostname }); + confirmSpy.mockImplementation(jest.fn(() => true)); + + const userCancelledInsecureUrlAutofill = + insertAutofillContentService["userCancelledInsecureUrlAutofill"](savedURLs); + + expect(confirmSpy).toHaveBeenCalled(); + expect(userCancelledInsecureUrlAutofill).toBe(false); + }); + }); + + it("returns true if Autofill occurring on http page with saved https URL and user disapproval", () => { + setMockWindowLocation({ protocol: "http:", hostname: currentHostname }); + confirmSpy.mockImplementation(jest.fn(() => false)); + + const userCancelledInsecureUrlAutofill = + insertAutofillContentService["userCancelledInsecureUrlAutofill"](savedURLs); + + expect(confirmSpy).toHaveBeenCalled(); + expect(userCancelledInsecureUrlAutofill).toBe(true); + }); + + it("returns false if the vault item contains uris with both secure and insecure uris, but a insecure uri is being used on a insecure web page", () => { + setMockWindowLocation({ protocol: "http:", hostname: currentHostname }); + savedURLs = ["http://bitwarden.com", "https://some-other-uri.com"]; + + const userCancelledInsecureUrlAutofill = + insertAutofillContentService["userCancelledInsecureUrlAutofill"](savedURLs); + + expect(confirmSpy).not.toHaveBeenCalled(); + expect(userCancelledInsecureUrlAutofill).toBe(false); + }); + }); + + describe("userCancelledUntrustedIframeAutofill", () => { + it("returns false if Autofill occurring within a trusted iframe", () => { + fillScript.untrustedIframe = false; + + const result = + insertAutofillContentService["userCancelledUntrustedIframeAutofill"](fillScript); + + expect(result).toBe(false); + expect(confirmSpy).not.toHaveBeenCalled(); + }); + + it("returns false if Autofill occurring within an untrusted iframe and the user approves", () => { + fillScript.untrustedIframe = true; + confirmSpy.mockImplementation(jest.fn(() => true)); + + const result = + insertAutofillContentService["userCancelledUntrustedIframeAutofill"](fillScript); + + expect(result).toBe(false); + expect(confirmSpy).toHaveBeenCalled(); + }); + + it("returns true if Autofill occurring within an untrusted iframe and the user disapproves", () => { + fillScript.untrustedIframe = true; + confirmSpy.mockImplementation(jest.fn(() => false)); + + const result = + insertAutofillContentService["userCancelledUntrustedIframeAutofill"](fillScript); + + expect(result).toBe(true); + expect(confirmSpy).toHaveBeenCalled(); + }); + }); + + describe("runFillScriptAction", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + it("returns early if no opid is provided", () => { + const action = "fill_by_opid"; + const opid = ""; + const value = "value"; + const scriptAction: FillScript = [action, opid, value]; + jest.spyOn(insertAutofillContentService["autofillInsertActions"], action); + + insertAutofillContentService["runFillScriptAction"](scriptAction, 0); + jest.advanceTimersByTime(20); + + expect(insertAutofillContentService["autofillInsertActions"][action]).not.toHaveBeenCalled(); + }); + + describe("given a valid fill script action and opid", () => { + const fillScriptActions: FillScriptActions[] = [ + "fill_by_opid", + "click_on_opid", + "focus_by_opid", + ]; + fillScriptActions.forEach((action) => { + it(`triggers a ${action} action`, () => { + const opid = "opid"; + const value = "value"; + const scriptAction: FillScript = [action, opid, value]; + jest.spyOn(insertAutofillContentService["autofillInsertActions"], action); + + insertAutofillContentService["runFillScriptAction"](scriptAction, 0); + jest.advanceTimersByTime(20); + + expect( + insertAutofillContentService["autofillInsertActions"][action] + ).toHaveBeenCalledWith({ + opid, + value, + }); + }); + }); + }); + }); + + describe("handleFillFieldByOpidAction", () => { + it("finds the field element by opid and inserts the value into the field", () => { + const opid = "__1"; + const value = "value"; + const textInput = document.querySelector('input[type="text"]') as FormElementWithAttribute; + textInput.opid = opid; + textInput.value = value; + jest.spyOn( + insertAutofillContentService["collectAutofillContentService"], + "getAutofillFieldElementByOpid" + ); + jest.spyOn(insertAutofillContentService as any, "insertValueIntoField"); + + insertAutofillContentService["handleFillFieldByOpidAction"](opid, value); + + expect( + insertAutofillContentService["collectAutofillContentService"].getAutofillFieldElementByOpid + ).toHaveBeenCalledWith(opid); + expect(insertAutofillContentService["insertValueIntoField"]).toHaveBeenCalledWith( + textInput, + value + ); + }); + }); + + describe("handleClickOnFieldByOpidAction", () => { + it("clicks on the elements targeted by the passed opid", () => { + const textInput = document.querySelector('input[type="text"]') as FormElementWithAttribute; + textInput.opid = "__1"; + let clickEventCount = 0; + const expectedClickEventCount = 1; + const clickEventHandler: (handledEvent: Event) => void = (handledEvent) => { + const eventTarget = handledEvent.target as HTMLInputElement; + + if (eventTarget.id === "username") { + clickEventCount++; + } + }; + textInput.addEventListener("click", clickEventHandler); + jest.spyOn( + insertAutofillContentService["collectAutofillContentService"], + "getAutofillFieldElementByOpid" + ); + jest.spyOn(insertAutofillContentService as any, "triggerClickOnElement"); + + insertAutofillContentService["handleClickOnFieldByOpidAction"]("__1"); + + expect( + insertAutofillContentService["collectAutofillContentService"].getAutofillFieldElementByOpid + ).toBeCalledWith("__1"); + expect((insertAutofillContentService as any)["triggerClickOnElement"]).toHaveBeenCalledWith( + textInput + ); + expect(clickEventCount).toBe(expectedClickEventCount); + + textInput.removeEventListener("click", clickEventHandler); + }); + + it("should not trigger click when no suitable elements can be found", () => { + const textInput = document.querySelector('input[type="text"]') as FormElementWithAttribute; + let clickEventCount = 0; + const expectedClickEventCount = 0; + const clickEventHandler: (handledEvent: Event) => void = (handledEvent) => { + const eventTarget = handledEvent.target as HTMLInputElement; + + if (eventTarget.id === "username") { + clickEventCount++; + } + }; + textInput.addEventListener("click", clickEventHandler); + + insertAutofillContentService["handleClickOnFieldByOpidAction"]("__2"); + + expect(clickEventCount).toEqual(expectedClickEventCount); + + textInput.removeEventListener("click", clickEventHandler); + }); + }); + + describe("handleFocusOnFieldByOpidAction", () => { + it("simulates click and focus events on the element targeted by the passed opid", () => { + const targetInput = document.querySelector('input[type="text"]') as FormElementWithAttribute; + targetInput.opid = "__0"; + const elementEventCount: { [key: string]: number } = { + ...initEventCount, + }; + // Testing all the relevant events to ensure downstream side-effects are firing correctly + const expectedElementEventCount: { [key: string]: number } = { + ...initEventCount, + click: 1, + focus: 1, + focusin: 1, + }; + const eventHandlers: { [key: string]: EventListener } = {}; + eventsToTest.forEach((eventType) => { + eventHandlers[eventType] = (handledEvent) => { + elementEventCount[handledEvent.type]++; + }; + targetInput.addEventListener(eventType, eventHandlers[eventType]); + }); + jest.spyOn( + insertAutofillContentService["collectAutofillContentService"], + "getAutofillFieldElementByOpid" + ); + jest.spyOn( + insertAutofillContentService as any, + "simulateUserMouseClickAndFocusEventInteractions" + ); + + insertAutofillContentService["handleFocusOnFieldByOpidAction"]("__0"); + + expect( + insertAutofillContentService["collectAutofillContentService"].getAutofillFieldElementByOpid + ).toBeCalledWith("__0"); + expect( + insertAutofillContentService["simulateUserMouseClickAndFocusEventInteractions"] + ).toHaveBeenCalledWith(targetInput, true); + expect(elementEventCount).toEqual(expectedElementEventCount); + }); + }); + + describe("insertValueIntoField", () => { + it("returns early if an element is not provided", () => { + const value = "test"; + const element: FormFieldElement | null = null; + jest.spyOn(insertAutofillContentService as any, "handleInsertValueAndTriggerSimulatedEvents"); + + insertAutofillContentService["insertValueIntoField"](element, value); + + expect( + insertAutofillContentService["handleInsertValueAndTriggerSimulatedEvents"] + ).not.toHaveBeenCalled(); + }); + + it("returns early if a value is not provided", () => { + const value = ""; + const element: FormFieldElement | null = document.querySelector('input[type="text"]'); + jest.spyOn(insertAutofillContentService as any, "handleInsertValueAndTriggerSimulatedEvents"); + + insertAutofillContentService["insertValueIntoField"](element, value); + + expect( + insertAutofillContentService["handleInsertValueAndTriggerSimulatedEvents"] + ).not.toHaveBeenCalled(); + }); + + it("will set the inner text of the element if a span element is passed", () => { + document.body.innerHTML = ``; + const value = "test"; + const element = document.getElementById("username") as FormFieldElement; + jest.spyOn(insertAutofillContentService as any, "handleInsertValueAndTriggerSimulatedEvents"); + + insertAutofillContentService["insertValueIntoField"](element, value); + + expect(element.innerText).toBe(value); + expect( + insertAutofillContentService["handleInsertValueAndTriggerSimulatedEvents"] + ).toHaveBeenCalledWith(element, expect.any(Function)); + }); + + it("will set the `checked` attribute of any passed checkbox or radio elements", () => { + document.body.innerHTML = ``; + const checkboxElement = document.getElementById("checkbox") as HTMLInputElement; + const radioElement = document.getElementById("radio") as HTMLInputElement; + jest.spyOn(insertAutofillContentService as any, "handleInsertValueAndTriggerSimulatedEvents"); + + const possibleValues = ["true", "y", "1", "yes", "✓"]; + possibleValues.forEach((value) => { + insertAutofillContentService["insertValueIntoField"](checkboxElement, value); + insertAutofillContentService["insertValueIntoField"](radioElement, value); + + expect(checkboxElement.checked).toBe(true); + expect(radioElement.checked).toBe(true); + expect( + insertAutofillContentService["handleInsertValueAndTriggerSimulatedEvents"] + ).toHaveBeenCalledWith(checkboxElement, expect.any(Function)); + expect( + insertAutofillContentService["handleInsertValueAndTriggerSimulatedEvents"] + ).toHaveBeenCalledWith(radioElement, expect.any(Function)); + + checkboxElement.checked = false; + radioElement.checked = false; + }); + }); + + it("will set the `value` attribute of any passed input or textarea elements", () => { + document.body.innerHTML = ``; + const value1 = "test"; + const value2 = "test2"; + const textInputElement = document.getElementById("username") as HTMLInputElement; + textInputElement.value = value1; + const textareaElement = document.getElementById("bio") as HTMLTextAreaElement; + textareaElement.value = value2; + jest.spyOn(insertAutofillContentService as any, "handleInsertValueAndTriggerSimulatedEvents"); + + insertAutofillContentService["insertValueIntoField"](textInputElement, value1); + + expect(textInputElement.value).toBe(value1); + expect( + insertAutofillContentService["handleInsertValueAndTriggerSimulatedEvents"] + ).toHaveBeenCalledWith(textInputElement, expect.any(Function)); + + insertAutofillContentService["insertValueIntoField"](textareaElement, value2); + + expect(textareaElement.value).toBe(value2); + expect( + insertAutofillContentService["handleInsertValueAndTriggerSimulatedEvents"] + ).toHaveBeenCalledWith(textareaElement, expect.any(Function)); + }); + }); + + describe("handleInsertValueAndTriggerSimulatedEvents", () => { + it("triggers pre- and post-insert events on the element while filling the value into the element", () => { + const value = "test"; + const element = document.querySelector('input[type="text"]') as FormFieldElement; + jest.spyOn(insertAutofillContentService as any, "triggerPreInsertEventsOnElement"); + jest.spyOn(insertAutofillContentService as any, "triggerPostInsertEventsOnElement"); + jest.spyOn(insertAutofillContentService as any, "triggerFillAnimationOnElement"); + const valueChangeCallback = jest.fn( + () => ((element as FillableFormFieldElement).value = value) + ); + + insertAutofillContentService["handleInsertValueAndTriggerSimulatedEvents"]( + element, + valueChangeCallback + ); + + expect(insertAutofillContentService["triggerPreInsertEventsOnElement"]).toHaveBeenCalledWith( + element + ); + expect(valueChangeCallback).toHaveBeenCalled(); + expect(insertAutofillContentService["triggerPostInsertEventsOnElement"]).toHaveBeenCalledWith( + element + ); + expect(insertAutofillContentService["triggerFillAnimationOnElement"]).toHaveBeenCalledWith( + element + ); + expect((element as FillableFormFieldElement).value).toBe(value); + }); + }); + + describe("triggerPreInsertEventsOnElement", () => { + it("triggers a simulated click and keyboard event on the element", () => { + const initialElementValue = "test"; + document.body.innerHTML = ``; + const element = document.getElementById("username") as FillableFormFieldElement; + jest.spyOn( + insertAutofillContentService as any, + "simulateUserMouseClickAndFocusEventInteractions" + ); + jest.spyOn(insertAutofillContentService as any, "simulateUserKeyboardEventInteractions"); + + insertAutofillContentService["triggerPreInsertEventsOnElement"](element); + + expect( + insertAutofillContentService["simulateUserMouseClickAndFocusEventInteractions"] + ).toHaveBeenCalledWith(element); + expect( + insertAutofillContentService["simulateUserKeyboardEventInteractions"] + ).toHaveBeenCalledWith(element); + expect(element.value).toBe(initialElementValue); + }); + }); + + describe("triggerPostInsertEventsOnElement", () => { + it("triggers simulated event interactions and blurs the element after", () => { + const elementValue = "test"; + document.body.innerHTML = ``; + const element = document.getElementById("username") as FillableFormFieldElement; + jest.spyOn(element, "blur"); + jest.spyOn(insertAutofillContentService as any, "simulateUserKeyboardEventInteractions"); + jest.spyOn(insertAutofillContentService as any, "simulateInputElementChangedEvent"); + + insertAutofillContentService["triggerPostInsertEventsOnElement"](element); + + expect( + insertAutofillContentService["simulateUserKeyboardEventInteractions"] + ).toHaveBeenCalledWith(element); + expect(insertAutofillContentService["simulateInputElementChangedEvent"]).toHaveBeenCalledWith( + element + ); + expect(element.blur).toHaveBeenCalled(); + expect(element.value).toBe(elementValue); + }); + }); + + describe("triggerFillAnimationOnElement", () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllTimers(); + }); + + describe("will not trigger the animation when...", () => { + it("the element is a non-hidden hidden input type", async () => { + document.body.innerHTML = mockLoginForm + ''; + const testElement = document.querySelector( + 'input[type="hidden"]' + ) as FillableFormFieldElement; + jest.spyOn(testElement.classList, "add"); + jest.spyOn(testElement.classList, "remove"); + + insertAutofillContentService["triggerFillAnimationOnElement"](testElement); + await jest.advanceTimersByTime(200); + + expect(testElement.classList.add).not.toHaveBeenCalled(); + expect(testElement.classList.remove).not.toHaveBeenCalled(); + }); + + it("the element is a non-hidden textarea", () => { + document.body.innerHTML = mockLoginForm + ""; + const testElement = document.querySelector("textarea") as FillableFormFieldElement; + jest.spyOn(testElement.classList, "add"); + jest.spyOn(testElement.classList, "remove"); + + insertAutofillContentService["triggerFillAnimationOnElement"](testElement); + jest.advanceTimersByTime(200); + + expect(testElement.classList.add).not.toHaveBeenCalled(); + expect(testElement.classList.remove).not.toHaveBeenCalled(); + }); + + it("the element is a unsupported tag", () => { + document.body.innerHTML = mockLoginForm + '
'; + const testElement = document.querySelector("#input-tag") as FillableFormFieldElement; + jest.spyOn(testElement.classList, "add"); + jest.spyOn(testElement.classList, "remove"); + + insertAutofillContentService["triggerFillAnimationOnElement"](testElement); + jest.advanceTimersByTime(200); + + expect(testElement.classList.add).not.toHaveBeenCalled(); + expect(testElement.classList.remove).not.toHaveBeenCalled(); + }); + + it("the element has a `visibility: hidden;` CSS rule applied to it", () => { + const testElement = document.querySelector( + 'input[type="password"]' + ) as FillableFormFieldElement; + testElement.style.visibility = "hidden"; + jest.spyOn(testElement.classList, "add"); + jest.spyOn(testElement.classList, "remove"); + + insertAutofillContentService["triggerFillAnimationOnElement"](testElement); + jest.advanceTimersByTime(200); + + expect(testElement.classList.add).not.toHaveBeenCalled(); + expect(testElement.classList.remove).not.toHaveBeenCalled(); + }); + + it("the element has a `display: none;` CSS rule applied to it", () => { + const testElement = document.querySelector( + 'input[type="password"]' + ) as FillableFormFieldElement; + testElement.style.display = "none"; + jest.spyOn(testElement.classList, "add"); + jest.spyOn(testElement.classList, "remove"); + + insertAutofillContentService["triggerFillAnimationOnElement"](testElement); + jest.advanceTimersByTime(200); + + expect(testElement.classList.add).not.toHaveBeenCalled(); + expect(testElement.classList.remove).not.toHaveBeenCalled(); + }); + + it("a parent of the element has an `opacity: 0;` CSS rule applied to it", () => { + document.body.innerHTML = + mockLoginForm + '
'; + const testElement = document.querySelector( + 'input[type="email"]' + ) as FillableFormFieldElement; + jest.spyOn(testElement.classList, "add"); + jest.spyOn(testElement.classList, "remove"); + + insertAutofillContentService["triggerFillAnimationOnElement"](testElement); + jest.advanceTimersByTime(200); + + expect(testElement.classList.add).not.toHaveBeenCalled(); + expect(testElement.classList.remove).not.toHaveBeenCalled(); + }); + }); + + describe("will trigger the animation when...", () => { + it("the element is a non-hidden password field", () => { + const testElement = document.querySelector( + 'input[type="password"]' + ) as FillableFormFieldElement; + jest.spyOn( + insertAutofillContentService["domElementVisibilityService"], + "isElementHiddenByCss" + ); + jest.spyOn(testElement.classList, "add"); + jest.spyOn(testElement.classList, "remove"); + + insertAutofillContentService["triggerFillAnimationOnElement"](testElement); + jest.advanceTimersByTime(200); + + expect( + insertAutofillContentService["domElementVisibilityService"].isElementHiddenByCss + ).toHaveBeenCalledWith(testElement); + expect(testElement.classList.add).toHaveBeenCalledWith( + "com-bitwarden-browser-animated-fill" + ); + expect(testElement.classList.remove).toHaveBeenCalledWith( + "com-bitwarden-browser-animated-fill" + ); + }); + + it("the element is a non-hidden email input", () => { + document.body.innerHTML = mockLoginForm + ''; + const testElement = document.querySelector( + 'input[type="email"]' + ) as FillableFormFieldElement; + jest.spyOn(testElement.classList, "add"); + jest.spyOn(testElement.classList, "remove"); + + insertAutofillContentService["triggerFillAnimationOnElement"](testElement); + jest.advanceTimersByTime(200); + + expect(testElement.classList.add).toHaveBeenCalledWith( + "com-bitwarden-browser-animated-fill" + ); + expect(testElement.classList.remove).toHaveBeenCalledWith( + "com-bitwarden-browser-animated-fill" + ); + }); + + it("the element is a non-hidden text input", () => { + document.body.innerHTML = mockLoginForm + ''; + const testElement = document.querySelector( + 'input[type="text"]' + ) as FillableFormFieldElement; + jest.spyOn(testElement.classList, "add"); + jest.spyOn(testElement.classList, "remove"); + + insertAutofillContentService["triggerFillAnimationOnElement"](testElement); + jest.advanceTimersByTime(200); + + expect(testElement.classList.add).toHaveBeenCalledWith( + "com-bitwarden-browser-animated-fill" + ); + expect(testElement.classList.remove).toHaveBeenCalledWith( + "com-bitwarden-browser-animated-fill" + ); + }); + + it("the element is a non-hidden number input", () => { + document.body.innerHTML = mockLoginForm + ''; + const testElement = document.querySelector( + 'input[type="number"]' + ) as FillableFormFieldElement; + jest.spyOn(testElement.classList, "add"); + jest.spyOn(testElement.classList, "remove"); + + insertAutofillContentService["triggerFillAnimationOnElement"](testElement); + jest.advanceTimersByTime(200); + + expect(testElement.classList.add).toHaveBeenCalledWith( + "com-bitwarden-browser-animated-fill" + ); + expect(testElement.classList.remove).toHaveBeenCalledWith( + "com-bitwarden-browser-animated-fill" + ); + }); + + it("the element is a non-hidden tel input", () => { + document.body.innerHTML = mockLoginForm + ''; + const testElement = document.querySelector('input[type="tel"]') as FillableFormFieldElement; + jest.spyOn(testElement.classList, "add"); + jest.spyOn(testElement.classList, "remove"); + + insertAutofillContentService["triggerFillAnimationOnElement"](testElement); + jest.advanceTimersByTime(200); + + expect(testElement.classList.add).toHaveBeenCalledWith( + "com-bitwarden-browser-animated-fill" + ); + expect(testElement.classList.remove).toHaveBeenCalledWith( + "com-bitwarden-browser-animated-fill" + ); + }); + + it("the element is a non-hidden url input", () => { + document.body.innerHTML = mockLoginForm + ''; + const testElement = document.querySelector('input[type="url"]') as FillableFormFieldElement; + jest.spyOn(testElement.classList, "add"); + jest.spyOn(testElement.classList, "remove"); + + insertAutofillContentService["triggerFillAnimationOnElement"](testElement); + jest.advanceTimersByTime(200); + + expect(testElement.classList.add).toHaveBeenCalledWith( + "com-bitwarden-browser-animated-fill" + ); + expect(testElement.classList.remove).toHaveBeenCalledWith( + "com-bitwarden-browser-animated-fill" + ); + }); + + it("the element is a non-hidden span", () => { + document.body.innerHTML = mockLoginForm + ''; + const testElement = document.querySelector("#input-tag") as FillableFormFieldElement; + jest.spyOn(testElement.classList, "add"); + jest.spyOn(testElement.classList, "remove"); + + insertAutofillContentService["triggerFillAnimationOnElement"](testElement); + jest.advanceTimersByTime(200); + + expect(testElement.classList.add).toHaveBeenCalledWith( + "com-bitwarden-browser-animated-fill" + ); + expect(testElement.classList.remove).toHaveBeenCalledWith( + "com-bitwarden-browser-animated-fill" + ); + }); + }); + }); + + describe("triggerClickOnElement", () => { + it("will trigger a click event on the passed element", () => { + const inputElement = document.querySelector('input[type="text"]') as HTMLElement; + jest.spyOn(inputElement, "click"); + + insertAutofillContentService["triggerClickOnElement"](inputElement); + + expect(inputElement.click).toHaveBeenCalled(); + }); + }); + + describe("triggerFocusOnElement", () => { + it("will trigger a focus event on the passed element and attempt to reset the value", () => { + const value = "test"; + const inputElement = document.querySelector('input[type="text"]') as HTMLInputElement; + inputElement.value = "test"; + jest.spyOn(inputElement, "focus"); + jest.spyOn(window, "String"); + + insertAutofillContentService["triggerFocusOnElement"](inputElement, true); + + expect(window.String).toHaveBeenCalledWith(value); + expect(inputElement.focus).toHaveBeenCalled(); + expect(inputElement.value).toEqual(value); + }); + + it("will not attempt to reset the value but will still focus the element", () => { + const value = "test"; + const inputElement = document.querySelector('input[type="text"]') as HTMLInputElement; + inputElement.value = "test"; + jest.spyOn(inputElement, "focus"); + jest.spyOn(window, "String"); + + insertAutofillContentService["triggerFocusOnElement"](inputElement, false); + + expect(window.String).not.toHaveBeenCalledWith(); + expect(inputElement.focus).toHaveBeenCalled(); + expect(inputElement.value).toEqual(value); + }); + }); + + describe("simulateUserMouseClickAndFocusEventInteractions", () => { + it("will trigger click and focus events on the passed element", () => { + const inputElement = document.querySelector('input[type="text"]') as HTMLInputElement; + jest.spyOn(insertAutofillContentService as any, "triggerClickOnElement"); + jest.spyOn(insertAutofillContentService as any, "triggerFocusOnElement"); + + insertAutofillContentService["simulateUserMouseClickAndFocusEventInteractions"](inputElement); + + expect(insertAutofillContentService["triggerClickOnElement"]).toHaveBeenCalledWith( + inputElement + ); + expect(insertAutofillContentService["triggerFocusOnElement"]).toHaveBeenCalledWith( + inputElement, + false + ); + }); + }); + + describe("simulateUserKeyboardEventInteractions", () => { + it("will trigger `keydown`, `keypress`, and `keyup` events on the passed element", () => { + const inputElement = document.querySelector('input[type="text"]') as HTMLInputElement; + jest.spyOn(inputElement, "dispatchEvent"); + + insertAutofillContentService["simulateUserKeyboardEventInteractions"](inputElement); + + [EVENTS.KEYDOWN, EVENTS.KEYPRESS, EVENTS.KEYUP].forEach((eventName) => { + expect(inputElement.dispatchEvent).toHaveBeenCalledWith( + new KeyboardEvent(eventName, { bubbles: true }) + ); + }); + }); + }); + + describe("simulateInputElementChangedEvent", () => { + it("will trigger `input` and `change` events on the passed element", () => { + const inputElement = document.querySelector('input[type="text"]') as HTMLInputElement; + jest.spyOn(inputElement, "dispatchEvent"); + + insertAutofillContentService["simulateInputElementChangedEvent"](inputElement); + + [EVENTS.INPUT, EVENTS.CHANGE].forEach((eventName) => { + expect(inputElement.dispatchEvent).toHaveBeenCalledWith( + new Event(eventName, { bubbles: true }) + ); + }); + }); + }); +}); diff --git a/apps/browser/src/autofill/services/insert-autofill-content.service.ts b/apps/browser/src/autofill/services/insert-autofill-content.service.ts new file mode 100644 index 0000000000..89f644ba6b --- /dev/null +++ b/apps/browser/src/autofill/services/insert-autofill-content.service.ts @@ -0,0 +1,349 @@ +import { EVENTS, TYPE_CHECK } from "../constants"; +import AutofillScript, { AutofillInsertActions, FillScript } from "../models/autofill-script"; +import { FormFieldElement } from "../types"; + +import { InsertAutofillContentService as InsertAutofillContentServiceInterface } from "./abstractions/insert-autofill-content.service"; +import CollectAutofillContentService from "./collect-autofill-content.service"; +import DomElementVisibilityService from "./dom-element-visibility.service"; + +class InsertAutofillContentService implements InsertAutofillContentServiceInterface { + private readonly domElementVisibilityService: DomElementVisibilityService; + private readonly collectAutofillContentService: CollectAutofillContentService; + private readonly autofillInsertActions: AutofillInsertActions = { + fill_by_opid: ({ opid, value }) => this.handleFillFieldByOpidAction(opid, value), + click_on_opid: ({ opid }) => this.handleClickOnFieldByOpidAction(opid), + focus_by_opid: ({ opid }) => this.handleFocusOnFieldByOpidAction(opid), + }; + + /** + * InsertAutofillContentService constructor. Instantiates the + * DomElementVisibilityService and CollectAutofillContentService classes. + */ + constructor( + domElementVisibilityService: DomElementVisibilityService, + collectAutofillContentService: CollectAutofillContentService + ) { + this.domElementVisibilityService = domElementVisibilityService; + this.collectAutofillContentService = collectAutofillContentService; + } + + /** + * Handles autofill of the forms on the current page based on the + * data within the passed fill script object. + * @param {AutofillScript} fillScript + * @public + */ + fillForm(fillScript: AutofillScript) { + if ( + !fillScript.script?.length || + this.fillingWithinSandboxedIframe() || + this.userCancelledInsecureUrlAutofill(fillScript.savedUrls) || + this.userCancelledUntrustedIframeAutofill(fillScript) + ) { + return; + } + + fillScript.script.forEach(this.runFillScriptAction); + } + + /** + * Identifies if the execution of this script is happening + * within a sandboxed iframe. + * @returns {boolean} + * @private + */ + private fillingWithinSandboxedIframe() { + return ( + String(self.origin).toLowerCase() === "null" || + window.frameElement?.hasAttribute("sandbox") || + window.location.hostname === "" + ); + } + + /** + * Checks if the autofill is occurring on a page that can be considered secure. If the page is not secure, + * the user is prompted to confirm that they want to autofill on the page. + * @param {string[] | null} savedUrls + * @returns {boolean} + * @private + */ + private userCancelledInsecureUrlAutofill(savedUrls?: string[] | null): boolean { + if ( + !savedUrls?.some((url) => url.startsWith(`https://${window.location.hostname}`)) || + window.location.protocol !== "http:" || + !document.querySelectorAll("input[type=password]")?.length + ) { + return false; + } + + const confirmationWarning = [ + chrome.i18n.getMessage("insecurePageWarning"), + chrome.i18n.getMessage("insecurePageWarningFillPrompt", [window.location.hostname]), + ].join("\n\n"); + + return !confirm(confirmationWarning); + } + + /** + * Checking if the autofill is occurring within an untrusted iframe. If the page is within an untrusted iframe, + * the user is prompted to confirm that they want to autofill on the page. If the user cancels the autofill, + * the script will not continue. + * + * Note: confirm() is blocked by sandboxed iframes, but we don't want to fill sandboxed iframes anyway. + * If this occurs, confirm() returns false without displaying the dialog box, and autofill will be aborted. + * The browser may print a message to the console, but this is not a standard error that we can handle. + * @param {AutofillScript} fillScript + * @returns {boolean} + * @private + */ + private userCancelledUntrustedIframeAutofill(fillScript: AutofillScript): boolean { + if (!fillScript.untrustedIframe) { + return false; + } + + const confirmationWarning = [ + chrome.i18n.getMessage("autofillIframeWarning"), + chrome.i18n.getMessage("autofillIframeWarningTip", [window.location.hostname]), + ].join("\n\n"); + + return !confirm(confirmationWarning); + } + + /** + * Runs the autofill action based on the action type and the opid. + * Each action is subsequently delayed by 20 milliseconds. + * @param {FillScriptActions} action + * @param {string} opid + * @param {string} value + * @param {number} actionIndex + */ + private runFillScriptAction = ([action, opid, value]: FillScript, actionIndex: number): void => { + if (!opid || !this.autofillInsertActions[action]) { + return; + } + + const delayActionsInMilliseconds = 20; + setTimeout( + () => this.autofillInsertActions[action]({ opid, value }), + delayActionsInMilliseconds * actionIndex + ); + }; + + /** + * Queries the DOM for an element by opid and inserts the passed value into the element. + * @param {string} opid + * @param {string} value + * @private + */ + private handleFillFieldByOpidAction(opid: string, value: string) { + const element = this.collectAutofillContentService.getAutofillFieldElementByOpid(opid); + this.insertValueIntoField(element, value); + } + + /** + * Handles finding an element by opid and triggering a click event on the element. + * @param {string} opid + * @private + */ + private handleClickOnFieldByOpidAction(opid: string) { + const element = this.collectAutofillContentService.getAutofillFieldElementByOpid(opid); + this.triggerClickOnElement(element); + } + + /** + * Handles finding an element by opid and triggering click and focus events on the element. + * @param {string} opid + * @private + */ + private handleFocusOnFieldByOpidAction(opid: string) { + const element = this.collectAutofillContentService.getAutofillFieldElementByOpid(opid); + this.simulateUserMouseClickAndFocusEventInteractions(element, true); + } + + /** + * Identifies the type of element passed and inserts the value into the element. + * Will trigger simulated events on the element to ensure that the element is + * properly updated. + * @param {FormFieldElement | null} element + * @param {string} value + * @private + */ + private insertValueIntoField(element: FormFieldElement | null, value: string) { + const elementCanBeReadonly = + element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement; + const elementCanBeFilled = elementCanBeReadonly || element instanceof HTMLSelectElement; + + if ( + !element || + !value || + (elementCanBeReadonly && element.readOnly) || + (elementCanBeFilled && element.disabled) + ) { + return; + } + + if (element instanceof HTMLSpanElement) { + this.handleInsertValueAndTriggerSimulatedEvents(element, () => (element.innerText = value)); + return; + } + + const isFillableCheckboxOrRadioElement = + element instanceof HTMLInputElement && + new Set(["checkbox", "radio"]).has(element.type) && + new Set(["true", "y", "1", "yes", "✓"]).has(String(value).toLowerCase()); + if (isFillableCheckboxOrRadioElement) { + this.handleInsertValueAndTriggerSimulatedEvents(element, () => (element.checked = true)); + return; + } + + this.handleInsertValueAndTriggerSimulatedEvents(element, () => (element.value = value)); + } + + /** + * Simulates pre- and post-insert events on the element meant to mimic user interactions + * while inserting the autofill value into the element. + * @param {FormFieldElement} element + * @param {Function} valueChangeCallback + * @private + */ + private handleInsertValueAndTriggerSimulatedEvents( + element: FormFieldElement, + valueChangeCallback: CallableFunction + ): void { + this.triggerPreInsertEventsOnElement(element); + valueChangeCallback(); + this.triggerPostInsertEventsOnElement(element); + this.triggerFillAnimationOnElement(element); + } + + /** + * Simulates a mouse click event on the element, including focusing the event, and + * the triggers a simulated keyboard event on the element. Will attempt to ensure + * that the initial element value is not arbitrarily changed by the simulated events. + * @param {FormFieldElement} element + * @private + */ + private triggerPreInsertEventsOnElement(element: FormFieldElement): void { + const initialElementValue = "value" in element ? element.value : ""; + + this.simulateUserMouseClickAndFocusEventInteractions(element); + this.simulateUserKeyboardEventInteractions(element); + + if ("value" in element && initialElementValue !== element.value) { + element.value = initialElementValue; + } + } + + /** + * Simulates a keyboard event on the element before assigning the autofilled value to the element, and then + * simulates an input change event on the element to trigger expected events after autofill occurs. + * @param {FormFieldElement} element + * @private + */ + private triggerPostInsertEventsOnElement(element: FormFieldElement): void { + const autofilledValue = "value" in element ? element.value : ""; + this.simulateUserKeyboardEventInteractions(element); + + if ("value" in element && autofilledValue !== element.value) { + element.value = autofilledValue; + } + + this.simulateInputElementChangedEvent(element); + element.blur(); + } + + /** + * Identifies if a passed element can be animated and sets a class on the element + * to trigger a CSS animation. The animation is removed after a short delay. + * @param {FormFieldElement} element + * @private + */ + private triggerFillAnimationOnElement(element: FormFieldElement): void { + const skipAnimatingElement = + !(element instanceof HTMLSpanElement) && + !new Set(["email", "text", "password", "number", "tel", "url"]).has(element?.type); + + if (this.domElementVisibilityService.isElementHiddenByCss(element) || skipAnimatingElement) { + return; + } + + element.classList.add("com-bitwarden-browser-animated-fill"); + setTimeout(() => element.classList.remove("com-bitwarden-browser-animated-fill"), 200); + } + + /** + * Simulates a click event on the element. + * @param {HTMLElement} element + * @private + */ + private triggerClickOnElement(element?: HTMLElement): void { + if (typeof element?.click !== TYPE_CHECK.FUNCTION) { + return; + } + + element.click(); + } + + /** + * Simulates a focus event on the element. Will optionally reset the value of the element + * if the element has a value property. + * @param {HTMLElement | undefined} element + * @param {boolean} shouldResetValue + * @private + */ + private triggerFocusOnElement(element: HTMLElement | undefined, shouldResetValue = false): void { + if (typeof element?.focus !== TYPE_CHECK.FUNCTION) { + return; + } + + let initialValue = ""; + if (shouldResetValue && "value" in element) { + initialValue = String(element.value); + } + + element.focus(); + + if (initialValue && "value" in element) { + element.value = initialValue; + } + } + + /** + * Simulates a mouse click and focus event on the element. + * @param {FormFieldElement} element + * @param {boolean} shouldResetValue + * @private + */ + private simulateUserMouseClickAndFocusEventInteractions( + element: FormFieldElement, + shouldResetValue = false + ): void { + this.triggerClickOnElement(element); + this.triggerFocusOnElement(element, shouldResetValue); + } + + /** + * Simulates several keyboard events on the element, mocking a user interaction with the element. + * @param {FormFieldElement} element + * @private + */ + private simulateUserKeyboardEventInteractions(element: FormFieldElement): void { + [EVENTS.KEYDOWN, EVENTS.KEYPRESS, EVENTS.KEYUP].forEach((eventType) => + element.dispatchEvent(new KeyboardEvent(eventType, { bubbles: true })) + ); + } + + /** + * Simulates an input change event on the element, mocking behavior that would occur if a user + * manually changed a value for the element. + * @param {FormFieldElement} element + * @private + */ + private simulateInputElementChangedEvent(element: FormFieldElement): void { + [EVENTS.INPUT, EVENTS.CHANGE].forEach((eventType) => + element.dispatchEvent(new Event(eventType, { bubbles: true })) + ); + } +} + +export default InsertAutofillContentService; diff --git a/apps/browser/src/autofill/types/index.ts b/apps/browser/src/autofill/types/index.ts index d689132535..8bab87709d 100644 --- a/apps/browser/src/autofill/types/index.ts +++ b/apps/browser/src/autofill/types/index.ts @@ -39,3 +39,22 @@ export type UserSettings = { vaultTimeout: number; vaultTimeoutAction: VaultTimeoutAction; }; + +/** + * A HTMLElement (usually a form element) with additional custom properties added by this script + */ +export type ElementWithOpId = T & { + opid: string; +}; + +/** + * A Form Element that we can set a value on (fill) + */ +export type FillableFormFieldElement = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; + +/** + * The autofill script's definition of a Form Element (only a subset of HTML form elements) + */ +export type FormFieldElement = FillableFormFieldElement | HTMLSpanElement; + +export type FormElementWithAttribute = FormFieldElement & Record; diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 31e81c198f..f9963bcf7d 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -52,6 +52,7 @@ import { SystemService as SystemServiceAbstraction } from "@bitwarden/common/pla import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; import { AppIdService } from "@bitwarden/common/platform/services/app-id.service"; +import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service"; import { ConfigService } from "@bitwarden/common/platform/services/config/config.service"; import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service"; import { ContainerService } from "@bitwarden/common/platform/services/container.service"; @@ -532,6 +533,7 @@ export default class MainBackground { this.authService, this.messagingService ); + this.configApiService = new ConfigApiService(this.apiService, this.authService); this.configService = new ConfigService( this.stateService, this.configApiService, diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts index ee15c0a3b9..c1cfdf0420 100644 --- a/apps/browser/src/background/runtime.background.ts +++ b/apps/browser/src/background/runtime.background.ts @@ -1,4 +1,5 @@ import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -135,6 +136,12 @@ export default class RuntimeBackground { BrowserApi.closeBitwardenExtensionTab(); }, msg.delay ?? 0); break; + case "triggerAutofillScriptInjection": + await this.autofillService.injectAutofillScripts( + sender, + await this.configService.getFeatureFlagBool(FeatureFlag.AutofillV2) + ); + break; case "bgCollectPageDetails": await this.main.collectPageDetailsForContentScript(sender.tab, msg.sender, sender.frameId); break; diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index 1e7b3a3139..b6276b434e 100644 --- a/apps/browser/src/manifest.json +++ b/apps/browser/src/manifest.json @@ -17,12 +17,7 @@ "content_scripts": [ { "all_frames": true, - "js": [ - "content/autofill.js", - "content/autofiller.js", - "content/notificationBar.js", - "content/contextMenuHandler.js" - ], + "js": ["content/trigger-autofill-script-injection.js"], "matches": ["http://*/*", "https://*/*", "file:///*"], "run_at": "document_start" }, diff --git a/apps/browser/src/platform/browser/browser-api.spec.ts b/apps/browser/src/platform/browser/browser-api.spec.ts new file mode 100644 index 0000000000..af9e633a7f --- /dev/null +++ b/apps/browser/src/platform/browser/browser-api.spec.ts @@ -0,0 +1,56 @@ +import { mock } from "jest-mock-extended"; + +import { BrowserApi } from "./browser-api"; + +describe("BrowserApi", () => { + const executeScriptResult = ["value"]; + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("executeScriptInTab", () => { + it("calls to the extension api to execute a script within the give tabId", async () => { + const tabId = 1; + const injectDetails = mock(); + jest.spyOn(BrowserApi, "manifestVersion", "get").mockReturnValue(2); + (chrome.tabs.executeScript as jest.Mock).mockImplementation( + (tabId, injectDetails, callback) => callback(executeScriptResult) + ); + + const result = await BrowserApi.executeScriptInTab(tabId, injectDetails); + + expect(chrome.tabs.executeScript).toHaveBeenCalledWith( + tabId, + injectDetails, + expect.any(Function) + ); + expect(result).toEqual(executeScriptResult); + }); + + it("calls the manifest v3 scripting API if the extension manifest is for v3", async () => { + const tabId = 1; + const injectDetails = mock({ + file: "file.js", + allFrames: true, + runAt: "document_start", + frameId: null, + }); + jest.spyOn(BrowserApi, "manifestVersion", "get").mockReturnValue(3); + (chrome.scripting.executeScript as jest.Mock).mockResolvedValue(executeScriptResult); + + const result = await BrowserApi.executeScriptInTab(tabId, injectDetails); + + expect(chrome.scripting.executeScript).toHaveBeenCalledWith({ + target: { + tabId: tabId, + allFrames: injectDetails.allFrames, + frameIds: null, + }, + files: [injectDetails.file], + injectImmediately: true, + }); + expect(result).toEqual(executeScriptResult); + }); + }); +}); diff --git a/apps/browser/src/platform/browser/browser-api.ts b/apps/browser/src/platform/browser/browser-api.ts index 675fd0b119..5a5596a795 100644 --- a/apps/browser/src/platform/browser/browser-api.ts +++ b/apps/browser/src/platform/browser/browser-api.ts @@ -308,4 +308,31 @@ export class BrowserApi { } return win.opr?.sidebarAction || browser.sidebarAction; } + + /** + * Extension API helper method used to execute a script in a tab. + * @see https://developer.chrome.com/docs/extensions/reference/tabs/#method-executeScript + * @param {number} tabId + * @param {chrome.tabs.InjectDetails} details + * @returns {Promise} + */ + static executeScriptInTab(tabId: number, details: chrome.tabs.InjectDetails) { + if (BrowserApi.manifestVersion === 3) { + return chrome.scripting.executeScript({ + target: { + tabId: tabId, + allFrames: details.allFrames, + frameIds: details.frameId ? [details.frameId] : null, + }, + files: details.file ? [details.file] : null, + injectImmediately: details.runAt === "document_start", + }); + } + + return new Promise((resolve) => { + chrome.tabs.executeScript(tabId, details, (result) => { + resolve(result); + }); + }); + } } diff --git a/apps/browser/test.setup.ts b/apps/browser/test.setup.ts index f87fa9c2c1..6feb163e0a 100644 --- a/apps/browser/test.setup.ts +++ b/apps/browser/test.setup.ts @@ -30,9 +30,25 @@ const contextMenus = { removeAll: jest.fn(), }; +const i18n = { + getMessage: jest.fn(), +}; + +const tabs = { + executeScript: jest.fn(), + sendMessage: jest.fn(), +}; + +const scripting = { + executeScript: jest.fn(), +}; + // set chrome global.chrome = { + i18n, storage, runtime, contextMenus, + tabs, + scripting, } as any; diff --git a/apps/browser/tsconfig.spec.json b/apps/browser/tsconfig.spec.json index de184bd760..79b5f5bc4b 100644 --- a/apps/browser/tsconfig.spec.json +++ b/apps/browser/tsconfig.spec.json @@ -1,4 +1,7 @@ { "extends": "./tsconfig.json", - "files": ["./test.setup.ts"] + "files": ["./test.setup.ts"], + "compilerOptions": { + "esModuleInterop": true + } } diff --git a/apps/browser/webpack.config.js b/apps/browser/webpack.config.js index 231b9ab156..23fba305b6 100644 --- a/apps/browser/webpack.config.js +++ b/apps/browser/webpack.config.js @@ -14,10 +14,9 @@ if (process.env.NODE_ENV == null) { } const ENV = (process.env.ENV = process.env.NODE_ENV); const manifestVersion = process.env.MANIFEST_VERSION == 3 ? 3 : 2; -const autofillVersion = process.env.AUTOFILL_VERSION == 2 ? 2 : 1; console.log(`Building Manifest Version ${manifestVersion} app`); -console.log(`Using Autofill v${autofillVersion}`); + const envConfig = configurator.load(ENV); configurator.log(envConfig); @@ -153,6 +152,10 @@ const mainConfig = { entry: { "popup/polyfills": "./src/popup/polyfills.ts", "popup/main": "./src/popup/main.ts", + "content/trigger-autofill-script-injection": + "./src/autofill/content/trigger-autofill-script-injection.ts", + "content/autofill": "./src/autofill/content/autofill.js", + "content/autofill-init": "./src/autofill/content/autofill-init.ts", "content/autofiller": "./src/autofill/content/autofiller.ts", "content/notificationBar": "./src/autofill/content/notification-bar.ts", "content/contextMenuHandler": "./src/autofill/content/context-menu-handler.ts", @@ -312,12 +315,4 @@ if (manifestVersion == 2) { configs.push(backgroundConfig); } -if (autofillVersion == 2) { - // Typescript refactors (WIP) - mainConfig.entry["content/autofill"] = "./src/autofill/content/autofillv2.ts"; -} else { - // Javascript (used in production) - mainConfig.entry["content/autofill"] = "./src/autofill/content/autofill.js"; -} - module.exports = configs; diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index fb155f54e2..7016849b3b 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -2,5 +2,6 @@ export enum FeatureFlag { DisplayEuEnvironmentFlag = "display-eu-environment", DisplayLowKdfIterationWarningFlag = "display-kdf-iteration-warning", TrustedDeviceEncryption = "trusted-device-encryption", + AutofillV2 = "autofill-v2", SecretsManagerBilling = "sm-ga-billing", }