import { EVENTS } from "../../constants"; import { createPortSpyMock } from "../../jest/autofill-mocks"; import { flushPromises, sendPortMessage, triggerPortOnDisconnectEvent, } from "../../jest/testing-utils"; import { AutofillOverlayPort } from "../../utils/autofill-overlay.enum"; import AutofillOverlayIframeService from "./autofill-overlay-iframe.service"; describe("AutofillOverlayIframeService", () => { const iframePath = "overlay/list.html"; let autofillOverlayIframeService: AutofillOverlayIframeService; let portSpy: chrome.runtime.Port; let shadowAppendSpy: jest.SpyInstance; let handlePortDisconnectSpy: jest.SpyInstance; let handlePortMessageSpy: jest.SpyInstance; let handleWindowMessageSpy: jest.SpyInstance; beforeEach(() => { const shadow = document.createElement("div").attachShadow({ mode: "open" }); autofillOverlayIframeService = new AutofillOverlayIframeService( iframePath, AutofillOverlayPort.Button, shadow, ); shadowAppendSpy = jest.spyOn(shadow, "appendChild"); handlePortDisconnectSpy = jest.spyOn( autofillOverlayIframeService as any, "handlePortDisconnect", ); handlePortMessageSpy = jest.spyOn(autofillOverlayIframeService as any, "handlePortMessage"); handleWindowMessageSpy = jest.spyOn(autofillOverlayIframeService as any, "handleWindowMessage"); chrome.runtime.connect = jest.fn((connectInfo: chrome.runtime.ConnectInfo) => createPortSpyMock(connectInfo.name), ) as unknown as typeof chrome.runtime.connect; }); afterEach(() => { jest.clearAllMocks(); }); describe("initOverlayIframe", () => { it("sets up the iframe's attributes", () => { autofillOverlayIframeService.initOverlayIframe({ height: "0px" }, "title"); expect(autofillOverlayIframeService["iframe"]).toMatchSnapshot(); }); it("appends the iframe to the shadowDom", () => { jest.spyOn(autofillOverlayIframeService["shadow"], "appendChild"); autofillOverlayIframeService.initOverlayIframe({}, "title"); expect(autofillOverlayIframeService["shadow"].appendChild).toBeCalledWith( autofillOverlayIframeService["iframe"], ); }); it("creates an aria alert element if the ariaAlert param is passed", () => { const ariaAlert = "aria alert"; jest.spyOn(autofillOverlayIframeService as any, "createAriaAlertElement"); autofillOverlayIframeService.initOverlayIframe({}, "title", ariaAlert); expect(autofillOverlayIframeService["createAriaAlertElement"]).toBeCalledWith(ariaAlert); expect(autofillOverlayIframeService["ariaAlertElement"]).toMatchSnapshot(); }); describe("on load of the iframe source", () => { beforeEach(() => { autofillOverlayIframeService.initOverlayIframe({ height: "0px" }, "title", "ariaAlert"); }); it("sets up and connects the port message listener to the extension background", () => { jest.spyOn(globalThis, "addEventListener"); autofillOverlayIframeService["iframe"].dispatchEvent(new Event(EVENTS.LOAD)); portSpy = autofillOverlayIframeService["port"]; expect(chrome.runtime.connect).toBeCalledWith({ name: AutofillOverlayPort.Button }); expect(portSpy.onDisconnect.addListener).toBeCalledWith(handlePortDisconnectSpy); expect(portSpy.onMessage.addListener).toBeCalledWith(handlePortMessageSpy); expect(globalThis.addEventListener).toBeCalledWith(EVENTS.MESSAGE, handleWindowMessageSpy); }); it("skips announcing the aria alert if the aria alert element is not populated", () => { jest.spyOn(globalThis, "setTimeout"); autofillOverlayIframeService["ariaAlertElement"] = undefined; autofillOverlayIframeService["iframe"].dispatchEvent(new Event(EVENTS.LOAD)); expect(globalThis.setTimeout).not.toBeCalled(); }); it("announces the aria alert if the aria alert element is populated", () => { jest.useFakeTimers(); jest.spyOn(globalThis, "setTimeout"); autofillOverlayIframeService["ariaAlertElement"] = document.createElement("div"); autofillOverlayIframeService["ariaAlertTimeout"] = setTimeout(jest.fn(), 2000); autofillOverlayIframeService["iframe"].dispatchEvent(new Event(EVENTS.LOAD)); expect(globalThis.setTimeout).toBeCalled(); jest.advanceTimersByTime(2000); expect(shadowAppendSpy).toBeCalledWith(autofillOverlayIframeService["ariaAlertElement"]); }); }); }); describe("event listeners", () => { beforeEach(() => { autofillOverlayIframeService.initOverlayIframe({ height: "0px" }, "title", "ariaAlert"); autofillOverlayIframeService["iframe"].dispatchEvent(new Event(EVENTS.LOAD)); Object.defineProperty(autofillOverlayIframeService["iframe"], "contentWindow", { value: { postMessage: jest.fn(), }, writable: true, }); jest.spyOn(autofillOverlayIframeService["iframe"].contentWindow, "postMessage"); portSpy = autofillOverlayIframeService["port"]; }); describe("handlePortDisconnect", () => { it("ignores ports that do not have the correct port name", () => { portSpy.name = "wrong-port-name"; triggerPortOnDisconnectEvent(portSpy); expect(autofillOverlayIframeService["port"]).not.toBeNull(); }); it("resets the iframe element's opacity, height, and display styles", () => { triggerPortOnDisconnectEvent(portSpy); expect(autofillOverlayIframeService["iframe"].style.opacity).toBe("0"); expect(autofillOverlayIframeService["iframe"].style.height).toBe("0px"); expect(autofillOverlayIframeService["iframe"].style.display).toBe("block"); }); it("removes the global message listener", () => { jest.spyOn(globalThis, "removeEventListener"); triggerPortOnDisconnectEvent(portSpy); expect(globalThis.removeEventListener).toBeCalledWith( EVENTS.MESSAGE, handleWindowMessageSpy, ); }); it("removes the port's onMessage listener", () => { triggerPortOnDisconnectEvent(portSpy); expect(portSpy.onMessage.removeListener).toBeCalledWith(handlePortMessageSpy); }); it("removes the port's onDisconnect listener", () => { triggerPortOnDisconnectEvent(portSpy); expect(portSpy.onDisconnect.removeListener).toBeCalledWith(handlePortDisconnectSpy); }); it("disconnects the port", () => { triggerPortOnDisconnectEvent(portSpy); expect(portSpy.disconnect).toBeCalled(); expect(autofillOverlayIframeService["port"]).toBeNull(); }); }); describe("handlePortMessage", () => { it("ignores port messages that do not correlate to the correct port name", () => { portSpy.name = "wrong-port-name"; sendPortMessage(portSpy, {}); expect(autofillOverlayIframeService["iframe"].contentWindow.postMessage).not.toBeCalled(); }); it("passes on the message to the iframe if the message is not registered with the message handlers", () => { const message = { command: "unregisteredMessage" }; sendPortMessage(portSpy, message); expect(autofillOverlayIframeService["iframe"].contentWindow.postMessage).toBeCalledWith( message, "*", ); }); it("handles port messages that are registered with the message handlers and does not pass the message on to the iframe", () => { jest.spyOn(autofillOverlayIframeService as any, "updateIframePosition"); sendPortMessage(portSpy, { command: "updateIframePosition" }); expect(autofillOverlayIframeService["iframe"].contentWindow.postMessage).not.toBeCalled(); }); describe("initializing the overlay list", () => { let updateElementStylesSpy: jest.SpyInstance; beforeEach(() => { updateElementStylesSpy = jest.spyOn( autofillOverlayIframeService as any, "updateElementStyles", ); }); it("passed the message on to the iframe element", () => { const message = { command: "initAutofillOverlayList", theme: "theme_light", }; sendPortMessage(portSpy, message); expect(updateElementStylesSpy).not.toBeCalled(); expect(autofillOverlayIframeService["iframe"].contentWindow.postMessage).toBeCalledWith( message, "*", ); }); it("updates the border to match the `dark` theme", () => { const message = { command: "initAutofillOverlayList", theme: "theme_dark", }; sendPortMessage(portSpy, message); expect(updateElementStylesSpy).toBeCalledWith(autofillOverlayIframeService["iframe"], { borderColor: "#4c525f", }); }); it("updates the border to match the `nord` theme", () => { const message = { command: "initAutofillOverlayList", theme: "theme_nord", }; sendPortMessage(portSpy, message); expect(updateElementStylesSpy).toBeCalledWith(autofillOverlayIframeService["iframe"], { borderColor: "#2E3440", }); }); it("updates the border to match the `solarizedDark` theme", () => { const message = { command: "initAutofillOverlayList", theme: "theme_solarizedDark", }; sendPortMessage(portSpy, message); expect(updateElementStylesSpy).toBeCalledWith(autofillOverlayIframeService["iframe"], { borderColor: "#073642", }); }); }); describe("updating the iframe's position", () => { beforeEach(() => { jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(true); }); it("ignores updating the iframe position if the document does not have focus", () => { jest.spyOn(autofillOverlayIframeService as any, "updateElementStyles"); jest.spyOn(globalThis.document, "hasFocus").mockReturnValue(false); sendPortMessage(portSpy, { command: "updateIframePosition", styles: { top: 100, left: 100 }, }); expect(autofillOverlayIframeService["updateElementStyles"]).not.toBeCalled(); }); it("updates the iframe position if the document has focus", () => { const styles = { top: "100px", left: "100px" }; sendPortMessage(portSpy, { command: "updateIframePosition", styles, }); expect(autofillOverlayIframeService["iframe"].style.top).toBe(styles.top); expect(autofillOverlayIframeService["iframe"].style.left).toBe(styles.left); }); it("fades the iframe element in after positioning the element", () => { jest.useFakeTimers(); const styles = { top: "100px", left: "100px" }; sendPortMessage(portSpy, { command: "updateIframePosition", styles, }); expect(autofillOverlayIframeService["iframe"].style.opacity).toBe("0"); jest.advanceTimersByTime(10); expect(autofillOverlayIframeService["iframe"].style.opacity).toBe("1"); }); it("announces the opening of the iframe using an aria alert", () => { jest.useFakeTimers(); const styles = { top: "100px", left: "100px" }; sendPortMessage(portSpy, { command: "updateIframePosition", styles, }); jest.advanceTimersByTime(2000); expect(shadowAppendSpy).toBeCalledWith(autofillOverlayIframeService["ariaAlertElement"]); }); }); it("updates the visibility of the iframe", () => { sendPortMessage(portSpy, { command: "updateOverlayHidden", styles: { display: "none" }, }); expect(autofillOverlayIframeService["iframe"].style.display).toBe("none"); }); }); describe("handleWindowMessage", () => { it("ignores window messages when the port is not set", () => { autofillOverlayIframeService["port"] = null; globalThis.dispatchEvent(new MessageEvent("message", { data: {} })); expect(autofillOverlayIframeService["port"]).toBeNull(); }); it("ignores window messages whose source is not the iframe's content window", () => { globalThis.dispatchEvent( new MessageEvent("message", { data: {}, source: window, }), ); expect(portSpy.postMessage).not.toBeCalled(); }); it("ignores window messages whose origin is not from the extension origin", () => { globalThis.dispatchEvent( new MessageEvent("message", { data: {}, source: autofillOverlayIframeService["iframe"].contentWindow, origin: "https://www.google.com", }), ); expect(portSpy.postMessage).not.toBeCalled(); }); it("passes the window message from an iframe element to the background port", () => { globalThis.dispatchEvent( new MessageEvent("message", { data: { command: "not-a-handled-command" }, source: autofillOverlayIframeService["iframe"].contentWindow, origin: "chrome-extension://id", }), ); expect(portSpy.postMessage).toBeCalledWith({ command: "not-a-handled-command" }); }); it("updates the overlay list height", () => { globalThis.dispatchEvent( new MessageEvent("message", { data: { command: "updateAutofillOverlayListHeight", styles: { height: "300px" } }, source: autofillOverlayIframeService["iframe"].contentWindow, origin: "chrome-extension://id", }), ); expect(autofillOverlayIframeService["iframe"].style.height).toBe("300px"); }); }); }); describe("mutation observer", () => { beforeEach(() => { autofillOverlayIframeService.initOverlayIframe({ height: "0px" }, "title", "ariaAlert"); autofillOverlayIframeService["iframe"].dispatchEvent(new Event(EVENTS.LOAD)); }); it("reverts any styles changes made directly to the iframe", async () => { jest.useFakeTimers(); autofillOverlayIframeService["iframe"].style.visibility = "hidden"; await flushPromises(); expect(autofillOverlayIframeService["iframe"].style.visibility).toBe("visible"); }); }); });