mirror of
https://github.com/bitwarden/browser.git
synced 2024-09-28 04:08:47 +02:00
[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 <mr-manuel@outlook.it> * 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 <mr-manuel@outlook.it> Co-authored-by: Cesar Gonzalez <cgonzalez@bitwarden.com> Co-authored-by: Cesar Gonzalez <cesar.a.gonzalezcs@gmail.com> * [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 <mr-manuel@outlook.it> * 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 <jprusik@classynemesis.com> Co-authored-by: Manuel <mr-manuel@outlook.it> Co-authored-by: Jonathan Prusik <jprusik@users.noreply.github.com> * [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 <jprusik@users.noreply.github.com> Co-authored-by: Manuel <mr-manuel@outlook.it> Co-authored-by: Jonathan Prusik <jprusik@classynemesis.com>
This commit is contained in:
parent
30e8a906ab
commit
8de65ea791
@ -6,7 +6,6 @@
|
|||||||
"build:mv3": "cross-env MANIFEST_VERSION=3 webpack",
|
"build:mv3": "cross-env MANIFEST_VERSION=3 webpack",
|
||||||
"build:watch": "webpack --watch",
|
"build:watch": "webpack --watch",
|
||||||
"build:watch:mv3": "cross-env MANIFEST_VERSION=3 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": "cross-env NODE_ENV=production webpack",
|
||||||
"build:prod:watch": "cross-env NODE_ENV=production webpack --watch",
|
"build:prod:watch": "cross-env NODE_ENV=production webpack --watch",
|
||||||
"dist": "npm run build:prod && gulp dist",
|
"dist": "npm run build:prod && gulp dist",
|
||||||
@ -19,6 +18,7 @@
|
|||||||
"dist:safari:masdev": "npm run build:prod && gulp dist:safari:masdev",
|
"dist:safari:masdev": "npm run build:prod && gulp dist:safari:masdev",
|
||||||
"dist:safari:dmg": "npm run build:prod && gulp dist:safari:dmg",
|
"dist:safari:dmg": "npm run build:prod && gulp dist:safari:dmg",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
|
"test:coverage": "jest --coverage --coverageDirectory=coverage",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:watch:all": "jest --watchAll"
|
"test:watch:all": "jest --watchAll"
|
||||||
}
|
}
|
||||||
|
13
apps/browser/src/autofill/constants.ts
Normal file
13
apps/browser/src/autofill/constants.ts
Normal file
@ -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;
|
@ -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 };
|
175
apps/browser/src/autofill/content/autofill-init.spec.ts
Normal file
175
apps/browser/src/autofill/content/autofill-init.spec.ts
Normal file
@ -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<chrome.tabs.Tab>(),
|
||||||
|
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<AutofillScript>();
|
||||||
|
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<chrome.tabs.Tab>(),
|
||||||
|
sender: "sender",
|
||||||
|
};
|
||||||
|
sender = mock<chrome.runtime.MessageSender>();
|
||||||
|
});
|
||||||
|
|
||||||
|
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<AutofillScript>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
130
apps/browser/src/autofill/content/autofill-init.ts
Normal file
130
apps/browser/src/autofill/content/autofill-init.ts
Normal file
@ -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<AutofillPageDetails | void> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
})();
|
@ -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 pageHref: string = null;
|
||||||
let filledThisHref = false;
|
let filledThisHref = false;
|
||||||
let delayFillTimeout: number;
|
let delayFillTimeout: number;
|
||||||
@ -49,4 +55,4 @@ document.addEventListener("DOMContentLoaded", (event) => {
|
|||||||
chrome.runtime.sendMessage(msg);
|
chrome.runtime.sendMessage(msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -27,67 +27,13 @@ interface HTMLElementWithFormOpId extends HTMLElement {
|
|||||||
* and async scripts to finish loading.
|
* and async scripts to finish loading.
|
||||||
* https://developer.mozilla.org/en-US/docs/Web/API/Window/DOMContentLoaded_event
|
* https://developer.mozilla.org/en-US/docs/Web/API/Window/DOMContentLoaded_event
|
||||||
*/
|
*/
|
||||||
document.addEventListener("DOMContentLoaded", async (event) => {
|
if (document.readyState === "loading") {
|
||||||
// These are preferences for whether to show the notification bar based on the user's settings
|
document.addEventListener("DOMContentLoaded", loadNotificationBar);
|
||||||
// and they are set in the Settings > Options page in the browser extension.
|
} else {
|
||||||
let disabledAddLoginNotification = false;
|
loadNotificationBar();
|
||||||
let disabledChangedPasswordNotification = false;
|
|
||||||
let showNotificationBar = true;
|
|
||||||
|
|
||||||
// Look up the active user id from storage
|
|
||||||
const activeUserIdKey = "activeUserId";
|
|
||||||
let activeUserId: string;
|
|
||||||
await chrome.storage.local.get(activeUserIdKey, (obj: any) => {
|
|
||||||
if (obj == null || obj[activeUserIdKey] == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
activeUserId = obj[activeUserIdKey];
|
|
||||||
});
|
|
||||||
|
|
||||||
// Look up the user's settings from storage
|
|
||||||
await chrome.storage.local.get(activeUserId, (obj: any) => {
|
|
||||||
if (obj?.[activeUserId] == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userSettings: UserSettings = obj[activeUserId].settings;
|
|
||||||
|
|
||||||
// Do not show the notification bar on the Bitwarden vault
|
|
||||||
// because they can add logins and change passwords there
|
|
||||||
if (window.location.origin === userSettings.serverConfig.environment.vault) {
|
|
||||||
showNotificationBar = false;
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// NeverDomains is a dictionary of domains that the user has chosen to never
|
|
||||||
// show the notification bar on (for login detail collection or password change).
|
|
||||||
// It is managed in the Settings > Excluded Domains page in the browser extension.
|
|
||||||
// Example: '{"bitwarden.com":null}'
|
|
||||||
const excludedDomainsDict = userSettings.neverDomains;
|
|
||||||
|
|
||||||
if (
|
|
||||||
excludedDomainsDict != null &&
|
|
||||||
// eslint-disable-next-line
|
|
||||||
excludedDomainsDict.hasOwnProperty(window.location.hostname)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set local disabled preferences
|
|
||||||
disabledAddLoginNotification = userSettings.disableAddLoginNotification;
|
|
||||||
disabledChangedPasswordNotification = userSettings.disableChangedPasswordNotification;
|
|
||||||
|
|
||||||
if (!disabledAddLoginNotification || !disabledChangedPasswordNotification) {
|
|
||||||
// If the user has not disabled both notifications, then handle the initial page change (null -> actual page)
|
|
||||||
handlePageChange();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!showNotificationBar) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadNotificationBar() {
|
||||||
// Initialize required variables and set default values
|
// Initialize required variables and set default values
|
||||||
const watchedForms: WatchedForm[] = [];
|
const watchedForms: WatchedForm[] = [];
|
||||||
let barType: string = null;
|
let barType: string = null;
|
||||||
@ -132,6 +78,53 @@ document.addEventListener("DOMContentLoaded", async (event) => {
|
|||||||
]);
|
]);
|
||||||
const changePasswordButtonContainsNames = new Set(["pass", "change", "contras", "senha"]);
|
const changePasswordButtonContainsNames = new Set(["pass", "change", "contras", "senha"]);
|
||||||
|
|
||||||
|
// These are preferences for whether to show the notification bar based on the user's settings
|
||||||
|
// and they are set in the Settings > Options page in the browser extension.
|
||||||
|
let disabledAddLoginNotification = false;
|
||||||
|
let disabledChangedPasswordNotification = false;
|
||||||
|
let showNotificationBar = true;
|
||||||
|
|
||||||
|
// Look up the active user id from storage
|
||||||
|
const activeUserIdKey = "activeUserId";
|
||||||
|
let activeUserId: string;
|
||||||
|
|
||||||
|
const activeUserStorageValue = await getFromLocalStorage(activeUserIdKey);
|
||||||
|
if (activeUserStorageValue[activeUserIdKey]) {
|
||||||
|
activeUserId = activeUserStorageValue[activeUserIdKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the user's settings from storage
|
||||||
|
const userSettingsStorageValue = await getFromLocalStorage(activeUserId);
|
||||||
|
if (userSettingsStorageValue[activeUserId]) {
|
||||||
|
const userSettings: UserSettings = userSettingsStorageValue[activeUserId].settings;
|
||||||
|
|
||||||
|
// Do not show the notification bar on the Bitwarden vault
|
||||||
|
// because they can add logins and change passwords there
|
||||||
|
if (window.location.origin === userSettings.serverConfig.environment.vault) {
|
||||||
|
showNotificationBar = false;
|
||||||
|
} else {
|
||||||
|
// NeverDomains is a dictionary of domains that the user has chosen to never
|
||||||
|
// show the notification bar on (for login detail collection or password change).
|
||||||
|
// It is managed in the Settings > Excluded Domains page in the browser extension.
|
||||||
|
// Example: '{"bitwarden.com":null}'
|
||||||
|
const excludedDomainsDict = userSettings.neverDomains;
|
||||||
|
if (!excludedDomainsDict || !(window.location.hostname in excludedDomainsDict)) {
|
||||||
|
// Set local disabled preferences
|
||||||
|
disabledAddLoginNotification = userSettings.disableAddLoginNotification;
|
||||||
|
disabledChangedPasswordNotification = userSettings.disableChangedPasswordNotification;
|
||||||
|
|
||||||
|
if (!disabledAddLoginNotification || !disabledChangedPasswordNotification) {
|
||||||
|
// If the user has not disabled both notifications, then handle the initial page change (null -> actual page)
|
||||||
|
handlePageChange();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!showNotificationBar) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Message Processing
|
// Message Processing
|
||||||
|
|
||||||
// Listen for messages from the background script
|
// Listen for messages from the background script
|
||||||
@ -1002,4 +995,10 @@ document.addEventListener("DOMContentLoaded", async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// End Helper Functions
|
// End Helper Functions
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getFromLocalStorage(keys: string | string[]): Promise<Record<string, any>> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
chrome.storage.local.get(keys, (storage: Record<string, any>) => resolve(storage));
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
@ -0,0 +1,16 @@
|
|||||||
|
describe("TriggerAutofillScriptInjection", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.resetModules();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("init", () => {
|
||||||
|
it("sends a message to the extension background", () => {
|
||||||
|
require("../content/trigger-autofill-script-injection");
|
||||||
|
|
||||||
|
expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({
|
||||||
|
command: "triggerAutofillScriptInjection",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,3 @@
|
|||||||
|
(function () {
|
||||||
|
chrome.runtime.sendMessage({ command: "triggerAutofillScriptInjection" });
|
||||||
|
})();
|
7
apps/browser/src/autofill/globals.d.ts
vendored
Normal file
7
apps/browser/src/autofill/globals.d.ts
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { AutofillInit } from "./content/abstractions/autofill-init";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
bitwardenAutofillInit?: AutofillInit;
|
||||||
|
}
|
||||||
|
}
|
131
apps/browser/src/autofill/jest/autofill-mocks.ts
Normal file
131
apps/browser/src/autofill/jest/autofill-mocks.ts
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import { mock } from "jest-mock-extended";
|
||||||
|
|
||||||
|
import { UriMatchType } from "@bitwarden/common/enums";
|
||||||
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
|
|
||||||
|
import AutofillField from "../models/autofill-field";
|
||||||
|
import AutofillPageDetails from "../models/autofill-page-details";
|
||||||
|
import AutofillScript, { FillScript } from "../models/autofill-script";
|
||||||
|
import { GenerateFillScriptOptions } from "../services/abstractions/autofill.service";
|
||||||
|
|
||||||
|
function createAutofillFieldMock(customFields = {}): AutofillField {
|
||||||
|
return {
|
||||||
|
opid: "default-input-field-opid",
|
||||||
|
elementNumber: 0,
|
||||||
|
viewable: true,
|
||||||
|
htmlID: "default-htmlID",
|
||||||
|
htmlName: "default-htmlName",
|
||||||
|
htmlClass: "default-htmlClass",
|
||||||
|
tabindex: "0",
|
||||||
|
title: "default-title",
|
||||||
|
"label-left": "default-label-left",
|
||||||
|
"label-right": "default-label-right",
|
||||||
|
"label-top": "default-label-top",
|
||||||
|
"label-tag": "default-label-tag",
|
||||||
|
"label-aria": "default-label-aria",
|
||||||
|
placeholder: "default-placeholder",
|
||||||
|
type: "text",
|
||||||
|
value: "default-value",
|
||||||
|
disabled: false,
|
||||||
|
readonly: false,
|
||||||
|
onePasswordFieldType: "",
|
||||||
|
form: "invalidFormId",
|
||||||
|
autoCompleteType: "off",
|
||||||
|
selectInfo: "",
|
||||||
|
maxLength: 0,
|
||||||
|
tagName: "input",
|
||||||
|
...customFields,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAutofillPageDetailsMock(customFields = {}): AutofillPageDetails {
|
||||||
|
return {
|
||||||
|
title: "title",
|
||||||
|
url: "url",
|
||||||
|
documentUrl: "documentUrl",
|
||||||
|
forms: {
|
||||||
|
validFormId: {
|
||||||
|
opid: "opid",
|
||||||
|
htmlName: "htmlName",
|
||||||
|
htmlID: "htmlID",
|
||||||
|
htmlAction: "htmlAction",
|
||||||
|
htmlMethod: "htmlMethod",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fields: [createAutofillFieldMock({ opid: "non-password-field" })],
|
||||||
|
collectedTimestamp: 0,
|
||||||
|
...customFields,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createChromeTabMock(customFields = {}): chrome.tabs.Tab {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
index: 1,
|
||||||
|
pinned: false,
|
||||||
|
highlighted: false,
|
||||||
|
windowId: 2,
|
||||||
|
active: true,
|
||||||
|
incognito: false,
|
||||||
|
selected: true,
|
||||||
|
discarded: false,
|
||||||
|
autoDiscardable: false,
|
||||||
|
groupId: 2,
|
||||||
|
url: "https://tacos.com",
|
||||||
|
...customFields,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createGenerateFillScriptOptionsMock(customFields = {}): GenerateFillScriptOptions {
|
||||||
|
return {
|
||||||
|
skipUsernameOnlyFill: false,
|
||||||
|
onlyEmptyFields: false,
|
||||||
|
onlyVisibleFields: false,
|
||||||
|
fillNewPassword: false,
|
||||||
|
allowTotpAutofill: false,
|
||||||
|
cipher: mock<CipherView>(),
|
||||||
|
tabUrl: "https://tacos.com",
|
||||||
|
defaultUriMatch: UriMatchType.Domain,
|
||||||
|
...customFields,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAutofillScriptMock(
|
||||||
|
customFields = {},
|
||||||
|
scriptTypes?: Record<string, string>
|
||||||
|
): AutofillScript {
|
||||||
|
let script: FillScript[] = [
|
||||||
|
["click_on_opid", "default-field"],
|
||||||
|
["focus_by_opid", "default-field"],
|
||||||
|
["fill_by_opid", "default-field", "default"],
|
||||||
|
];
|
||||||
|
if (scriptTypes) {
|
||||||
|
script = [];
|
||||||
|
for (const scriptType in scriptTypes) {
|
||||||
|
script.push(["click_on_opid", scriptType]);
|
||||||
|
script.push(["focus_by_opid", scriptType]);
|
||||||
|
script.push(["fill_by_opid", scriptType, scriptTypes[scriptType]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
autosubmit: null,
|
||||||
|
metadata: {},
|
||||||
|
properties: {
|
||||||
|
delay_between_operations: 20,
|
||||||
|
},
|
||||||
|
savedUrls: [],
|
||||||
|
script,
|
||||||
|
itemType: "",
|
||||||
|
untrustedIframe: false,
|
||||||
|
...customFields,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
createAutofillFieldMock,
|
||||||
|
createAutofillPageDetailsMock,
|
||||||
|
createChromeTabMock,
|
||||||
|
createGenerateFillScriptOptionsMock,
|
||||||
|
createAutofillScriptMock,
|
||||||
|
};
|
5
apps/browser/src/autofill/jest/testing-utils.ts
Normal file
5
apps/browser/src/autofill/jest/testing-utils.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
function triggerTestFailure() {
|
||||||
|
expect(true).toBe("Test has failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
export { triggerTestFailure };
|
@ -2,6 +2,7 @@
|
|||||||
* Represents a single field that is collected from the page source and is potentially autofilled.
|
* Represents a single field that is collected from the page source and is potentially autofilled.
|
||||||
*/
|
*/
|
||||||
export default class AutofillField {
|
export default class AutofillField {
|
||||||
|
[key: string]: any;
|
||||||
/**
|
/**
|
||||||
* The unique identifier assigned to this field during collection of the page details
|
* The unique identifier assigned to this field during collection of the page details
|
||||||
*/
|
*/
|
||||||
@ -11,10 +12,6 @@ export default class AutofillField {
|
|||||||
* Used to do perform proximal checks for username and password fields on the DOM.
|
* Used to do perform proximal checks for username and password fields on the DOM.
|
||||||
*/
|
*/
|
||||||
elementNumber: number;
|
elementNumber: number;
|
||||||
/**
|
|
||||||
* Designates whether the field is visible, based on the element's style
|
|
||||||
*/
|
|
||||||
visible: boolean;
|
|
||||||
/**
|
/**
|
||||||
* Designates whether the field is viewable on the current part of the DOM that the user can see
|
* Designates whether the field is viewable on the current part of the DOM that the user can see
|
||||||
*/
|
*/
|
||||||
@ -22,80 +19,91 @@ export default class AutofillField {
|
|||||||
/**
|
/**
|
||||||
* The HTML `id` attribute of the field
|
* The HTML `id` attribute of the field
|
||||||
*/
|
*/
|
||||||
htmlID: string;
|
htmlID: string | null;
|
||||||
/**
|
/**
|
||||||
* The HTML `name` attribute of the field
|
* The HTML `name` attribute of the field
|
||||||
*/
|
*/
|
||||||
htmlName: string;
|
htmlName: string | null;
|
||||||
/**
|
/**
|
||||||
* The HTML `class` attribute of the field
|
* The HTML `class` attribute of the field
|
||||||
*/
|
*/
|
||||||
htmlClass: string;
|
htmlClass: string | null;
|
||||||
/**
|
|
||||||
* The concatenated `innerText` or `textContent` of all the elements that are to the "left" of the field in the DOM
|
tabindex: string | null;
|
||||||
*/
|
|
||||||
"label-left": string;
|
title: string | null;
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
/**
|
|
||||||
* The HTML `placeholder` attribute for the field
|
|
||||||
*/
|
|
||||||
placeholder: string;
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
/**
|
|
||||||
* @deprecated
|
|
||||||
* The `onePasswordFieldType` from the `dataset` on the element.
|
|
||||||
* If empty it contains the HTML `type` attribute for the field.
|
|
||||||
*/
|
|
||||||
onePasswordFieldType: string;
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
/**
|
|
||||||
* For `<select>` elements, an array of the element's option `text` values
|
|
||||||
*/
|
|
||||||
selectInfo: any;
|
|
||||||
/**
|
|
||||||
* The `maxLength` attribute for the field
|
|
||||||
*/
|
|
||||||
maxLength: number;
|
|
||||||
/**
|
/**
|
||||||
* The `tagName` for the field
|
* The `tagName` for the field
|
||||||
*/
|
*/
|
||||||
tagName: string;
|
tagName?: string | null;
|
||||||
[key: string]: any;
|
/**
|
||||||
|
* 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 `<select>` elements, an array of the element's option `text` values
|
||||||
|
*/
|
||||||
|
selectInfo?: any;
|
||||||
|
/**
|
||||||
|
* The `maxLength` attribute for the field
|
||||||
|
*/
|
||||||
|
maxLength?: number | null;
|
||||||
|
|
||||||
|
rel?: string | null;
|
||||||
|
|
||||||
|
checked?: boolean;
|
||||||
}
|
}
|
||||||
|
@ -5,10 +5,6 @@ import AutofillForm from "./autofill-form";
|
|||||||
* The details of a page that have been collected and can be used for autofill
|
* The details of a page that have been collected and can be used for autofill
|
||||||
*/
|
*/
|
||||||
export default class AutofillPageDetails {
|
export default class AutofillPageDetails {
|
||||||
/**
|
|
||||||
* A unique identifier for the page
|
|
||||||
*/
|
|
||||||
documentUUID: string;
|
|
||||||
title: string;
|
title: string;
|
||||||
url: string;
|
url: string;
|
||||||
documentUrl: string;
|
documentUrl: string;
|
||||||
|
@ -1,29 +1,24 @@
|
|||||||
// String values affect code flow in autofill.ts and must not be changed
|
// String values affect code flow in autofill.ts and must not be changed
|
||||||
export type FillScriptOp = "click_on_opid" | "focus_by_opid" | "fill_by_opid" | "delay";
|
export type FillScriptActions = "click_on_opid" | "focus_by_opid" | "fill_by_opid";
|
||||||
|
|
||||||
export type FillScript = [op: FillScriptOp, opid: string, value?: string];
|
export type FillScript = [action: FillScriptActions, opid: string, value?: string];
|
||||||
|
|
||||||
export type AutofillScriptOptions = {
|
|
||||||
animate?: boolean;
|
|
||||||
markFilling?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AutofillScriptProperties = {
|
export type AutofillScriptProperties = {
|
||||||
delay_between_operations?: number;
|
delay_between_operations?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AutofillInsertActions = {
|
||||||
|
fill_by_opid: ({ opid, value }: { opid: string; value: string }) => void;
|
||||||
|
click_on_opid: ({ opid }: { opid: string }) => void;
|
||||||
|
focus_by_opid: ({ opid }: { opid: string }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
export default class AutofillScript {
|
export default class AutofillScript {
|
||||||
script: FillScript[] = [];
|
script: FillScript[] = [];
|
||||||
documentUUID = "";
|
|
||||||
properties: AutofillScriptProperties = {};
|
properties: AutofillScriptProperties = {};
|
||||||
options: AutofillScriptOptions = {};
|
|
||||||
metadata: any = {}; // Unused, not written or read
|
metadata: any = {}; // Unused, not written or read
|
||||||
autosubmit: any = null; // Appears to be unused, read but not written
|
autosubmit: any = null; // Appears to be unused, read but not written
|
||||||
savedUrls: string[];
|
savedUrls: string[];
|
||||||
untrustedIframe: boolean;
|
untrustedIframe: boolean;
|
||||||
itemType: string; // Appears to be unused, read but not written
|
itemType: string; // Appears to be unused, read but not written
|
||||||
|
|
||||||
constructor(documentUUID: string) {
|
|
||||||
this.documentUUID = documentUUID;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { UriMatchType } from "@bitwarden/common/enums";
|
||||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
|
|
||||||
import AutofillField from "../../models/autofill-field";
|
import AutofillField from "../../models/autofill-field";
|
||||||
@ -31,13 +32,28 @@ export interface FormData {
|
|||||||
passwords: AutofillField[];
|
passwords: AutofillField[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GenerateFillScriptOptions {
|
||||||
|
skipUsernameOnlyFill: boolean;
|
||||||
|
onlyEmptyFields: boolean;
|
||||||
|
onlyVisibleFields: boolean;
|
||||||
|
fillNewPassword: boolean;
|
||||||
|
allowTotpAutofill: boolean;
|
||||||
|
cipher: CipherView;
|
||||||
|
tabUrl: string;
|
||||||
|
defaultUriMatch: UriMatchType;
|
||||||
|
}
|
||||||
|
|
||||||
export abstract class AutofillService {
|
export abstract class AutofillService {
|
||||||
|
injectAutofillScripts: (
|
||||||
|
sender: chrome.runtime.MessageSender,
|
||||||
|
autofillV2?: boolean
|
||||||
|
) => Promise<void>;
|
||||||
getFormsWithPasswordFields: (pageDetails: AutofillPageDetails) => FormData[];
|
getFormsWithPasswordFields: (pageDetails: AutofillPageDetails) => FormData[];
|
||||||
doAutoFill: (options: AutoFillOptions) => Promise<string>;
|
doAutoFill: (options: AutoFillOptions) => Promise<string | null>;
|
||||||
doAutoFillOnTab: (
|
doAutoFillOnTab: (
|
||||||
pageDetails: PageDetail[],
|
pageDetails: PageDetail[],
|
||||||
tab: chrome.tabs.Tab,
|
tab: chrome.tabs.Tab,
|
||||||
fromCommand: boolean
|
fromCommand: boolean
|
||||||
) => Promise<string>;
|
) => Promise<string | null>;
|
||||||
doAutoFillActiveTab: (pageDetails: PageDetail[], fromCommand: boolean) => Promise<string>;
|
doAutoFillActiveTab: (pageDetails: PageDetail[], fromCommand: boolean) => Promise<string | null>;
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,8 @@
|
|||||||
|
import AutofillPageDetails from "../../models/autofill-page-details";
|
||||||
|
|
||||||
|
interface CollectAutofillContentService {
|
||||||
|
getPageDetails(): Promise<AutofillPageDetails>;
|
||||||
|
getAutofillFieldElementByOpid(opid: string): HTMLElement | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CollectAutofillContentService };
|
@ -0,0 +1,6 @@
|
|||||||
|
interface DomElementVisibilityService {
|
||||||
|
isFormFieldViewable: (element: HTMLElement) => Promise<boolean>;
|
||||||
|
isElementHiddenByCss: (element: HTMLElement) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DomElementVisibilityService };
|
@ -0,0 +1,7 @@
|
|||||||
|
import AutofillScript from "../../models/autofill-script";
|
||||||
|
|
||||||
|
interface InsertAutofillContentService {
|
||||||
|
fillForm(fillScript: AutofillScript): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { InsertAutofillContentService };
|
4226
apps/browser/src/autofill/services/autofill.service.spec.ts
Normal file
4226
apps/browser/src/autofill/services/autofill.service.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -21,6 +21,7 @@ import {
|
|||||||
AutofillService as AutofillServiceInterface,
|
AutofillService as AutofillServiceInterface,
|
||||||
PageDetail,
|
PageDetail,
|
||||||
FormData,
|
FormData,
|
||||||
|
GenerateFillScriptOptions,
|
||||||
} from "./abstractions/autofill.service";
|
} from "./abstractions/autofill.service";
|
||||||
import {
|
import {
|
||||||
AutoFillConstants,
|
AutoFillConstants,
|
||||||
@ -28,17 +29,6 @@ import {
|
|||||||
IdentityAutoFillConstants,
|
IdentityAutoFillConstants,
|
||||||
} from "./autofill-constants";
|
} from "./autofill-constants";
|
||||||
|
|
||||||
export interface GenerateFillScriptOptions {
|
|
||||||
skipUsernameOnlyFill: boolean;
|
|
||||||
onlyEmptyFields: boolean;
|
|
||||||
onlyVisibleFields: boolean;
|
|
||||||
fillNewPassword: boolean;
|
|
||||||
allowTotpAutofill: boolean;
|
|
||||||
cipher: CipherView;
|
|
||||||
tabUrl: string;
|
|
||||||
defaultUriMatch: UriMatchType;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class AutofillService implements AutofillServiceInterface {
|
export default class AutofillService implements AutofillServiceInterface {
|
||||||
constructor(
|
constructor(
|
||||||
private cipherService: CipherService,
|
private cipherService: CipherService,
|
||||||
@ -50,6 +40,40 @@ export default class AutofillService implements AutofillServiceInterface {
|
|||||||
private userVerificationService: UserVerificationService
|
private userVerificationService: UserVerificationService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injects the autofill scripts into the current tab and all frames
|
||||||
|
* found within the tab. Temporarily, will conditionally inject
|
||||||
|
* the refactor of the core autofill script if the feature flag
|
||||||
|
* is enabled.
|
||||||
|
* @param {chrome.runtime.MessageSender} sender
|
||||||
|
* @param {boolean} autofillV2
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async injectAutofillScripts(sender: chrome.runtime.MessageSender, autofillV2 = false) {
|
||||||
|
const mainAutofillScript = autofillV2 ? `autofill-init.js` : "autofill.js";
|
||||||
|
|
||||||
|
const injectedScripts = [
|
||||||
|
mainAutofillScript,
|
||||||
|
"autofiller.js",
|
||||||
|
"notificationBar.js",
|
||||||
|
"contextMenuHandler.js",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const injectedScript of injectedScripts) {
|
||||||
|
await BrowserApi.executeScriptInTab(sender.tab.id, {
|
||||||
|
file: `content/${injectedScript}`,
|
||||||
|
allFrames: true,
|
||||||
|
runAt: "document_start",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all forms with password fields and formats the data
|
||||||
|
* for both forms and password input elements.
|
||||||
|
* @param {AutofillPageDetails} pageDetails
|
||||||
|
* @returns {FormData[]}
|
||||||
|
*/
|
||||||
getFormsWithPasswordFields(pageDetails: AutofillPageDetails): FormData[] {
|
getFormsWithPasswordFields(pageDetails: AutofillPageDetails): FormData[] {
|
||||||
const formData: FormData[] = [];
|
const formData: FormData[] = [];
|
||||||
|
|
||||||
@ -114,11 +138,11 @@ export default class AutofillService implements AutofillServiceInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Autofills a given tab with a given login item
|
* Autofill a given tab with a given login item
|
||||||
* @param options Instructions about the autofill operation, including tab and login item
|
* @param {AutoFillOptions} options Instructions about the autofill operation, including tab and login item
|
||||||
* @returns The TOTP code of the successfully autofilled login, if any
|
* @returns {Promise<string | null>} The TOTP code of the successfully autofilled login, if any
|
||||||
*/
|
*/
|
||||||
async doAutoFill(options: AutoFillOptions): Promise<string> {
|
async doAutoFill(options: AutoFillOptions): Promise<string | null> {
|
||||||
const tab = options.tab;
|
const tab = options.tab;
|
||||||
if (!tab || !options.cipher || !options.pageDetails || !options.pageDetails.length) {
|
if (!tab || !options.cipher || !options.pageDetails || !options.pageDetails.length) {
|
||||||
throw new Error("Nothing to auto-fill.");
|
throw new Error("Nothing to auto-fill.");
|
||||||
@ -210,17 +234,17 @@ export default class AutofillService implements AutofillServiceInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Autofills the specified tab with the next login item from the cache
|
* Autofill the specified tab with the next login item from the cache
|
||||||
* @param pageDetails The data scraped from the page
|
* @param {PageDetail[]} pageDetails The data scraped from the page
|
||||||
* @param tab The tab to be autofilled
|
* @param {chrome.tabs.Tab} tab The tab to be autofilled
|
||||||
* @param fromCommand Whether the autofill is triggered by a keyboard shortcut (`true`) or autofill on page load (`false`)
|
* @param {boolean} fromCommand Whether the autofill is triggered by a keyboard shortcut (`true`) or autofill on page load (`false`)
|
||||||
* @returns The TOTP code of the successfully autofilled login, if any
|
* @returns {Promise<string | null>} The TOTP code of the successfully autofilled login, if any
|
||||||
*/
|
*/
|
||||||
async doAutoFillOnTab(
|
async doAutoFillOnTab(
|
||||||
pageDetails: PageDetail[],
|
pageDetails: PageDetail[],
|
||||||
tab: chrome.tabs.Tab,
|
tab: chrome.tabs.Tab,
|
||||||
fromCommand: boolean
|
fromCommand: boolean
|
||||||
): Promise<string> {
|
): Promise<string | null> {
|
||||||
let cipher: CipherView;
|
let cipher: CipherView;
|
||||||
if (fromCommand) {
|
if (fromCommand) {
|
||||||
cipher = await this.cipherService.getNextCipherForUrl(tab.url);
|
cipher = await this.cipherService.getNextCipherForUrl(tab.url);
|
||||||
@ -265,7 +289,7 @@ export default class AutofillService implements AutofillServiceInterface {
|
|||||||
allowTotpAutofill: fromCommand,
|
allowTotpAutofill: fromCommand,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update last used index as autofill has succeed
|
// Update last used index as autofill has succeeded
|
||||||
if (fromCommand) {
|
if (fromCommand) {
|
||||||
this.cipherService.updateLastUsedIndexForUrl(tab.url);
|
this.cipherService.updateLastUsedIndexForUrl(tab.url);
|
||||||
}
|
}
|
||||||
@ -274,26 +298,29 @@ export default class AutofillService implements AutofillServiceInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Autofills the active tab with the next login item from the cache
|
* Autofill the active tab with the next login item from the cache
|
||||||
* @param pageDetails The data scraped from the page
|
* @param {PageDetail[]} pageDetails The data scraped from the page
|
||||||
* @param fromCommand Whether the autofill is triggered by a keyboard shortcut (`true`) or autofill on page load (`false`)
|
* @param {boolean} fromCommand Whether the autofill is triggered by a keyboard shortcut (`true`) or autofill on page load (`false`)
|
||||||
* @returns The TOTP code of the successfully autofilled login, if any
|
* @returns {Promise<string | null>} The TOTP code of the successfully autofilled login, if any
|
||||||
*/
|
*/
|
||||||
async doAutoFillActiveTab(pageDetails: PageDetail[], fromCommand: boolean): Promise<string> {
|
async doAutoFillActiveTab(
|
||||||
if (!pageDetails[0]?.details?.fields?.length) {
|
pageDetails: PageDetail[],
|
||||||
return;
|
fromCommand: boolean
|
||||||
}
|
): Promise<string | null> {
|
||||||
|
|
||||||
const tab = await this.getActiveTab();
|
const tab = await this.getActiveTab();
|
||||||
if (!tab || !tab.url) {
|
if (!tab || !tab.url) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.doAutoFillOnTab(pageDetails, tab, fromCommand);
|
return await this.doAutoFillOnTab(pageDetails, tab, fromCommand);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helpers
|
/**
|
||||||
|
* Gets the active tab from the current window.
|
||||||
|
* Throws an error if no tab is found.
|
||||||
|
* @returns {Promise<chrome.tabs.Tab>}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
private async getActiveTab(): Promise<chrome.tabs.Tab> {
|
private async getActiveTab(): Promise<chrome.tabs.Tab> {
|
||||||
const tab = await BrowserApi.getTabFromCurrentWindow();
|
const tab = await BrowserApi.getTabFromCurrentWindow();
|
||||||
if (!tab) {
|
if (!tab) {
|
||||||
@ -303,15 +330,22 @@ export default class AutofillService implements AutofillServiceInterface {
|
|||||||
return tab;
|
return tab;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the autofill script for the specified page details and cipher.
|
||||||
|
* @param {AutofillPageDetails} pageDetails
|
||||||
|
* @param {GenerateFillScriptOptions} options
|
||||||
|
* @returns {Promise<AutofillScript | null>}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
private async generateFillScript(
|
private async generateFillScript(
|
||||||
pageDetails: AutofillPageDetails,
|
pageDetails: AutofillPageDetails,
|
||||||
options: GenerateFillScriptOptions
|
options: GenerateFillScriptOptions
|
||||||
): Promise<AutofillScript> {
|
): Promise<AutofillScript | null> {
|
||||||
if (!pageDetails || !options.cipher) {
|
if (!pageDetails || !options.cipher) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let fillScript = new AutofillScript(pageDetails.documentUUID);
|
let fillScript = new AutofillScript();
|
||||||
const filledFields: { [id: string]: AutofillField } = {};
|
const filledFields: { [id: string]: AutofillField } = {};
|
||||||
const fields = options.cipher.fields;
|
const fields = options.cipher.fields;
|
||||||
|
|
||||||
@ -381,12 +415,21 @@ export default class AutofillService implements AutofillServiceInterface {
|
|||||||
return fillScript;
|
return fillScript;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the autofill script for the specified page details and login cipher item.
|
||||||
|
* @param {AutofillScript} fillScript
|
||||||
|
* @param {AutofillPageDetails} pageDetails
|
||||||
|
* @param {{[p: string]: AutofillField}} filledFields
|
||||||
|
* @param {GenerateFillScriptOptions} options
|
||||||
|
* @returns {Promise<AutofillScript | null>}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
private async generateLoginFillScript(
|
private async generateLoginFillScript(
|
||||||
fillScript: AutofillScript,
|
fillScript: AutofillScript,
|
||||||
pageDetails: AutofillPageDetails,
|
pageDetails: AutofillPageDetails,
|
||||||
filledFields: { [id: string]: AutofillField },
|
filledFields: { [id: string]: AutofillField },
|
||||||
options: GenerateFillScriptOptions
|
options: GenerateFillScriptOptions
|
||||||
): Promise<AutofillScript> {
|
): Promise<AutofillScript | null> {
|
||||||
if (!options.cipher.login) {
|
if (!options.cipher.login) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -555,12 +598,21 @@ export default class AutofillService implements AutofillServiceInterface {
|
|||||||
return fillScript;
|
return fillScript;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the autofill script for the specified page details and credit card cipher item.
|
||||||
|
* @param {AutofillScript} fillScript
|
||||||
|
* @param {AutofillPageDetails} pageDetails
|
||||||
|
* @param {{[p: string]: AutofillField}} filledFields
|
||||||
|
* @param {GenerateFillScriptOptions} options
|
||||||
|
* @returns {AutofillScript|null}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
private generateCardFillScript(
|
private generateCardFillScript(
|
||||||
fillScript: AutofillScript,
|
fillScript: AutofillScript,
|
||||||
pageDetails: AutofillPageDetails,
|
pageDetails: AutofillPageDetails,
|
||||||
filledFields: { [id: string]: AutofillField },
|
filledFields: { [id: string]: AutofillField },
|
||||||
options: GenerateFillScriptOptions
|
options: GenerateFillScriptOptions
|
||||||
): AutofillScript {
|
): AutofillScript | null {
|
||||||
if (!options.cipher.card) {
|
if (!options.cipher.card) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -876,9 +928,10 @@ export default class AutofillService implements AutofillServiceInterface {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines whether an iframe is potentially dangerous ("untrusted") to autofill
|
* Determines whether an iframe is potentially dangerous ("untrusted") to autofill
|
||||||
* @param pageUrl The url of the page/iframe, usually from AutofillPageDetails
|
* @param {string} pageUrl The url of the page/iframe, usually from AutofillPageDetails
|
||||||
* @param options The GenerateFillScript options
|
* @param {GenerateFillScriptOptions} options The GenerateFillScript options
|
||||||
* @returns `true` if the iframe is untrusted and a warning should be shown, `false` otherwise
|
* @returns {boolean} `true` if the iframe is untrusted and a warning should be shown, `false` otherwise
|
||||||
|
* @private
|
||||||
*/
|
*/
|
||||||
private inUntrustedIframe(pageUrl: string, options: GenerateFillScriptOptions): boolean {
|
private inUntrustedIframe(pageUrl: string, options: GenerateFillScriptOptions): boolean {
|
||||||
// If the pageUrl (from the content script) matches the tabUrl (from the sender tab), we are not in an iframe
|
// If the pageUrl (from the content script) matches the tabUrl (from the sender tab), we are not in an iframe
|
||||||
@ -899,7 +952,15 @@ export default class AutofillService implements AutofillServiceInterface {
|
|||||||
return !matchesUri;
|
return !matchesUri;
|
||||||
}
|
}
|
||||||
|
|
||||||
private fieldAttrsContain(field: AutofillField, containsVal: string) {
|
/**
|
||||||
|
* Used when handling autofill on credit card fields. Determines whether
|
||||||
|
* the field has an attribute that matches the given value.
|
||||||
|
* @param {AutofillField} field
|
||||||
|
* @param {string} containsVal
|
||||||
|
* @returns {boolean}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private fieldAttrsContain(field: AutofillField, containsVal: string): boolean {
|
||||||
if (!field) {
|
if (!field) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -919,6 +980,15 @@ export default class AutofillService implements AutofillServiceInterface {
|
|||||||
return doesContain;
|
return doesContain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the autofill script for the specified page details and identify cipher item.
|
||||||
|
* @param {AutofillScript} fillScript
|
||||||
|
* @param {AutofillPageDetails} pageDetails
|
||||||
|
* @param {{[p: string]: AutofillField}} filledFields
|
||||||
|
* @param {GenerateFillScriptOptions} options
|
||||||
|
* @returns {AutofillScript}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
private generateIdentityFillScript(
|
private generateIdentityFillScript(
|
||||||
fillScript: AutofillScript,
|
fillScript: AutofillScript,
|
||||||
pageDetails: AutofillPageDetails,
|
pageDetails: AutofillPageDetails,
|
||||||
@ -1153,10 +1223,29 @@ export default class AutofillService implements AutofillServiceInterface {
|
|||||||
return fillScript;
|
return fillScript;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accepts an HTMLInputElement type value and a list of
|
||||||
|
* excluded types and returns true if the type is excluded.
|
||||||
|
* @param {string} type
|
||||||
|
* @param {string[]} excludedTypes
|
||||||
|
* @returns {boolean}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
private isExcludedType(type: string, excludedTypes: string[]) {
|
private isExcludedType(type: string, excludedTypes: string[]) {
|
||||||
return excludedTypes.indexOf(type) > -1;
|
return excludedTypes.indexOf(type) > -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accepts the value of a field, a list of possible options that define if
|
||||||
|
* a field can be matched to a vault cipher, and a secondary optional list
|
||||||
|
* of options that define if a field can be matched to a vault cipher. Returns
|
||||||
|
* true if the field value matches one of the options.
|
||||||
|
* @param {string} value
|
||||||
|
* @param {string[]} options
|
||||||
|
* @param {string[]} containsOptions
|
||||||
|
* @returns {boolean}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
private static isFieldMatch(
|
private static isFieldMatch(
|
||||||
value: string,
|
value: string,
|
||||||
options: string[],
|
options: string[],
|
||||||
@ -1178,6 +1267,17 @@ export default class AutofillService implements AutofillServiceInterface {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper method used to create a script action for a field. Conditionally
|
||||||
|
* accepts a fieldProp value that will be used in place of the dataProp value.
|
||||||
|
* @param {AutofillScript} fillScript
|
||||||
|
* @param cipherData
|
||||||
|
* @param {{[p: string]: AutofillField}} fillFields
|
||||||
|
* @param {{[p: string]: AutofillField}} filledFields
|
||||||
|
* @param {string} dataProp
|
||||||
|
* @param {string} fieldProp
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
private makeScriptAction(
|
private makeScriptAction(
|
||||||
fillScript: AutofillScript,
|
fillScript: AutofillScript,
|
||||||
cipherData: any,
|
cipherData: any,
|
||||||
@ -1195,6 +1295,17 @@ export default class AutofillService implements AutofillServiceInterface {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles updating the list of filled fields and adding a script action
|
||||||
|
* to the fill script. If a select field is passed as part of the fill options,
|
||||||
|
* we iterate over the options to check if the passed value matches one of the
|
||||||
|
* options. If it does, we add a script action to select the option.
|
||||||
|
* @param {AutofillScript} fillScript
|
||||||
|
* @param dataValue
|
||||||
|
* @param {AutofillField} field
|
||||||
|
* @param {{[p: string]: AutofillField}} filledFields
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
private makeScriptActionWithValue(
|
private makeScriptActionWithValue(
|
||||||
fillScript: AutofillScript,
|
fillScript: AutofillScript,
|
||||||
dataValue: any,
|
dataValue: any,
|
||||||
@ -1234,6 +1345,16 @@ export default class AutofillService implements AutofillServiceInterface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accepts a pageDetails object with a list of fields and returns a list of
|
||||||
|
* fields that are likely to be password fields.
|
||||||
|
* @param {AutofillPageDetails} pageDetails
|
||||||
|
* @param {boolean} canBeHidden
|
||||||
|
* @param {boolean} canBeReadOnly
|
||||||
|
* @param {boolean} mustBeEmpty
|
||||||
|
* @param {boolean} fillNewPassword
|
||||||
|
* @returns {AutofillField[]}
|
||||||
|
*/
|
||||||
static loadPasswordFields(
|
static loadPasswordFields(
|
||||||
pageDetails: AutofillPageDetails,
|
pageDetails: AutofillPageDetails,
|
||||||
canBeHidden: boolean,
|
canBeHidden: boolean,
|
||||||
@ -1295,13 +1416,24 @@ export default class AutofillService implements AutofillServiceInterface {
|
|||||||
return arr;
|
return arr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accepts a pageDetails object with a list of fields and returns a list of
|
||||||
|
* fields that are likely to be username fields.
|
||||||
|
* @param {AutofillPageDetails} pageDetails
|
||||||
|
* @param {AutofillField} passwordField
|
||||||
|
* @param {boolean} canBeHidden
|
||||||
|
* @param {boolean} canBeReadOnly
|
||||||
|
* @param {boolean} withoutForm
|
||||||
|
* @returns {AutofillField}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
private findUsernameField(
|
private findUsernameField(
|
||||||
pageDetails: AutofillPageDetails,
|
pageDetails: AutofillPageDetails,
|
||||||
passwordField: AutofillField,
|
passwordField: AutofillField,
|
||||||
canBeHidden: boolean,
|
canBeHidden: boolean,
|
||||||
canBeReadOnly: boolean,
|
canBeReadOnly: boolean,
|
||||||
withoutForm: boolean
|
withoutForm: boolean
|
||||||
) {
|
): AutofillField | null {
|
||||||
let usernameField: AutofillField = null;
|
let usernameField: AutofillField = null;
|
||||||
for (let i = 0; i < pageDetails.fields.length; i++) {
|
for (let i = 0; i < pageDetails.fields.length; i++) {
|
||||||
const f = pageDetails.fields[i];
|
const f = pageDetails.fields[i];
|
||||||
@ -1332,13 +1464,24 @@ export default class AutofillService implements AutofillServiceInterface {
|
|||||||
return usernameField;
|
return usernameField;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accepts a pageDetails object with a list of fields and returns a list of
|
||||||
|
* fields that are likely to be TOTP fields.
|
||||||
|
* @param {AutofillPageDetails} pageDetails
|
||||||
|
* @param {AutofillField} passwordField
|
||||||
|
* @param {boolean} canBeHidden
|
||||||
|
* @param {boolean} canBeReadOnly
|
||||||
|
* @param {boolean} withoutForm
|
||||||
|
* @returns {AutofillField}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
private findTotpField(
|
private findTotpField(
|
||||||
pageDetails: AutofillPageDetails,
|
pageDetails: AutofillPageDetails,
|
||||||
passwordField: AutofillField,
|
passwordField: AutofillField,
|
||||||
canBeHidden: boolean,
|
canBeHidden: boolean,
|
||||||
canBeReadOnly: boolean,
|
canBeReadOnly: boolean,
|
||||||
withoutForm: boolean
|
withoutForm: boolean
|
||||||
) {
|
): AutofillField | null {
|
||||||
let totpField: AutofillField = null;
|
let totpField: AutofillField = null;
|
||||||
for (let i = 0; i < pageDetails.fields.length; i++) {
|
for (let i = 0; i < pageDetails.fields.length; i++) {
|
||||||
const f = pageDetails.fields[i];
|
const f = pageDetails.fields[i];
|
||||||
@ -1369,6 +1512,14 @@ export default class AutofillService implements AutofillServiceInterface {
|
|||||||
return totpField;
|
return totpField;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accepts a field and returns the index of the first matching property
|
||||||
|
* present in a list of attribute names.
|
||||||
|
* @param {AutofillField} field
|
||||||
|
* @param {string[]} names
|
||||||
|
* @returns {number}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
private findMatchingFieldIndex(field: AutofillField, names: string[]): number {
|
private findMatchingFieldIndex(field: AutofillField, names: string[]): number {
|
||||||
for (let i = 0; i < names.length; i++) {
|
for (let i = 0; i < names.length; i++) {
|
||||||
if (names[i].indexOf("=") > -1) {
|
if (names[i].indexOf("=") > -1) {
|
||||||
@ -1421,6 +1572,17 @@ export default class AutofillService implements AutofillServiceInterface {
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accepts a field, property, name, and prefix and returns true if the field
|
||||||
|
* contains a value that matches the given prefixed property.
|
||||||
|
* @param field
|
||||||
|
* @param {string} property
|
||||||
|
* @param {string} name
|
||||||
|
* @param {string} prefix
|
||||||
|
* @param {string} separator
|
||||||
|
* @returns {boolean}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
private fieldPropertyIsPrefixMatch(
|
private fieldPropertyIsPrefixMatch(
|
||||||
field: any,
|
field: any,
|
||||||
property: string,
|
property: string,
|
||||||
@ -1436,6 +1598,18 @@ export default class AutofillService implements AutofillServiceInterface {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identifies if a given property within a field matches the value
|
||||||
|
* of the passed "name" parameter. If the name starts with "regex=",
|
||||||
|
* the value is tested against a case-insensitive regular expression.
|
||||||
|
* If the name starts with "csv=", the value is treated as a
|
||||||
|
* comma-separated list of values to match.
|
||||||
|
* @param field
|
||||||
|
* @param {string} property
|
||||||
|
* @param {string} name
|
||||||
|
* @returns {boolean}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
private fieldPropertyIsMatch(field: any, property: string, name: string): boolean {
|
private fieldPropertyIsMatch(field: any, property: string, name: string): boolean {
|
||||||
let fieldVal = field[property] as string;
|
let fieldVal = field[property] as string;
|
||||||
if (!AutofillService.hasValue(fieldVal)) {
|
if (!AutofillService.hasValue(fieldVal)) {
|
||||||
@ -1470,6 +1644,13 @@ export default class AutofillService implements AutofillServiceInterface {
|
|||||||
return fieldVal.toLowerCase() === name;
|
return fieldVal.toLowerCase() === name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accepts a field and returns true if the field contains a
|
||||||
|
* value that matches any of the names in the provided list.
|
||||||
|
* @param {AutofillField} field
|
||||||
|
* @param {string[]} names
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
static fieldIsFuzzyMatch(field: AutofillField, names: string[]): boolean {
|
static fieldIsFuzzyMatch(field: AutofillField, names: string[]): boolean {
|
||||||
if (AutofillService.hasValue(field.htmlID) && this.fuzzyMatch(names, field.htmlID)) {
|
if (AutofillService.hasValue(field.htmlID) && this.fuzzyMatch(names, field.htmlID)) {
|
||||||
return true;
|
return true;
|
||||||
@ -1508,6 +1689,14 @@ export default class AutofillService implements AutofillServiceInterface {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accepts a list of options and a value and returns
|
||||||
|
* true if the value matches any of the options.
|
||||||
|
* @param {string[]} options
|
||||||
|
* @param {string} value
|
||||||
|
* @returns {boolean}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
private static fuzzyMatch(options: string[], value: string): boolean {
|
private static fuzzyMatch(options: string[], value: string): boolean {
|
||||||
if (options == null || options.length === 0 || value == null || value === "") {
|
if (options == null || options.length === 0 || value == null || value === "") {
|
||||||
return false;
|
return false;
|
||||||
@ -1527,10 +1716,23 @@ export default class AutofillService implements AutofillServiceInterface {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accepts a string and returns true if the
|
||||||
|
* string is not falsy and not empty.
|
||||||
|
* @param {string} str
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
static hasValue(str: string): boolean {
|
static hasValue(str: string): boolean {
|
||||||
return Boolean(str && str !== "");
|
return Boolean(str && str !== "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the `focus_by_opid` autofill script
|
||||||
|
* action to the last field that was filled.
|
||||||
|
* @param {{[p: string]: AutofillField}} filledFields
|
||||||
|
* @param {AutofillScript} fillScript
|
||||||
|
* @returns {AutofillScript}
|
||||||
|
*/
|
||||||
static setFillScriptForFocus(
|
static setFillScriptForFocus(
|
||||||
filledFields: { [id: string]: AutofillField },
|
filledFields: { [id: string]: AutofillField },
|
||||||
fillScript: AutofillScript
|
fillScript: AutofillScript
|
||||||
@ -1559,6 +1761,13 @@ export default class AutofillService implements AutofillServiceInterface {
|
|||||||
return fillScript;
|
return fillScript;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates a fill script to place the `cilck_on_opid`, `focus_on_opid`, and `fill_by_opid`
|
||||||
|
* fill script actions associated with the provided field.
|
||||||
|
* @param {AutofillScript} fillScript
|
||||||
|
* @param {AutofillField} field
|
||||||
|
* @param {string} value
|
||||||
|
*/
|
||||||
static fillByOpid(fillScript: AutofillScript, field: AutofillField, value: string): void {
|
static fillByOpid(fillScript: AutofillScript, field: AutofillField, value: string): void {
|
||||||
if (field.maxLength && value && value.length > field.maxLength) {
|
if (field.maxLength && value && value.length > field.maxLength) {
|
||||||
value = value.substr(0, value.length);
|
value = value.substr(0, value.length);
|
||||||
@ -1570,6 +1779,12 @@ export default class AutofillService implements AutofillServiceInterface {
|
|||||||
fillScript.script.push(["fill_by_opid", field.opid, value]);
|
fillScript.script.push(["fill_by_opid", field.opid, value]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identifies if the field is a custom field, a custom
|
||||||
|
* field is defined as a field that is a `span` element.
|
||||||
|
* @param {AutofillField} field
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
static forCustomFieldsOnly(field: AutofillField): boolean {
|
static forCustomFieldsOnly(field: AutofillField): boolean {
|
||||||
return field.tagName === "span";
|
return field.tagName === "span";
|
||||||
}
|
}
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -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<AutofillPageDetails>}
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
async getPageDetails(): Promise<AutofillPageDetails> {
|
||||||
|
const autofillFormsData: Record<string, AutofillForm> = 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<FormFieldElement>).opid === opid
|
||||||
|
) as ElementWithOpId<FormFieldElement>[];
|
||||||
|
|
||||||
|
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<string, AutofillForm>}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private buildAutofillFormsData(): Record<string, AutofillForm> {
|
||||||
|
const autofillForms: Record<string, AutofillForm> = {};
|
||||||
|
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<AutofillField[]>}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private async buildAutofillFieldsData(): Promise<AutofillField[]> {
|
||||||
|
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<FormFieldElement>),
|
||||||
|
];
|
||||||
|
|
||||||
|
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<FormFieldElement>} element
|
||||||
|
* @param {number} index
|
||||||
|
* @returns {Promise<AutofillField>}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private buildAutofillFieldItem = async (
|
||||||
|
element: ElementWithOpId<FormFieldElement>,
|
||||||
|
index: number
|
||||||
|
): Promise<AutofillField> => {
|
||||||
|
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<HTMLElement> = new Set(element.labels);
|
||||||
|
|
||||||
|
if (labelElementsSet.size) {
|
||||||
|
return this.createLabelElementsTag(labelElementsSet);
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelElements: NodeListOf<HTMLLabelElement> | 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<HTMLLabelElement> | null}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private queryElementLabels(
|
||||||
|
element: FillableFormFieldElement
|
||||||
|
): NodeListOf<HTMLLabelElement> | 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<HTMLElement>} labelElementsSet
|
||||||
|
* @returns {string}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private createLabelElementsTag = (labelElementsSet: Set<HTMLElement>): 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;
|
@ -0,0 +1,409 @@
|
|||||||
|
import { FormFieldElement } from "../types";
|
||||||
|
|
||||||
|
import DomElementVisibilityService from "./dom-element-visibility.service";
|
||||||
|
|
||||||
|
function createBoundingClientRectMock(customProperties: Partial<any> = {}): 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 = `
|
||||||
|
<form id="root">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input type="text" name="username" id="username">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" name="password" id="password">
|
||||||
|
</form>
|
||||||
|
`;
|
||||||
|
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 = `
|
||||||
|
<input type="text" name="username" id="username" />
|
||||||
|
`;
|
||||||
|
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 = `
|
||||||
|
<input type="text" name="username" id="username" style="visibility: hidden;" />
|
||||||
|
<input type="password" name="password" id="password" />
|
||||||
|
<style>
|
||||||
|
#password {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
`;
|
||||||
|
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 = `
|
||||||
|
<input type="text" name="username" id="username" style="display: none;" />
|
||||||
|
<input type="password" name="password" id="password" />
|
||||||
|
<style>
|
||||||
|
#password {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
`;
|
||||||
|
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 = `
|
||||||
|
<input type="text" name="username" id="username" style="opacity: 0;" />
|
||||||
|
<input type="password" name="password" id="password" />
|
||||||
|
<style>
|
||||||
|
#password {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
`;
|
||||||
|
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 = `
|
||||||
|
<input type="text" name="username" id="username" style="clip-path: inset(50%);" />
|
||||||
|
<input type="password" name="password" id="password" />
|
||||||
|
<input type="text" >
|
||||||
|
<style>
|
||||||
|
#password {
|
||||||
|
clip-path: inset(100%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<label>
|
||||||
|
<span>Username</span>
|
||||||
|
<input type="text" name="username" id="username" />
|
||||||
|
</label>
|
||||||
|
`;
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -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<boolean>}
|
||||||
|
*/
|
||||||
|
async isFormFieldViewable(element: FormFieldElement): Promise<boolean> {
|
||||||
|
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;
|
File diff suppressed because it is too large
Load Diff
@ -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;
|
@ -39,3 +39,22 @@ export type UserSettings = {
|
|||||||
vaultTimeout: number;
|
vaultTimeout: number;
|
||||||
vaultTimeoutAction: VaultTimeoutAction;
|
vaultTimeoutAction: VaultTimeoutAction;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A HTMLElement (usually a form element) with additional custom properties added by this script
|
||||||
|
*/
|
||||||
|
export type ElementWithOpId<T> = 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<string, string | null | undefined>;
|
||||||
|
@ -52,6 +52,7 @@ import { SystemService as SystemServiceAbstraction } from "@bitwarden/common/pla
|
|||||||
import { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
|
import { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
|
||||||
import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state";
|
import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state";
|
||||||
import { AppIdService } from "@bitwarden/common/platform/services/app-id.service";
|
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 { ConfigService } from "@bitwarden/common/platform/services/config/config.service";
|
||||||
import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
|
import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
|
||||||
import { ContainerService } from "@bitwarden/common/platform/services/container.service";
|
import { ContainerService } from "@bitwarden/common/platform/services/container.service";
|
||||||
@ -532,6 +533,7 @@ export default class MainBackground {
|
|||||||
this.authService,
|
this.authService,
|
||||||
this.messagingService
|
this.messagingService
|
||||||
);
|
);
|
||||||
|
this.configApiService = new ConfigApiService(this.apiService, this.authService);
|
||||||
this.configService = new ConfigService(
|
this.configService = new ConfigService(
|
||||||
this.stateService,
|
this.stateService,
|
||||||
this.configApiService,
|
this.configApiService,
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service";
|
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 { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
@ -135,6 +136,12 @@ export default class RuntimeBackground {
|
|||||||
BrowserApi.closeBitwardenExtensionTab();
|
BrowserApi.closeBitwardenExtensionTab();
|
||||||
}, msg.delay ?? 0);
|
}, msg.delay ?? 0);
|
||||||
break;
|
break;
|
||||||
|
case "triggerAutofillScriptInjection":
|
||||||
|
await this.autofillService.injectAutofillScripts(
|
||||||
|
sender,
|
||||||
|
await this.configService.getFeatureFlagBool(FeatureFlag.AutofillV2)
|
||||||
|
);
|
||||||
|
break;
|
||||||
case "bgCollectPageDetails":
|
case "bgCollectPageDetails":
|
||||||
await this.main.collectPageDetailsForContentScript(sender.tab, msg.sender, sender.frameId);
|
await this.main.collectPageDetailsForContentScript(sender.tab, msg.sender, sender.frameId);
|
||||||
break;
|
break;
|
||||||
|
@ -17,12 +17,7 @@
|
|||||||
"content_scripts": [
|
"content_scripts": [
|
||||||
{
|
{
|
||||||
"all_frames": true,
|
"all_frames": true,
|
||||||
"js": [
|
"js": ["content/trigger-autofill-script-injection.js"],
|
||||||
"content/autofill.js",
|
|
||||||
"content/autofiller.js",
|
|
||||||
"content/notificationBar.js",
|
|
||||||
"content/contextMenuHandler.js"
|
|
||||||
],
|
|
||||||
"matches": ["http://*/*", "https://*/*", "file:///*"],
|
"matches": ["http://*/*", "https://*/*", "file:///*"],
|
||||||
"run_at": "document_start"
|
"run_at": "document_start"
|
||||||
},
|
},
|
||||||
|
56
apps/browser/src/platform/browser/browser-api.spec.ts
Normal file
56
apps/browser/src/platform/browser/browser-api.spec.ts
Normal file
@ -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<chrome.tabs.InjectDetails>();
|
||||||
|
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<chrome.tabs.InjectDetails>({
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -308,4 +308,31 @@ export class BrowserApi {
|
|||||||
}
|
}
|
||||||
return win.opr?.sidebarAction || browser.sidebarAction;
|
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<unknown>}
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -30,9 +30,25 @@ const contextMenus = {
|
|||||||
removeAll: jest.fn(),
|
removeAll: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const i18n = {
|
||||||
|
getMessage: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabs = {
|
||||||
|
executeScript: jest.fn(),
|
||||||
|
sendMessage: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const scripting = {
|
||||||
|
executeScript: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
// set chrome
|
// set chrome
|
||||||
global.chrome = {
|
global.chrome = {
|
||||||
|
i18n,
|
||||||
storage,
|
storage,
|
||||||
runtime,
|
runtime,
|
||||||
contextMenus,
|
contextMenus,
|
||||||
|
tabs,
|
||||||
|
scripting,
|
||||||
} as any;
|
} as any;
|
||||||
|
@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"files": ["./test.setup.ts"]
|
"files": ["./test.setup.ts"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"esModuleInterop": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -14,10 +14,9 @@ if (process.env.NODE_ENV == null) {
|
|||||||
}
|
}
|
||||||
const ENV = (process.env.ENV = process.env.NODE_ENV);
|
const ENV = (process.env.ENV = process.env.NODE_ENV);
|
||||||
const manifestVersion = process.env.MANIFEST_VERSION == 3 ? 3 : 2;
|
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(`Building Manifest Version ${manifestVersion} app`);
|
||||||
console.log(`Using Autofill v${autofillVersion}`);
|
|
||||||
const envConfig = configurator.load(ENV);
|
const envConfig = configurator.load(ENV);
|
||||||
configurator.log(envConfig);
|
configurator.log(envConfig);
|
||||||
|
|
||||||
@ -153,6 +152,10 @@ const mainConfig = {
|
|||||||
entry: {
|
entry: {
|
||||||
"popup/polyfills": "./src/popup/polyfills.ts",
|
"popup/polyfills": "./src/popup/polyfills.ts",
|
||||||
"popup/main": "./src/popup/main.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/autofiller": "./src/autofill/content/autofiller.ts",
|
||||||
"content/notificationBar": "./src/autofill/content/notification-bar.ts",
|
"content/notificationBar": "./src/autofill/content/notification-bar.ts",
|
||||||
"content/contextMenuHandler": "./src/autofill/content/context-menu-handler.ts",
|
"content/contextMenuHandler": "./src/autofill/content/context-menu-handler.ts",
|
||||||
@ -312,12 +315,4 @@ if (manifestVersion == 2) {
|
|||||||
configs.push(backgroundConfig);
|
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;
|
module.exports = configs;
|
||||||
|
@ -2,5 +2,6 @@ export enum FeatureFlag {
|
|||||||
DisplayEuEnvironmentFlag = "display-eu-environment",
|
DisplayEuEnvironmentFlag = "display-eu-environment",
|
||||||
DisplayLowKdfIterationWarningFlag = "display-kdf-iteration-warning",
|
DisplayLowKdfIterationWarningFlag = "display-kdf-iteration-warning",
|
||||||
TrustedDeviceEncryption = "trusted-device-encryption",
|
TrustedDeviceEncryption = "trusted-device-encryption",
|
||||||
|
AutofillV2 = "autofill-v2",
|
||||||
SecretsManagerBilling = "sm-ga-billing",
|
SecretsManagerBilling = "sm-ga-billing",
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user