diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts index 0e675a1e39d..0a0bcbba670 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts @@ -1,14 +1,14 @@ import { mock } from "jest-mock-extended"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; -import { EVENTS, AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants"; +import { AutofillOverlayVisibility, EVENTS } from "@bitwarden/common/autofill/constants"; import AutofillInit from "../content/autofill-init"; import { AutofillOverlayElement, RedirectFocusDirection } from "../enums/autofill-overlay.enum"; import AutofillField from "../models/autofill-field"; import { createAutofillFieldMock } from "../spec/autofill-mocks"; import { flushPromises, sendMockExtensionMessage } from "../spec/testing-utils"; -import { ElementWithOpId, FormFieldElement } from "../types"; +import { ElementWithOpId, FillableFormFieldElement, FormFieldElement } from "../types"; import { AutoFillConstants } from "./autofill-constants"; import { AutofillOverlayContentService } from "./autofill-overlay-content.service"; @@ -44,6 +44,10 @@ describe("AutofillOverlayContentService", () => { value: 1080, writable: true, }); + Object.defineProperty(window, "top", { + value: window, + writable: true, + }); }); afterEach(() => { @@ -327,7 +331,7 @@ describe("AutofillOverlayContentService", () => { jest.spyOn(globalThis.customElements, "define").mockImplementation(); }); - it("closes the autofill overlay when the `Escape` key is pressed", () => { + it("closes the autofill inline menu when the `Escape` key is pressed", () => { autofillFieldElement.dispatchEvent(new KeyboardEvent("keyup", { code: "Escape" })); expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu", { @@ -509,7 +513,7 @@ describe("AutofillOverlayContentService", () => { }); }); - it("opens the autofill overlay if the form field is empty", async () => { + it("opens the autofill inline menu if the form field is empty", async () => { jest.spyOn(autofillOverlayContentService as any, "openAutofillInlineMenu"); (autofillFieldElement as HTMLInputElement).value = ""; @@ -523,7 +527,7 @@ describe("AutofillOverlayContentService", () => { expect(autofillOverlayContentService["openAutofillInlineMenu"]).toHaveBeenCalled(); }); - it("opens the autofill overlay if the form field is empty and the user is authed", async () => { + it("opens the autofill inline menu if the form field is empty and the user is authed", async () => { jest.spyOn(autofillOverlayContentService as any, "isUserAuthed").mockReturnValue(true); jest.spyOn(autofillOverlayContentService as any, "openAutofillInlineMenu"); (autofillFieldElement as HTMLInputElement).value = ""; @@ -538,7 +542,7 @@ describe("AutofillOverlayContentService", () => { expect(autofillOverlayContentService["openAutofillInlineMenu"]).toHaveBeenCalled(); }); - it("opens the autofill overlay if the form field is empty and the overlay ciphers are not populated", async () => { + it("opens the autofill inline menu if the form field is empty and the overlay ciphers are not populated", async () => { jest.spyOn(autofillOverlayContentService as any, "isUserAuthed").mockReturnValue(false); jest .spyOn(autofillOverlayContentService as any, "isInlineMenuCiphersPopulated") @@ -558,18 +562,10 @@ describe("AutofillOverlayContentService", () => { }); describe("form field click event listener", () => { - let isInlineMenuButtonVisibleSpy: jest.SpyInstance; - beforeEach(async () => { jest .spyOn(autofillOverlayContentService as any, "triggerFormFieldFocusedAction") .mockImplementation(); - isInlineMenuButtonVisibleSpy = jest - .spyOn(autofillOverlayContentService as any, "isInlineMenuButtonVisible") - .mockResolvedValue(false); - jest - .spyOn(autofillOverlayContentService as any, "isInlineMenuListVisible") - .mockResolvedValue(false); await autofillOverlayContentService.setupAutofillInlineMenuListenerOnField( autofillFieldElement, autofillFieldData, @@ -584,7 +580,8 @@ describe("AutofillOverlayContentService", () => { }); it("skips triggering the field focused handler if the overlay list is visible", () => { - isInlineMenuButtonVisibleSpy.mockResolvedValue(true); + // Mock resolved value from `isInlineMenuButtonVisible` message + sendExtensionMessageSpy.mockResolvedValueOnce(true); autofillFieldElement.dispatchEvent(new Event("click")); @@ -594,7 +591,8 @@ describe("AutofillOverlayContentService", () => { }); it("skips triggering the field focused handler if the overlay button is visible", () => { - isInlineMenuButtonVisibleSpy.mockResolvedValue(true); + // Mock resolved value from `isInlineMenuButtonVisible` message + sendExtensionMessageSpy.mockResolvedValueOnce(true); autofillFieldElement.dispatchEvent(new Event("click")); @@ -686,7 +684,7 @@ describe("AutofillOverlayContentService", () => { }); }); - it("opens the autofill overlay if the form element has no value", async () => { + it("opens the autofill inline menu if the form element has no value", async () => { (autofillFieldElement as HTMLInputElement).value = ""; autofillOverlayContentService["inlineMenuVisibility"] = AutofillOverlayVisibility.OnFieldFocus; @@ -701,7 +699,7 @@ describe("AutofillOverlayContentService", () => { expect(sendExtensionMessageSpy).toHaveBeenCalledWith("openAutofillInlineMenu"); }); - it("opens the autofill overlay if the overlay ciphers are not populated and the user is authed", async () => { + it("opens the autofill inline menu if the overlay ciphers are not populated and the user is authed", async () => { (autofillFieldElement as HTMLInputElement).value = ""; autofillOverlayContentService["inlineMenuVisibility"] = AutofillOverlayVisibility.OnFieldFocus; @@ -859,10 +857,9 @@ describe("AutofillOverlayContentService", () => { `; - const usernameField = document.getElementById( + autofillOverlayContentService["mostRecentlyFocusedField"] = document.getElementById( "username-field", ) as ElementWithOpId; - autofillOverlayContentService["mostRecentlyFocusedField"] = usernameField; autofillOverlayContentService["setOverlayRepositionEventListeners"](); checkShouldRepositionInlineMenuSpy = jest .spyOn(autofillOverlayContentService as any, "checkShouldRepositionInlineMenu") @@ -942,7 +939,9 @@ describe("AutofillOverlayContentService", () => { globalThis.dispatchEvent(new Event(EVENTS.SCROLL)); await flushPromises(); - jest.advanceTimersByTime(800); + jest.advanceTimersByTime(750); + await flushPromises(); + jest.advanceTimersByTime(50); await flushPromises(); expect(sendExtensionMessageSpy).toHaveBeenCalledWith("updateAutofillInlineMenuPosition", { @@ -954,7 +953,36 @@ describe("AutofillOverlayContentService", () => { expect(clearUserInteractionEventTimeoutSpy).toHaveBeenCalled(); }); - it("removes the autofill overlay if the focused field is outside of the viewport", async () => { + it("removes the inline menu list if the focused field has a value", async () => { + jest.useFakeTimers(); + jest + .spyOn(autofillOverlayContentService as any, "updateMostRecentlyFocusedField") + .mockImplementation(() => { + autofillOverlayContentService["focusedFieldData"] = { + focusedFieldRects: { + top: 100, + }, + focusedFieldStyles: {}, + }; + }); + ( + autofillOverlayContentService["mostRecentlyFocusedField"] as FillableFormFieldElement + ).value = "test"; + + globalThis.dispatchEvent(new Event(EVENTS.SCROLL)); + await flushPromises(); + jest.advanceTimersByTime(750); + await flushPromises(); + jest.advanceTimersByTime(50); + await flushPromises(); + + expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu", { + overlayElement: AutofillOverlayElement.List, + forceCloseAutofillInlineMenu: true, + }); + }); + + it("removes the autofill inline menu if the focused field is outside of the viewport", async () => { jest.useFakeTimers(); jest .spyOn(autofillOverlayContentService as any, "updateMostRecentlyFocusedField") @@ -1165,7 +1193,7 @@ describe("AutofillOverlayContentService", () => { expect(autofillOverlayContentService["authStatus"]).toEqual(AuthenticationStatus.Unlocked); }); - it("opens both autofill overlay elements", () => { + it("opens both autofill inline menu elements", () => { autofillOverlayContentService["mostRecentlyFocusedField"] = autofillFieldElement; sendMockExtensionMessage({ command: "openAutofillInlineMenu" }); @@ -1178,7 +1206,7 @@ describe("AutofillOverlayContentService", () => { }); }); - it("opens the autofill overlay button only if overlay visibility is set for onButtonClick", () => { + it("opens the autofill inline menu button only if overlay visibility is set for onButtonClick", () => { autofillOverlayContentService["inlineMenuVisibility"] = AutofillOverlayVisibility.OnButtonClick; @@ -1557,6 +1585,81 @@ describe("AutofillOverlayContentService", () => { "*", ); }); + + describe("calculateSubFramePositioning", () => { + beforeEach(() => { + autofillOverlayContentService.init(); + jest.spyOn(globalThis.parent, "postMessage"); + document.body.innerHTML = ``; + }); + + it("calculates the sub frame offset for the current frame and sends those values to the parent if not in the top frame", async () => { + Object.defineProperty(window, "top", { + value: null, + writable: true, + }); + document.body.innerHTML = ``; + const iframe = document.querySelector("iframe") as HTMLIFrameElement; + const subFrameData = { + url: "https://example.com/", + frameId: 10, + left: 0, + top: 0, + parentFrameIds: [1, 2, 3], + }; + const event = mock(); + // @ts-expect-error - Need to mock the source to be the iframe content window + event.source = iframe.contentWindow; + event.data.subFrameData = subFrameData; + sendExtensionMessageSpy.mockResolvedValue(4); + + await autofillOverlayContentService["calculateSubFramePositioning"](event); + await flushPromises(); + + expect(globalThis.parent.postMessage).toHaveBeenCalledWith( + { + command: "calculateSubFramePositioning", + subFrameData: { + frameId: 10, + left: 2, + parentFrameIds: [1, 2, 3, 4], + top: 2, + url: "https://example.com/", + }, + }, + "*", + ); + }); + + it("posts the calculated sub frame data to the background", async () => { + document.body.innerHTML = ``; + const iframe = document.querySelector("iframe") as HTMLIFrameElement; + const subFrameData = { + url: "https://example.com/", + frameId: 10, + left: 0, + top: 0, + parentFrameIds: [1, 2, 3], + }; + const event = mock(); + // @ts-expect-error - Need to mock the source to be the iframe content window + event.source = iframe.contentWindow; + event.data.subFrameData = subFrameData; + + await autofillOverlayContentService["calculateSubFramePositioning"](event); + await flushPromises(); + + expect(sendExtensionMessageSpy).toHaveBeenCalledWith("updateSubFrameData", { + subFrameData: { + frameId: 10, + left: 2, + top: 2, + url: "https://example.com/", + parentFrameIds: [1, 2, 3], + }, + }); + }); + }); }); describe("checkMostRecentlyFocusedFieldHasValue", () => { diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts index 2732847c67f..7719458b82f 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts @@ -205,7 +205,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ if (direction === RedirectFocusDirection.Current) { this.focusMostRecentlyFocusedField(); - setTimeout(() => void this.sendExtensionMessage("closeAutofillInlineMenu"), 100); + globalThis.setTimeout(() => void this.sendExtensionMessage("closeAutofillInlineMenu"), 100); return; } @@ -343,7 +343,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ if (this.mostRecentlyFocusedField && !(await this.isInlineMenuListVisible())) { await this.updateMostRecentlyFocusedField(this.mostRecentlyFocusedField); this.openAutofillInlineMenu({ isOpeningFullAutofillInlineMenu: true }); - setTimeout(() => this.sendExtensionMessage("focusAutofillInlineMenuList"), 125); + globalThis.setTimeout(() => this.sendExtensionMessage("focusAutofillInlineMenuList"), 125); return; } @@ -808,7 +808,10 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ this.rebuildSubFrameOffsets(); this.toggleAutofillInlineMenuHidden(true); this.clearUserInteractionEventTimeout(); - this.userInteractionEventTimeout = setTimeout(this.triggerOverlayRepositionUpdates, 750); + this.userInteractionEventTimeout = globalThis.setTimeout( + this.triggerOverlayRepositionUpdates, + 750, + ); }; /** @@ -816,7 +819,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ */ private rebuildSubFrameOffsets() { this.clearRecalculateSubFrameOffsetsTimeout(); - this.recalculateSubFrameOffsetsTimeout = setTimeout( + this.recalculateSubFrameOffsetsTimeout = globalThis.setTimeout( () => void this.sendExtensionMessage("rebuildSubFrameOffsets"), 150, ); @@ -837,7 +840,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ await this.updateMostRecentlyFocusedField(this.mostRecentlyFocusedField); this.updateAutofillInlineMenuElementsPosition(); - setTimeout(async () => { + globalThis.setTimeout(async () => { this.toggleAutofillInlineMenuHidden(false, true); if ( await this.hideAutofillInlineMenuListOnFilledField( @@ -931,7 +934,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ * autofill overlay if the document is not visible. */ private handleVisibilityChangeEvent = () => { - if (!this.mostRecentlyFocusedField || document.visibilityState === "visible") { + if (!this.mostRecentlyFocusedField || globalThis.document.visibilityState === "visible") { return; } @@ -968,7 +971,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ const subFrameUrlWithoutTrailingSlash = subFrameUrl?.replace(/\/$/, ""); let iframeElement: HTMLIFrameElement | null = null; - const iframeElements = document.querySelectorAll( + const iframeElements = globalThis.document.querySelectorAll( `iframe[src="${subFrameUrl}"], iframe[src="${subFrameUrlWithoutTrailingSlash}"]`, ) as NodeListOf; if (iframeElements.length === 1) { @@ -1052,7 +1055,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ private calculateSubFramePositioning = async (event: MessageEvent) => { const subFrameData = event.data.subFrameData; let subFrameOffsets: SubFrameOffsetData; - const iframes = document.querySelectorAll("iframe"); + const iframes = globalThis.document.querySelectorAll("iframe"); for (let i = 0; i < iframes.length; i++) { if (iframes[i].contentWindow === event.source) { const iframeElement = iframes[i]; @@ -1061,11 +1064,14 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ subFrameData.url, subFrameData.frameId, ); - const parentFrameId = await this.sendExtensionMessage("getCurrentTabFrameId"); subFrameData.top += subFrameOffsets.top; subFrameData.left += subFrameOffsets.left; - subFrameData.parentFrameIds.push(parentFrameId); + + const parentFrameId = await this.sendExtensionMessage("getCurrentTabFrameId"); + if (typeof parentFrameId !== "undefined") { + subFrameData.parentFrameIds.push(parentFrameId); + } break; } @@ -1076,7 +1082,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ return; } - void sendExtensionMessage("updateSubFrameData", { + void this.sendExtensionMessage("updateSubFrameData", { subFrameData, }); };