From cf39ab9f8c72e66caf06e36a7cb491c8856997f9 Mon Sep 17 00:00:00 2001 From: ashwani yadav <22ashwaniyadav@gmail.com> Date: Sat, 4 Jul 2026 19:31:22 +0530 Subject: [PATCH 1/3] test: Add fast-check and vitest config infrastructure Signed-off-by: ashwani yadav <22ashwaniyadav@gmail.com> --- package-lock.json | 2 +- package.json | 3 +- src/__mocks__/kubeObject.ts | 70 +++++++++++++++++++++++++++++++++++++ vitest.config.mts | 50 ++++++++++++++++++++++++++ 4 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 src/__mocks__/kubeObject.ts create mode 100644 vitest.config.mts diff --git a/package-lock.json b/package-lock.json index 50eaafa..bbb1c42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@kinvolk/headlamp-plugin": "^0.14.0", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", + "fast-check": "^4.8.0", "vite": "^6.4.3", "vite-plugin-svgr": "^4.5.0" } @@ -9016,7 +9017,6 @@ "url": "https://opencollective.com/fast-check" } ], - "license": "MIT", "dependencies": { "pure-rand": "^8.0.0" }, diff --git a/package.json b/package.json index 2ac1671..c5c1529 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.mts", "i18n": "headlamp-plugin i18n" }, "keywords": [ @@ -43,6 +43,7 @@ "@kinvolk/headlamp-plugin": "^0.14.0", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", + "fast-check": "^4.8.0", "vite": "^6.4.3", "vite-plugin-svgr": "^4.5.0" } diff --git a/src/__mocks__/kubeObject.ts b/src/__mocks__/kubeObject.ts new file mode 100644 index 0000000..20591f9 --- /dev/null +++ b/src/__mocks__/kubeObject.ts @@ -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. + */ + +/** + * Minimal stand-in for `KubeObject` from `@kinvolk/headlamp-plugin/lib/k8s/cluster`. + * + * Headlamp externalises this import at build time (it is provided by the + * host app at runtime), so the physical path does not exist inside + * `node_modules`. During Vitest runs we redirect the import here via a + * `resolve.alias` in `vitest.config.mts`. + * + * Only the constructor, `metadata`, and `jsonData` are needed for unit and + * property tests — methods like `patch`, `delete`, and `useList` are stubs. + */ +export class KubeObject { + jsonData: any; + + constructor(json: any) { + this.jsonData = json; + } + + get metadata() { + return this.jsonData.metadata; + } + + static useList() { + return [null]; + } + + static apiVersion = ''; + static kind = ''; + static apiName = ''; + static isNamespaced = true; + + async patch(body: unknown) { + if (body && typeof body === 'object') { + this.jsonData = { ...this.jsonData, ...(body as Record) }; + } + return this.jsonData; + } +} + +// Re-export the interface (type-only) so that `import { KubeObjectInterface }` works. +export interface KubeObjectInterface { + apiVersion: string; + kind: string; + metadata: { + name: string; + namespace?: string; + uid?: string; + labels?: Record; + annotations?: Record; + ownerReferences?: Array<{ uid: string; kind: string; name: string }>; + [key: string]: any; + }; + [key: string]: any; +} diff --git a/vitest.config.mts b/vitest.config.mts new file mode 100644 index 0000000..9ec612d --- /dev/null +++ b/vitest.config.mts @@ -0,0 +1,50 @@ +/* + * 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 { resolve } from 'path'; +import { defineConfig, mergeConfig } from 'vitest/config'; +import baseConfig from '@kinvolk/headlamp-plugin/config/vite.config.mjs'; + +/** + * Extend the upstream Headlamp Vitest/Vite config with resolve aliases for + * paths that are externalised at build time but need stubs during testing. + * + * `@kinvolk/headlamp-plugin/lib/k8s/cluster` is the primary case — it is + * provided by the host app at runtime but does not physically exist in + * `node_modules`. We redirect it to a minimal {@link src/__mocks__/kubeObject.ts} + * so that resource-model tests can instantiate `KubeObject` subclasses. + */ +export default mergeConfig( + baseConfig, + defineConfig({ + resolve: { + alias: { + '@kinvolk/headlamp-plugin/lib/k8s/cluster': resolve( + __dirname, + 'src/__mocks__/kubeObject.ts' + ), + }, + }, + test: { + // Override setupFiles because the base config uses `import.meta.dirname` + // which resolves to undefined when loaded from an external config file. + setupFiles: resolve( + __dirname, + 'node_modules/@kinvolk/headlamp-plugin/config/setupTests.js' + ), + }, + }) +); From 9420c5153d312fc014ee6a9d9c81cfd114b88b35 Mon Sep 17 00:00:00 2001 From: ashwani yadav <22ashwaniyadav@gmail.com> Date: Sat, 4 Jul 2026 19:32:28 +0530 Subject: [PATCH 2/3] test: Add property-based tests for Agones resources Signed-off-by: ashwani yadav <22ashwaniyadav@gmail.com> --- src/components/StateChip.property.test.ts | 90 ++++++++ src/resources/fleet.property.test.ts | 107 +++++++++ .../fleetautoscaler.property.test.ts | 102 +++++++++ src/resources/gameserver.property.test.ts | 199 +++++++++++++++++ .../gameserverallocation.property.test.ts | 92 ++++++++ .../buildAllocationBody.property.test.ts | 208 ++++++++++++++++++ 6 files changed, 798 insertions(+) create mode 100644 src/components/StateChip.property.test.ts create mode 100644 src/resources/fleet.property.test.ts create mode 100644 src/resources/fleetautoscaler.property.test.ts create mode 100644 src/resources/gameserver.property.test.ts create mode 100644 src/resources/gameserverallocation.property.test.ts create mode 100644 src/utils/buildAllocationBody.property.test.ts diff --git a/src/components/StateChip.property.test.ts b/src/components/StateChip.property.test.ts new file mode 100644 index 0000000..04a294b --- /dev/null +++ b/src/components/StateChip.property.test.ts @@ -0,0 +1,90 @@ +/* + * 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 * as fc from 'fast-check'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; +import { StateChip } from './StateChip'; + +/** + * The complete set of valid MUI Chip color values. + * + * @see {@link https://mui.com/material-ui/api/chip/#chip-prop-color | MUI Chip color prop} + */ +const VALID_CHIP_COLORS = new Set([ + 'default', + 'primary', + 'secondary', + 'error', + 'info', + 'success', + 'warning', +]); + +/** + * Known Agones GameServer lifecycle states and their expected chip colors. + * + * @see {@link https://agones.dev/site/docs/reference/gameserver/#gameserver-state-diagram | Agones GameServer State Diagram} + */ +const KNOWN_STATE_COLORS: Record = { + PortAllocation: 'info', + Creating: 'info', + Starting: 'info', + Scheduled: 'info', + RequestReady: 'info', + Ready: 'success', + Allocated: 'warning', + Reserved: 'secondary', + Shutdown: 'default', + Error: 'error', + Unhealthy: 'error', +}; + +describe('StateChip — property tests', () => { + it('should never crash on any arbitrary string input', () => { + fc.assert( + fc.property(fc.string(), state => { + const element = React.createElement(StateChip, { state }); + expect(element).toBeDefined(); + expect(element.type).toBe(StateChip); + }) + ); + }); + + it('should always resolve to a valid MUI Chip color for any input', () => { + fc.assert( + fc.property(fc.string(), state => { + // Replicate the exact lookup logic used by StateChip: + // STATE_COLORS[state] ?? 'default' + // Use Object.hasOwn to avoid prototype keys like __proto__ + const resolved = Object.prototype.hasOwnProperty.call(KNOWN_STATE_COLORS, state) + ? KNOWN_STATE_COLORS[state] + : 'default'; + expect(VALID_CHIP_COLORS.has(resolved)).toBe(true); + }) + ); + }); + + it('should map every known Agones state to its documented color', () => { + fc.assert( + fc.property(fc.constantFrom(...Object.keys(KNOWN_STATE_COLORS)), state => { + const STATE_COLORS: Record = KNOWN_STATE_COLORS; + const resolved = STATE_COLORS[state] ?? 'default'; + expect(resolved).toBe(KNOWN_STATE_COLORS[state]); + }) + ); + }); +}); diff --git a/src/resources/fleet.property.test.ts b/src/resources/fleet.property.test.ts new file mode 100644 index 0000000..07763ef --- /dev/null +++ b/src/resources/fleet.property.test.ts @@ -0,0 +1,107 @@ +/* + * 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 * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { Fleet } from './fleet'; + +/** + * Arbitrary that generates random Agones Fleet JSON blobs. + * Covers present/absent status fields and different strategy configurations. + */ +function fcFleetJson(): fc.Arbitrary { + return fc.record({ + apiVersion: fc.constant('agones.dev/v1'), + kind: fc.constant('Fleet'), + metadata: fc.record({ + name: fc.string({ minLength: 1, maxLength: 30 }), + namespace: fc.string({ minLength: 1, maxLength: 30 }), + uid: fc.uuid(), + }), + spec: fc.record({ + replicas: fc.nat({ max: 100 }), + scheduling: fc.oneof(fc.constantFrom('Packed', 'Distributed'), fc.constant('')), + template: fc.constant({}), + strategy: fc.option( + fc.record({ + type: fc.oneof(fc.constantFrom('RollingUpdate', 'Recreate'), fc.constant('')), + rollingUpdate: fc.option( + fc.record({ + maxSurge: fc.oneof(fc.nat({ max: 10 }), fc.constant('25%')), + maxUnavailable: fc.oneof(fc.nat({ max: 10 }), fc.constant('25%')), + }), + { nil: undefined } + ), + }), + { nil: undefined } + ), + }), + status: fc.option( + fc.record({ + replicas: fc.option(fc.nat({ max: 100 }), { nil: undefined }), + readyReplicas: fc.option(fc.nat({ max: 100 }), { nil: undefined }), + reservedReplicas: fc.option(fc.nat({ max: 100 }), { nil: undefined }), + allocatedReplicas: fc.option(fc.nat({ max: 100 }), { nil: undefined }), + }), + { nil: undefined } + ), + }); +} + +describe('Fleet getters — property tests', () => { + it('numeric getters should always return typeof number', () => { + fc.assert( + fc.property(fcFleetJson(), json => { + const fleet = new Fleet(json); + expect(typeof fleet.desiredReplicas).toBe('number'); + expect(typeof fleet.currentReplicas).toBe('number'); + expect(typeof fleet.allocatedReplicas).toBe('number'); + expect(typeof fleet.readyReplicas).toBe('number'); + expect(typeof fleet.reservedReplicas).toBe('number'); + }) + ); + }); + + it('scheduling should always default to "Packed" when falsy', () => { + fc.assert( + fc.property(fcFleetJson(), json => { + const fleet = new Fleet(json); + const result = fleet.scheduling; + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + // When spec.scheduling is empty/falsy, it defaults to 'Packed' + if (!json.spec.scheduling) { + expect(result).toBe('Packed'); + } + }) + ); + }); + + it('strategy should always default to "RollingUpdate" when falsy', () => { + fc.assert( + fc.property(fcFleetJson(), json => { + const fleet = new Fleet(json); + const result = fleet.strategy; + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + // When spec.strategy.type is empty/falsy/missing, it defaults to 'RollingUpdate' + if (!json.spec.strategy?.type) { + expect(result).toBe('RollingUpdate'); + } + }) + ); + }); +}); diff --git a/src/resources/fleetautoscaler.property.test.ts b/src/resources/fleetautoscaler.property.test.ts new file mode 100644 index 0000000..1461341 --- /dev/null +++ b/src/resources/fleetautoscaler.property.test.ts @@ -0,0 +1,102 @@ +/* + * 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 * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { FleetAutoscaler } from './fleetautoscaler'; + +/** + * Arbitrary that generates random Agones FleetAutoscaler JSON blobs. + * Covers present/absent status fields and various policy types. + */ +function fcFleetAutoscalerJson(): fc.Arbitrary { + return fc.record({ + apiVersion: fc.constant('autoscaling.agones.dev/v1'), + kind: fc.constant('FleetAutoscaler'), + metadata: fc.record({ + name: fc.string({ minLength: 1, maxLength: 30 }), + namespace: fc.string({ minLength: 1, maxLength: 30 }), + uid: fc.uuid(), + }), + spec: fc.record({ + fleetName: fc.string({ minLength: 1, maxLength: 30 }), + policy: fc.record({ + type: fc.constantFrom('Buffer', 'Webhook', 'Counter', 'List', 'Schedule', 'Chain'), + buffer: fc.option( + fc.record({ + bufferSize: fc.oneof(fc.nat({ max: 50 }), fc.constant('50%')), + minReplicas: fc.nat({ max: 100 }), + maxReplicas: fc.nat({ max: 100 }), + }), + { nil: undefined } + ), + }), + sync: fc.option( + fc.record({ + type: fc.constant('FixedInterval'), + fixedInterval: fc.option(fc.record({ seconds: fc.nat({ max: 300 }) }), { + nil: undefined, + }), + }), + { nil: undefined } + ), + }), + status: fc.option( + fc.record({ + currentReplicas: fc.option(fc.nat({ max: 100 }), { nil: undefined }), + desiredReplicas: fc.option(fc.nat({ max: 100 }), { nil: undefined }), + ableToScale: fc.option(fc.boolean(), { nil: undefined }), + scalingLimited: fc.option(fc.boolean(), { nil: undefined }), + lastScaleTime: fc.option( + fc.date().map(d => d.toISOString()), + { nil: undefined } + ), + lastAppliedPolicy: fc.option(fc.string({ maxLength: 30 }), { nil: undefined }), + }), + { nil: undefined } + ), + }); +} + +describe('FleetAutoscaler getters — property tests', () => { + it('currentReplicas and desiredReplicas should always return numbers', () => { + fc.assert( + fc.property(fcFleetAutoscalerJson(), json => { + const fas = new FleetAutoscaler(json); + expect(typeof fas.currentReplicas).toBe('number'); + expect(typeof fas.desiredReplicas).toBe('number'); + }) + ); + }); + + it('ableToScale should always return a boolean', () => { + fc.assert( + fc.property(fcFleetAutoscalerJson(), json => { + const fas = new FleetAutoscaler(json); + expect(typeof fas.ableToScale).toBe('boolean'); + }) + ); + }); + + it('scalingLimited should always return a boolean', () => { + fc.assert( + fc.property(fcFleetAutoscalerJson(), json => { + const fas = new FleetAutoscaler(json); + expect(typeof fas.scalingLimited).toBe('boolean'); + }) + ); + }); +}); diff --git a/src/resources/gameserver.property.test.ts b/src/resources/gameserver.property.test.ts new file mode 100644 index 0000000..4efaa62 --- /dev/null +++ b/src/resources/gameserver.property.test.ts @@ -0,0 +1,199 @@ +/* + * 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 * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { GameServer } from './gameserver'; + +/** + * Arbitrary that generates random Agones GameServer JSON blobs. + * Covers present/absent status, port arrays, counters, lists, and labels. + */ +function fcGameServerJson(): fc.Arbitrary { + const fcSpecPort = fc.record({ + name: fc.option(fc.string({ minLength: 1, maxLength: 15 }), { nil: undefined }), + portPolicy: fc.option(fc.constantFrom('Dynamic', 'Static', 'Passthrough'), { nil: undefined }), + containerPort: fc.option(fc.integer({ min: 1, max: 65535 }), { nil: undefined }), + protocol: fc.option(fc.constantFrom('UDP', 'TCP', 'TCPUDP'), { nil: undefined }), + }); + + const fcStatusPort = fc.record({ + name: fc.string({ minLength: 1, maxLength: 15 }), + port: fc.integer({ min: 1, max: 65535 }), + }); + + const fcCounterStatus = fc.record({ + count: fc.nat({ max: 1000 }), + capacity: fc.nat({ max: 1000 }), + }); + + const fcListStatus = fc.record({ + values: fc.array(fc.string({ maxLength: 10 }), { maxLength: 5 }), + capacity: fc.nat({ max: 100 }), + }); + + return fc.record({ + apiVersion: fc.constant('agones.dev/v1'), + kind: fc.constant('GameServer'), + metadata: fc.record({ + name: fc.string({ minLength: 1, maxLength: 30 }), + namespace: fc.string({ minLength: 1, maxLength: 30 }), + uid: fc.uuid(), + labels: fc.option( + fc.record({ + 'agones.dev/fleet': fc.option(fc.string({ minLength: 1, maxLength: 20 }), { + nil: undefined, + }), + }), + { nil: undefined } + ), + }), + spec: fc.record({ + scheduling: fc.constantFrom('Packed', 'Distributed'), + template: fc.constant({}), + ports: fc.option(fc.array(fcSpecPort, { maxLength: 4 }), { nil: undefined }), + }), + status: fc.option( + fc.record({ + state: fc.option( + fc.constantFrom( + 'PortAllocation', + 'Creating', + 'Starting', + 'Scheduled', + 'RequestReady', + 'Ready', + 'Allocated', + 'Reserved', + 'Shutdown', + 'Error', + 'Unhealthy' + ), + { nil: undefined } + ), + address: fc.option(fc.ipV4(), { nil: undefined }), + nodeName: fc.option(fc.string({ minLength: 1, maxLength: 20 }), { nil: undefined }), + ports: fc.option(fc.array(fcStatusPort, { maxLength: 4 }), { nil: undefined }), + counters: fc.option( + fc.dictionary(fc.string({ minLength: 1, maxLength: 10 }), fcCounterStatus, { + maxKeys: 3, + }), + { nil: undefined } + ), + lists: fc.option( + fc.dictionary(fc.string({ minLength: 1, maxLength: 10 }), fcListStatus, { maxKeys: 3 }), + { nil: undefined } + ), + }), + { nil: undefined } + ), + }); +} + +describe('GameServer getters — property tests', () => { + it('state should always return a string', () => { + fc.assert( + fc.property(fcGameServerJson(), json => { + const gs = new GameServer(json); + expect(typeof gs.state).toBe('string'); + }) + ); + }); + + it('address should always return a string', () => { + fc.assert( + fc.property(fcGameServerJson(), json => { + const gs = new GameServer(json); + expect(typeof gs.address).toBe('string'); + }) + ); + }); + + it('nodeName should always return a string', () => { + fc.assert( + fc.property(fcGameServerJson(), json => { + const gs = new GameServer(json); + expect(typeof gs.nodeName).toBe('string'); + }) + ); + }); + + it('fleet should always return a string (even without labels)', () => { + fc.assert( + fc.property(fcGameServerJson(), json => { + const gs = new GameServer(json); + expect(typeof gs.fleet).toBe('string'); + }) + ); + }); + + it('ports should always return a string', () => { + fc.assert( + fc.property(fcGameServerJson(), json => { + const gs = new GameServer(json); + const result = gs.ports; + expect(typeof result).toBe('string'); + }) + ); + }); + + it('counters should always return an object (never undefined)', () => { + fc.assert( + fc.property(fcGameServerJson(), json => { + const gs = new GameServer(json); + expect(gs.counters).toBeDefined(); + expect(typeof gs.counters).toBe('object'); + }) + ); + }); + + it('lists should always return an object (never undefined)', () => { + fc.assert( + fc.property(fcGameServerJson(), json => { + const gs = new GameServer(json); + expect(gs.lists).toBeDefined(); + expect(typeof gs.lists).toBe('object'); + }) + ); + }); + + it('mergedPorts entries should always have a non-empty name', () => { + fc.assert( + fc.property(fcGameServerJson(), json => { + // Ensure there are spec ports to trigger mergedPorts logic + if (!json.spec.ports || json.spec.ports.length === 0) return; + const gs = new GameServer(json); + for (const mp of gs.mergedPorts) { + expect(typeof mp.name).toBe('string'); + expect(mp.name.length).toBeGreaterThan(0); + } + }) + ); + }); + + it('mergedPorts should always default protocol to "UDP" and portPolicy to "Dynamic"', () => { + fc.assert( + fc.property(fcGameServerJson(), json => { + if (!json.spec.ports || json.spec.ports.length === 0) return; + const gs = new GameServer(json); + for (const mp of gs.mergedPorts) { + expect(['UDP', 'TCP', 'TCPUDP']).toContain(mp.protocol); + expect(['Dynamic', 'Static', 'Passthrough']).toContain(mp.portPolicy); + } + }) + ); + }); +}); diff --git a/src/resources/gameserverallocation.property.test.ts b/src/resources/gameserverallocation.property.test.ts new file mode 100644 index 0000000..7a70476 --- /dev/null +++ b/src/resources/gameserverallocation.property.test.ts @@ -0,0 +1,92 @@ +/* + * 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 * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { GameServerAllocation } from './gameserverallocation'; + +/** + * Arbitrary that generates random Agones GameServerAllocation JSON blobs. + * Covers present/absent status fields, port arrays, and address info. + */ +function fcGameServerAllocationJson(): fc.Arbitrary { + return fc.record({ + apiVersion: fc.constant('allocation.agones.dev/v1'), + kind: fc.constant('GameServerAllocation'), + metadata: fc.record({ + name: fc.string({ minLength: 1, maxLength: 30 }), + namespace: fc.string({ minLength: 1, maxLength: 30 }), + uid: fc.uuid(), + }), + spec: fc.record({ + scheduling: fc.option(fc.constantFrom('Packed' as const, 'Distributed' as const), { + nil: undefined, + }), + }), + status: fc.option( + fc.record({ + state: fc.option(fc.constantFrom('Allocated', 'UnAllocated', 'Contention'), { + nil: undefined, + }), + gameServerName: fc.option(fc.string({ minLength: 1, maxLength: 30 }), { nil: undefined }), + address: fc.option(fc.ipV4(), { nil: undefined }), + ports: fc.option( + fc.array( + fc.record({ + name: fc.string({ minLength: 1, maxLength: 15 }), + port: fc.integer({ min: 1, max: 65535 }), + }), + { maxLength: 4 } + ), + { nil: undefined } + ), + }), + { nil: undefined } + ), + }); +} + +describe('GameServerAllocation getters — property tests', () => { + it('string getters should always return strings (never undefined)', () => { + fc.assert( + fc.property(fcGameServerAllocationJson(), json => { + const gsa = new GameServerAllocation(json); + expect(typeof gsa.allocationState).toBe('string'); + expect(typeof gsa.gameServerName).toBe('string'); + expect(typeof gsa.address).toBe('string'); + }) + ); + }); + + it('ports should always return a string in "name:port" format or empty', () => { + fc.assert( + fc.property(fcGameServerAllocationJson(), json => { + const gsa = new GameServerAllocation(json); + const result = gsa.ports; + expect(typeof result).toBe('string'); + + // If there are ports in status, verify the format + if (result.length > 0) { + const parts = result.split(', '); + for (const part of parts) { + // Each part should be "name:port" + expect(part).toMatch(/^.+:\d+$/); + } + } + }) + ); + }); +}); diff --git a/src/utils/buildAllocationBody.property.test.ts b/src/utils/buildAllocationBody.property.test.ts new file mode 100644 index 0000000..11b7e28 --- /dev/null +++ b/src/utils/buildAllocationBody.property.test.ts @@ -0,0 +1,208 @@ +/* + * 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 * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { AllocationFormState, buildAllocationBody } from './buildAllocationBody'; + +/** + * Arbitrary that generates random {@link AllocationFormState} objects. + * Covers the full surface area of the form: namespaces, label selectors, + * counter/list filters, counter/list mutations, and priorities. + */ +function fcAllocationFormState(): fc.Arbitrary { + return fc.record({ + namespace: fc.string({ minLength: 1, maxLength: 63 }), + labelSelector: fc + .array(fc.tuple(fc.string({ maxLength: 20 }), fc.string({ maxLength: 20 })), { maxLength: 5 }) + .map(pairs => pairs.map(([k, v]) => `${k}=${v}`).join(',')), + gameServerState: fc.constantFrom('Ready' as const, 'Allocated' as const), + scheduling: fc.constantFrom('Packed' as const, 'Distributed' as const), + priorities: fc.array( + fc.record({ + type: fc.constantFrom('Counter' as const, 'List' as const), + key: fc.string({ maxLength: 20 }), + order: fc.constantFrom('Ascending' as const, 'Descending' as const), + }), + { maxLength: 3 } + ), + counterFilters: fc.array( + fc.record({ + key: fc.string({ maxLength: 20 }), + minAvailable: fc.oneof(fc.constant(''), fc.nat({ max: 100 }).map(String)), + }), + { maxLength: 3 } + ), + listFilters: fc.array( + fc.record({ + key: fc.string({ maxLength: 20 }), + containsValue: fc.string({ maxLength: 20 }), + minAvailable: fc.oneof(fc.constant(''), fc.nat({ max: 100 }).map(String)), + }), + { maxLength: 3 } + ), + counterMuts: fc.array( + fc.record({ + key: fc.string({ maxLength: 20 }), + action: fc.constantFrom('Increment' as const, 'Decrement' as const), + amount: fc.oneof(fc.constant(''), fc.nat({ max: 100 }).map(String)), + }), + { maxLength: 3 } + ), + listMuts: fc.array( + fc.record({ + key: fc.string({ maxLength: 20 }), + addValues: fc.array(fc.string({ maxLength: 10 }), { maxLength: 5 }).map(v => v.join(',')), + }), + { maxLength: 3 } + ), + }); +} + +describe('buildAllocationBody — property tests', () => { + it('should always produce apiVersion "allocation.agones.dev/v1"', () => { + fc.assert( + fc.property(fcAllocationFormState(), form => { + const body = buildAllocationBody(form); + expect(body.apiVersion).toBe('allocation.agones.dev/v1'); + }) + ); + }); + + it('should always produce kind "GameServerAllocation"', () => { + fc.assert( + fc.property(fcAllocationFormState(), form => { + const body = buildAllocationBody(form); + expect(body.kind).toBe('GameServerAllocation'); + }) + ); + }); + + it('should always set metadata.namespace to the form namespace', () => { + fc.assert( + fc.property(fcAllocationFormState(), form => { + const body = buildAllocationBody(form); + const meta = body.metadata as Record; + expect(meta.namespace).toBe(form.namespace); + }) + ); + }); + + it('should always set metadata.generateName to "ui-allocation-"', () => { + fc.assert( + fc.property(fcAllocationFormState(), form => { + const body = buildAllocationBody(form); + const meta = body.metadata as Record; + expect(meta.generateName).toBe('ui-allocation-'); + }) + ); + }); + + it('should always have exactly one selector in spec.selectors', () => { + fc.assert( + fc.property(fcAllocationFormState(), form => { + const body = buildAllocationBody(form); + const spec = body.spec as Record; + expect(spec.selectors).toHaveLength(1); + }) + ); + }); + + it('should always propagate scheduling from form to spec', () => { + fc.assert( + fc.property(fcAllocationFormState(), form => { + const body = buildAllocationBody(form); + const spec = body.spec as Record; + expect(spec.scheduling).toBe(form.scheduling); + }) + ); + }); + + it('should always propagate gameServerState to the selector', () => { + fc.assert( + fc.property(fcAllocationFormState(), form => { + const body = buildAllocationBody(form); + const spec = body.spec as Record; + const selector = spec.selectors[0] as Record; + expect(selector.gameServerState).toBe(form.gameServerState); + }) + ); + }); + + it('should never have leading/trailing whitespace in matchLabels keys or values', () => { + fc.assert( + fc.property(fcAllocationFormState(), form => { + const body = buildAllocationBody(form); + const spec = body.spec as Record; + const selector = spec.selectors[0] as Record>; + const labels = selector.matchLabels ?? {}; + for (const [key, val] of Object.entries(labels)) { + expect(key).toBe(key.trim()); + expect(val).toBe(val.trim()); + } + }) + ); + }); + + it('should never include counter mutations with empty amount', () => { + fc.assert( + fc.property(fcAllocationFormState(), form => { + const body = buildAllocationBody(form); + const spec = body.spec as Record>; + if (spec.counters) { + for (const mut of Object.values(spec.counters)) { + expect(mut.amount).toBeDefined(); + expect(typeof mut.amount).toBe('number'); + } + } + }) + ); + }); + + it('should never include priorities with empty keys', () => { + fc.assert( + fc.property(fcAllocationFormState(), form => { + const body = buildAllocationBody(form); + const spec = body.spec as Record>; + if (spec.priorities) { + for (const p of spec.priorities) { + expect(p.key.trim()).not.toBe(''); + } + } + }) + ); + }); + + it('should never include counter/list filter keys that are empty after trimming', () => { + fc.assert( + fc.property(fcAllocationFormState(), form => { + const body = buildAllocationBody(form); + const spec = body.spec as Record; + const selector = spec.selectors[0] as Record>; + if (selector.counters) { + for (const key of Object.keys(selector.counters)) { + expect(key.trim()).not.toBe(''); + } + } + if (selector.lists) { + for (const key of Object.keys(selector.lists)) { + expect(key.trim()).not.toBe(''); + } + } + }) + ); + }); +}); From 337ae6ea1036e3ac47f46602db524527170fc8a1 Mon Sep 17 00:00:00 2001 From: ashwani yadav <22ashwaniyadav@gmail.com> Date: Thu, 9 Jul 2026 13:07:15 +0530 Subject: [PATCH 3/3] test: Add None to portPolicy values in GameServer property test Signed-off-by: ashwani yadav <22ashwaniyadav@gmail.com> --- src/resources/gameserver.property.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/resources/gameserver.property.test.ts b/src/resources/gameserver.property.test.ts index 4efaa62..28471d0 100644 --- a/src/resources/gameserver.property.test.ts +++ b/src/resources/gameserver.property.test.ts @@ -25,7 +25,7 @@ import { GameServer } from './gameserver'; function fcGameServerJson(): fc.Arbitrary { const fcSpecPort = fc.record({ name: fc.option(fc.string({ minLength: 1, maxLength: 15 }), { nil: undefined }), - portPolicy: fc.option(fc.constantFrom('Dynamic', 'Static', 'Passthrough'), { nil: undefined }), + portPolicy: fc.option(fc.constantFrom('Dynamic', 'Static', 'Passthrough', 'None'), { nil: undefined }), containerPort: fc.option(fc.integer({ min: 1, max: 65535 }), { nil: undefined }), protocol: fc.option(fc.constantFrom('UDP', 'TCP', 'TCPUDP'), { nil: undefined }), }); @@ -191,7 +191,7 @@ describe('GameServer getters — property tests', () => { const gs = new GameServer(json); for (const mp of gs.mergedPorts) { expect(['UDP', 'TCP', 'TCPUDP']).toContain(mp.protocol); - expect(['Dynamic', 'Static', 'Passthrough']).toContain(mp.portPolicy); + expect(['Dynamic', 'Static', 'Passthrough', 'None']).toContain(mp.portPolicy); } }) );