diff --git a/Makefile b/Makefile index ea7c84d018..658be09690 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,8 @@ +CMD_ARGS = $(filter-out $@,$(MAKECMDGOALS)) + +%: + @: + EXTENSIONS_DIR = ./extensions extensions = $(foreach dir, $(wildcard $(EXTENSIONS_DIR)/*), ${dir}) extension_node_modules = $(foreach dir, $(wildcard $(EXTENSIONS_DIR)/*), ${dir}/node_modules) @@ -16,16 +21,16 @@ node_modules: yarn.lock yarn install --frozen-lockfile yarn check --verify-tree --integrity -static/build/LensDev.html: +static/build/LensDev.html: node_modules yarn compile:renderer .PHONY: compile-dev -compile-dev: +compile-dev: node_modules yarn compile:main --cache yarn compile:renderer --cache .PHONY: dev -dev: node_modules binaries/client build-extensions static/build/LensDev.html +dev: binaries/client build-extensions static/build/LensDev.html yarn dev .PHONY: lint @@ -34,7 +39,7 @@ lint: .PHONY: test test: binaries/client - yarn test + yarn run jest $(or $(CMD_ARGS), "src") .PHONY: integration-linux integration-linux: binaries/client build-extension-types build-extensions @@ -58,10 +63,6 @@ integration-win: binaries/client build-extension-types build-extensions yarn build:win yarn integration -.PHONY: test-app -test-app: - yarn test - .PHONY: build build: node_modules binaries/client build-extensions ifeq "$(DETECTED_OS)" "Windows" @@ -77,7 +78,7 @@ $(extension_dists): src/extensions/npm/extensions/dist cd $(@:/dist=) && npm run build .PHONY: build-extensions -build-extensions: $(extension_node_modules) $(extension_dists) +build-extensions: node_modules $(extension_node_modules) $(extension_dists) .PHONY: test-extensions test-extensions: $(extension_node_modules) diff --git a/integration/__tests__/app.tests.ts b/integration/__tests__/app.tests.ts index af029a23e9..cf03bccf7c 100644 --- a/integration/__tests__/app.tests.ts +++ b/integration/__tests__/app.tests.ts @@ -1,9 +1,18 @@ +/** + * @jest-environment node + */ + +/* + Cluster tests are run if there is a pre-existing minikube cluster. Before running cluster tests the TEST_NAMESPACE + namespace is removed, if it exists, from the minikube cluster. Resources are created as part of the cluster tests in the + TEST_NAMESPACE namespace. This is done to minimize destructive impact of the cluster tests on an existing minikube + cluster and vice versa. +*/ import { Application } from "spectron"; import * as utils from "../helpers/utils"; import { listHelmRepositories } from "../helpers/utils"; import { fail } from "assert"; - jest.setTimeout(60000); // FIXME (!): improve / simplify all css-selectors + use [data-test-id="some-id"] (already used in some tests below) @@ -11,9 +20,11 @@ describe("Lens integration tests", () => { let app: Application; describe("app start", () => { - beforeAll(async () => app = await utils.appStart(), 20000); + utils.beforeAllWrapped(async () => { + app = await utils.appStart(); + }); - afterAll(async () => { + utils.afterAllWrapped(async () => { if (app?.isRunning()) { await utils.tearDown(app); } diff --git a/integration/__tests__/cluster-pages.tests.ts b/integration/__tests__/cluster-pages.tests.ts index 510110707e..ed2340f86f 100644 --- a/integration/__tests__/cluster-pages.tests.ts +++ b/integration/__tests__/cluster-pages.tests.ts @@ -33,10 +33,12 @@ describe("Lens cluster pages", () => { }; describe("cluster add", () => { - beforeAll(async () => app = await utils.appStart(), 20000); + utils.beforeAllWrapped(async () => { + app = await utils.appStart(); + }); - afterAll(async () => { - if (app && app.isRunning()) { + utils.afterAllWrapped(async () => { + if (app?.isRunning()) { return utils.tearDown(app); } }); @@ -64,11 +66,10 @@ describe("Lens cluster pages", () => { } describe("cluster pages", () => { + utils.beforeAllWrapped(appStartAddCluster); - beforeAll(appStartAddCluster, 40000); - - afterAll(async () => { - if (app && app.isRunning()) { + utils.afterAllWrapped(async () => { + if (app?.isRunning()) { return utils.tearDown(app); } }); @@ -355,10 +356,10 @@ describe("Lens cluster pages", () => { }); describe("viewing pod logs", () => { - beforeEach(appStartAddCluster, 40000); + utils.beforeEachWrapped(appStartAddCluster); - afterEach(async () => { - if (app && app.isRunning()) { + utils.afterEachWrapped(async () => { + if (app?.isRunning()) { return utils.tearDown(app); } }); @@ -405,10 +406,10 @@ describe("Lens cluster pages", () => { }); describe("cluster operations", () => { - beforeEach(appStartAddCluster, 40000); + utils.beforeEachWrapped(appStartAddCluster); - afterEach(async () => { - if (app && app.isRunning()) { + utils.afterEachWrapped(async () => { + if (app?.isRunning()) { return utils.tearDown(app); } }); diff --git a/integration/__tests__/command-palette.tests.ts b/integration/__tests__/command-palette.tests.ts index 789806c445..08da064bd2 100644 --- a/integration/__tests__/command-palette.tests.ts +++ b/integration/__tests__/command-palette.tests.ts @@ -7,9 +7,11 @@ describe("Lens command palette", () => { let app: Application; describe("menu", () => { - beforeAll(async () => app = await utils.appStart(), 20000); + utils.beforeAllWrapped(async () => { + app = await utils.appStart(); + }); - afterAll(async () => { + utils.afterAllWrapped(async () => { if (app?.isRunning()) { await utils.tearDown(app); } diff --git a/integration/__tests__/workspace.tests.ts b/integration/__tests__/workspace.tests.ts index 4164151b0f..6ad56d5255 100644 --- a/integration/__tests__/workspace.tests.ts +++ b/integration/__tests__/workspace.tests.ts @@ -13,13 +13,13 @@ describe("Lens integration tests", () => { const ready = minikubeReady("workspace-int-tests"); utils.describeIf(ready)("workspaces", () => { - beforeAll(async () => { + utils.beforeAllWrapped(async () => { app = await utils.appStart(); await utils.clickWhatsNew(app); - }, 20000); + }); - afterAll(async () => { - if (app && app.isRunning()) { + utils.afterAllWrapped(async () => { + if (app?.isRunning()) { return utils.tearDown(app); } }); diff --git a/integration/helpers/utils.ts b/integration/helpers/utils.ts index 1db9af1c4d..c7fd4e8ddb 100644 --- a/integration/helpers/utils.ts +++ b/integration/helpers/utils.ts @@ -8,6 +8,39 @@ const AppPaths: Partial> = { "darwin": "./dist/mac/Lens.app/Contents/MacOS/Lens", }; +interface DoneCallback { + (...args: any[]): any; + fail(error?: string | { message: string }): any; +} + +/** + * This is necessary because Jest doesn't do this correctly. + * @param fn The function to call + */ +export function wrapJestLifecycle(fn: () => Promise): (done: DoneCallback) => void { + return function (done: DoneCallback) { + fn() + .then(() => done()) + .catch(error => done.fail(error)); + }; +} + +export function beforeAllWrapped(fn: () => Promise): void { + beforeAll(wrapJestLifecycle(fn)); +} + +export function beforeEachWrapped(fn: () => Promise): void { + beforeEach(wrapJestLifecycle(fn)); +} + +export function afterAllWrapped(fn: () => Promise): void { + afterAll(wrapJestLifecycle(fn)); +} + +export function afterEachWrapped(fn: () => Promise): void { + afterEach(wrapJestLifecycle(fn)); +} + export function itIf(condition: boolean) { return condition ? it : it.skip; } diff --git a/package.json b/package.json index 25b08758ca..ffa8c1484c 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,6 @@ "build:linux": "yarn run compile && electron-builder --linux --dir -c.productName=Lens", "build:mac": "yarn run compile && electron-builder --mac --dir -c.productName=Lens", "build:win": "yarn run compile && electron-builder --win --dir -c.productName=Lens", - "test": "scripts/test.sh", "integration": "jest --runInBand integration", "dist": "yarn run compile && electron-builder --publish onTag", "dist:win": "yarn run compile && electron-builder --publish onTag --x64 --ia32", @@ -36,7 +35,7 @@ "download:kubectl": "yarn run ts-node build/download_kubectl.ts", "download:helm": "yarn run ts-node build/download_helm.ts", "build:tray-icons": "yarn run ts-node build/build_tray_icon.ts", - "lint": "yarn run eslint $@ --ext js,ts,tsx --max-warnings=0 .", + "lint": "yarn run eslint --ext js,ts,tsx --max-warnings=0 .", "lint:fix": "yarn run lint --fix", "mkdocs-serve-local": "docker build -t mkdocs-serve-local:latest mkdocs/ && docker run --rm -it -p 8000:8000 -v ${PWD}:/docs mkdocs-serve-local:latest", "verify-docs": "docker build -t mkdocs-serve-local:latest mkdocs/ && docker run --rm -v ${PWD}:/docs mkdocs-serve-local:latest build --strict", @@ -52,7 +51,6 @@ "jest": { "collectCoverage": false, "verbose": true, - "testEnvironment": "node", "transform": { "^.+\\.tsx?$": "ts-jest" }, @@ -288,7 +286,7 @@ "@types/react-beautiful-dnd": "^13.0.0", "@types/react-dom": "^17.0.0", "@types/react-router-dom": "^5.1.6", - "@types/react-select": "^3.0.13", + "@types/react-select": "^4.0.13", "@types/react-window": "^1.8.2", "@types/readable-stream": "^2.3.9", "@types/request": "^2.48.5", @@ -352,7 +350,7 @@ "react-beautiful-dnd": "^13.0.0", "react-refresh": "^0.9.0", "react-router-dom": "^5.2.0", - "react-select": "^3.1.0", + "react-select": "^4.3.0", "react-select-event": "^5.1.0", "react-window": "^1.8.5", "sass-loader": "^8.0.2", diff --git a/scripts/test.sh b/scripts/test.sh deleted file mode 100755 index 19c1f71c47..0000000000 --- a/scripts/test.sh +++ /dev/null @@ -1 +0,0 @@ -jest --env=jsdom ${1:-src} diff --git a/src/common/__tests__/cluster-store.test.ts b/src/common/__tests__/cluster-store.test.ts index 85b148d74b..ec8e244fd2 100644 --- a/src/common/__tests__/cluster-store.test.ts +++ b/src/common/__tests__/cluster-store.test.ts @@ -34,7 +34,8 @@ jest.mock("electron", () => { app: { getVersion: () => "99.99.99", getPath: () => "tmp", - getLocale: () => "en" + getLocale: () => "en", + setLoginItemSettings: jest.fn(), }, ipcMain: { handle: jest.fn(), @@ -91,7 +92,7 @@ describe("empty config", () => { it("removes cluster from store", async () => { await clusterStore.removeById("foo"); - expect(clusterStore.getById("foo")).toBeUndefined(); + expect(clusterStore.getById("foo")).toBeNull(); }); it("sets active cluster", () => { @@ -197,7 +198,7 @@ describe("config with existing clusters", () => { expect(storedCluster).toBeTruthy(); const storedCluster2 = clusterStore.getById("cluster2"); - expect(storedCluster2).toBeUndefined(); + expect(storedCluster2).toBeNull(); }); it("allows getting all of the clusters", async () => { diff --git a/src/common/__tests__/search-store.test.ts b/src/common/__tests__/search-store.test.ts index 27d52971de..6193863192 100644 --- a/src/common/__tests__/search-store.test.ts +++ b/src/common/__tests__/search-store.test.ts @@ -1,7 +1,3 @@ -/** - * @jest-environment jsdom - */ - import { SearchStore } from "../search-store"; let searchStore: SearchStore = null; diff --git a/src/common/__tests__/user-store.test.ts b/src/common/__tests__/user-store.test.ts index b74941a790..1715a1a593 100644 --- a/src/common/__tests__/user-store.test.ts +++ b/src/common/__tests__/user-store.test.ts @@ -1,3 +1,7 @@ +import { Console } from "console"; + +console = new Console(process.stdout, process.stderr); + import mockFs from "mock-fs"; jest.mock("electron", () => { @@ -5,7 +9,8 @@ jest.mock("electron", () => { app: { getVersion: () => "99.99.99", getPath: () => "tmp", - getLocale: () => "en" + getLocale: () => "en", + setLoginItemSettings: jest.fn(), } }; }); diff --git a/src/common/base-store.ts b/src/common/base-store.ts index f7ad946bfd..91d3ef8312 100644 --- a/src/common/base-store.ts +++ b/src/common/base-store.ts @@ -19,7 +19,7 @@ export interface BaseStoreParams extends ConfOptions { * Note: T should only contain base JSON serializable types. */ export abstract class BaseStore extends Singleton { - protected storeConfig: Config; + protected storeConfig?: Config; protected syncDisposers: Function[] = []; whenLoaded = when(() => this.isLoaded); @@ -36,7 +36,7 @@ export abstract class BaseStore extends Singleton { } get name() { - return path.basename(this.storeConfig.path); + return path.basename(this.path); } protected get syncRendererChannel() { @@ -48,7 +48,7 @@ export abstract class BaseStore extends Singleton { } get path() { - return this.storeConfig.path; + return this.storeConfig?.path || ""; } protected async init() { @@ -82,10 +82,13 @@ export abstract class BaseStore extends Singleton { protected async saveToFile(model: T) { logger.info(`[STORE]: SAVING ${this.path}`); + // todo: update when fixed https://github.com/sindresorhus/conf/issues/114 - Object.entries(model).forEach(([key, value]) => { - this.storeConfig.set(key, value); - }); + if (this.storeConfig) { + for (const [key, value] of Object.entries(model)) { + this.storeConfig.set(key, value); + } + } } enableSync() { diff --git a/src/common/cluster-store.ts b/src/common/cluster-store.ts index 989823352b..b936bc2fc7 100644 --- a/src/common/cluster-store.ts +++ b/src/common/cluster-store.ts @@ -212,8 +212,12 @@ export class ClusterStore extends BaseStore { } @action - setActive(id: ClusterId) { - const clusterId = this.clusters.has(id) ? id : null; + setActive(clusterId: ClusterId) { + const cluster = this.clusters.get(clusterId); + + if (!cluster?.enabled) { + clusterId = null; + } this.activeCluster = clusterId; } @@ -244,8 +248,8 @@ export class ClusterStore extends BaseStore { return this.clusters.size > 0; } - getById(id: ClusterId): Cluster { - return this.clusters.get(id); + getById(id: ClusterId): Cluster | null { + return this.clusters.get(id) ?? null; } @action diff --git a/src/jest.setup.ts b/src/jest.setup.ts index ef6565c907..d8c6ce9161 100644 --- a/src/jest.setup.ts +++ b/src/jest.setup.ts @@ -4,3 +4,7 @@ fetchMock.enableMocks(); // Mock __non_webpack_require__ for tests globalThis.__non_webpack_require__ = jest.fn(); + +process.on("unhandledRejection", (err) => { + fail(err); +}); diff --git a/src/main/routes/helm-route.ts b/src/main/routes/helm-route.ts index 4d1cae8bc2..c3c0bdb353 100644 --- a/src/main/routes/helm-route.ts +++ b/src/main/routes/helm-route.ts @@ -70,7 +70,7 @@ class HelmApiRoute extends LensApi { this.respondJson(response, result); } catch (error) { logger.debug(error); - this.respondText(response, error); + this.respondText(response, error, 422); } } @@ -83,7 +83,7 @@ class HelmApiRoute extends LensApi { this.respondJson(response, result); } catch(error) { logger.debug(error); - this.respondText(response, error); + this.respondText(response, error, 422); } } diff --git a/src/main/tray.ts b/src/main/tray.ts index 4f4186f255..a15fa845d3 100644 --- a/src/main/tray.ts +++ b/src/main/tray.ts @@ -1,6 +1,6 @@ import path from "path"; import packageInfo from "../../package.json"; -import { Menu, NativeImage, Tray } from "electron"; +import { Menu, Tray } from "electron"; import { autorun } from "mobx"; import { showAbout } from "./menu"; import { checkForUpdates } from "./app-updater"; @@ -10,6 +10,8 @@ import logger from "./logger"; import { isDevelopment, isWindows } from "../common/vars"; import { exitApp } from "./exit-app"; +const TRAY_LOG_PREFIX = "[TRAY]"; + // note: instance of Tray should be saved somewhere, otherwise it disappears export let tray: Tray; @@ -22,69 +24,70 @@ export function getTrayIcon(): string { } export function initTray(windowManager: WindowManager) { - const dispose = autorun(() => { - try { - const menu = createTrayMenu(windowManager); + const icon = getTrayIcon(); - buildTray(getTrayIcon(), menu, windowManager); - } catch (err) { - logger.error(`[TRAY]: building failed: ${err}`); - } - }); + tray = new Tray(icon); + tray.setToolTip(packageInfo.description); + tray.setIgnoreDoubleClickEvents(true); + + if (isWindows) { + tray.on("click", () => { + windowManager + .ensureMainWindow() + .catch(error => logger.error(`${TRAY_LOG_PREFIX}: Failed to open lens`, { error })); + }); + } + + const disposers = [ + autorun(() => { + try { + const menu = createTrayMenu(windowManager); + + tray.setContextMenu(menu); + } catch (error) { + logger.error(`${TRAY_LOG_PREFIX}: building failed`, { error }); + } + }), + ]; return () => { - dispose(); + disposers.forEach(disposer => disposer()); tray?.destroy(); tray = null; }; } -function buildTray(icon: string | NativeImage, menu: Menu, windowManager: WindowManager) { - if (!tray) { - tray = new Tray(icon); - tray.setToolTip(packageInfo.description); - tray.setIgnoreDoubleClickEvents(true); - tray.setImage(icon); - tray.setContextMenu(menu); - - if (isWindows) { - tray.on("click", () => { - windowManager.ensureMainWindow(); - }); - } - } - - return tray; -} - function createTrayMenu(windowManager: WindowManager): Menu { return Menu.buildFromTemplate([ { label: "Open Lens", - async click() { - await windowManager.ensureMainWindow(); + click() { + windowManager + .ensureMainWindow() + .catch(error => logger.error(`${TRAY_LOG_PREFIX}: Failed to open lens`, { error })); }, }, { label: "Preferences", click() { - windowManager.navigate(preferencesURL()); + windowManager + .navigate(preferencesURL()) + .catch(error => logger.error(`${TRAY_LOG_PREFIX}: Failed to nativate to Preferences`, { error })); }, }, { label: "Check for updates", - async click() { - await checkForUpdates(); - await windowManager.ensureMainWindow(); + click() { + checkForUpdates() + .then(() => windowManager.ensureMainWindow()); }, }, { label: "About Lens", - async click() { - // note: argument[1] (browserWindow) not available when app is not focused / hidden - const browserWindow = await windowManager.ensureMainWindow(); - - showAbout(browserWindow); + click() { + windowManager.ensureMainWindow() + .then(showAbout) + .catch(error => logger.error(`${TRAY_LOG_PREFIX}: Failed to show Lens About view`, { error })); }, }, { type: "separator" }, diff --git a/src/renderer/api/__tests__/kube-api-parse.test.ts b/src/renderer/api/__tests__/kube-api-parse.test.ts index c2aec7fd58..bc4528ad4e 100644 --- a/src/renderer/api/__tests__/kube-api-parse.test.ts +++ b/src/renderer/api/__tests__/kube-api-parse.test.ts @@ -1,134 +1,115 @@ +jest.mock("../kube-object"); +jest.mock("../kube-api"); +jest.mock("../api-manager", () => ({ + apiManager() { + return { + registerStore: jest.fn(), + }; + } +})); + import { IKubeApiParsed, parseKubeApi } from "../kube-api-parse"; -interface KubeApiParseTestData { - url: string; - expected: Required; -} +/** + * [, ] + */ +type KubeApiParseTestData = [string, Required]; const tests: KubeApiParseTestData[] = [ - { - url: "/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions/prometheuses.monitoring.coreos.com", - expected: { - apiBase: "/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions", - apiPrefix: "/apis", - apiGroup: "apiextensions.k8s.io", - apiVersion: "v1beta1", - apiVersionWithGroup: "apiextensions.k8s.io/v1beta1", - namespace: undefined, - resource: "customresourcedefinitions", - name: "prometheuses.monitoring.coreos.com" - }, - }, - { - url: "/api/v1/namespaces/kube-system/pods/coredns-6955765f44-v8p27", - expected: { - apiBase: "/api/v1/pods", - apiPrefix: "/api", - apiGroup: "", - apiVersion: "v1", - apiVersionWithGroup: "v1", - namespace: "kube-system", - resource: "pods", - name: "coredns-6955765f44-v8p27" - }, - }, - { - url: "/apis/stable.example.com/foo1/crontabs", - expected: { - apiBase: "/apis/stable.example.com/foo1/crontabs", - apiPrefix: "/apis", - apiGroup: "stable.example.com", - apiVersion: "foo1", - apiVersionWithGroup: "stable.example.com/foo1", - resource: "crontabs", - name: undefined, - namespace: undefined, - }, - }, - { - url: "/apis/cluster.k8s.io/v1alpha1/clusters", - expected: { - apiBase: "/apis/cluster.k8s.io/v1alpha1/clusters", - apiPrefix: "/apis", - apiGroup: "cluster.k8s.io", - apiVersion: "v1alpha1", - apiVersionWithGroup: "cluster.k8s.io/v1alpha1", - resource: "clusters", - name: undefined, - namespace: undefined, - }, - }, - { - url: "/api/v1/namespaces", - expected: { - apiBase: "/api/v1/namespaces", - apiPrefix: "/api", - apiGroup: "", - apiVersion: "v1", - apiVersionWithGroup: "v1", - resource: "namespaces", - name: undefined, - namespace: undefined, - }, - }, - { - url: "/api/v1/secrets", - expected: { - apiBase: "/api/v1/secrets", - apiPrefix: "/api", - apiGroup: "", - apiVersion: "v1", - apiVersionWithGroup: "v1", - resource: "secrets", - name: undefined, - namespace: undefined, - }, - }, - { - url: "/api/v1/nodes/minikube", - expected: { - apiBase: "/api/v1/nodes", - apiPrefix: "/api", - apiGroup: "", - apiVersion: "v1", - apiVersionWithGroup: "v1", - resource: "nodes", - name: "minikube", - namespace: undefined, - }, - }, - { - url: "/api/foo-bar/nodes/minikube", - expected: { - apiBase: "/api/foo-bar/nodes", - apiPrefix: "/api", - apiGroup: "", - apiVersion: "foo-bar", - apiVersionWithGroup: "foo-bar", - resource: "nodes", - name: "minikube", - namespace: undefined, - }, - }, - { - url: "/api/v1/namespaces/kube-public", - expected: { - apiBase: "/api/v1/namespaces", - apiPrefix: "/api", - apiGroup: "", - apiVersion: "v1", - apiVersionWithGroup: "v1", - resource: "namespaces", - name: "kube-public", - namespace: undefined, - }, - }, + ["/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions/prometheuses.monitoring.coreos.com", { + apiBase: "/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions", + apiPrefix: "/apis", + apiGroup: "apiextensions.k8s.io", + apiVersion: "v1beta1", + apiVersionWithGroup: "apiextensions.k8s.io/v1beta1", + namespace: undefined, + resource: "customresourcedefinitions", + name: "prometheuses.monitoring.coreos.com" + }], + ["/api/v1/namespaces/kube-system/pods/coredns-6955765f44-v8p27", { + apiBase: "/api/v1/pods", + apiPrefix: "/api", + apiGroup: "", + apiVersion: "v1", + apiVersionWithGroup: "v1", + namespace: "kube-system", + resource: "pods", + name: "coredns-6955765f44-v8p27" + }], + ["/apis/stable.example.com/foo1/crontabs", { + apiBase: "/apis/stable.example.com/foo1/crontabs", + apiPrefix: "/apis", + apiGroup: "stable.example.com", + apiVersion: "foo1", + apiVersionWithGroup: "stable.example.com/foo1", + resource: "crontabs", + name: undefined, + namespace: undefined, + }], + ["/apis/cluster.k8s.io/v1alpha1/clusters", { + apiBase: "/apis/cluster.k8s.io/v1alpha1/clusters", + apiPrefix: "/apis", + apiGroup: "cluster.k8s.io", + apiVersion: "v1alpha1", + apiVersionWithGroup: "cluster.k8s.io/v1alpha1", + resource: "clusters", + name: undefined, + namespace: undefined, + }], + ["/api/v1/namespaces", { + apiBase: "/api/v1/namespaces", + apiPrefix: "/api", + apiGroup: "", + apiVersion: "v1", + apiVersionWithGroup: "v1", + resource: "namespaces", + name: undefined, + namespace: undefined, + }], + ["/api/v1/secrets", { + apiBase: "/api/v1/secrets", + apiPrefix: "/api", + apiGroup: "", + apiVersion: "v1", + apiVersionWithGroup: "v1", + resource: "secrets", + name: undefined, + namespace: undefined, + }], + ["/api/v1/nodes/minikube", { + apiBase: "/api/v1/nodes", + apiPrefix: "/api", + apiGroup: "", + apiVersion: "v1", + apiVersionWithGroup: "v1", + resource: "nodes", + name: "minikube", + namespace: undefined, + }], + ["/api/foo-bar/nodes/minikube", { + apiBase: "/api/foo-bar/nodes", + apiPrefix: "/api", + apiGroup: "", + apiVersion: "foo-bar", + apiVersionWithGroup: "foo-bar", + resource: "nodes", + name: "minikube", + namespace: undefined, + }], + ["/api/v1/namespaces/kube-public", { + apiBase: "/api/v1/namespaces", + apiPrefix: "/api", + apiGroup: "", + apiVersion: "v1", + apiVersionWithGroup: "v1", + resource: "namespaces", + name: "kube-public", + namespace: undefined, + }], ]; describe("parseApi unit tests", () => { - for (const { url, expected } of tests) { - test(`testing "${url}"`, () => { - expect(parseKubeApi(url)).toStrictEqual(expected); - }); - } + it.each(tests)("testing %s", (url, expected) => { + expect(parseKubeApi(url)).toStrictEqual(expected); + }); }); diff --git a/src/renderer/api/__tests__/kube-api.test.ts b/src/renderer/api/__tests__/kube-api.test.ts index 7481bd096a..9d3c41869d 100644 --- a/src/renderer/api/__tests__/kube-api.test.ts +++ b/src/renderer/api/__tests__/kube-api.test.ts @@ -28,7 +28,7 @@ describe("KubeApi", () => { }; } }); - + const apiBase = "/apis/networking.k8s.io/v1/ingresses"; const fallbackApiBase = "/apis/extensions/v1beta1/ingresses"; const kubeApi = new KubeApi({ @@ -36,7 +36,7 @@ describe("KubeApi", () => { fallbackApiBases: [fallbackApiBase], checkPreferredVersion: true, }); - + await kubeApi.get(); expect(kubeApi.apiPrefix).toEqual("/apis"); expect(kubeApi.apiGroup).toEqual("networking.k8s.io"); diff --git a/src/renderer/api/__tests__/pods.test.ts b/src/renderer/api/__tests__/pods.test.ts new file mode 100644 index 0000000000..99cf95bf7f --- /dev/null +++ b/src/renderer/api/__tests__/pods.test.ts @@ -0,0 +1,303 @@ +import { Pod } from "../endpoints"; + +interface GetDummyPodOptions { + running?: number; + dead?: number; + initRunning?: number; + initDead?: number; +} + +function getDummyPodDefaultOptions(): Required { + return { + running: 0, + dead: 0, + initDead: 0, + initRunning: 0, + }; +} + +function getDummyPod(opts: GetDummyPodOptions = getDummyPodDefaultOptions()): Pod { + const pod = new Pod({ + apiVersion: "v1", + kind: "Pod", + metadata: { + uid: "1", + name: "test", + resourceVersion: "v1", + selfLink: "http" + } + }); + + pod.spec = { + containers: [], + initContainers: [], + serviceAccount: "dummy", + serviceAccountName: "dummy", + }; + + pod.status = { + phase: "Running", + conditions: [], + hostIP: "10.0.0.1", + podIP: "10.0.0.1", + startTime: "now", + containerStatuses: [], + initContainerStatuses: [], + }; + + for (let i = 0; i < opts.running; i += 1) { + const name = `container_r_${i}`; + + pod.spec.containers.push({ + image: "dummy", + imagePullPolicy: "dummy", + name, + }); + pod.status.containerStatuses.push({ + image: "dummy", + imageID: "dummy", + name, + ready: true, + restartCount: i, + state: { + running: { + startedAt: "before" + }, + } + }); + } + + for (let i = 0; i < opts.dead; i += 1) { + const name = `container_d_${i}`; + + pod.spec.containers.push({ + image: "dummy", + imagePullPolicy: "dummy", + name, + }); + pod.status.containerStatuses.push({ + image: "dummy", + imageID: "dummy", + name, + ready: false, + restartCount: i, + state: { + terminated: { + startedAt: "before", + exitCode: i+1, + finishedAt: "later", + reason: `reason_${i}` + } + } + }); + } + + for (let i = 0; i < opts.initRunning; i += 1) { + const name = `container_ir_${i}`; + + pod.spec.initContainers.push({ + image: "dummy", + imagePullPolicy: "dummy", + name, + }); + pod.status.initContainerStatuses.push({ + image: "dummy", + imageID: "dummy", + name, + ready: true, + restartCount: i, + state: { + running: { + startedAt: "before" + } + } + }); + } + + for (let i = 0; i < opts.initDead; i += 1) { + const name = `container_id_${i}`; + + pod.spec.initContainers.push({ + image: "dummy", + imagePullPolicy: "dummy", + name, + }); + pod.status.initContainerStatuses.push({ + image: "dummy", + imageID: "dummy", + name, + ready: false, + restartCount: i, + state: { + terminated: { + startedAt: "before", + exitCode: i+1, + finishedAt: "later", + reason: `reason_${i}` + } + } + }); + } + + return pod; +} + +describe("Pods", () => { + const podTests = []; + + for (let r = 0; r < 10; r += 1) { + for (let d = 0; d < 10; d += 1) { + for (let ir = 0; ir < 10; ir += 1) { + for (let id = 0; id < 10; id += 1) { + podTests.push([r, d, ir, id]); + } + } + } + } + + describe.each(podTests)("for [%d running, %d dead] & initial [%d running, %d dead]", (running, dead, initRunning, initDead) => { + const pod = getDummyPod({ running, dead, initRunning, initDead }); + + function getNamedContainer(name: string) { + return { + image: "dummy", + imagePullPolicy: "dummy", + name + }; + } + + it("getRunningContainers should return only running and init running", () => { + const res = [ + ...Array.from(new Array(running), (val, index) => getNamedContainer(`container_r_${index}`)), + ...Array.from(new Array(initRunning), (val, index) => getNamedContainer(`container_ir_${index}`)), + ]; + + expect(pod.getRunningContainers()).toStrictEqual(res); + }); + + it("getAllContainers should return all containers", () => { + const res = [ + ...Array.from(new Array(running), (val, index) => getNamedContainer(`container_r_${index}`)), + ...Array.from(new Array(dead), (val, index) => getNamedContainer(`container_d_${index}`)), + ...Array.from(new Array(initRunning), (val, index) => getNamedContainer(`container_ir_${index}`)), + ...Array.from(new Array(initDead), (val, index) => getNamedContainer(`container_id_${index}`)), + ]; + + expect(pod.getAllContainers()).toStrictEqual(res); + }); + + it("getRestartsCount should return total restart counts", () => { + function sum(len: number): number { + let res = 0; + + for (let i = 0; i < len; i += 1) { + res += i; + } + + return res; + } + + expect(pod.getRestartsCount()).toStrictEqual(sum(running) + sum(dead)); + }); + + it("hasIssues should return false", () => { + expect(pod.hasIssues()).toStrictEqual(false); + }); + }); + + describe("getSelectedNodeOs", () => { + it("should return stable", () => { + const pod = getDummyPod(); + + pod.spec.nodeSelector = { + "kubernetes.io/os": "foobar" + }; + + expect(pod.getSelectedNodeOs()).toStrictEqual("foobar"); + }); + + it("should return beta", () => { + const pod = getDummyPod(); + + pod.spec.nodeSelector = { + "beta.kubernetes.io/os": "foobar1" + }; + + expect(pod.getSelectedNodeOs()).toStrictEqual("foobar1"); + }); + + it("should return stable over beta", () => { + const pod = getDummyPod(); + + pod.spec.nodeSelector = { + "kubernetes.io/os": "foobar2", + "beta.kubernetes.io/os": "foobar3" + }; + + expect(pod.getSelectedNodeOs()).toStrictEqual("foobar2"); + }); + + it("should return undefined if none set", () => { + const pod = getDummyPod(); + + expect(pod.getSelectedNodeOs()).toStrictEqual(undefined); + }); + }); + + describe("hasIssues", () => { + it("should return true if a condition isn't ready", () => { + const pod = getDummyPod({ running: 1 }); + + pod.status.conditions.push({ + type: "Ready", + status: "foobar", + lastProbeTime: 1, + lastTransitionTime: "longer ago" + }); + + expect(pod.hasIssues()).toStrictEqual(true); + }); + + it("should return false if a condition is non-ready", () => { + const pod = getDummyPod({ running: 1 }); + + pod.status.conditions.push({ + type: "dummy", + status: "foobar", + lastProbeTime: 1, + lastTransitionTime: "longer ago" + }); + + expect(pod.hasIssues()).toStrictEqual(false); + }); + + it("should return true if a current container is in a crash loop back off", () => { + const pod = getDummyPod({ running: 1 }); + + pod.status.containerStatuses[0].state = { + waiting: { + reason: "CrashLookBackOff", + message: "too much foobar" + } + }; + + expect(pod.hasIssues()).toStrictEqual(true); + }); + + it("should return true if a current phase isn't running", () => { + const pod = getDummyPod({ running: 1 }); + + pod.status.phase = "not running"; + + expect(pod.hasIssues()).toStrictEqual(true); + }); + + it("should return false if a current phase is running", () => { + const pod = getDummyPod({ running: 1 }); + + pod.status.phase = "Running"; + + expect(pod.hasIssues()).toStrictEqual(false); + }); + }); +}); diff --git a/src/renderer/api/endpoints/pods.api.ts b/src/renderer/api/endpoints/pods.api.ts index 3176cae4d2..5bf319fbe8 100644 --- a/src/renderer/api/endpoints/pods.api.ts +++ b/src/renderer/api/endpoints/pods.api.ts @@ -270,60 +270,55 @@ export class Pod extends WorkloadKubeObject { } getAllContainers() { - return this.getContainers().concat(this.getInitContainers()); + return [...this.getContainers(), ...this.getInitContainers()]; } getRunningContainers() { - const statuses = this.getContainerStatuses(); - - return this.getAllContainers().filter(container => { - return statuses.find(status => status.name === container.name && !!status.state["running"]); - } + const runningContainerNames = new Set( + this.getContainerStatuses() + .filter(({ state }) => state.running) + .map(({ name }) => name) ); + + return this.getAllContainers() + .filter(({ name }) => runningContainerNames.has(name)); } getContainerStatuses(includeInitContainers = true) { - const statuses: IPodContainerStatus[] = []; - const { containerStatuses, initContainerStatuses } = this.status; + const { containerStatuses = [], initContainerStatuses = [] } = this.status ?? {}; - if (containerStatuses) { - statuses.push(...containerStatuses); + if (includeInitContainers) { + return [...containerStatuses, ...initContainerStatuses]; } - if (includeInitContainers && initContainerStatuses) { - statuses.push(...initContainerStatuses); - } - - return statuses; + return [...containerStatuses]; } getRestartsCount(): number { - const { containerStatuses } = this.status; + const { containerStatuses = [] } = this.status ?? {}; - if (!containerStatuses) return 0; - - return containerStatuses.reduce((count, item) => count + item.restartCount, 0); + return containerStatuses.reduce((totalCount, { restartCount }) => totalCount + restartCount, 0); } getQosClass() { - return this.status.qosClass || ""; + return this.status?.qosClass || ""; } getReason() { - return this.status.reason || ""; + return this.status?.reason || ""; } getPriorityClassName() { return this.spec.priorityClassName || ""; } - // Returns one of 5 statuses: Running, Succeeded, Pending, Failed, Evicted - getStatus() { + getStatus(): PodStatus { const phase = this.getStatusPhase(); const reason = this.getReason(); - const goodConditions = ["Initialized", "Ready"].every(condition => - !!this.getConditions().find(item => item.type === condition && item.status === "True") - ); + const trueConditionTypes = new Set(this.getConditions() + .filter(({ status }) => status === "True") + .map(({ type }) => type)); + const isInGoodCondition = ["Initialized", "Ready"].every(condition => trueConditionTypes.has(condition)); if (reason === PodStatus.EVICTED) { return PodStatus.EVICTED; @@ -337,7 +332,7 @@ export class Pod extends WorkloadKubeObject { return PodStatus.SUCCEEDED; } - if (phase === PodStatus.RUNNING && goodConditions) { + if (phase === PodStatus.RUNNING && isInGoodCondition) { return PodStatus.RUNNING; } @@ -349,37 +344,27 @@ export class Pod extends WorkloadKubeObject { if (this.getReason() === PodStatus.EVICTED) return "Evicted"; if (this.getStatus() === PodStatus.RUNNING && this.metadata.deletionTimestamp) return "Terminating"; - let message = ""; const statuses = this.getContainerStatuses(false); // not including initContainers - if (statuses.length) { - statuses.forEach(status => { - const { state } = status; + for (const { state } of statuses.reverse()) { + if (state.waiting) { + return state.waiting.reason || "Waiting"; + } - if (state.waiting) { - const { reason } = state.waiting; - - message = reason ? reason : "Waiting"; - } - - if (state.terminated) { - const { reason } = state.terminated; - - message = reason ? reason : "Terminated"; - } - }); + if (state.terminated) { + return state.terminated.reason || "Terminated"; + } } - if (message) return message; return this.getStatusPhase(); } getStatusPhase() { - return this.status.phase; + return this.status?.phase; } getConditions() { - return this.status.conditions || []; + return this.status?.conditions || []; } getVolumes() { @@ -393,9 +378,7 @@ export class Pod extends WorkloadKubeObject { } getNodeSelectors(): string[] { - const { nodeSelector } = this.spec; - - if (!nodeSelector) return []; + const { nodeSelector = {} } = this.spec; return Object.entries(nodeSelector).map(values => values.join(": ")); } @@ -409,20 +392,19 @@ export class Pod extends WorkloadKubeObject { } hasIssues() { - const notReady = !!this.getConditions().find(condition => { - return condition.type == "Ready" && condition.status !== "True"; - }); - const crashLoop = !!this.getContainerStatuses().find(condition => { - const waiting = condition.state.waiting; + for (const { type, status } of this.getConditions()) { + if (type === "Ready" && status !== "True") { + return true; + } + } - return (waiting && waiting.reason == "CrashLoopBackOff"); - }); + for (const { state } of this.getContainerStatuses()) { + if (state?.waiting?.reason === "CrashLookBackOff") { + return true; + } + } - return ( - notReady || - crashLoop || - this.getStatusPhase() !== "Running" - ); + return this.getStatusPhase() !== "Running"; } getLivenessProbe(container: IPodContainer) { @@ -476,14 +458,11 @@ export class Pod extends WorkloadKubeObject { } getNodeName() { - return this.spec?.nodeName; + return this.spec.nodeName; } - getSelectedNodeOs() { - if (!this.spec.nodeSelector) return; - if (!this.spec.nodeSelector["kubernetes.io/os"] && !this.spec.nodeSelector["beta.kubernetes.io/os"]) return; - - return this.spec.nodeSelector["kubernetes.io/os"] || this.spec.nodeSelector["beta.kubernetes.io/os"]; + getSelectedNodeOs(): string | undefined { + return this.spec.nodeSelector?.["kubernetes.io/os"] || this.spec.nodeSelector?.["beta.kubernetes.io/os"]; } } diff --git a/src/renderer/bootstrap.tsx b/src/renderer/bootstrap.tsx index c9ef607871..ce42e56f3c 100644 --- a/src/renderer/bootstrap.tsx +++ b/src/renderer/bootstrap.tsx @@ -19,6 +19,7 @@ import { filesystemProvisionerStore } from "../main/extension-filesystem"; import { App } from "./components/app"; import { LensApp } from "./lens-app"; import { themeStore } from "./theme.store"; +import { NonceProvider as StyleCache } from "react-select"; /** * If this is a development buid, wait a second to attach @@ -78,9 +79,14 @@ export async function bootstrap(App: AppComponent) { window.location.href = "about:blank"; } }); + + const cacheProps = { nonce: "lens", cacheKey: "lens" }; + render(<> {isMac &&
} - + + + , rootElem); } diff --git a/src/renderer/components/+apps-releases/release.store.ts b/src/renderer/components/+apps-releases/release.store.ts index 559a90eb01..59e8d9a5bc 100644 --- a/src/renderer/components/+apps-releases/release.store.ts +++ b/src/renderer/components/+apps-releases/release.store.ts @@ -6,6 +6,7 @@ import { ItemStore } from "../../item.store"; import { Secret } from "../../api/endpoints"; import { secretsStore } from "../+config-secrets/secrets.store"; import { namespaceStore } from "../+namespaces/namespace.store"; +import { Notifications } from "../notifications"; @autobind() export class ReleaseStore extends ItemStore { @@ -67,7 +68,11 @@ export class ReleaseStore extends ItemStore { this.items.replace(this.sortItems(items)); this.isLoaded = true; } catch (error) { - console.error(`Loading Helm Chart releases has failed: ${error}`); + console.error("Loading Helm Chart releases has failed", error); + + if (error.error) { + Notifications.error(error.error); + } } finally { this.isLoading = false; } diff --git a/src/renderer/components/+namespaces/namespace-select-filter.scss b/src/renderer/components/+namespaces/namespace-select-filter.scss new file mode 100644 index 0000000000..b9ad196e04 --- /dev/null +++ b/src/renderer/components/+namespaces/namespace-select-filter.scss @@ -0,0 +1,41 @@ +.NamespaceSelectFilter { + .Select { + &__placeholder { + width: 100%; + white-space: nowrap; + overflow: scroll!important; + text-overflow: unset!important; + margin-left: -8px; + padding-left: 8px; + margin-right: -8px; + padding-right: 8px; + + &::-webkit-scrollbar { + display: none; + } + } + + &__value-container { + position: relative; + + &::before, &::after { + content: ' '; + position: absolute; + z-index: 20; + display: block; + width: 8px; + height: var(--font-size); + } + + &::before { + left: 0px; + background: linear-gradient(to right, var(--contentColor) 0px, transparent); + } + + &::after { + right: 0px; + background: linear-gradient(to left, var(--contentColor) 0px, transparent); + } + } + } +} \ No newline at end of file diff --git a/src/renderer/components/+namespaces/namespace-select-filter.tsx b/src/renderer/components/+namespaces/namespace-select-filter.tsx index 85a6299b17..7b73cfc99d 100644 --- a/src/renderer/components/+namespaces/namespace-select-filter.tsx +++ b/src/renderer/components/+namespaces/namespace-select-filter.tsx @@ -2,7 +2,7 @@ import "./namespace-select.scss"; import React from "react"; import { observer } from "mobx-react"; -import { components, PlaceholderProps } from "react-select"; +import { components, OptionTypeBase, PlaceholderProps } from "react-select"; import { Icon } from "../icon"; import { FilterIcon } from "../item-object-list/filter-icon"; @@ -11,7 +11,7 @@ import { SelectOption } from "../select"; import { NamespaceSelect } from "./namespace-select"; import { namespaceStore } from "./namespace.store"; -const Placeholder = observer((props: PlaceholderProps) => { +const Placeholder = observer((props: PlaceholderProps) => { const getPlaceholder = (): React.ReactNode => { const namespaces = namespaceStore.contextNamespaces; @@ -71,6 +71,7 @@ export class NamespaceSelectFilter extends React.Component { placeholder={""} onChange={this.onChange} formatOptionLabel={this.formatOptionLabel} + className="NamespaceSelectFilter" /> ); } diff --git a/src/renderer/components/+namespaces/namespace-select.scss b/src/renderer/components/+namespaces/namespace-select.scss index 33293a6d7a..f300544cfb 100644 --- a/src/renderer/components/+namespaces/namespace-select.scss +++ b/src/renderer/components/+namespaces/namespace-select.scss @@ -23,22 +23,6 @@ .NamespaceSelect { @include namespaceSelectCommon; - - .Select { - &__placeholder { - width: 100%; - white-space: nowrap; - overflow: scroll; - margin-left: -8px; - padding-left: 8px; - margin-right: -8px; - padding-right: 8px; - - &::-webkit-scrollbar { - display: none; - } - } - } } .NamespaceSelectMenu { diff --git a/src/renderer/components/+namespaces/namespace-select.tsx b/src/renderer/components/+namespaces/namespace-select.tsx index d398f91f0a..d3df621b50 100644 --- a/src/renderer/components/+namespaces/namespace-select.tsx +++ b/src/renderer/components/+namespaces/namespace-select.tsx @@ -1,4 +1,4 @@ -import "./namespace-select.scss"; +import "./namespace-select-filter.scss"; import React from "react"; import { computed } from "mobx"; @@ -8,7 +8,6 @@ import { cssNames } from "../../utils"; import { Icon } from "../icon"; import { namespaceStore } from "./namespace.store"; import { kubeWatchApi } from "../../api/kube-watch-api"; -import { components, ValueContainerProps } from "react-select"; interface Props extends SelectProps { showIcons?: boolean; @@ -22,16 +21,6 @@ const defaultProps: Partial = { showClusterOption: false, }; -function GradientValueContainer({children, ...rest}: ValueContainerProps) { - return ( - -
- {children} -
- - ); -} - @observer export class NamespaceSelect extends React.Component { static defaultProps = defaultProps as object; @@ -77,8 +66,6 @@ export class NamespaceSelect extends React.Component { render() { const { className, showIcons, customizeOptions, components = {}, ...selectProps } = this.props; - components.ValueContainer ??= GradientValueContainer; - return (