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/fleet.test.ts b/src/resources/fleet.test.ts new file mode 100644 index 0000000..3fb44bc --- /dev/null +++ b/src/resources/fleet.test.ts @@ -0,0 +1,260 @@ +/* + * 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 { AgonesFleet, Fleet } from './fleet'; + +/** + * Helper to create a Fleet instance from a partial JSON object. + * Fills in minimal required fields so tests stay focused. + */ +function makeFleet(overrides: Partial = {}): Fleet { + const base: AgonesFleet = { + apiVersion: 'agones.dev/v1', + kind: 'Fleet', + metadata: { + name: 'test-fleet', + namespace: 'default', + uid: 'fleet-123', + creationTimestamp: '2026-01-01T00:00:00Z', + ...(overrides.metadata as any), + }, + spec: { + replicas: 5, + scheduling: 'Packed', + template: {}, + ...(overrides.spec as any), + }, + status: overrides.status, + }; + return new Fleet(base); +} + +describe('Fleet', () => { + // ── Static fields ──────────────────────────────────────────────────────── + + it('has correct static apiVersion', () => { + expect(Fleet.apiVersion).toBe('agones.dev/v1'); + }); + + it('has correct static kind', () => { + expect(Fleet.kind).toBe('Fleet'); + }); + + it('has correct static apiName', () => { + expect(Fleet.apiName).toBe('fleets'); + }); + + it('is namespaced', () => { + expect(Fleet.isNamespaced).toBe(true); + }); + + it('has correct detailsRoute', () => { + expect(Fleet.detailsRoute).toBe('agones-fleet'); + }); + + // ── scheduling getter ──────────────────────────────────────────────────── + + it('returns scheduling from spec', () => { + const f = makeFleet({ spec: { replicas: 3, scheduling: 'Distributed', template: {} } as any }); + expect(f.scheduling).toBe('Distributed'); + }); + + it('defaults scheduling to Packed', () => { + const f = makeFleet({ spec: { replicas: 3, scheduling: '', template: {} } as any }); + expect(f.scheduling).toBe('Packed'); + }); + + // ── desiredReplicas getter ─────────────────────────────────────────────── + + it('returns desired replicas from spec', () => { + const f = makeFleet({ spec: { replicas: 10, scheduling: 'Packed', template: {} } as any }); + expect(f.desiredReplicas).toBe(10); + }); + + it('returns 0 when replicas is 0', () => { + const f = makeFleet({ spec: { replicas: 0, scheduling: 'Packed', template: {} } as any }); + expect(f.desiredReplicas).toBe(0); + }); + + // ── currentReplicas getter ─────────────────────────────────────────────── + + it('returns current replicas from status', () => { + const f = makeFleet({ status: { replicas: 5 } }); + expect(f.currentReplicas).toBe(5); + }); + + it('returns 0 when status is undefined', () => { + const f = makeFleet(); + expect(f.currentReplicas).toBe(0); + }); + + // ── allocatedReplicas getter ───────────────────────────────────────────── + + it('returns allocated replicas from status', () => { + const f = makeFleet({ status: { allocatedReplicas: 3 } }); + expect(f.allocatedReplicas).toBe(3); + }); + + it('returns 0 when allocated replicas missing', () => { + const f = makeFleet({ status: {} }); + expect(f.allocatedReplicas).toBe(0); + }); + + // ── readyReplicas getter ───────────────────────────────────────────────── + + it('returns ready replicas from status', () => { + const f = makeFleet({ status: { readyReplicas: 4 } }); + expect(f.readyReplicas).toBe(4); + }); + + it('returns 0 when ready replicas missing', () => { + const f = makeFleet({ status: {} }); + expect(f.readyReplicas).toBe(0); + }); + + // ── reservedReplicas getter ────────────────────────────────────────────── + + it('returns reserved replicas from status', () => { + const f = makeFleet({ status: { reservedReplicas: 2 } }); + expect(f.reservedReplicas).toBe(2); + }); + + it('returns 0 when reserved replicas missing', () => { + const f = makeFleet({ status: {} }); + expect(f.reservedReplicas).toBe(0); + }); + + // ── strategy getter ────────────────────────────────────────────────────── + + it('returns strategy type from spec', () => { + const f = makeFleet({ + spec: { + replicas: 5, + scheduling: 'Packed', + template: {}, + strategy: { type: 'Recreate' }, + } as any, + }); + expect(f.strategy).toBe('Recreate'); + }); + + it('defaults strategy to RollingUpdate', () => { + const f = makeFleet(); + expect(f.strategy).toBe('RollingUpdate'); + }); + + // ── maxSurge getter ────────────────────────────────────────────────────── + + it('returns maxSurge from rolling update config', () => { + const f = makeFleet({ + spec: { + replicas: 5, + scheduling: 'Packed', + template: {}, + strategy: { type: 'RollingUpdate', rollingUpdate: { maxSurge: '25%', maxUnavailable: 1 } }, + } as any, + }); + expect(f.maxSurge).toBe('25%'); + }); + + it('returns undefined when no strategy defined', () => { + const f = makeFleet(); + expect(f.maxSurge).toBeUndefined(); + }); + + // ── maxUnavailable getter ──────────────────────────────────────────────── + + it('returns maxUnavailable from rolling update config', () => { + const f = makeFleet({ + spec: { + replicas: 5, + scheduling: 'Packed', + template: {}, + strategy: { type: 'RollingUpdate', rollingUpdate: { maxSurge: 1, maxUnavailable: 0 } }, + } as any, + }); + expect(f.maxUnavailable).toBe(0); + }); + + // ── allocationOverflow getter ──────────────────────────────────────────── + + it('returns allocationOverflow from spec', () => { + const overflow = { labels: { 'agones.dev/safe-to-evict': 'true' } }; + const f = makeFleet({ + spec: { + replicas: 5, + scheduling: 'Packed', + template: {}, + allocationOverflow: overflow, + } as any, + }); + expect(f.allocationOverflow).toEqual(overflow); + }); + + it('returns undefined when allocationOverflow not set', () => { + const f = makeFleet(); + expect(f.allocationOverflow).toBeUndefined(); + }); + + // ── priorities getter ──────────────────────────────────────────────────── + + it('returns priorities from spec', () => { + const priorities = [{ type: 'Counter' as const, key: 'rooms', order: 'Ascending' as const }]; + const f = makeFleet({ + spec: { + replicas: 5, + scheduling: 'Packed', + template: {}, + priorities, + } as any, + }); + expect(f.priorities).toEqual(priorities); + }); + + it('returns empty array when priorities not set', () => { + const f = makeFleet(); + expect(f.priorities).toEqual([]); + }); + + // ── counters getter ────────────────────────────────────────────────────── + + it('returns aggregate counters from status', () => { + const f = makeFleet({ + status: { counters: { rooms: { count: 15, capacity: 100 } } }, + }); + expect(f.counters).toEqual({ rooms: { count: 15, capacity: 100 } }); + }); + + it('returns empty object when counters missing', () => { + const f = makeFleet({ status: {} }); + expect(f.counters).toEqual({}); + }); + + // ── lists getter ───────────────────────────────────────────────────────── + + it('returns aggregate lists from status', () => { + const f = makeFleet({ + status: { lists: { maps: { values: ['dust2', 'mirage'], capacity: 10 } } }, + }); + expect(f.lists).toEqual({ maps: { values: ['dust2', 'mirage'], capacity: 10 } }); + }); + + it('returns empty object when lists missing', () => { + const f = makeFleet({ status: {} }); + expect(f.lists).toEqual({}); + }); +}); diff --git a/src/resources/fleetautoscaler.test.ts b/src/resources/fleetautoscaler.test.ts new file mode 100644 index 0000000..2e5459e --- /dev/null +++ b/src/resources/fleetautoscaler.test.ts @@ -0,0 +1,336 @@ +/* + * 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 { AgonesFleetAutoscaler, FleetAutoscaler } from './fleetautoscaler'; + +/** + * Helper to create a FleetAutoscaler instance from a partial JSON object. + * Fills in minimal required fields so tests stay focused. + */ +function makeFAS(overrides: Partial = {}): FleetAutoscaler { + const base: AgonesFleetAutoscaler = { + apiVersion: 'autoscaling.agones.dev/v1', + kind: 'FleetAutoscaler', + metadata: { + name: 'test-fas', + namespace: 'default', + uid: 'fas-123', + creationTimestamp: '2026-01-01T00:00:00Z', + ...(overrides.metadata as any), + }, + spec: { + fleetName: 'test-fleet', + policy: { + type: 'Buffer', + buffer: { bufferSize: 2, minReplicas: 1, maxReplicas: 10 }, + }, + ...(overrides.spec as any), + }, + status: overrides.status, + }; + return new FleetAutoscaler(base); +} + +describe('FleetAutoscaler', () => { + // ── Static fields ──────────────────────────────────────────────────────── + + it('has correct static apiVersion', () => { + expect(FleetAutoscaler.apiVersion).toBe('autoscaling.agones.dev/v1'); + }); + + it('has correct static kind', () => { + expect(FleetAutoscaler.kind).toBe('FleetAutoscaler'); + }); + + it('has correct static apiName', () => { + expect(FleetAutoscaler.apiName).toBe('fleetautoscalers'); + }); + + it('is namespaced', () => { + expect(FleetAutoscaler.isNamespaced).toBe(true); + }); + + it('has correct detailsRoute', () => { + expect(FleetAutoscaler.detailsRoute).toBe('agones-fleetautoscaler'); + }); + + // ── fleetName getter ───────────────────────────────────────────────────── + + it('returns fleet name from spec', () => { + const fas = makeFAS(); + expect(fas.fleetName).toBe('test-fleet'); + }); + + // ── policyType getter ──────────────────────────────────────────────────── + + it('returns policy type from spec', () => { + const fas = makeFAS(); + expect(fas.policyType).toBe('Buffer'); + }); + + it('returns Webhook policy type', () => { + const fas = makeFAS({ + spec: { + fleetName: 'test-fleet', + policy: { type: 'Webhook', webhook: { url: 'http://example.com' } }, + }, + }); + expect(fas.policyType).toBe('Webhook'); + }); + + it('returns Counter policy type', () => { + const fas = makeFAS({ + spec: { + fleetName: 'test-fleet', + policy: { + type: 'Counter', + counter: { key: 'rooms', bufferSize: 5, minCapacity: 0, maxCapacity: 100 }, + }, + }, + }); + expect(fas.policyType).toBe('Counter'); + }); + + it('returns List policy type', () => { + const fas = makeFAS({ + spec: { + fleetName: 'test-fleet', + policy: { + type: 'List', + list: { key: 'players', bufferSize: 5, minCapacity: 0, maxCapacity: 100 }, + }, + }, + }); + expect(fas.policyType).toBe('List'); + }); + + // ── bufferSize getter ──────────────────────────────────────────────────── + + it('returns buffer size as number', () => { + const fas = makeFAS(); + expect(fas.bufferSize).toBe(2); + }); + + it('returns buffer size as percentage string', () => { + const fas = makeFAS({ + spec: { + fleetName: 'test-fleet', + policy: { + type: 'Buffer', + buffer: { bufferSize: '50%', minReplicas: 1, maxReplicas: 20 }, + }, + }, + }); + expect(fas.bufferSize).toBe('50%'); + }); + + it('returns undefined when no buffer policy', () => { + const fas = makeFAS({ + spec: { + fleetName: 'test-fleet', + policy: { type: 'Webhook', webhook: { url: 'http://example.com' } }, + }, + }); + expect(fas.bufferSize).toBeUndefined(); + }); + + // ── minReplicas getter ─────────────────────────────────────────────────── + + it('returns min replicas from buffer policy', () => { + const fas = makeFAS(); + expect(fas.minReplicas).toBe(1); + }); + + it('returns undefined when no buffer policy', () => { + const fas = makeFAS({ + spec: { + fleetName: 'test-fleet', + policy: { type: 'Webhook', webhook: { url: 'http://example.com' } }, + }, + }); + expect(fas.minReplicas).toBeUndefined(); + }); + + // ── maxReplicas getter ─────────────────────────────────────────────────── + + it('returns max replicas from buffer policy', () => { + const fas = makeFAS(); + expect(fas.maxReplicas).toBe(10); + }); + + // ── currentReplicas getter ─────────────────────────────────────────────── + + it('returns current replicas from status', () => { + const fas = makeFAS({ status: { currentReplicas: 5 } }); + expect(fas.currentReplicas).toBe(5); + }); + + it('returns 0 when status is undefined', () => { + const fas = makeFAS(); + expect(fas.currentReplicas).toBe(0); + }); + + // ── desiredReplicas getter ─────────────────────────────────────────────── + + it('returns desired replicas from status', () => { + const fas = makeFAS({ status: { desiredReplicas: 8 } }); + expect(fas.desiredReplicas).toBe(8); + }); + + it('returns 0 when desired replicas missing', () => { + const fas = makeFAS({ status: {} }); + expect(fas.desiredReplicas).toBe(0); + }); + + // ── ableToScale getter ─────────────────────────────────────────────────── + + it('returns ableToScale from status', () => { + const fas = makeFAS({ status: { ableToScale: true } }); + expect(fas.ableToScale).toBe(true); + }); + + it('returns false when ableToScale missing', () => { + const fas = makeFAS({ status: {} }); + expect(fas.ableToScale).toBe(false); + }); + + // ── scalingLimited getter ──────────────────────────────────────────────── + + it('returns scalingLimited from status', () => { + const fas = makeFAS({ status: { scalingLimited: true } }); + expect(fas.scalingLimited).toBe(true); + }); + + it('returns false when scalingLimited missing', () => { + const fas = makeFAS({ status: {} }); + expect(fas.scalingLimited).toBe(false); + }); + + // ── lastScaleTime getter ───────────────────────────────────────────────── + + it('returns lastScaleTime from status', () => { + const fas = makeFAS({ status: { lastScaleTime: '2026-06-20T10:00:00Z' } }); + expect(fas.lastScaleTime).toBe('2026-06-20T10:00:00Z'); + }); + + it('returns undefined when lastScaleTime missing', () => { + const fas = makeFAS({ status: {} }); + expect(fas.lastScaleTime).toBeUndefined(); + }); + + // ── lastAppliedPolicy getter ───────────────────────────────────────────── + + it('returns lastAppliedPolicy from status', () => { + const fas = makeFAS({ status: { lastAppliedPolicy: 'Buffer' } }); + expect(fas.lastAppliedPolicy).toBe('Buffer'); + }); + + it('returns undefined when lastAppliedPolicy missing', () => { + const fas = makeFAS({ status: {} }); + expect(fas.lastAppliedPolicy).toBeUndefined(); + }); + + // ── syncInterval getter ────────────────────────────────────────────────── + + it('returns sync interval from spec', () => { + const fas = makeFAS({ + spec: { + fleetName: 'test-fleet', + policy: { type: 'Buffer', buffer: { bufferSize: 2, minReplicas: 1, maxReplicas: 10 } }, + sync: { type: 'FixedInterval', fixedInterval: { seconds: 30 } }, + }, + }); + expect(fas.syncInterval).toBe(30); + }); + + it('returns undefined when no sync config', () => { + const fas = makeFAS(); + expect(fas.syncInterval).toBeUndefined(); + }); + + // ── Interface shape tests ──────────────────────────────────────────────── + + it('accepts a schedule policy with between block', () => { + const fas = makeFAS({ + spec: { + fleetName: 'test-fleet', + policy: { + type: 'Schedule', + schedule: { + between: { + start: '2026-06-20T00:00:00Z', + end: '2026-06-21T00:00:00Z', + minReplicas: 2, + maxReplicas: 20, + }, + policy: { type: 'Buffer', buffer: { bufferSize: 3, minReplicas: 2, maxReplicas: 20 } }, + }, + }, + }, + }); + expect(fas.policyType).toBe('Schedule'); + }); + + it('accepts a schedule policy with activePeriod block', () => { + const fas = makeFAS({ + spec: { + fleetName: 'test-fleet', + policy: { + type: 'Schedule', + schedule: { + activePeriod: { + timezone: 'America/New_York', + startCron: '0 18 * * 5', + duration: '3h', + }, + policy: { type: 'Buffer', buffer: { bufferSize: 5, minReplicas: 3, maxReplicas: 50 } }, + }, + }, + }, + }); + expect(fas.policyType).toBe('Schedule'); + }); + + it('accepts a wasm policy', () => { + const fas = makeFAS({ + spec: { + fleetName: 'test-fleet', + policy: { + type: 'Wasm', + wasm: { url: 'https://example.com/autoscaler.wasm', requestsPerSecond: 10 }, + }, + }, + }); + expect(fas.policyType).toBe('Wasm'); + }); + + it('accepts a chain policy', () => { + const fas = makeFAS({ + spec: { + fleetName: 'test-fleet', + policy: { + type: 'Chain', + chain: [ + { id: 'weekday', type: 'Buffer' }, + { id: 'weekend', type: 'Buffer' }, + ], + }, + }, + }); + expect(fas.policyType).toBe('Chain'); + }); +}); 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', + }, +});