diff --git a/package.json b/package.json index 2ac1671..90f37ec 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "tsc": "headlamp-plugin tsc", "storybook": "headlamp-plugin storybook", "storybook-build": "headlamp-plugin storybook-build", - "test": "headlamp-plugin test", + "test": "vitest run -c vitest.config.ts", "i18n": "headlamp-plugin i18n" }, "keywords": [ diff --git a/src/__testutils__/kubeObjectMock.ts b/src/__testutils__/kubeObjectMock.ts new file mode 100644 index 0000000..72ea22f --- /dev/null +++ b/src/__testutils__/kubeObjectMock.ts @@ -0,0 +1,59 @@ +/* + * 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. + */ + +/** + * Test-only mock for `@kinvolk/headlamp-plugin/lib/k8s/cluster`. + * + * Headlamp plugins import `KubeObject` from a path that only resolves at + * build time (it's externalized to `pluginLib.K8s.cluster`). This file + * provides a minimal stand-in so Vitest can load resource model classes + * that extend `KubeObject`. + * + * Only the subset of KubeObject used by our resource models is implemented: + * - `constructor(json)` — stores the raw Kubernetes JSON + * - `get metadata()` — delegates to `jsonData.metadata` + * - `static useList()` — returns `[null]` (hook stub, not called in unit tests) + */ +export class KubeObject { + jsonData: T; + + constructor(json: T) { + this.jsonData = json; + } + + get metadata() { + return this.jsonData.metadata; + } + + static useList() { + return [null]; + } +} + +export type KubeObjectInterface = { + apiVersion: string; + kind: string; + metadata: { + name: string; + namespace?: string; + uid?: string; + creationTimestamp: string; + labels?: Record; + annotations?: Record; + [key: string]: any; + }; + [key: string]: any; +}; diff --git a/src/resources/gameserver.test.ts b/src/resources/gameserver.test.ts new file mode 100644 index 0000000..9b6490f --- /dev/null +++ b/src/resources/gameserver.test.ts @@ -0,0 +1,275 @@ +/* + * 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 } from 'vitest'; +import { AgonesGameServer, GameServer } from './gameserver'; + +/** + * Helper to create a GameServer instance from a partial JSON object. + * Fills in minimal required fields so tests stay focused. + */ +function makeGS(overrides: Partial = {}): GameServer { + const base: AgonesGameServer = { + apiVersion: 'agones.dev/v1', + kind: 'GameServer', + metadata: { + name: 'test-gs', + namespace: 'default', + uid: 'abc-123', + creationTimestamp: '2026-01-01T00:00:00Z', + labels: {}, + ...(overrides.metadata as any), + }, + spec: { + scheduling: 'Packed', + template: {}, + ...(overrides.spec as any), + }, + status: overrides.status, + }; + return new GameServer(base); +} + +describe('GameServer', () => { + // ── Static fields ──────────────────────────────────────────────────────── + + it('has correct static apiVersion', () => { + expect(GameServer.apiVersion).toBe('agones.dev/v1'); + }); + + it('has correct static kind', () => { + expect(GameServer.kind).toBe('GameServer'); + }); + + it('has correct static apiName', () => { + expect(GameServer.apiName).toBe('gameservers'); + }); + + it('is namespaced', () => { + expect(GameServer.isNamespaced).toBe(true); + }); + + it('has correct detailsRoute', () => { + expect(GameServer.detailsRoute).toBe('agones-gameserver'); + }); + + // ── state getter ───────────────────────────────────────────────────────── + + it('returns state from status', () => { + const gs = makeGS({ status: { state: 'Ready' } }); + expect(gs.state).toBe('Ready'); + }); + + it('returns empty string when status is undefined', () => { + const gs = makeGS(); + expect(gs.state).toBe(''); + }); + + it('returns empty string when state is undefined', () => { + const gs = makeGS({ status: {} }); + expect(gs.state).toBe(''); + }); + + // ── address getter ─────────────────────────────────────────────────────── + + it('returns address from status', () => { + const gs = makeGS({ status: { address: '192.168.1.1' } }); + expect(gs.address).toBe('192.168.1.1'); + }); + + it('returns empty string when address is missing', () => { + const gs = makeGS({ status: {} }); + expect(gs.address).toBe(''); + }); + + // ── nodeName getter ────────────────────────────────────────────────────── + + it('returns nodeName from status', () => { + const gs = makeGS({ status: { nodeName: 'node-1' } }); + expect(gs.nodeName).toBe('node-1'); + }); + + it('returns empty string when nodeName is missing', () => { + const gs = makeGS({ status: {} }); + expect(gs.nodeName).toBe(''); + }); + + // ── fleet getter ───────────────────────────────────────────────────────── + + it('returns fleet name from labels', () => { + const gs = makeGS({ + metadata: { + name: 'test-gs', + namespace: 'default', + uid: 'abc-123', + creationTimestamp: '2026-01-01T00:00:00Z', + labels: { 'agones.dev/fleet': 'my-fleet' }, + } as any, + }); + expect(gs.fleet).toBe('my-fleet'); + }); + + it('returns empty string when fleet label is missing', () => { + const gs = makeGS(); + expect(gs.fleet).toBe(''); + }); + + // ── ports getter ───────────────────────────────────────────────────────── + + it('formats status ports as name:port pairs', () => { + const gs = makeGS({ + status: { + ports: [ + { name: 'game', port: 7777 }, + { name: 'query', port: 27015 }, + ], + }, + }); + expect(gs.ports).toBe('game:7777, query:27015'); + }); + + it('returns empty string when no ports', () => { + const gs = makeGS({ status: {} }); + expect(gs.ports).toBe(''); + }); + + // ── counters getter ────────────────────────────────────────────────────── + + it('returns counters from status', () => { + const gs = makeGS({ + status: { counters: { rooms: { count: 3, capacity: 10 } } }, + }); + expect(gs.counters).toEqual({ rooms: { count: 3, capacity: 10 } }); + }); + + it('returns empty object when counters missing', () => { + const gs = makeGS({ status: {} }); + expect(gs.counters).toEqual({}); + }); + + // ── lists getter ───────────────────────────────────────────────────────── + + it('returns lists from status', () => { + const gs = makeGS({ + status: { lists: { players: { values: ['p1', 'p2'], capacity: 10 } } }, + }); + expect(gs.lists).toEqual({ players: { values: ['p1', 'p2'], capacity: 10 } }); + }); + + it('returns empty object when lists missing', () => { + const gs = makeGS({ status: {} }); + expect(gs.lists).toEqual({}); + }); + + // ── eviction getter ────────────────────────────────────────────────────── + + it('returns eviction safe mode from spec', () => { + const gs = makeGS({ + spec: { scheduling: 'Packed', template: {}, eviction: { safe: 'Never' } } as any, + }); + expect(gs.eviction).toBe('Never'); + }); + + it('defaults eviction to Never when not set', () => { + const gs = makeGS(); + expect(gs.eviction).toBe('Never'); + }); + + // ── addresses getter ───────────────────────────────────────────────────── + + it('returns addresses from status', () => { + const gs = makeGS({ + status: { + addresses: [ + { type: 'NodeExternalIP', address: '34.56.78.90' }, + { type: 'NodeInternalIP', address: '10.0.0.5' }, + ], + }, + }); + expect(gs.addresses).toEqual([ + { type: 'NodeExternalIP', address: '34.56.78.90' }, + { type: 'NodeInternalIP', address: '10.0.0.5' }, + ]); + }); + + it('returns empty array when addresses missing', () => { + const gs = makeGS({ status: {} }); + expect(gs.addresses).toEqual([]); + }); + + // ── reservedUntil getter ───────────────────────────────────────────────── + + it('returns reservedUntil from status', () => { + const gs = makeGS({ + status: { reservedUntil: '2026-06-25T12:00:00Z' }, + }); + expect(gs.reservedUntil).toBe('2026-06-25T12:00:00Z'); + }); + + it('returns undefined when reservedUntil is not set', () => { + const gs = makeGS({ status: {} }); + expect(gs.reservedUntil).toBeUndefined(); + }); + + // ── mergedPorts getter ─────────────────────────────────────────────────── + + it('merges spec and status ports', () => { + const gs = makeGS({ + spec: { + scheduling: 'Packed', + template: {}, + ports: [{ name: 'game', portPolicy: 'Dynamic', containerPort: 7777, protocol: 'UDP' }], + } as any, + status: { + ports: [{ name: 'game', port: 7654 }], + }, + }); + expect(gs.mergedPorts).toEqual([ + { + name: 'game', + hostPort: 7654, + containerPort: 7777, + protocol: 'UDP', + portPolicy: 'Dynamic', + }, + ]); + }); + + it('returns empty array when no ports defined', () => { + const gs = makeGS({ status: {} }); + expect(gs.mergedPorts).toEqual([]); + }); + + it('handles spec ports without matching status ports', () => { + const gs = makeGS({ + spec: { + scheduling: 'Packed', + template: {}, + ports: [{ name: 'game', portPolicy: 'Static', containerPort: 7777, protocol: 'TCP' }], + } as any, + status: { ports: [] }, + }); + expect(gs.mergedPorts).toEqual([ + { + name: 'game', + hostPort: undefined, + containerPort: 7777, + protocol: 'TCP', + portPolicy: 'Static', + }, + ]); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..92036c7 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,54 @@ +/* + * 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 { defineConfig } from 'vitest/config'; +import { resolve } from 'path'; + +/** + * Vitest configuration for the Agones Headlamp plugin. + * + * Headlamp plugins import KubeObject from `@kinvolk/headlamp-plugin/lib/k8s/cluster`, + * a virtual path that is externalized to `pluginLib.K8s.cluster` at build time. + * It doesn't physically exist on disk. + * + * Vite's import-analysis plugin validates file existence during source + * transformation, which fails before Vitest's vi.mock() can intercept. + * The resolve alias below redirects that path to a lightweight mock in + * `src/__testutils__/kubeObjectMock.ts` that provides just enough of the + * KubeObject API for resource model unit tests. + * + * NOTE: The real `KubeObject` class in headlamp imports React, lodash, + * jsonpath-plus, and headlamp's entire API layer — it cannot be loaded + * standalone in a test environment without the full headlamp runtime. + */ +export default defineConfig({ + define: { + global: 'globalThis', + }, + resolve: { + alias: { + '@kinvolk/headlamp-plugin/lib/k8s/cluster': resolve( + __dirname, + 'src/__testutils__/kubeObjectMock.ts' + ), + }, + }, + test: { + globals: true, + environment: 'jsdom', + }, +});