mirror of
https://github.com/bitwarden/browser
synced 2026-02-06 11:43:51 +00:00
Merge branch 'main' into beeep/dev-container
This commit is contained in:
@@ -347,6 +347,18 @@ describe("AutofillInit", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("removes the LOAD event listener", () => {
|
||||
jest.spyOn(window, "removeEventListener");
|
||||
|
||||
autofillInit.init();
|
||||
autofillInit.destroy();
|
||||
|
||||
expect(window.removeEventListener).toHaveBeenCalledWith(
|
||||
"load",
|
||||
autofillInit["sendCollectDetailsMessage"],
|
||||
);
|
||||
});
|
||||
|
||||
it("removes the extension message listeners", () => {
|
||||
autofillInit.destroy();
|
||||
|
||||
|
||||
@@ -72,21 +72,24 @@ class AutofillInit implements AutofillInitInterface {
|
||||
* to act on the page.
|
||||
*/
|
||||
private collectPageDetailsOnLoad() {
|
||||
const sendCollectDetailsMessage = () => {
|
||||
this.clearCollectPageDetailsOnLoadTimeout();
|
||||
this.collectPageDetailsOnLoadTimeout = setTimeout(
|
||||
() => this.sendExtensionMessage("bgCollectPageDetails", { sender: "autofillInit" }),
|
||||
750,
|
||||
);
|
||||
};
|
||||
|
||||
if (globalThis.document.readyState === "complete") {
|
||||
sendCollectDetailsMessage();
|
||||
this.sendCollectDetailsMessage();
|
||||
}
|
||||
|
||||
globalThis.addEventListener(EVENTS.LOAD, sendCollectDetailsMessage);
|
||||
globalThis.addEventListener(EVENTS.LOAD, this.sendCollectDetailsMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a message to collect page details after a short delay.
|
||||
*/
|
||||
private sendCollectDetailsMessage = () => {
|
||||
this.clearCollectPageDetailsOnLoadTimeout();
|
||||
this.collectPageDetailsOnLoadTimeout = setTimeout(
|
||||
() => this.sendExtensionMessage("bgCollectPageDetails", { sender: "autofillInit" }),
|
||||
750,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Collects the page details and sends them to the
|
||||
* extension background script. If the `sendDetailsInResponse`
|
||||
@@ -218,6 +221,7 @@ class AutofillInit implements AutofillInitInterface {
|
||||
*/
|
||||
destroy() {
|
||||
this.clearCollectPageDetailsOnLoadTimeout();
|
||||
globalThis.removeEventListener(EVENTS.LOAD, this.sendCollectDetailsMessage);
|
||||
chrome.runtime.onMessage.removeListener(this.handleExtensionMessage);
|
||||
this.collectAutofillContentService.destroy();
|
||||
this.autofillOverlayContentService?.destroy();
|
||||
|
||||
@@ -32,4 +32,5 @@ export type BackgroundPortMessageHandlers = {
|
||||
|
||||
export interface AutofillInlineMenuIframeService {
|
||||
initMenuIframe(): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
@@ -645,6 +645,292 @@ describe("AutofillInlineMenuContentService", () => {
|
||||
|
||||
expect(disconnectSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("unobserves custom elements", () => {
|
||||
const disconnectSpy = jest.spyOn(
|
||||
autofillInlineMenuContentService["inlineMenuElementsMutationObserver"],
|
||||
"disconnect",
|
||||
);
|
||||
|
||||
autofillInlineMenuContentService.destroy();
|
||||
|
||||
expect(disconnectSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("unobserves the container element", () => {
|
||||
const disconnectSpy = jest.spyOn(
|
||||
autofillInlineMenuContentService["containerElementMutationObserver"],
|
||||
"disconnect",
|
||||
);
|
||||
|
||||
autofillInlineMenuContentService.destroy();
|
||||
|
||||
expect(disconnectSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears the mutation observer iterations reset timeout", () => {
|
||||
jest.useFakeTimers();
|
||||
const clearTimeoutSpy = jest.spyOn(globalThis, "clearTimeout");
|
||||
autofillInlineMenuContentService["mutationObserverIterationsResetTimeout"] = setTimeout(
|
||||
jest.fn(),
|
||||
1000,
|
||||
);
|
||||
|
||||
autofillInlineMenuContentService.destroy();
|
||||
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||
expect(autofillInlineMenuContentService["mutationObserverIterationsResetTimeout"]).toBeNull();
|
||||
});
|
||||
|
||||
it("destroys the button iframe", () => {
|
||||
const mockButtonIframe = { destroy: jest.fn() };
|
||||
autofillInlineMenuContentService["buttonIframe"] = mockButtonIframe as any;
|
||||
|
||||
autofillInlineMenuContentService.destroy();
|
||||
|
||||
expect(mockButtonIframe.destroy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("destroys the list iframe", () => {
|
||||
const mockListIframe = { destroy: jest.fn() };
|
||||
autofillInlineMenuContentService["listIframe"] = mockListIframe as any;
|
||||
|
||||
autofillInlineMenuContentService.destroy();
|
||||
|
||||
expect(mockListIframe.destroy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("observeCustomElements", () => {
|
||||
it("observes the button element for attribute mutations", () => {
|
||||
const buttonElement = document.createElement("div");
|
||||
autofillInlineMenuContentService["buttonElement"] = buttonElement;
|
||||
const observeSpy = jest.spyOn(
|
||||
autofillInlineMenuContentService["inlineMenuElementsMutationObserver"],
|
||||
"observe",
|
||||
);
|
||||
|
||||
autofillInlineMenuContentService["observeCustomElements"]();
|
||||
|
||||
expect(observeSpy).toHaveBeenCalledWith(buttonElement, { attributes: true });
|
||||
});
|
||||
|
||||
it("observes the list element for attribute mutations", () => {
|
||||
const listElement = document.createElement("div");
|
||||
autofillInlineMenuContentService["listElement"] = listElement;
|
||||
const observeSpy = jest.spyOn(
|
||||
autofillInlineMenuContentService["inlineMenuElementsMutationObserver"],
|
||||
"observe",
|
||||
);
|
||||
|
||||
autofillInlineMenuContentService["observeCustomElements"]();
|
||||
|
||||
expect(observeSpy).toHaveBeenCalledWith(listElement, { attributes: true });
|
||||
});
|
||||
|
||||
it("does not observe when no elements exist", () => {
|
||||
autofillInlineMenuContentService["buttonElement"] = undefined;
|
||||
autofillInlineMenuContentService["listElement"] = undefined;
|
||||
const observeSpy = jest.spyOn(
|
||||
autofillInlineMenuContentService["inlineMenuElementsMutationObserver"],
|
||||
"observe",
|
||||
);
|
||||
|
||||
autofillInlineMenuContentService["observeCustomElements"]();
|
||||
|
||||
expect(observeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("observeContainerElement", () => {
|
||||
it("observes the container element for child list mutations", () => {
|
||||
const containerElement = document.createElement("div");
|
||||
const observeSpy = jest.spyOn(
|
||||
autofillInlineMenuContentService["containerElementMutationObserver"],
|
||||
"observe",
|
||||
);
|
||||
|
||||
autofillInlineMenuContentService["observeContainerElement"](containerElement);
|
||||
|
||||
expect(observeSpy).toHaveBeenCalledWith(containerElement, { childList: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("unobserveContainerElement", () => {
|
||||
it("disconnects the container element mutation observer", () => {
|
||||
const disconnectSpy = jest.spyOn(
|
||||
autofillInlineMenuContentService["containerElementMutationObserver"],
|
||||
"disconnect",
|
||||
);
|
||||
|
||||
autofillInlineMenuContentService["unobserveContainerElement"]();
|
||||
|
||||
expect(disconnectSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles the case when the mutation observer is undefined", () => {
|
||||
autofillInlineMenuContentService["containerElementMutationObserver"] = undefined as any;
|
||||
|
||||
expect(() => autofillInlineMenuContentService["unobserveContainerElement"]()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("observePageAttributes", () => {
|
||||
it("observes the document element for attribute mutations", () => {
|
||||
const observeSpy = jest.spyOn(
|
||||
autofillInlineMenuContentService["htmlMutationObserver"],
|
||||
"observe",
|
||||
);
|
||||
|
||||
autofillInlineMenuContentService["observePageAttributes"]();
|
||||
|
||||
expect(observeSpy).toHaveBeenCalledWith(document.documentElement, { attributes: true });
|
||||
});
|
||||
|
||||
it("observes the body element for attribute mutations", () => {
|
||||
const observeSpy = jest.spyOn(
|
||||
autofillInlineMenuContentService["bodyMutationObserver"],
|
||||
"observe",
|
||||
);
|
||||
|
||||
autofillInlineMenuContentService["observePageAttributes"]();
|
||||
|
||||
expect(observeSpy).toHaveBeenCalledWith(document.body, { attributes: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("unobservePageAttributes", () => {
|
||||
it("disconnects the html mutation observer", () => {
|
||||
const disconnectSpy = jest.spyOn(
|
||||
autofillInlineMenuContentService["htmlMutationObserver"],
|
||||
"disconnect",
|
||||
);
|
||||
|
||||
autofillInlineMenuContentService["unobservePageAttributes"]();
|
||||
|
||||
expect(disconnectSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disconnects the body mutation observer", () => {
|
||||
const disconnectSpy = jest.spyOn(
|
||||
autofillInlineMenuContentService["bodyMutationObserver"],
|
||||
"disconnect",
|
||||
);
|
||||
|
||||
autofillInlineMenuContentService["unobservePageAttributes"]();
|
||||
|
||||
expect(disconnectSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkPageRisks", () => {
|
||||
it("returns true and closes inline menu when page is not opaque", async () => {
|
||||
jest.spyOn(autofillInlineMenuContentService as any, "getPageIsOpaque").mockReturnValue(false);
|
||||
const closeInlineMenuSpy = jest.spyOn(
|
||||
autofillInlineMenuContentService as any,
|
||||
"closeInlineMenu",
|
||||
);
|
||||
|
||||
const result = await autofillInlineMenuContentService["checkPageRisks"]();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(closeInlineMenuSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns true and closes inline menu when inline menu is disabled", async () => {
|
||||
jest.spyOn(autofillInlineMenuContentService as any, "getPageIsOpaque").mockReturnValue(true);
|
||||
autofillInlineMenuContentService["inlineMenuEnabled"] = false;
|
||||
const closeInlineMenuSpy = jest.spyOn(
|
||||
autofillInlineMenuContentService as any,
|
||||
"closeInlineMenu",
|
||||
);
|
||||
|
||||
const result = await autofillInlineMenuContentService["checkPageRisks"]();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(closeInlineMenuSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns false when page is opaque and inline menu is enabled", async () => {
|
||||
jest.spyOn(autofillInlineMenuContentService as any, "getPageIsOpaque").mockReturnValue(true);
|
||||
autofillInlineMenuContentService["inlineMenuEnabled"] = true;
|
||||
const closeInlineMenuSpy = jest.spyOn(
|
||||
autofillInlineMenuContentService as any,
|
||||
"closeInlineMenu",
|
||||
);
|
||||
|
||||
const result = await autofillInlineMenuContentService["checkPageRisks"]();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(closeInlineMenuSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handlePageMutations", () => {
|
||||
it("checks page risks when mutations include attribute changes", async () => {
|
||||
const checkPageRisksSpy = jest.spyOn(
|
||||
autofillInlineMenuContentService as any,
|
||||
"checkPageRisks",
|
||||
);
|
||||
const mutations = [{ type: "attributes" } as MutationRecord];
|
||||
|
||||
await autofillInlineMenuContentService["handlePageMutations"](mutations);
|
||||
|
||||
expect(checkPageRisksSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not check page risks when mutations do not include attribute changes", async () => {
|
||||
const checkPageRisksSpy = jest.spyOn(
|
||||
autofillInlineMenuContentService as any,
|
||||
"checkPageRisks",
|
||||
);
|
||||
const mutations = [{ type: "childList" } as MutationRecord];
|
||||
|
||||
await autofillInlineMenuContentService["handlePageMutations"](mutations);
|
||||
|
||||
expect(checkPageRisksSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearPersistentLastChildOverrideTimeout", () => {
|
||||
it("clears the timeout when it exists", () => {
|
||||
jest.useFakeTimers();
|
||||
const clearTimeoutSpy = jest.spyOn(globalThis, "clearTimeout");
|
||||
autofillInlineMenuContentService["handlePersistentLastChildOverrideTimeout"] = setTimeout(
|
||||
jest.fn(),
|
||||
1000,
|
||||
);
|
||||
|
||||
autofillInlineMenuContentService["clearPersistentLastChildOverrideTimeout"]();
|
||||
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when the timeout is null", () => {
|
||||
const clearTimeoutSpy = jest.spyOn(globalThis, "clearTimeout");
|
||||
autofillInlineMenuContentService["handlePersistentLastChildOverrideTimeout"] = null;
|
||||
|
||||
autofillInlineMenuContentService["clearPersistentLastChildOverrideTimeout"]();
|
||||
|
||||
expect(clearTimeoutSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("elementAtCenterOfInlineMenuPosition", () => {
|
||||
it("returns the element at the center of the given position", () => {
|
||||
const mockElement = document.createElement("div");
|
||||
jest.spyOn(globalThis.document, "elementFromPoint").mockReturnValue(mockElement);
|
||||
|
||||
const result = autofillInlineMenuContentService["elementAtCenterOfInlineMenuPosition"]({
|
||||
top: 100,
|
||||
left: 200,
|
||||
width: 50,
|
||||
height: 30,
|
||||
});
|
||||
|
||||
expect(globalThis.document.elementFromPoint).toHaveBeenCalledWith(225, 115);
|
||||
expect(result).toBe(mockElement);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOwnedTagNames", () => {
|
||||
@@ -975,6 +1261,25 @@ describe("AutofillInlineMenuContentService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("unobserveCustomElements", () => {
|
||||
it("disconnects the inline menu elements mutation observer", () => {
|
||||
const disconnectSpy = jest.spyOn(
|
||||
autofillInlineMenuContentService["inlineMenuElementsMutationObserver"],
|
||||
"disconnect",
|
||||
);
|
||||
|
||||
autofillInlineMenuContentService["unobserveCustomElements"]();
|
||||
|
||||
expect(disconnectSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles the case when the mutation observer is undefined", () => {
|
||||
autofillInlineMenuContentService["inlineMenuElementsMutationObserver"] = undefined as any;
|
||||
|
||||
expect(() => autofillInlineMenuContentService["unobserveCustomElements"]()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPageIsOpaque", () => {
|
||||
it("returns false when no page elements exist", () => {
|
||||
jest.spyOn(globalThis.document, "querySelectorAll").mockReturnValue([] as any);
|
||||
|
||||
@@ -41,7 +41,9 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
|
||||
globalThis.navigator.userAgent.indexOf(" Firefox/") !== -1 ||
|
||||
globalThis.navigator.userAgent.indexOf(" Gecko/") !== -1;
|
||||
private buttonElement?: HTMLElement;
|
||||
private buttonIframe?: AutofillInlineMenuButtonIframe;
|
||||
private listElement?: HTMLElement;
|
||||
private listIframe?: AutofillInlineMenuListIframe;
|
||||
private htmlMutationObserver: MutationObserver;
|
||||
private bodyMutationObserver: MutationObserver;
|
||||
private inlineMenuElementsMutationObserver: MutationObserver;
|
||||
@@ -264,18 +266,19 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
|
||||
if (this.isFirefoxBrowser) {
|
||||
this.buttonElement = globalThis.document.createElement("div");
|
||||
this.buttonElement.setAttribute("popover", "manual");
|
||||
new AutofillInlineMenuButtonIframe(this.buttonElement);
|
||||
this.buttonIframe = new AutofillInlineMenuButtonIframe(this.buttonElement);
|
||||
|
||||
return this.buttonElement;
|
||||
}
|
||||
|
||||
const customElementName = this.generateRandomCustomElementName();
|
||||
const self = this;
|
||||
globalThis.customElements?.define(
|
||||
customElementName,
|
||||
class extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
new AutofillInlineMenuButtonIframe(this);
|
||||
self.buttonIframe = new AutofillInlineMenuButtonIframe(this);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -293,18 +296,19 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
|
||||
if (this.isFirefoxBrowser) {
|
||||
this.listElement = globalThis.document.createElement("div");
|
||||
this.listElement.setAttribute("popover", "manual");
|
||||
new AutofillInlineMenuListIframe(this.listElement);
|
||||
this.listIframe = new AutofillInlineMenuListIframe(this.listElement);
|
||||
|
||||
return this.listElement;
|
||||
}
|
||||
|
||||
const customElementName = this.generateRandomCustomElementName();
|
||||
const self = this;
|
||||
globalThis.customElements?.define(
|
||||
customElementName,
|
||||
class extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
new AutofillInlineMenuListIframe(this);
|
||||
self.listIframe = new AutofillInlineMenuListIframe(this);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -778,5 +782,13 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
|
||||
this.closeInlineMenu();
|
||||
this.clearPersistentLastChildOverrideTimeout();
|
||||
this.unobservePageAttributes();
|
||||
this.unobserveCustomElements();
|
||||
this.unobserveContainerElement();
|
||||
if (this.mutationObserverIterationsResetTimeout) {
|
||||
clearTimeout(this.mutationObserverIterationsResetTimeout);
|
||||
this.mutationObserverIterationsResetTimeout = null;
|
||||
}
|
||||
this.buttonIframe?.destroy();
|
||||
this.listIframe?.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { AutofillInlineMenuIframeService } from "./autofill-inline-menu-iframe.service";
|
||||
|
||||
export class AutofillInlineMenuIframeElement {
|
||||
private autofillInlineMenuIframeService: AutofillInlineMenuIframeService;
|
||||
|
||||
constructor(
|
||||
element: HTMLElement,
|
||||
portName: string,
|
||||
@@ -12,14 +14,14 @@ export class AutofillInlineMenuIframeElement {
|
||||
const shadow: ShadowRoot = element.attachShadow({ mode: "closed" });
|
||||
shadow.prepend(style);
|
||||
|
||||
const autofillInlineMenuIframeService = new AutofillInlineMenuIframeService(
|
||||
this.autofillInlineMenuIframeService = new AutofillInlineMenuIframeService(
|
||||
shadow,
|
||||
portName,
|
||||
initStyles,
|
||||
iframeTitle,
|
||||
ariaAlert,
|
||||
);
|
||||
autofillInlineMenuIframeService.initMenuIframe();
|
||||
this.autofillInlineMenuIframeService.initMenuIframe();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,4 +69,11 @@ export class AutofillInlineMenuIframeElement {
|
||||
|
||||
return style;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up the iframe service to prevent memory leaks.
|
||||
*/
|
||||
destroy() {
|
||||
this.autofillInlineMenuIframeService?.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -752,4 +752,164 @@ describe("AutofillInlineMenuIframeService", () => {
|
||||
expect(autofillInlineMenuIframeService["iframe"].title).toBe("title");
|
||||
});
|
||||
});
|
||||
|
||||
describe("destroy", () => {
|
||||
beforeEach(() => {
|
||||
autofillInlineMenuIframeService.initMenuIframe();
|
||||
autofillInlineMenuIframeService["iframe"].dispatchEvent(new Event(EVENTS.LOAD));
|
||||
portSpy = autofillInlineMenuIframeService["port"];
|
||||
});
|
||||
|
||||
it("removes the LOAD event listener from the iframe", () => {
|
||||
const removeEventListenerSpy = jest.spyOn(
|
||||
autofillInlineMenuIframeService["iframe"],
|
||||
"removeEventListener",
|
||||
);
|
||||
|
||||
autofillInlineMenuIframeService.destroy();
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
EVENTS.LOAD,
|
||||
autofillInlineMenuIframeService["setupPortMessageListener"],
|
||||
);
|
||||
});
|
||||
|
||||
it("clears the aria alert timeout", () => {
|
||||
jest.spyOn(autofillInlineMenuIframeService, "clearAriaAlert");
|
||||
autofillInlineMenuIframeService["ariaAlertTimeout"] = setTimeout(jest.fn(), 1000);
|
||||
|
||||
autofillInlineMenuIframeService.destroy();
|
||||
|
||||
expect(autofillInlineMenuIframeService.clearAriaAlert).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears the fade in timeout", () => {
|
||||
jest.useFakeTimers();
|
||||
jest.spyOn(globalThis, "clearTimeout");
|
||||
autofillInlineMenuIframeService["fadeInTimeout"] = setTimeout(jest.fn(), 1000);
|
||||
|
||||
autofillInlineMenuIframeService.destroy();
|
||||
|
||||
expect(globalThis.clearTimeout).toHaveBeenCalled();
|
||||
expect(autofillInlineMenuIframeService["fadeInTimeout"]).toBeNull();
|
||||
});
|
||||
|
||||
it("clears the delayed close timeout", () => {
|
||||
jest.useFakeTimers();
|
||||
jest.spyOn(globalThis, "clearTimeout");
|
||||
autofillInlineMenuIframeService["delayedCloseTimeout"] = setTimeout(jest.fn(), 1000);
|
||||
|
||||
autofillInlineMenuIframeService.destroy();
|
||||
|
||||
expect(globalThis.clearTimeout).toHaveBeenCalled();
|
||||
expect(autofillInlineMenuIframeService["delayedCloseTimeout"]).toBeNull();
|
||||
});
|
||||
|
||||
it("clears the mutation observer iterations reset timeout", () => {
|
||||
jest.useFakeTimers();
|
||||
jest.spyOn(globalThis, "clearTimeout");
|
||||
autofillInlineMenuIframeService["mutationObserverIterationsResetTimeout"] = setTimeout(
|
||||
jest.fn(),
|
||||
1000,
|
||||
);
|
||||
|
||||
autofillInlineMenuIframeService.destroy();
|
||||
|
||||
expect(globalThis.clearTimeout).toHaveBeenCalled();
|
||||
expect(autofillInlineMenuIframeService["mutationObserverIterationsResetTimeout"]).toBeNull();
|
||||
});
|
||||
|
||||
it("unobserves the iframe mutation observer", () => {
|
||||
const disconnectSpy = jest.spyOn(
|
||||
autofillInlineMenuIframeService["iframeMutationObserver"],
|
||||
"disconnect",
|
||||
);
|
||||
|
||||
autofillInlineMenuIframeService.destroy();
|
||||
|
||||
expect(disconnectSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes the port message listeners and disconnects the port", () => {
|
||||
autofillInlineMenuIframeService.destroy();
|
||||
|
||||
expect(portSpy.onMessage.removeListener).toHaveBeenCalledWith(handlePortMessageSpy);
|
||||
expect(portSpy.onDisconnect.removeListener).toHaveBeenCalledWith(handlePortDisconnectSpy);
|
||||
expect(portSpy.disconnect).toHaveBeenCalled();
|
||||
expect(autofillInlineMenuIframeService["port"]).toBeNull();
|
||||
});
|
||||
|
||||
it("handles the case when the port is null", () => {
|
||||
autofillInlineMenuIframeService["port"] = null;
|
||||
|
||||
expect(() => autofillInlineMenuIframeService.destroy()).not.toThrow();
|
||||
});
|
||||
|
||||
it("handles the case when the iframe is undefined", () => {
|
||||
autofillInlineMenuIframeService["iframe"] = undefined as any;
|
||||
|
||||
expect(() => autofillInlineMenuIframeService.destroy()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearAriaAlert", () => {
|
||||
it("clears the aria alert timeout when it exists", () => {
|
||||
jest.useFakeTimers();
|
||||
jest.spyOn(globalThis, "clearTimeout");
|
||||
autofillInlineMenuIframeService["ariaAlertTimeout"] = setTimeout(jest.fn(), 1000);
|
||||
|
||||
autofillInlineMenuIframeService.clearAriaAlert();
|
||||
|
||||
expect(globalThis.clearTimeout).toHaveBeenCalled();
|
||||
expect(autofillInlineMenuIframeService["ariaAlertTimeout"]).toBeNull();
|
||||
});
|
||||
|
||||
it("does nothing when the aria alert timeout is null", () => {
|
||||
jest.spyOn(globalThis, "clearTimeout");
|
||||
autofillInlineMenuIframeService["ariaAlertTimeout"] = null;
|
||||
|
||||
autofillInlineMenuIframeService.clearAriaAlert();
|
||||
|
||||
expect(globalThis.clearTimeout).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("unobserveIframe", () => {
|
||||
it("disconnects the iframe mutation observer", () => {
|
||||
autofillInlineMenuIframeService.initMenuIframe();
|
||||
const disconnectSpy = jest.spyOn(
|
||||
autofillInlineMenuIframeService["iframeMutationObserver"],
|
||||
"disconnect",
|
||||
);
|
||||
|
||||
autofillInlineMenuIframeService["unobserveIframe"]();
|
||||
|
||||
expect(disconnectSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles the case when the mutation observer is undefined", () => {
|
||||
autofillInlineMenuIframeService["iframeMutationObserver"] = undefined as any;
|
||||
|
||||
expect(() => autofillInlineMenuIframeService["unobserveIframe"]()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("observeIframe", () => {
|
||||
beforeEach(() => {
|
||||
autofillInlineMenuIframeService.initMenuIframe();
|
||||
});
|
||||
|
||||
it("observes the iframe for attribute mutations", () => {
|
||||
const observeSpy = jest.spyOn(
|
||||
autofillInlineMenuIframeService["iframeMutationObserver"],
|
||||
"observe",
|
||||
);
|
||||
|
||||
autofillInlineMenuIframeService["observeIframe"]();
|
||||
|
||||
expect(observeSpy).toHaveBeenCalledWith(autofillInlineMenuIframeService["iframe"], {
|
||||
attributes: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -555,4 +555,26 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up all event listeners, timeouts, and observers to prevent memory leaks.
|
||||
*/
|
||||
destroy() {
|
||||
this.iframe?.removeEventListener(EVENTS.LOAD, this.setupPortMessageListener);
|
||||
this.clearAriaAlert();
|
||||
this.clearFadeInTimeout();
|
||||
if (this.delayedCloseTimeout) {
|
||||
clearTimeout(this.delayedCloseTimeout);
|
||||
this.delayedCloseTimeout = null;
|
||||
}
|
||||
if (this.mutationObserverIterationsResetTimeout) {
|
||||
clearTimeout(this.mutationObserverIterationsResetTimeout);
|
||||
this.mutationObserverIterationsResetTimeout = null;
|
||||
}
|
||||
this.unobserveIframe();
|
||||
this.port?.onMessage.removeListener(this.handlePortMessage);
|
||||
this.port?.onDisconnect.removeListener(this.handlePortDisconnect);
|
||||
this.port?.disconnect();
|
||||
this.port = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,12 +229,20 @@ export class ItemFooterComponent implements OnInit, OnChanges {
|
||||
}
|
||||
|
||||
protected async archive() {
|
||||
await this.archiveCipherUtilitiesService.archiveCipher(this.cipher);
|
||||
/**
|
||||
* When the Archive Button is used in the footer we can skip the reprompt since
|
||||
* the user will have already passed the reprompt when they opened the item.
|
||||
*/
|
||||
await this.archiveCipherUtilitiesService.archiveCipher(this.cipher, true);
|
||||
this.onArchiveToggle.emit();
|
||||
}
|
||||
|
||||
protected async unarchive() {
|
||||
await this.archiveCipherUtilitiesService.unarchiveCipher(this.cipher);
|
||||
/**
|
||||
* When the Unarchive Button is used in the footer we can skip the reprompt since
|
||||
* the user will have already passed the reprompt when they opened the item.
|
||||
*/
|
||||
await this.archiveCipherUtilitiesService.unarchiveCipher(this.cipher, true);
|
||||
this.onArchiveToggle.emit();
|
||||
}
|
||||
|
||||
|
||||
@@ -363,7 +363,7 @@ describe("VaultItemDialogComponent", () => {
|
||||
});
|
||||
|
||||
it("refocuses the dialog header", async () => {
|
||||
const focusOnHeaderSpy = jest.spyOn(component["dialogComponent"](), "focusOnHeader");
|
||||
const focusOnHeaderSpy = jest.spyOn(component["dialogComponent"](), "handleAutofocus");
|
||||
|
||||
await component["changeMode"]("view");
|
||||
|
||||
|
||||
@@ -692,7 +692,7 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
|
||||
this.dialogContent().nativeElement.parentElement.scrollTop = 0;
|
||||
|
||||
// Refocus on title element, the built-in focus management of the dialog only works for the initial open.
|
||||
this.dialogComponent().focusOnHeader();
|
||||
this.dialogComponent().handleAutofocus();
|
||||
|
||||
// Update the URL query params to reflect the new mode.
|
||||
await this.router.navigate([], {
|
||||
|
||||
@@ -46,17 +46,21 @@
|
||||
bitMenuItem
|
||||
(click)="unlinkSso(organization)"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-minus-circle"></i>
|
||||
{{ "unlinkSso" | i18n }}
|
||||
</button>
|
||||
<ng-template #linkSso>
|
||||
<button type="button" bitMenuItem (click)="handleLinkSso(organization)">
|
||||
<i class="bwi bwi-fw bwi-plus-circle"></i>
|
||||
{{ "linkSso" | i18n }}
|
||||
</button>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
<button *ngIf="showLeaveOrgOption" type="button" bitMenuItem (click)="leave(organization)">
|
||||
<i class="bwi bwi-fw bwi-sign-out tw-text-danger" aria-hidden="true"></i>
|
||||
<span class="tw-text-danger">{{ "leave" | i18n }}</span>
|
||||
<span class="tw-text-danger">
|
||||
<i class="bwi bwi-fw bwi-sign-out" aria-hidden="true"></i>
|
||||
{{ "leave" | i18n }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</bit-menu>
|
||||
|
||||
@@ -3,12 +3,10 @@
|
||||
} @else {
|
||||
@let drawerDetails = dataService.drawerDetails$ | async;
|
||||
<div class="tw-mt-4 tw-flex tw-flex-col">
|
||||
<h2 class="tw-mb-6" bitTypography="h2">{{ "allApplications" | i18n }}</h2>
|
||||
|
||||
<div class="tw-flex tw-mb-4 tw-gap-4 tw-items-center">
|
||||
<bit-search
|
||||
[placeholder]="'searchApps' | i18n"
|
||||
class="tw-w-1/2"
|
||||
class="tw-min-w-96"
|
||||
[formControl]="searchControl"
|
||||
></bit-search>
|
||||
|
||||
@@ -20,7 +18,8 @@
|
||||
(ngModelChange)="setFilterApplicationsByStatus($event)"
|
||||
fullWidth="false"
|
||||
class="tw-min-w-48"
|
||||
></bit-chip-select>
|
||||
>
|
||||
</bit-chip-select>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -96,10 +96,12 @@ export class ApplicationsComponent implements OnInit {
|
||||
{
|
||||
label: this.i18nService.t("critical", this.criticalApplicationsCount()),
|
||||
value: ApplicationFilterOption.Critical,
|
||||
icon: " ",
|
||||
},
|
||||
{
|
||||
label: this.i18nService.t("notCritical", this.nonCriticalApplicationsCount()),
|
||||
value: ApplicationFilterOption.NonCritical,
|
||||
icon: " ",
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -12,9 +12,10 @@ import {
|
||||
computed,
|
||||
signal,
|
||||
AfterViewInit,
|
||||
NgZone,
|
||||
} from "@angular/core";
|
||||
import { toObservable } from "@angular/core/rxjs-interop";
|
||||
import { combineLatest, switchMap } from "rxjs";
|
||||
import { combineLatest, firstValueFrom, switchMap } from "rxjs";
|
||||
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
@@ -65,6 +66,9 @@ const drawerSizeToWidth = {
|
||||
})
|
||||
export class DialogComponent implements AfterViewInit {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly ngZone = inject(NgZone);
|
||||
private readonly el = inject(ElementRef);
|
||||
|
||||
private readonly dialogHeader =
|
||||
viewChild.required<ElementRef<HTMLHeadingElement>>("dialogHeader");
|
||||
private readonly scrollableBody = viewChild.required(CdkScrollable);
|
||||
@@ -144,10 +148,6 @@ export class DialogComponent implements AfterViewInit {
|
||||
return [...baseClasses, this.width(), ...sizeClasses, ...animationClasses];
|
||||
});
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.focusOnHeader();
|
||||
}
|
||||
|
||||
handleEsc(event: Event) {
|
||||
if (!this.dialogRef?.disableClose) {
|
||||
this.dialogRef?.close();
|
||||
@@ -159,24 +159,54 @@ export class DialogComponent implements AfterViewInit {
|
||||
this.animationCompleted.set(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves focus to the dialog header element.
|
||||
* This is done automatically when the dialog is opened but can be called manually
|
||||
* when the contents of the dialog change and focus should be reset.
|
||||
*/
|
||||
focusOnHeader(): void {
|
||||
async ngAfterViewInit() {
|
||||
/**
|
||||
* Wait a tick for any focus management to occur on the trigger element before moving focus to
|
||||
* the dialog header. We choose the dialog header because it is always present, unlike possible
|
||||
* interactive elements.
|
||||
*
|
||||
* We are doing this manually instead of using `cdkTrapFocusAutoCapture` and `cdkFocusInitial`
|
||||
* because we need this delay behavior.
|
||||
* Wait for the zone to stabilize before performing any focus behaviors. This ensures that all
|
||||
* child elements are rendered and stable.
|
||||
*/
|
||||
const headerFocusTimeout = setTimeout(() => {
|
||||
this.dialogHeader().nativeElement.focus();
|
||||
}, 0);
|
||||
if (this.ngZone.isStable) {
|
||||
this.handleAutofocus();
|
||||
} else {
|
||||
await firstValueFrom(this.ngZone.onStable);
|
||||
this.handleAutofocus();
|
||||
}
|
||||
}
|
||||
|
||||
this.destroyRef.onDestroy(() => clearTimeout(headerFocusTimeout));
|
||||
/**
|
||||
* Ensure that the user's focus is in the dialog by autofocusing the appropriate element.
|
||||
*
|
||||
* If there is a descendant of the dialog with the AutofocusDirective applied, we defer to that.
|
||||
* If not, we want to fallback to a default behavior of focusing the dialog's header element. We
|
||||
* choose the dialog header as the default fallback for dialog focus because it is always present,
|
||||
* unlike possible interactive elements.
|
||||
*/
|
||||
handleAutofocus() {
|
||||
/**
|
||||
* Angular's contentChildren query cannot see into the internal templates of child components.
|
||||
* We need to use a regular DOM query instead to see if there are descendants using the
|
||||
* AutofocusDirective.
|
||||
*/
|
||||
const dialogRef = this.el.nativeElement;
|
||||
// Must match selectors of AutofocusDirective
|
||||
const autofocusDescendants = dialogRef.querySelectorAll("[appAutofocus], [bitAutofocus]");
|
||||
const hasAutofocusDescendants = autofocusDescendants.length > 0;
|
||||
|
||||
if (!hasAutofocusDescendants) {
|
||||
/**
|
||||
* Wait a tick for any focus management to occur on the trigger element before moving focus
|
||||
* to the dialog header.
|
||||
*
|
||||
* We are doing this manually instead of using Angular's built-in focus management
|
||||
* directives (`cdkTrapFocusAutoCapture` and `cdkFocusInitial`) because we need this delay
|
||||
* behavior.
|
||||
*
|
||||
* And yes, we need the timeout even though we are already waiting for ngZone to stabilize.
|
||||
*/
|
||||
const headerFocusTimeout = setTimeout(() => {
|
||||
this.dialogHeader().nativeElement.focus();
|
||||
}, 0);
|
||||
|
||||
this.destroyRef.onDestroy(() => clearTimeout(headerFocusTimeout));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ import { FocusableElement } from "../shared/focusable-element";
|
||||
*
|
||||
* If the component provides the `FocusableElement` interface, the `focus`
|
||||
* method will be called. Otherwise, the native element will be focused.
|
||||
*
|
||||
* If selector changes, `dialog.component.ts` must also be updated
|
||||
*/
|
||||
@Directive({
|
||||
selector: "[appAutofocus], [bitAutofocus]",
|
||||
|
||||
@@ -76,6 +76,9 @@ export class DefaultCipherFormService implements CipherFormService {
|
||||
.then((res) => res.cipher);
|
||||
} else {
|
||||
// Updating a cipher with collection changes is not supported with a single request currently
|
||||
// Save the new collectionIds before overwriting
|
||||
const newCollectionIdsToSave = cipher.collectionIds;
|
||||
|
||||
// First update the cipher with the original collectionIds
|
||||
cipher.collectionIds = config.originalCipher.collectionIds;
|
||||
const newCipher = await this.cipherService.updateWithServer(
|
||||
@@ -86,7 +89,7 @@ export class DefaultCipherFormService implements CipherFormService {
|
||||
);
|
||||
|
||||
// Then save the new collection changes separately
|
||||
newCipher.collectionIds = cipher.collectionIds;
|
||||
newCipher.collectionIds = newCollectionIdsToSave;
|
||||
|
||||
// TODO: Remove after migrating all SDK ops
|
||||
const { cipher: encryptedCipher } = await this.cipherService.encrypt(newCipher, activeUserId);
|
||||
|
||||
Reference in New Issue
Block a user