1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00
lens/src/renderer/components/test-utils/get-application-builder.tsx
Sebastian Malton 769180664e Get cluster frame to work
Signed-off-by: Sebastian Malton <sebastian@malton.name>
2022-12-21 15:05:03 -05:00

855 lines
27 KiB
TypeScript

/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { LensRendererExtension } from "../../../extensions/lens-renderer-extension";
import rendererExtensionsInjectable from "../../../extensions/renderer-extensions.injectable";
import currentlyInClusterFrameInjectable from "../../routes/currently-in-cluster-frame.injectable";
import type { IComputedValue, ObservableMap } from "mobx";
import { action, computed, observable, runInAction } from "mobx";
import React from "react";
import { Router } from "react-router";
import type { RenderResult } from "@testing-library/react";
import { fireEvent, queryByText } from "@testing-library/react";
import type { KubeApiResourceDescriptor } from "../../../common/rbac";
import { formatKubeApiResource } from "../../../common/rbac";
import type { DiContainer, Injectable } from "@ogre-tools/injectable";
import { getInjectable } from "@ogre-tools/injectable";
import mainExtensionsInjectable from "../../../extensions/main-extensions.injectable";
import { pipeline } from "@ogre-tools/fp";
import { filter, first, join, last, map, matches } from "lodash/fp";
import navigateToPreferencesInjectable from "../../../features/preferences/common/navigate-to-preferences.injectable";
import type { NavigateToHelmCharts } from "../../../common/front-end-routing/routes/cluster/helm/charts/navigate-to-helm-charts.injectable";
import navigateToHelmChartsInjectable from "../../../common/front-end-routing/routes/cluster/helm/charts/navigate-to-helm-charts.injectable";
import hostedClusterInjectable from "../../cluster-frame-context/hosted-cluster.injectable";
import type { Cluster } from "../../../common/cluster/cluster";
import startMainApplicationInjectable from "../../../main/start-main-application/start-main-application.injectable";
import startFrameInjectable from "../../start-frame/start-frame.injectable";
import type { NamespaceStore } from "../+namespaces/store";
import historyInjectable from "../../navigation/history.injectable";
import type { MinimalTrayMenuItem } from "../../../main/tray/electron-tray/electron-tray.injectable";
import electronTrayInjectable from "../../../main/tray/electron-tray/electron-tray.injectable";
import { getDiForUnitTesting as getRendererDi } from "../../getDiForUnitTesting";
import { getDiForUnitTesting as getMainDi } from "../../../main/getDiForUnitTesting";
import { overrideChannels } from "../../../test-utils/channel-fakes/override-channels";
import assert from "assert";
import { openMenu } from "react-select-event";
import userEvent from "@testing-library/user-event";
import lensProxyPortInjectable from "../../../features/lens-proxy/common/port.injectable";
import type { Route } from "../../../common/front-end-routing/front-end-route-injection-token";
import type { NavigateToRouteOptions } from "../../../common/front-end-routing/navigate-to-route-injection-token";
import { navigateToRouteInjectionToken } from "../../../common/front-end-routing/navigate-to-route-injection-token";
import type { LensMainExtension } from "../../../extensions/lens-main-extension";
import type { LensExtension } from "../../../extensions/lens-extension";
import extensionInjectable from "../../../extensions/extension-loader/extension/extension.injectable";
import { renderFor } from "./renderFor";
import { RootFrame } from "../../frames/root-frame/root-frame";
import { ClusterFrame } from "../../frames/cluster-frame/cluster-frame";
import hostedClusterIdInjectable from "../../cluster-frame-context/hosted-cluster-id.injectable";
import activeKubernetesClusterInjectable from "../../cluster-frame-context/active-kubernetes-cluster.injectable";
import { catalogEntityFromCluster } from "../../../main/cluster/manager";
import namespaceStoreInjectable from "../+namespaces/store.injectable";
import createApplicationWindowInjectable from "../../../main/start-main-application/lens-window/application-window/create-application-window.injectable";
import type { CreateElectronWindow } from "../../../main/start-main-application/lens-window/application-window/create-electron-window.injectable";
import createElectronWindowInjectable from "../../../main/start-main-application/lens-window/application-window/create-electron-window.injectable";
import { applicationWindowInjectionToken } from "../../../main/start-main-application/lens-window/application-window/application-window-injection-token";
import closeAllWindowsInjectable from "../../../main/start-main-application/lens-window/hide-all-windows/close-all-windows.injectable";
import type { LensWindow } from "../../../main/start-main-application/lens-window/application-window/create-lens-window.injectable";
import type { FakeExtensionOptions } from "./get-extension-fake";
import { getExtensionFakeForMain, getExtensionFakeForRenderer } from "./get-extension-fake";
import namespaceApiInjectable from "../../../common/k8s-api/endpoints/namespace.api.injectable";
import { Namespace } from "../../../common/k8s-api/endpoints";
import { getOverrideFsWithFakes } from "../../../test-utils/override-fs-with-fakes";
import applicationMenuItemCompositeInjectable from "../../../features/application-menu/main/application-menu-item-composite.injectable";
import { getCompositePaths } from "../../../common/utils/composite/get-composite-paths/get-composite-paths";
import { discoverFor } from "./discovery-of-html-elements";
import { findComposite } from "../../../common/utils/composite/find-composite/find-composite";
import shouldStartHiddenInjectable from "../../../main/electron-app/features/should-start-hidden.injectable";
import fsInjectable from "../../../common/fs/fs.injectable";
import joinPathsInjectable from "../../../common/path/join-paths.injectable";
import homeDirectoryPathInjectable from "../../../common/os/home-directory-path.injectable";
type Callback = (di: DiContainer) => void | Promise<void>;
type LensWindowWithHelpers = LensWindow & { rendered: RenderResult; di: DiContainer };
const createNamespacesFor = (namespaces: Set<string>): Namespace[] => (
Array.from(namespaces, (namespace) => new Namespace({
apiVersion: "v1",
kind: "Namespace",
metadata: {
name: namespace,
resourceVersion: "1",
selfLink: `/api/v1/namespaces/${namespace}`,
uid: `namespace-${namespace}`,
},
}))
);
export interface ApplicationBuilder {
mainDi: DiContainer;
setEnvironmentToClusterFrame: () => ApplicationBuilder;
extensions: {
enable: (...extensions: FakeExtensionOptions[]) => void;
disable: (...extensions: FakeExtensionOptions[]) => void;
get: (id: string) => {
main: LensMainExtension;
applicationWindows: Record<string, LensRendererExtension> & {
only: LensRendererExtension;
};
};
};
applicationWindow: {
closeAll: () => void;
only: LensWindowWithHelpers;
get: (id: string) => LensWindowWithHelpers;
getAll: () => LensWindowWithHelpers[];
create: (id: string) => LensWindowWithHelpers;
};
allowKubeResource: (resource: KubeApiResourceDescriptor) => ApplicationBuilder;
beforeApplicationStart: (callback: Callback) => ApplicationBuilder;
afterApplicationStart: (callback: Callback) => ApplicationBuilder;
beforeWindowStart: (callback: Callback) => ApplicationBuilder;
afterWindowStart: (callback: Callback) => ApplicationBuilder;
startHidden: () => Promise<void>;
render: () => Promise<RenderResult>;
tray: {
click: (id: string) => Promise<void>;
get: (id: string) => MinimalTrayMenuItem | null;
getIconPath: () => string;
};
applicationMenu: {
click: (...path: string[]) => void;
items: string[][];
};
preferences: {
close: () => void;
navigate: () => void;
navigateTo: (route: Route<any>, params: Partial<NavigateToRouteOptions<any>>) => void;
navigation: {
click: (id: string) => void;
};
};
namespaces: {
add: (namespace: string) => void;
select: (namespace: string) => void;
};
helmCharts: {
navigate: NavigateToHelmCharts;
};
navigateWith: (token: Injectable<() => void, any, void>) => void;
select: {
openMenu: (id: string) => { selectOption: (labelText: string) => void };
selectOption: (menuId: string, labelText: string) => void;
getValue: (menuId: string) => string;
};
}
interface Environment {
RootComponent: React.ElementType;
onAllowKubeResource: () => void;
}
export const getApplicationBuilder = () => {
const mainDi = getMainDi({
doGeneralOverrides: true,
});
runInAction(() => {
mainDi.register(mainExtensionsStateInjectable);
});
const { overrideForWindow, sendToWindow } = overrideChannels(mainDi);
const beforeApplicationStartCallbacks: Callback[] = [];
const afterApplicationStartCallbacks: Callback[] = [];
const beforeWindowStartCallbacks: Callback[] = [];
const afterWindowStartCallbacks: Callback[] = [];
const overrideFsWithFakes = getOverrideFsWithFakes();
overrideFsWithFakes(mainDi);
// Set up ~/.kube as existing as a folder
{
const { ensureDirSync } = mainDi.inject(fsInjectable);
const joinPaths = mainDi.inject(joinPathsInjectable);
const homeDirectoryPath = mainDi.inject(homeDirectoryPathInjectable);
ensureDirSync(joinPaths(homeDirectoryPath, ".kube"));
}
let environment = environments.application;
mainDi.override(mainExtensionsInjectable, (di) => {
const mainExtensionsState = di.inject(mainExtensionsStateInjectable);
return computed(() =>
[...mainExtensionsState.values()],
);
});
let trayMenuIconPath: string;
const traySetMenuItemsMock = jest.fn<any, [MinimalTrayMenuItem[]]>();
mainDi.override(electronTrayInjectable, () => ({
start: () => {},
stop: () => {},
setMenuItems: traySetMenuItemsMock,
setIconPath: (path) => {
trayMenuIconPath = path;
},
}));
const allowedResourcesState = observable.set<string>();
const windowHelpers = new Map<string, { di: DiContainer; getRendered: () => RenderResult }>();
const createElectronWindowFake: CreateElectronWindow = (configuration) => {
const windowId = configuration.id;
const windowDi = getRendererDi({ doGeneralOverrides: true });
overrideForWindow(windowDi, windowId);
overrideFsWithFakes(windowDi);
runInAction(() => {
windowDi.register(rendererExtensionsStateInjectable);
});
windowDi.override(
currentlyInClusterFrameInjectable,
() => environment === environments.clusterFrame,
);
windowDi.override(rendererExtensionsInjectable, (di) => {
const rendererExtensionState = di.inject(rendererExtensionsStateInjectable);
return computed(() => [...rendererExtensionState.values()]);
});
let rendered: RenderResult;
windowHelpers.set(windowId, { di: windowDi, getRendered: () => rendered });
return {
show: () => {},
close: () => {},
loadFile: async () => {},
loadUrl: async () => {
for (const callback of beforeWindowStartCallbacks) {
await callback(windowDi);
}
const startFrame = windowDi.inject(startFrameInjectable);
await startFrame();
for (const callback of afterWindowStartCallbacks) {
await callback(windowDi);
}
const history = windowDi.inject(historyInjectable);
const render = renderFor(windowDi);
rendered = render(
<Router history={history}>
<environment.RootComponent />
</Router>,
);
},
send: (arg) => {
sendToWindow(windowId, arg);
},
reload: () => {
throw new Error("Tried to reload application window which is not implemented yet.");
},
};
};
mainDi.override(createElectronWindowInjectable, () => createElectronWindowFake);
let applicationHasStarted = false;
const namespaces = observable.set<string>();
const namespaceItems = observable.array<Namespace>();
const selectedNamespaces = observable.set<string>();
const startMainApplication = mainDi.inject(startMainApplicationInjectable);
const startApplication = async ({ shouldStartHidden }: { shouldStartHidden: boolean }) => {
mainDi.inject(lensProxyPortInjectable).set(42);
for (const callback of beforeApplicationStartCallbacks) {
await callback(mainDi);
}
mainDi.override(shouldStartHiddenInjectable, () => shouldStartHidden);
await startMainApplication();
for (const callback of afterApplicationStartCallbacks) {
await callback(mainDi);
}
applicationHasStarted = true;
};
const builder: ApplicationBuilder = {
mainDi,
applicationWindow: {
closeAll: () => {
const closeAll = mainDi.inject(closeAllWindowsInjectable);
closeAll();
document.documentElement.innerHTML = "";
},
get only() {
const applicationWindows = builder.applicationWindow.getAll();
if (applicationWindows.length > 1) {
throw new Error(
"Tried to get only application window when there are multiple windows.",
);
}
const applicationWindow = first(applicationWindows);
if (!applicationWindow) {
throw new Error(
"Tried to get only application window when there are no windows.",
);
}
return applicationWindow;
},
getAll: () =>
mainDi
.injectMany(applicationWindowInjectionToken)
.map(toWindowWithHelpersFor(windowHelpers)),
get: (id) => {
const applicationWindow = builder.applicationWindow
.getAll()
.find((window) => window.id === id);
if (!applicationWindow) {
throw new Error(`Tried to get application window with ID "${id}" but it was not found.`);
}
return applicationWindow;
},
create: (id) => {
const createApplicationWindow = mainDi.inject(createApplicationWindowInjectable);
createApplicationWindow(id);
return builder.applicationWindow.get(id);
},
},
namespaces: {
add: action((namespace) => {
namespaces.add(namespace);
namespaceItems.replace(createNamespacesFor(namespaces));
}),
select: action((namespace) => selectedNamespaces.add(namespace)),
},
applicationMenu: {
get items() {
const composite = mainDi.inject(
applicationMenuItemCompositeInjectable,
).get();
return getCompositePaths(composite);
},
click: (...path: string[]) => {
const composite = mainDi.inject(
applicationMenuItemCompositeInjectable,
).get();
const clickableMenuItem = findComposite(...path)(composite).value;
if(clickableMenuItem.kind === "clickable-menu-item") {
// Todo: prevent leaking of Electron
(clickableMenuItem.onClick as any)();
} else {
throw new Error(`Tried to trigger clicking of an application menu item, but item at path '${path.join(" -> ")}' isn't clickable.`);
}
},
},
tray: {
get: (id: string) => {
const lastCall = last(traySetMenuItemsMock.mock.calls);
assert(lastCall);
return lastCall[0].find(matches({ id })) ?? null;
},
getIconPath: () => trayMenuIconPath,
click: async (id: string) => {
const lastCall = last(traySetMenuItemsMock.mock.calls);
assert(lastCall);
const trayMenuItems = lastCall[0];
const menuItem = trayMenuItems.find(matches({ id })) ?? null;
if (!menuItem) {
const availableIds = pipeline(
trayMenuItems,
filter(item => !!item.click),
map(item => item.id),
join(", "),
);
throw new Error(`Tried to click tray menu item with ID ${id} which does not exist. Available IDs are: "${availableIds}"`);
}
if (!menuItem.enabled) {
throw new Error(`Tried to click tray menu item with ID ${id} which is disabled.`);
}
await menuItem.click?.();
},
},
preferences: {
close: () => {
const rendered = builder.applicationWindow.only.rendered;
const link = rendered.getByTestId("close-preferences");
fireEvent.click(link);
},
navigate: () => {
const windowDi = builder.applicationWindow.only.di;
const navigateToPreferences = windowDi.inject(
navigateToPreferencesInjectable,
);
navigateToPreferences();
},
navigateTo: (route: Route<any>, params: Partial<NavigateToRouteOptions<any>>) => {
const windowDi = builder.applicationWindow.only.di;
const navigateToRoute = windowDi.inject(navigateToRouteInjectionToken);
navigateToRoute(route, params);
},
navigation: {
click: (pathId: string) => {
const { rendered } = builder.applicationWindow.only;
const discover = discoverFor(() => rendered);
const { discovered: link } = discover.getSingleElement(
"preference-tab-link",
pathId,
);
fireEvent.click(link);
},
},
},
helmCharts: {
navigate: (parameters) => {
const windowDi = builder.applicationWindow.only.di;
const navigateToHelmCharts = windowDi.inject(
navigateToHelmChartsInjectable,
);
navigateToHelmCharts(parameters);
},
},
navigateWith: (token) => {
const windowDi = builder.applicationWindow.only.di;
const navigate = windowDi.inject(token);
navigate();
},
setEnvironmentToClusterFrame: () => {
environment = environments.clusterFrame;
builder.beforeWindowStart((windowDi) => {
const clusterStub = {
id: "some-cluster-id",
accessibleNamespaces: observable.array(),
shouldShowResource: (kind) => allowedResourcesState.has(formatKubeApiResource(kind)),
} as Partial<Cluster> as Cluster;
windowDi.override(activeKubernetesClusterInjectable, () =>
computed(() => catalogEntityFromCluster(clusterStub)),
);
windowDi.override(hostedClusterIdInjectable, () => clusterStub.id);
// TODO: Figure out a way to remove this stub.
const namespaceStoreStub = {
isLoaded: true,
get contextNamespaces() {
return Array.from(selectedNamespaces);
},
get allowedNamespaces() {
return Array.from(namespaces);
},
contextItems: namespaceItems,
api: windowDi.inject(namespaceApiInjectable),
items: namespaceItems,
selectNamespaces: () => {},
selectSingle: () => {},
getByPath: () => undefined,
pickOnlySelected: () => [],
isSelectedAll: () => false,
getTotalCount: () => namespaceItems.length,
} as Partial<NamespaceStore> as NamespaceStore;
windowDi.override(namespaceStoreInjectable, () => namespaceStoreStub);
windowDi.override(hostedClusterInjectable, () => clusterStub);
});
return builder;
},
extensions: {
get: (id: string) => {
const windowInstances = pipeline(
builder.applicationWindow.getAll(),
map((window): [string, LensRendererExtension] => [
window.id,
findExtensionInstance(window.di, rendererExtensionsInjectable, id),
]),
items => Object.fromEntries(items),
);
return {
main: findExtensionInstance(mainDi, mainExtensionsInjectable, id),
applicationWindows: {
get only() {
return findExtensionInstance(
builder.applicationWindow.only.di,
rendererExtensionsInjectable,
id,
);
},
...windowInstances,
},
};
},
enable: (...extensions) => {
builder.afterWindowStart((windowDi) => {
const rendererExtensionInstances = extensions.map((options) =>
getExtensionFakeForRenderer(
windowDi,
options.id,
options.name,
options.rendererOptions || {},
),
);
rendererExtensionInstances.forEach(
enableExtensionFor(windowDi, rendererExtensionsStateInjectable),
);
});
builder.afterApplicationStart((mainDi) => {
const mainExtensionInstances = extensions.map((extension) =>
getExtensionFakeForMain(mainDi, extension.id, extension.name, extension.mainOptions || {}),
);
runInAction(() => {
mainExtensionInstances.forEach(
enableExtensionFor(mainDi, mainExtensionsStateInjectable),
);
});
});
},
disable: (...extensions) => {
builder.afterWindowStart(windowDi => {
extensions
.map((ext) => ext.id)
.forEach(
disableExtensionFor(windowDi, rendererExtensionsStateInjectable),
);
});
builder.afterApplicationStart(mainDi => {
extensions
.map((ext) => ext.id)
.forEach(
disableExtensionFor(mainDi, mainExtensionsStateInjectable),
);
});
},
},
allowKubeResource: (resource) => {
environment.onAllowKubeResource();
runInAction(() => {
allowedResourcesState.add(formatKubeApiResource(resource));
});
return builder;
},
beforeApplicationStart(callback) {
if (applicationHasStarted) {
callback(mainDi);
}
beforeApplicationStartCallbacks.push(callback);
return builder;
},
afterApplicationStart(callback) {
if (applicationHasStarted) {
callback(mainDi);
}
afterApplicationStartCallbacks.push(callback);
return builder;
},
beforeWindowStart(callback) {
const alreadyRenderedWindows = builder.applicationWindow.getAll();
alreadyRenderedWindows.forEach((window) => {
callback(window.di);
});
beforeWindowStartCallbacks.push(callback);
return builder;
},
afterWindowStart(callback) {
const alreadyRenderedWindows = builder.applicationWindow.getAll();
alreadyRenderedWindows.forEach((window) => {
callback(window.di);
});
afterWindowStartCallbacks.push(callback);
return builder;
},
startHidden: async () => {
await startApplication({ shouldStartHidden: true });
},
async render() {
await startApplication({ shouldStartHidden: false });
return builder
.applicationWindow
.get("first-application-window")
.rendered;
},
select: {
openMenu: (menuId) => {
const rendered = builder.applicationWindow.only.rendered;
const select = rendered.baseElement.querySelector<HTMLElement>(
`#${menuId}`,
);
assert(select, `Could not find select with ID "${menuId}"`);
openMenu(select);
return {
selectOption: selectOptionFor(builder, menuId),
};
},
selectOption: (menuId, labelText) => selectOptionFor(builder, menuId)(labelText),
getValue: (menuId) => {
const rendered = builder.applicationWindow.only.rendered;
const select = rendered.baseElement.querySelector<HTMLInputElement>(
`#${menuId}`,
);
assert(select, `Could not find select with ID "${menuId}"`);
const controlElement = select.closest(".Select__control");
assert(controlElement, `Could not find select value for menu with ID "${menuId}"`);
return controlElement.textContent || "";
},
},
};
return builder;
};
export const rendererExtensionsStateInjectable = getInjectable({
id: "renderer-extensions-state",
instantiate: () => observable.map<string, LensRendererExtension>(),
});
const mainExtensionsStateInjectable = getInjectable({
id: "main-extensions-state",
instantiate: () => observable.map<string, LensMainExtension>(),
});
const findExtensionInstance = <T extends LensExtension> (di: DiContainer, injectable: Injectable<IComputedValue<T[]>, any, any>, id: string) => {
const instance = di.inject(injectable).get().find(ext => ext.id === id);
if (!instance) {
throw new Error(`Tried to get extension with ID ${id}, but it didn't exist`);
}
return instance;
};
type ApplicationWindowHelpers = Map<string, { di: DiContainer; getRendered: () => RenderResult }>;
const toWindowWithHelpersFor =
(windowHelpers: ApplicationWindowHelpers) => (applicationWindow: LensWindow) => ({
...applicationWindow,
get rendered() {
const helpers = windowHelpers.get(applicationWindow.id);
if (!helpers) {
throw new Error(
`Tried to get rendered for application window "${applicationWindow.id}" before it was started.`,
);
}
return helpers.getRendered();
},
get di() {
const helpers = windowHelpers.get(applicationWindow.id);
if (!helpers) {
throw new Error(
`Tried to get di for application window "${applicationWindow.id}" before it was started.`,
);
}
return helpers.di;
},
});
const environments = {
application: {
RootComponent: RootFrame,
onAllowKubeResource: () => {
throw new Error(
"Tried to allow kube resource when environment is not cluster frame.",
);
},
} as Environment,
clusterFrame: {
RootComponent: ClusterFrame,
onAllowKubeResource: () => {},
} as Environment,
};
const selectOptionFor = (builder: ApplicationBuilder, menuId: string) => (labelText: string) => {
const rendered = builder.applicationWindow.only.rendered;
const menuOptions = rendered.baseElement.querySelector<HTMLElement>(
`.${menuId}-options`,
);
assert(menuOptions, `Could not find select options for menu with ID "${menuId}"`);
const option = queryByText(menuOptions, labelText);
assert(option, `Could not find select option with label "${labelText}" for menu with ID "${menuId}"`);
userEvent.click(option);
};
const enableExtensionFor = (
di: DiContainer,
stateInjectable: Injectable<ObservableMap<string, any>, any, any>,
) => {
const extensionState = di.inject(stateInjectable);
const getExtension = (extension: LensExtension) =>
di.inject(extensionInjectable, extension);
return (extensionInstance: LensExtension) => {
const extension = getExtension(extensionInstance);
runInAction(() => {
extension.register();
extensionState.set(extensionInstance.id, extensionInstance);
});
};
};
const disableExtensionFor =
(
di: DiContainer,
stateInjectable: Injectable<ObservableMap<string, any>, unknown, void>,
) =>
(id: string) => {
const getExtension = (extension: LensExtension) =>
di.inject(extensionInjectable, extension);
const extensionsState = di.inject(stateInjectable);
const instance = extensionsState.get(id);
if (!instance) {
throw new Error(
`Tried to disable extension with ID "${id}", but it wasn't enabled`,
);
}
const injectable = getExtension(instance);
runInAction(() => {
injectable.deregister();
extensionsState.delete(id);
});
};