diff --git a/package-lock.json b/package-lock.json index c8b7f36..50eaafa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "0.1.0", "devDependencies": { "@kinvolk/headlamp-plugin": "^0.14.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.2", "vite": "^6.4.3", "vite-plugin-svgr": "^4.5.0" } @@ -4060,7 +4062,6 @@ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, - "license": "MIT", "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -4103,11 +4104,10 @@ "license": "MIT" }, "node_modules/@testing-library/react": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz", - "integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", "dev": true, - "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.5" }, diff --git a/package.json b/package.json index 633c7e3..2ac1671 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,8 @@ }, "devDependencies": { "@kinvolk/headlamp-plugin": "^0.14.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.2", "vite": "^6.4.3", "vite-plugin-svgr": "^4.5.0" } diff --git a/src/components/AgonesInstallCheck.tsx b/src/components/AgonesInstallCheck.tsx new file mode 100644 index 0000000..3a4364b --- /dev/null +++ b/src/components/AgonesInstallCheck.tsx @@ -0,0 +1,96 @@ +/* + * Copyright Contributors to Agones a Series of LF Projects, LLC. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Box from '@mui/material/Box'; +import Grid from '@mui/material/Grid'; +import Link from '@mui/material/Link'; +import Typography from '@mui/material/Typography'; +import React from 'react'; +import { useAgonesInstalled } from '../hooks/useAgonesInstalled'; + +function NotInstalledBanner() { + return ( + + + + + Agones was not detected on your cluster. If you haven't already, please install it. + + + + + Learn how to{' '} + + install + {' '} + Agones + + + + + ); +} + +interface AgonesInstallCheckProps { + children: React.ReactNode; + fallback?: React.ReactNode; +} + +/** + * Wrapper component that gates its children behind an Agones CRD detection + * check. While the check is in flight nothing is rendered; if the CRDs are + * absent a {@link NotInstalledBanner} (or a custom `fallback`) is rendered + * instead of the children. + */ +export function AgonesInstallCheck({ children, fallback }: AgonesInstallCheckProps) { + const { isAgonesInstalled } = useAgonesInstalled(); + + if (isAgonesInstalled === null) { + return null; + } + + if (isAgonesInstalled === false) { + return <>{fallback || }; + } + + return <>{children}; +} + +/** + * Higher-order component that wraps `Component` with {@link AgonesInstallCheck}. + * + * Use this instead of manually nesting `` around every + * route component — it keeps `registerRoute` calls concise and ensures + * the guard is applied consistently. + * + * @example + * ```tsx + * registerRoute({ + * path: '/agones', + * component: withInstallCheck(AgonesOverview), + * }); + * ``` + */ +export const withInstallCheck = (Component: React.ComponentType) => () => + ( + + + + ); diff --git a/src/hooks/useAgonesInstalled.test.tsx b/src/hooks/useAgonesInstalled.test.tsx new file mode 100644 index 0000000..203e4f1 --- /dev/null +++ b/src/hooks/useAgonesInstalled.test.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Contributors to Agones a Series of LF Projects, LLC. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiProxy } from '@kinvolk/headlamp-plugin/lib'; +import { renderHook, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { useAgonesInstalled } from './useAgonesInstalled'; + +// Mock ApiProxy so the hook's internal isAgonesInstalled() call +// doesn't make real HTTP requests. +vi.mock('@kinvolk/headlamp-plugin/lib', () => ({ + ApiProxy: { + request: vi.fn(), + }, +})); + +describe('useAgonesInstalled', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should start in loading state', () => { + // Never-resolving promise keeps the hook in loading state + vi.mocked(ApiProxy.request).mockReturnValue(new Promise(() => {})); + const { result } = renderHook(() => useAgonesInstalled()); + + expect(result.current.isAgonesInstalled).toBeNull(); + expect(result.current.isAgonesCheckLoading).toBe(true); + }); + + it('should return isAgonesInstalled=true when Agones is detected', async () => { + vi.mocked(ApiProxy.request).mockResolvedValue({ + kind: 'APIResourceList', + resources: [{ name: 'gameservers' }], + }); + + const { result } = renderHook(() => useAgonesInstalled()); + + await waitFor(() => { + expect(result.current.isAgonesInstalled).toBe(true); + }); + + expect(result.current.isAgonesCheckLoading).toBe(false); + }); + + it('should return isAgonesInstalled=false when Agones is not detected', async () => { + vi.mocked(ApiProxy.request).mockRejectedValue(new Error('404 Not Found')); + + const { result } = renderHook(() => useAgonesInstalled()); + + await waitFor(() => { + expect(result.current.isAgonesInstalled).toBe(false); + }); + + expect(result.current.isAgonesCheckLoading).toBe(false); + }); +}); diff --git a/src/hooks/useAgonesInstalled.tsx b/src/hooks/useAgonesInstalled.tsx new file mode 100644 index 0000000..f117aa3 --- /dev/null +++ b/src/hooks/useAgonesInstalled.tsx @@ -0,0 +1,72 @@ +/* + * Copyright Contributors to Agones a Series of LF Projects, LLC. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiProxy } from '@kinvolk/headlamp-plugin/lib'; +import { useEffect, useState } from 'react'; + +/** + * Checks whether the Agones CRDs are installed on the current cluster by + * querying the {@link https://agones.dev/site/docs/reference/agones_crd_api_reference/ | Agones API group} + * at `/apis/agones.dev/v1`. + * + * The response is validated to be a genuine Kubernetes `APIResourceList` + * (not a `Status` error object that some proxies return for 404s). + * + * @returns `true` if Agones CRDs are present, `false` otherwise. + */ +export async function isAgonesInstalled(): Promise { + try { + const response = await ApiProxy.request('/apis/agones.dev/v1', { + method: 'GET', + }); + // Verify the response is a real K8s API resource list, not an error object. + return response?.kind === 'APIResourceList' && Array.isArray(response?.resources); + } catch { + return false; + } +} + +/** + * React hook that asynchronously checks whether the Agones CRDs are installed + * on the current Kubernetes cluster. + * + * @returns An object with: + * - `isAgonesInstalled` — `null` while loading, `true` if detected, `false` if not. + * - `isAgonesCheckLoading` — `true` while the API check is in progress. + * + * @example + * ```tsx + * const { isAgonesInstalled, isAgonesCheckLoading } = useAgonesInstalled(); + * if (isAgonesCheckLoading) return ; + * if (!isAgonesInstalled) return ; + * ``` + */ +export function useAgonesInstalled() { + const [isInstalled, setIsInstalled] = useState(null); + + useEffect(() => { + async function checkInstalled() { + const installed = await isAgonesInstalled(); + setIsInstalled(!!installed); + } + checkInstalled(); + }, []); + + return { + isAgonesInstalled: isInstalled, + isAgonesCheckLoading: isInstalled === null, + }; +} diff --git a/src/index.tsx b/src/index.tsx index 6f9e55e..f7a7d88 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -19,7 +19,7 @@ import { registerRoute, registerSidebarEntry, } from '@kinvolk/headlamp-plugin/lib'; -import React from 'react'; +import { withInstallCheck } from './components/AgonesInstallCheck'; import { agonesMapSource } from './mapView'; import { FleetAutoscalerDetail } from './views/fleetautoscalers/Detail'; import { FleetAutoscalerList } from './views/fleetautoscalers/List'; @@ -74,7 +74,7 @@ registerRoute({ sidebar: 'agones-overview', name: 'agones-overview', exact: true, - component: () => , + component: withInstallCheck(AgonesOverview), }); registerRoute({ @@ -82,13 +82,13 @@ registerRoute({ sidebar: 'agones-fleets', name: 'agones-fleets', exact: true, - component: () => , + component: withInstallCheck(FleetList), }); registerRoute({ path: '/agones/fleets/:namespace/:name', sidebar: 'agones-fleets', name: 'agones-fleet', - component: () => , + component: withInstallCheck(FleetDetail), }); registerRoute({ @@ -96,13 +96,13 @@ registerRoute({ sidebar: 'agones-gameservers', name: 'agones-gameservers', exact: true, - component: () => , + component: withInstallCheck(GameServerList), }); registerRoute({ path: '/agones/gameservers/:namespace/:name', sidebar: 'agones-gameservers', name: 'agones-gameserver', - component: () => , + component: withInstallCheck(GameServerDetail), }); registerRoute({ @@ -110,11 +110,11 @@ registerRoute({ sidebar: 'agones-fleetautoscalers', name: 'agones-fleetautoscalers', exact: true, - component: () => , + component: withInstallCheck(FleetAutoscalerList), }); registerRoute({ path: '/agones/fleetautoscalers/:namespace/:name', sidebar: 'agones-fleetautoscalers', name: 'agones-fleetautoscaler', - component: () => , + component: withInstallCheck(FleetAutoscalerDetail), }); diff --git a/src/isAgonesInstalled.test.ts b/src/isAgonesInstalled.test.ts new file mode 100644 index 0000000..5d14e22 --- /dev/null +++ b/src/isAgonesInstalled.test.ts @@ -0,0 +1,108 @@ +/* + * Copyright Contributors to Agones a Series of LF Projects, LLC. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it, vi } from 'vitest'; + +// Mock ApiProxy before importing the module under test +vi.mock('@kinvolk/headlamp-plugin/lib', () => ({ + ApiProxy: { + request: vi.fn(), + }, +})); + +import { ApiProxy } from '@kinvolk/headlamp-plugin/lib'; +import { isAgonesInstalled } from './hooks/useAgonesInstalled'; + +describe('isAgonesInstalled', () => { + it('should return true when the Agones API group responds with a valid APIResourceList', async () => { + vi.mocked(ApiProxy.request).mockResolvedValue({ + kind: 'APIResourceList', + resources: [{ name: 'gameservers' }], + }); + + const result = await isAgonesInstalled(); + + expect(result).toBe(true); + expect(ApiProxy.request).toHaveBeenCalledWith('/apis/agones.dev/v1', { + method: 'GET', + }); + }); + + it('should return true when resources array is empty but valid', async () => { + vi.mocked(ApiProxy.request).mockResolvedValue({ + kind: 'APIResourceList', + resources: [], + }); + + const result = await isAgonesInstalled(); + + expect(result).toBe(true); + }); + + it('should return false when the API call throws (Agones not installed)', async () => { + vi.mocked(ApiProxy.request).mockRejectedValue(new Error('404 Not Found')); + + const result = await isAgonesInstalled(); + + expect(result).toBe(false); + }); + + it('should return false on network errors', async () => { + vi.mocked(ApiProxy.request).mockRejectedValue(new Error('Network Error')); + + const result = await isAgonesInstalled(); + + expect(result).toBe(false); + }); + + it('should return false when the response is null', async () => { + vi.mocked(ApiProxy.request).mockResolvedValue(null); + + const result = await isAgonesInstalled(); + + expect(result).toBe(false); + }); + + it('should return false when the response is undefined', async () => { + vi.mocked(ApiProxy.request).mockResolvedValue(undefined); + + const result = await isAgonesInstalled(); + + expect(result).toBe(false); + }); + + it('should return false when response is a non-APIResourceList object (error object)', async () => { + vi.mocked(ApiProxy.request).mockResolvedValue({ + kind: 'Status', + status: 'Failure', + message: 'the server could not find the requested resource', + }); + + const result = await isAgonesInstalled(); + + expect(result).toBe(false); + }); + + it('should return false when response has no resources field', async () => { + vi.mocked(ApiProxy.request).mockResolvedValue({ + kind: 'APIResourceList', + }); + + const result = await isAgonesInstalled(); + + expect(result).toBe(false); + }); +});