diff --git a/src/behaviours/tray/clicking-tray-menu-item-originating-from-extension.test.ts b/src/behaviours/tray/clicking-tray-menu-item-originating-from-extension.test.ts new file mode 100644 index 0000000000..c9589b1dab --- /dev/null +++ b/src/behaviours/tray/clicking-tray-menu-item-originating-from-extension.test.ts @@ -0,0 +1,116 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { LensMainExtension } from "../../extensions/lens-main-extension"; +import type { TrayMenuRegistration } from "../../main/tray/tray-menu-registration"; +import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; +import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; +import loggerInjectable from "../../common/logger.injectable"; +import type { Logger } from "../../common/logger"; + +describe("clicking tray menu item originating from extension", () => { + let applicationBuilder: ApplicationBuilder; + let logErrorMock: jest.Mock; + + beforeEach(async () => { + applicationBuilder = getApplicationBuilder(); + + applicationBuilder.beforeApplicationStart(({ mainDi }) => { + logErrorMock = jest.fn(); + + mainDi.override(loggerInjectable, () => ({ error: logErrorMock }) as unknown as Logger); + }); + + await applicationBuilder.render(); + }); + + describe("when extension is enabled", () => { + let someExtension: SomeTestExtension; + let clickMock: jest.Mock; + + beforeEach(async () => { + clickMock = jest.fn(); + + someExtension = new SomeTestExtension({ + id: "some-extension-id", + trayMenus: [{ label: "some-label", click: clickMock }], + }); + + await applicationBuilder.addMainExtensions(someExtension); + }); + + it("when item is clicked, triggers the click handler", () => { + applicationBuilder.tray.click( + "some-label-tray-menu-item-for-extension-some-extension-id-instance-1", + ); + + expect(clickMock).toHaveBeenCalled(); + }); + + describe("given click handler throws synchronously, when item is clicked", () => { + beforeEach(() => { + clickMock.mockImplementation(() => { + throw new Error("some-error"); + }); + + applicationBuilder.tray.click( + "some-label-tray-menu-item-for-extension-some-extension-id-instance-1", + ); + }); + + it("logs the error", () => { + expect(logErrorMock).toHaveBeenCalledWith( + '[TRAY]: Clicking of tray item "some-label" from extension "some-extension-id" failed.', + expect.any(Error), + ); + }); + }); + + describe("given click handler rejects asynchronously, when item is clicked", () => { + beforeEach(() => { + clickMock.mockImplementation(() => Promise.reject("some-rejection")); + + applicationBuilder.tray.click( + "some-label-tray-menu-item-for-extension-some-extension-id-instance-1", + ); + }); + + it("logs the error", () => { + expect(logErrorMock).toHaveBeenCalledWith( + '[TRAY]: Clicking of tray item "some-label" from extension "some-extension-id" failed.', + "some-rejection", + ); + }); + }); + + it("when disabling extension, does not have tray menu items", () => { + applicationBuilder.removeMainExtensions(someExtension); + + expect( + applicationBuilder.tray.get( + "some-label-tray-menu-item-for-extension-some-extension-id-instance-1", + ), + ).toBeNull(); + }); + }); +}); + +class SomeTestExtension extends LensMainExtension { + constructor({ id, trayMenus }: { + id: string; + trayMenus: TrayMenuRegistration[]; + }) { + super({ + id, + absolutePath: "irrelevant", + isBundled: false, + isCompatible: false, + isEnabled: false, + manifest: { name: id, version: "some-version", engines: { lens: "^5.5.0" }}, + manifestPath: "irrelevant", + }); + + this.trayMenus = trayMenus; + } +} diff --git a/src/main/tray/tray-menu-item/tray-menu-item-registrator.injectable.ts b/src/main/tray/tray-menu-item/tray-menu-item-registrator.injectable.ts index 6cbd9e5d33..86d57c541c 100644 --- a/src/main/tray/tray-menu-item/tray-menu-item-registrator.injectable.ts +++ b/src/main/tray/tray-menu-item/tray-menu-item-registrator.injectable.ts @@ -55,20 +55,27 @@ const toItemInjectablesFor = (extension: LensMainExtension, installationCounter: label: computed(() => registration.label || ""), tooltip: registration.toolTip, - click: pipeline( - () => { - registration.click?.(registration); - }, + click: () => { + const decorated = pipeline( + registration.click || (() => {}), - withErrorLoggingFor(() => `[TRAY]: Clicking of tray item "${trayItemId}" from extension "${extension.sanitizedExtensionId}" failed.`), + withErrorLoggingFor( + () => + `[TRAY]: Clicking of tray item "${trayItemId}" from extension "${extension.sanitizedExtensionId}" failed.`, + ), - // TODO: Find out how to improve typing so that instead of - // x => withErrorSuppression(x) there could only be withErrorSuppression - (x) => withErrorSuppression(x), - ), + // TODO: Find out how to improve typing so that instead of + // x => withErrorSuppression(x) there could only be withErrorSuppression + x => withErrorSuppression(x), + ); + + return decorated(registration); + }, enabled: computed(() => !!registration.enabled), visible: computed(() => true), + + extension, }), injectionToken: trayMenuItemInjectionToken, diff --git a/src/main/tray/tray-menu-items.injectable.ts b/src/main/tray/tray-menu-items.injectable.ts deleted file mode 100644 index c008c123e0..0000000000 --- a/src/main/tray/tray-menu-items.injectable.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { getInjectable } from "@ogre-tools/injectable"; -import { computed } from "mobx"; -import mainExtensionsInjectable from "../../extensions/main-extensions.injectable"; - -const trayItemsInjectable = getInjectable({ - id: "tray-items", - - instantiate: (di) => { - const extensions = di.inject(mainExtensionsInjectable); - - return computed(() => extensions.get().flatMap(extension => extension.trayMenus)); - }, -}); - -export default trayItemsInjectable; diff --git a/src/main/tray/tray-menu-items.test.ts b/src/main/tray/tray-menu-items.test.ts deleted file mode 100644 index fb4f29f763..0000000000 --- a/src/main/tray/tray-menu-items.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import type { DiContainer } from "@ogre-tools/injectable"; -import { LensMainExtension } from "../../extensions/lens-main-extension"; -import trayItemsInjectable from "./tray-menu-items.injectable"; -import type { IComputedValue } from "mobx"; -import { computed, ObservableMap, runInAction } from "mobx"; -import { getDiForUnitTesting } from "../getDiForUnitTesting"; -import mainExtensionsInjectable from "../../extensions/main-extensions.injectable"; -import type { TrayMenuRegistration } from "./tray-menu-registration"; - -describe("tray-menu-items", () => { - let di: DiContainer; - let trayMenuItems: IComputedValue; - let extensionsStub: ObservableMap; - - beforeEach(async () => { - di = getDiForUnitTesting({ doGeneralOverrides: true }); - - extensionsStub = new ObservableMap(); - - di.override( - mainExtensionsInjectable, - () => computed(() => [...extensionsStub.values()]), - ); - - trayMenuItems = di.inject(trayItemsInjectable); - }); - - it("does not have any items yet", () => { - expect(trayMenuItems.get()).toHaveLength(0); - }); - - describe("when extension is enabled", () => { - beforeEach(() => { - const someExtension = new SomeTestExtension({ - id: "some-extension-id", - trayMenus: [{ label: "tray-menu-from-some-extension" }], - }); - - runInAction(() => { - extensionsStub.set("some-extension-id", someExtension); - }); - }); - - it("has tray menu items", () => { - expect(trayMenuItems.get()).toEqual([ - { - label: "tray-menu-from-some-extension", - }, - ]); - }); - - it("when disabling extension, does not have tray menu items", () => { - runInAction(() => { - extensionsStub.delete("some-extension-id"); - }); - - expect(trayMenuItems.get()).toHaveLength(0); - }); - - describe("when other extension is enabled", () => { - beforeEach(() => { - const someOtherExtension = new SomeTestExtension({ - id: "some-extension-id", - trayMenus: [{ label: "some-label-from-second-extension" }], - }); - - runInAction(() => { - extensionsStub.set("some-other-extension-id", someOtherExtension); - }); - }); - - it("has tray menu items for both extensions", () => { - expect(trayMenuItems.get()).toEqual([ - { - label: "tray-menu-from-some-extension", - }, - - { - label: "some-label-from-second-extension", - }, - ]); - }); - - it("when extension is disabled, still returns tray menu items for extensions that are enabled", () => { - runInAction(() => { - extensionsStub.delete("some-other-extension-id"); - }); - - expect(trayMenuItems.get()).toEqual([ - { - label: "tray-menu-from-some-extension", - }, - ]); - }); - }); - }); -}); - -class SomeTestExtension extends LensMainExtension { - constructor({ id, trayMenus }: { - id: string; - trayMenus: TrayMenuRegistration[]; - }) { - super({ - id, - absolutePath: "irrelevant", - isBundled: false, - isCompatible: false, - isEnabled: false, - manifest: { name: id, version: "some-version", engines: { lens: "^5.5.0" }}, - manifestPath: "irrelevant", - }); - - this.trayMenus = trayMenus; - } -} diff --git a/src/renderer/components/test-utils/get-application-builder.tsx b/src/renderer/components/test-utils/get-application-builder.tsx index 9154299b38..031b3a34fb 100644 --- a/src/renderer/components/test-utils/get-application-builder.tsx +++ b/src/renderer/components/test-utils/get-application-builder.tsx @@ -56,6 +56,8 @@ import { openMenu } from "react-select-event"; import userEvent from "@testing-library/user-event"; import { StatusBar } from "../status-bar/status-bar"; import lensProxyPortInjectable from "../../../main/lens-proxy/lens-proxy-port.injectable"; +import type { LensMainExtension } from "../../../extensions/lens-main-extension"; +import trayMenuItemsInjectable from "../../../main/tray/tray-menu-item/tray-menu-items.injectable"; type Callback = (dis: DiContainers) => void | Promise; @@ -63,6 +65,8 @@ export interface ApplicationBuilder { dis: DiContainers; setEnvironmentToClusterFrame: () => ApplicationBuilder; addExtensions: (...extensions: LensRendererExtension[]) => Promise; + addMainExtensions: (...extensions: LensMainExtension[]) => Promise; + removeMainExtensions: (...extensions: LensMainExtension[]) => ApplicationBuilder; allowKubeResource: (resourceName: KubeResource) => ApplicationBuilder; beforeApplicationStart: (callback: Callback) => ApplicationBuilder; beforeRender: (callback: Callback) => ApplicationBuilder; @@ -136,6 +140,7 @@ export const getApplicationBuilder = () => { const beforeRenderCallbacks: Callback[] = []; const extensionsState = observable.array(); + const mainExtensionsState = observable.set(); rendererDi.override(subscribeStoresInjectable, () => () => () => {}); @@ -178,10 +183,9 @@ export const getApplicationBuilder = () => { ); mainDi.override(mainExtensionsInjectable, () => - computed(() => []), + computed(() => [...mainExtensionsState]), ); - let trayMenuItemsStateFake: TrayMenuItem[]; let trayMenuIconPath: string; mainDi.override(electronTrayInjectable, () => ({ @@ -191,9 +195,7 @@ export const getApplicationBuilder = () => { trayMenuIconPath = iconPaths.normal; }, stop: () => {}, - setMenuItems: (items) => { - trayMenuItemsStateFake = items; - }, + setMenuItems: () => {}, setIconPath: (path) => { trayMenuIconPath = path; }, @@ -242,18 +244,20 @@ export const getApplicationBuilder = () => { tray: { get: (id: string) => { - return trayMenuItemsStateFake.find(matches({ id })) ?? null; + const trayMenuItems = mainDi.inject(trayMenuItemsInjectable).get(); + + return trayMenuItems.find(matches({ id })) ?? null; }, getIconPath: () => trayMenuIconPath, + click: async (id: string) => { - const menuItem = pipeline( - trayMenuItemsStateFake, - find((menuItem) => menuItem.id === id), - ); + const trayMenuItems = mainDi.inject(trayMenuItemsInjectable).get(); + + const menuItem = trayMenuItems.find(matches({ id })) ?? null; if (!menuItem) { const availableIds = pipeline( - trayMenuItemsStateFake, + trayMenuItems, filter(item => !!item.click), map(item => item.id), join(", "), @@ -373,6 +377,44 @@ export const getApplicationBuilder = () => { return builder; }, + addMainExtensions: async (...extensions) => { + const extensionRegistrators = mainDi.injectMany( + extensionRegistratorInjectionToken, + ); + + const addAndEnableExtensions = async () => { + const registratorPromises = extensions.flatMap((extension) => + extensionRegistrators.map((registrator) => registrator(extension, 1)), + ); + + await Promise.all(registratorPromises); + + runInAction(() => { + extensions.forEach((extension) => { + mainExtensionsState.add(extension); + }); + }); + }; + + if (rendered) { + await addAndEnableExtensions(); + } else { + builder.beforeRender(addAndEnableExtensions); + } + + return builder; + }, + + removeMainExtensions: (...extensions) => { + extensions.forEach(extension => { + runInAction(() => { + mainExtensionsState.delete(extension); + }); + }); + + return builder; + }, + allowKubeResource: (resourceName) => { environment.onAllowKubeResource();