diff --git a/packages/core/src/features/cluster/refresh-accessibility-technical.test.ts b/packages/core/src/features/cluster/refresh-accessibility-technical.test.ts index 74997cdd1d..73ff6151b9 100644 --- a/packages/core/src/features/cluster/refresh-accessibility-technical.test.ts +++ b/packages/core/src/features/cluster/refresh-accessibility-technical.test.ts @@ -22,8 +22,10 @@ import type { DetectClusterMetadata } from "../../main/cluster-detectors/detect- import detectClusterMetadataInjectable from "../../main/cluster-detectors/detect-cluster-metadata.injectable"; import type { ClusterConnection } from "../../main/cluster/cluster-connection.injectable"; import clusterConnectionInjectable from "../../main/cluster/cluster-connection.injectable"; +import type { KubeAuthProxy } from "../../main/kube-auth-proxy/create-kube-auth-proxy.injectable"; +import createKubeAuthProxyInjectable from "../../main/kube-auth-proxy/create-kube-auth-proxy.injectable"; +import type { Mocked } from "../../test-utils/mock-interface"; import { flushPromises } from "@k8slens/test-utils"; -import { setTimeout } from "timers/promises"; describe("Refresh Cluster Accessibility Technical Tests", () => { let builder: ApplicationBuilder; @@ -32,6 +34,7 @@ describe("Refresh Cluster Accessibility Technical Tests", () => { let listNamespaceMock: AsyncFnMock; let k8sRequestMock: AsyncFnMock; let detectClusterMetadataMock: AsyncFnMock; + let kubeAuthProxyMock: Mocked; beforeEach(async () => { builder = getApplicationBuilder(); @@ -40,6 +43,14 @@ describe("Refresh Cluster Accessibility Technical Tests", () => { mainDi.override(broadcastMessageInjectable, () => async () => {}); + kubeAuthProxyMock = { + apiPrefix: "/some-api-prefix", + port: 0, + exit: jest.fn(), + run: asyncFn(), + }; + mainDi.override(createKubeAuthProxyInjectable, () => () => kubeAuthProxyMock); + detectClusterMetadataMock = asyncFn(); mainDi.override(detectClusterMetadataInjectable, () => detectClusterMetadataMock); @@ -101,326 +112,333 @@ describe("Refresh Cluster Accessibility Technical Tests", () => { cluster = clusterStore.getById("some-cluster-id") ?? (() => { throw new Error("missing cluster"); })(); clusterConnection = mainDi.inject(clusterConnectionInjectable, cluster); refreshPromise = clusterConnection.refreshAccessibilityAndMetadata(); - - // NOTE: I don't know why these are all are required to get the tests to pass - await flushPromises(); - await setTimeout(50); - await flushPromises(); }); - it("requests if cluster has admin permissions", async () => { - expect(createSelfSubjectAccessReviewMock).toBeCalledWith(anyObject({ - spec: { - namespace: "kube-system", - resource: "*", - verb: "create", - }, - })); + it("starts kubeAuthProxy", () => { + expect(kubeAuthProxyMock.run).toBeCalled(); }); - describe.each([ true, false ])("when cluster admin request resolves to %p", (isAdmin) => { + describe("when kubeAuthProxy has started running and its port is found", () => { beforeEach(async () => { - await createSelfSubjectAccessReviewMock.resolve({ - body: { - status: { - allowed: isAdmin, - }, - } as PartialDeep, - } as any); + kubeAuthProxyMock.port = 1235; + await kubeAuthProxyMock.run.resolve(); + await flushPromises(); }); - it("requests if cluster has global watch permissions", () => { + it("requests if cluster has admin permissions", async () => { expect(createSelfSubjectAccessReviewMock).toBeCalledWith(anyObject({ spec: { - verb: "watch", + namespace: "kube-system", resource: "*", + verb: "create", }, })); }); - describe.each([ true, false ])("when cluster global watch request resolves with %p", (globalWatch) => { + describe.each([ true, false ])("when cluster admin request resolves to %p", (isAdmin) => { beforeEach(async () => { await createSelfSubjectAccessReviewMock.resolve({ body: { status: { - allowed: globalWatch, + allowed: isAdmin, }, } as PartialDeep, } as any); }); - it("requests namespaces", () => { - expect(listNamespaceMock).toBeCalled(); + it("requests if cluster has global watch permissions", () => { + expect(createSelfSubjectAccessReviewMock).toBeCalledWith(anyObject({ + spec: { + verb: "watch", + resource: "*", + }, + })); }); - describe("when list namespaces resolves", () => { + describe.each([ true, false ])("when cluster global watch request resolves with %p", (globalWatch) => { beforeEach(async () => { - await listNamespaceMock.resolve(listNamespaceResponse); + await createSelfSubjectAccessReviewMock.resolve({ + body: { + status: { + allowed: globalWatch, + }, + } as PartialDeep, + } as any); }); - it("requests core api versions", () => { - expect(k8sRequestMock).toBeCalledWith( - anyObject({ id: "some-cluster-id" }), - "/api", - ); + it("requests namespaces", () => { + expect(listNamespaceMock).toBeCalled(); }); - describe("when core api versions request resolves", () => { + describe("when list namespaces resolves", () => { beforeEach(async () => { - await k8sRequestMock.resolve({ - serverAddressByClientCIDRs: [], - versions: [ - "v1", - ], - } as V1APIVersions); + await listNamespaceMock.resolve(listNamespaceResponse); }); - it("requests non-core api resource kinds", () => { + it("requests core api versions", () => { expect(k8sRequestMock).toBeCalledWith( anyObject({ id: "some-cluster-id" }), - "/apis", + "/api", ); }); - describe("when non-core api resource kinds request resolves", () => { + describe("when core api versions request resolves", () => { beforeEach(async () => { - await k8sRequestMock.resolve(nonCoreApiResponse); + await k8sRequestMock.resolve({ + serverAddressByClientCIDRs: [], + versions: [ + "v1", + ], + } as V1APIVersions); }); - it("requests specific resource kinds in core", () => { + it("requests non-core api resource kinds", () => { expect(k8sRequestMock).toBeCalledWith( anyObject({ id: "some-cluster-id" }), - "/api/v1", + "/apis", ); }); - describe("when core specific resource kinds request resolves", () => { + describe("when non-core api resource kinds request resolves", () => { beforeEach(async () => { - await k8sRequestMock.resolve(coreApiKindsResponse); + await k8sRequestMock.resolve(nonCoreApiResponse); }); - it("requests specific resources kinds from the first non-core response", () => { + it("requests specific resource kinds in core", () => { expect(k8sRequestMock).toBeCalledWith( anyObject({ id: "some-cluster-id" }), - "/apis/node.k8s.io/v1", + "/api/v1", ); }); - describe("when first specific resource kinds request resolves", () => { + describe("when core specific resource kinds request resolves", () => { beforeEach(async () => { - await k8sRequestMock.resolve(nodeK8sIoKindsResponse); + await k8sRequestMock.resolve(coreApiKindsResponse); }); - it("requests specific resources kinds from the second non-core response", () => { + it("requests specific resources kinds from the first non-core response", () => { expect(k8sRequestMock).toBeCalledWith( anyObject({ id: "some-cluster-id" }), - "/apis/discovery.k8s.io/v1", + "/apis/node.k8s.io/v1", ); }); - describe("when second specific resource kinds request resolves", () => { + describe("when first specific resource kinds request resolves", () => { beforeEach(async () => { - await k8sRequestMock.resolve(discoveryK8sIoKindsResponse); + await k8sRequestMock.resolve(nodeK8sIoKindsResponse); }); - it("requests namespace list permissions for 'default' namespace", () => { - expect(createSelfSubjectRulesReviewMock).toBeCalledWith(anyObject({ - spec: { - namespace: "default", - }, - })); + it("requests specific resources kinds from the second non-core response", () => { + expect(k8sRequestMock).toBeCalledWith( + anyObject({ id: "some-cluster-id" }), + "/apis/discovery.k8s.io/v1", + ); }); - describe("when the permissions are incomplete", () => { + describe("when second specific resource kinds request resolves", () => { beforeEach(async () => { - await createSelfSubjectRulesReviewMock.resolve(defaultIncompletePermissions); + await k8sRequestMock.resolve(discoveryK8sIoKindsResponse); }); - it("requests namespace list permissions for 'my-namespace' namespace", () => { + it("requests namespace list permissions for 'default' namespace", () => { expect(createSelfSubjectRulesReviewMock).toBeCalledWith(anyObject({ spec: { - namespace: "my-namespace", + namespace: "default", }, })); }); - describe("when the permissions request for 'my-namespace' resolves as empty", () => { + describe("when the permissions are incomplete", () => { + beforeEach(async () => { + await createSelfSubjectRulesReviewMock.resolve(defaultIncompletePermissions); + }); + + it("requests namespace list permissions for 'my-namespace' namespace", () => { + expect(createSelfSubjectRulesReviewMock).toBeCalledWith(anyObject({ + spec: { + namespace: "my-namespace", + }, + })); + }); + + describe("when the permissions request for 'my-namespace' resolves as empty", () => { + beforeEach(async () => { + await createSelfSubjectRulesReviewMock.resolve(emptyPermissions); + }); + + it("requests cluster metadata", () => { + expect(detectClusterMetadataMock).toBeCalledWith(anyObject({ id: "some-cluster-id" })); + }); + + describe("when cluster metadata request resolves", () => { + beforeEach(async () => { + await detectClusterMetadataMock.resolve({}); + }); + + it("allows the call to refreshAccessibilityAndMetadata to resolve", async () => { + await refreshPromise; + }); + + it("should have the cluster displaying 'pods'", () => { + expect(cluster.resourcesToShow.has("pods")).toBe(true); + }); + + it("should have the cluster displaying 'namespaces'", () => { + expect(cluster.resourcesToShow.has("namespaces")).toBe(true); + }); + }); + }); + + describe.skip("when the permissions are incomplete", () => {}); + describe.skip("when the permissions resolve to a single entry with 'list' verb", () => {}); + describe.skip("when the permissions resolve to multiple entries with the 'list' verb not on the first entry", () => {}); + }); + + describe("when the permissions resolve to an empty list", () => { beforeEach(async () => { await createSelfSubjectRulesReviewMock.resolve(emptyPermissions); }); - it("requests cluster metadata", () => { - expect(detectClusterMetadataMock).toBeCalledWith(anyObject({ id: "some-cluster-id" })); + it("requests namespace list permissions for 'my-namespace' namespace", () => { + expect(createSelfSubjectRulesReviewMock).toBeCalledWith(anyObject({ + spec: { + namespace: "my-namespace", + }, + })); }); - describe("when cluster metadata request resolves", () => { + describe("when the permissions request for 'my-namespace' resolves as empty", () => { beforeEach(async () => { - await detectClusterMetadataMock.resolve({}); + await createSelfSubjectRulesReviewMock.resolve(emptyPermissions); }); - it("allows the call to refreshAccessibilityAndMetadata to resolve", async () => { - await refreshPromise; + it("requests cluster metadata", () => { + expect(detectClusterMetadataMock).toBeCalledWith(anyObject({ id: "some-cluster-id" })); }); - it("should have the cluster displaying 'pods'", () => { - expect(cluster.resourcesToShow.has("pods")).toBe(true); - }); + describe("when cluster metadata request resolves", () => { + beforeEach(async () => { + await detectClusterMetadataMock.resolve({}); + }); - it("should have the cluster displaying 'namespaces'", () => { - expect(cluster.resourcesToShow.has("namespaces")).toBe(true); + it("allows the call to refreshAccessibilityAndMetadata to resolve", async () => { + await refreshPromise; + }); + + it("should have the cluster displaying 'pods'", () => { + expect(cluster.resourcesToShow.has("pods")).toBe(false); + }); + + it("should have the cluster not displaying 'namespaces'", () => { + expect(cluster.resourcesToShow.has("namespaces")).toBe(false); + }); }); }); + + describe.skip("when the permissions are incomplete", () => {}); + describe.skip("when the permissions resolve to a single entry with 'list' verb", () => {}); + describe.skip("when the permissions resolve to multiple entries with the 'list' verb not on the first entry", () => {}); }); - describe.skip("when the permissions are incomplete", () => {}); - describe.skip("when the permissions resolve to a single entry with 'list' verb", () => {}); - describe.skip("when the permissions resolve to multiple entries with the 'list' verb not on the first entry", () => {}); - }); - - describe("when the permissions resolve to an empty list", () => { - beforeEach(async () => { - await createSelfSubjectRulesReviewMock.resolve(emptyPermissions); - }); - - it("requests namespace list permissions for 'my-namespace' namespace", () => { - expect(createSelfSubjectRulesReviewMock).toBeCalledWith(anyObject({ - spec: { - namespace: "my-namespace", - }, - })); - }); - - describe("when the permissions request for 'my-namespace' resolves as empty", () => { + describe("when the permissions resolve to a single entry with 'list' verb", () => { beforeEach(async () => { - await createSelfSubjectRulesReviewMock.resolve(emptyPermissions); + await createSelfSubjectRulesReviewMock.resolve(defaultSingleListPermissions); }); - it("requests cluster metadata", () => { - expect(detectClusterMetadataMock).toBeCalledWith(anyObject({ id: "some-cluster-id" })); + it("requests namespace list permissions for 'my-namespace' namespace", () => { + expect(createSelfSubjectRulesReviewMock).toBeCalledWith(anyObject({ + spec: { + namespace: "my-namespace", + }, + })); }); - describe("when cluster metadata request resolves", () => { + describe("when the permissions request for 'my-namespace' resolves as empty", () => { beforeEach(async () => { - await detectClusterMetadataMock.resolve({}); + await createSelfSubjectRulesReviewMock.resolve(emptyPermissions); }); - it("allows the call to refreshAccessibilityAndMetadata to resolve", async () => { - await refreshPromise; + it("requests cluster metadata", () => { + expect(detectClusterMetadataMock).toBeCalledWith(anyObject({ id: "some-cluster-id" })); }); - it("should have the cluster displaying 'pods'", () => { - expect(cluster.resourcesToShow.has("pods")).toBe(false); - }); + describe("when cluster metadata request resolves", () => { + beforeEach(async () => { + await detectClusterMetadataMock.resolve({}); + }); - it("should have the cluster not displaying 'namespaces'", () => { - expect(cluster.resourcesToShow.has("namespaces")).toBe(false); + it("allows the call to refreshAccessibilityAndMetadata to resolve", async () => { + await refreshPromise; + }); + + it("should have the cluster displaying 'pods'", () => { + expect(cluster.resourcesToShow.has("pods")).toBe(true); + }); + + it("should have the cluster not displaying 'namespaces'", () => { + expect(cluster.resourcesToShow.has("namespaces")).toBe(false); + }); }); }); + + describe.skip("when the permissions are incomplete", () => {}); + describe.skip("when the permissions resolve to a single entry with 'list' verb", () => {}); + describe.skip("when the permissions resolve to multiple entries with the 'list' verb not on the first entry", () => {}); }); - describe.skip("when the permissions are incomplete", () => {}); - describe.skip("when the permissions resolve to a single entry with 'list' verb", () => {}); - describe.skip("when the permissions resolve to multiple entries with the 'list' verb not on the first entry", () => {}); - }); - - describe("when the permissions resolve to a single entry with 'list' verb", () => { - beforeEach(async () => { - await createSelfSubjectRulesReviewMock.resolve(defaultSingleListPermissions); - }); - - it("requests namespace list permissions for 'my-namespace' namespace", () => { - expect(createSelfSubjectRulesReviewMock).toBeCalledWith(anyObject({ - spec: { - namespace: "my-namespace", - }, - })); - }); - - describe("when the permissions request for 'my-namespace' resolves as empty", () => { + describe("when the permissions resolve to multiple entries with the 'list' verb not on the first entry", () => { beforeEach(async () => { - await createSelfSubjectRulesReviewMock.resolve(emptyPermissions); + await createSelfSubjectRulesReviewMock.resolve(defaultMultipleListPermissions); }); - it("requests cluster metadata", () => { - expect(detectClusterMetadataMock).toBeCalledWith(anyObject({ id: "some-cluster-id" })); + it("requests namespace list permissions for 'my-namespace' namespace", () => { + expect(createSelfSubjectRulesReviewMock).toBeCalledWith(anyObject({ + spec: { + namespace: "my-namespace", + }, + })); }); - describe("when cluster metadata request resolves", () => { + describe("when the permissions request for 'my-namespace' resolves as empty", () => { beforeEach(async () => { - await detectClusterMetadataMock.resolve({}); + await createSelfSubjectRulesReviewMock.resolve(emptyPermissions); }); - it("allows the call to refreshAccessibilityAndMetadata to resolve", async () => { - await refreshPromise; + it("requests cluster metadata", () => { + expect(detectClusterMetadataMock).toBeCalledWith(anyObject({ id: "some-cluster-id" })); }); - it("should have the cluster displaying 'pods'", () => { - expect(cluster.resourcesToShow.has("pods")).toBe(true); - }); + describe("when cluster metadata request resolves", () => { + beforeEach(async () => { + await detectClusterMetadataMock.resolve({}); + }); - it("should have the cluster not displaying 'namespaces'", () => { - expect(cluster.resourcesToShow.has("namespaces")).toBe(false); + it("allows the call to refreshAccessibilityAndMetadata to resolve", async () => { + await refreshPromise; + }); + + it("should have the cluster displaying 'pods'", () => { + expect(cluster.resourcesToShow.has("pods")).toBe(true); + }); + + it("should have the cluster not displaying 'namespaces'", () => { + expect(cluster.resourcesToShow.has("namespaces")).toBe(false); + }); }); }); - }); - describe.skip("when the permissions are incomplete", () => {}); - describe.skip("when the permissions resolve to a single entry with 'list' verb", () => {}); - describe.skip("when the permissions resolve to multiple entries with the 'list' verb not on the first entry", () => {}); + describe.skip("when the permissions are incomplete", () => {}); + describe.skip("when the permissions resolve to a single entry with 'list' verb", () => {}); + describe.skip("when the permissions resolve to multiple entries with the 'list' verb not on the first entry", () => {}); + }); }); - describe("when the permissions resolve to multiple entries with the 'list' verb not on the first entry", () => { - beforeEach(async () => { - await createSelfSubjectRulesReviewMock.resolve(defaultMultipleListPermissions); - }); - - it("requests namespace list permissions for 'my-namespace' namespace", () => { - expect(createSelfSubjectRulesReviewMock).toBeCalledWith(anyObject({ - spec: { - namespace: "my-namespace", - }, - })); - }); - - describe("when the permissions request for 'my-namespace' resolves as empty", () => { - beforeEach(async () => { - await createSelfSubjectRulesReviewMock.resolve(emptyPermissions); - }); - - it("requests cluster metadata", () => { - expect(detectClusterMetadataMock).toBeCalledWith(anyObject({ id: "some-cluster-id" })); - }); - - describe("when cluster metadata request resolves", () => { - beforeEach(async () => { - await detectClusterMetadataMock.resolve({}); - }); - - it("allows the call to refreshAccessibilityAndMetadata to resolve", async () => { - await refreshPromise; - }); - - it("should have the cluster displaying 'pods'", () => { - expect(cluster.resourcesToShow.has("pods")).toBe(true); - }); - - it("should have the cluster not displaying 'namespaces'", () => { - expect(cluster.resourcesToShow.has("namespaces")).toBe(false); - }); - }); - }); - - describe.skip("when the permissions are incomplete", () => {}); - describe.skip("when the permissions resolve to a single entry with 'list' verb", () => {}); - describe.skip("when the permissions resolve to multiple entries with the 'list' verb not on the first entry", () => {}); - }); + describe.skip("when second specific resource kinds rejects", () => {}); }); - - describe.skip("when second specific resource kinds rejects", () => {}); }); - }); - describe.skip("when first specific resource kinds rejects", () => {}); + describe.skip("when first specific resource kinds rejects", () => {}); + }); }); }); }); diff --git a/packages/core/src/main/cluster/kube-auth-proxy-server.injectable.ts b/packages/core/src/main/cluster/kube-auth-proxy-server.injectable.ts index 9942c40168..ff70af3a7b 100644 --- a/packages/core/src/main/cluster/kube-auth-proxy-server.injectable.ts +++ b/packages/core/src/main/cluster/kube-auth-proxy-server.injectable.ts @@ -8,7 +8,7 @@ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import type { Cluster } from "../../common/cluster/cluster"; import createKubeAuthProxyInjectable from "../kube-auth-proxy/create-kube-auth-proxy.injectable"; import kubeAuthProxyCertificateInjectable from "../kube-auth-proxy/kube-auth-proxy-certificate.injectable"; -import type { KubeAuthProxy } from "../kube-auth-proxy/kube-auth-proxy"; +import type { KubeAuthProxy } from "../kube-auth-proxy/create-kube-auth-proxy.injectable"; export interface KubeAuthProxyServer { getApiTarget(isLongRunningRequest?: boolean): Promise; diff --git a/packages/core/src/main/kube-auth-proxy/create-kube-auth-proxy.injectable.ts b/packages/core/src/main/kube-auth-proxy/create-kube-auth-proxy.injectable.ts index 56260347bf..fd4acf0d7a 100644 --- a/packages/core/src/main/kube-auth-proxy/create-kube-auth-proxy.injectable.ts +++ b/packages/core/src/main/kube-auth-proxy/create-kube-auth-proxy.injectable.ts @@ -4,7 +4,7 @@ */ import { getInjectable } from "@ogre-tools/injectable"; import type { KubeAuthProxyDependencies } from "./kube-auth-proxy"; -import { KubeAuthProxy } from "./kube-auth-proxy"; +import { KubeAuthProxyImpl } from "./kube-auth-proxy"; import type { Cluster } from "../../common/cluster/cluster"; import spawnInjectable from "../child-process/spawn.injectable"; import kubeAuthProxyCertificateInjectable from "./kube-auth-proxy-certificate.injectable"; @@ -15,6 +15,13 @@ import getPortFromStreamInjectable from "../utils/get-port-from-stream.injectabl import getDirnameOfPathInjectable from "../../common/path/get-dirname.injectable"; import broadcastConnectionUpdateInjectable from "../cluster/broadcast-connection-update.injectable"; +export interface KubeAuthProxy { + readonly apiPrefix: string; + readonly port: number; + run: () => Promise; + exit: () => void; +} + export type CreateKubeAuthProxy = (cluster: Cluster, env: NodeJS.ProcessEnv) => KubeAuthProxy; const createKubeAuthProxyInjectable = getInjectable({ @@ -33,7 +40,7 @@ const createKubeAuthProxyInjectable = getInjectable({ return (cluster, env) => { const clusterUrl = new URL(cluster.apiUrl.get()); - return new KubeAuthProxy({ + return new KubeAuthProxyImpl({ ...dependencies, proxyCert: di.inject(kubeAuthProxyCertificateInjectable, clusterUrl.hostname), broadcastConnectionUpdate: di.inject(broadcastConnectionUpdateInjectable, cluster), diff --git a/packages/core/src/main/kube-auth-proxy/kube-auth-proxy.ts b/packages/core/src/main/kube-auth-proxy/kube-auth-proxy.ts index 9a9b5a249f..160566c3a5 100644 --- a/packages/core/src/main/kube-auth-proxy/kube-auth-proxy.ts +++ b/packages/core/src/main/kube-auth-proxy/kube-auth-proxy.ts @@ -16,6 +16,7 @@ import type { Logger } from "../../common/logger"; import type { WaitUntilPortIsUsed } from "./wait-until-port-is-used/wait-until-port-is-used.injectable"; import type { GetDirnameOfPath } from "../../common/path/get-dirname.injectable"; import type { BroadcastConnectionUpdate } from "../cluster/broadcast-connection-update.injectable"; +import type { KubeAuthProxy } from "./create-kube-auth-proxy.injectable"; const startingServeMatcher = "starting to serve on (?
.+)"; const startingServeRegex = Object.assign(TypedRegEx(startingServeMatcher, "i"), { @@ -33,7 +34,7 @@ export interface KubeAuthProxyDependencies { broadcastConnectionUpdate: BroadcastConnectionUpdate; } -export class KubeAuthProxy { +export class KubeAuthProxyImpl implements KubeAuthProxy { public readonly apiPrefix = `/${randomBytes(8).toString("hex")}`; public get port(): number { diff --git a/packages/core/src/test-utils/mock-interface.ts b/packages/core/src/test-utils/mock-interface.ts new file mode 100644 index 0000000000..e9870282b3 --- /dev/null +++ b/packages/core/src/test-utils/mock-interface.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { AsyncFnMock } from "@async-fn/jest"; + +type GetMockedType = + T extends (...args: any[]) => Promise + ? AsyncFnMock + : T extends (...args: any[]) => any + ? jest.MockedFunction + : T; + +export type Mocked = { + -readonly [P in keyof T]: GetMockedType; +};