feat: Add a unit test target (jest) to the UI extension and run it in CI (#1192)

- Introduce test targets.
- Adding additional tests.

Related to: https://github.com/harvester/harvester/issues/11892

Signed-off-by: Volker Theile <vtheile@suse.com>
This commit is contained in:
Volker Theile
2026-10-13 18:00:38 +02:00
committed by GitHub
parent 02862bbeea
commit 71141b257a
18 changed files with 924 additions and 108 deletions
@@ -2,9 +2,23 @@ import { mount } from '@vue/test-utils';
import { _EDIT } from '@shell/config/query-params';
import HarvesterEditContainer from '../container.vue';
const mountOptions = () => ({
props: { mode: _EDIT, value: {} },
global: {
mocks: {
$store: {
getters: {
'harvester-common/getFeatureEnabled': () => true,
'i18n/t': jest.fn(),
}
}
}
}
});
describe('component: HarvesterEditContainer', () => {
it('should display all the inputs', () => {
const wrapper = mount(HarvesterEditContainer, { propsData: { mode: _EDIT, value: {} } });
const wrapper = mount(HarvesterEditContainer, mountOptions());
const inputWraps = wrapper.findAll('[data-testid^=input-hec-]');
@@ -15,7 +29,7 @@ describe('component: HarvesterEditContainer', () => {
'name',
'container',
])('should emit an update on %p input', (field) => {
const wrapper = mount(HarvesterEditContainer, { propsData: { mode: _EDIT, value: {} } });
const wrapper = mount(HarvesterEditContainer, mountOptions());
const input = wrapper.find(`[data-testid="input-hec-${ field }"]`).find('input');
const newValue = 123;
@@ -28,7 +42,7 @@ describe('component: HarvesterEditContainer', () => {
'type',
'bus',
])('should emit an update on %p selection change', async(field) => {
const wrapper = mount(HarvesterEditContainer, { propsData: { mode: _EDIT, value: {} } });
const wrapper = mount(HarvesterEditContainer, mountOptions());
const select = wrapper.find(`[data-testid="input-hec-${ field }"]`);
select.find('button').trigger('click');
@@ -5,14 +5,17 @@ import HarvesterEditExisting from '../existing.vue';
describe('component: HarvesterEditExisting', () => {
it('should display all the inputs', () => {
const wrapper = mount(HarvesterEditExisting, {
propsData: {
props: {
mode: _EDIT, value: {}, rows: []
},
mocks: {
$store: {
getters: {
'harvester/all': () => [],
'i18n/t': jest.fn()
global: {
mocks: {
$store: {
getters: {
'harvester-common/getFeatureEnabled': () => true,
'harvester/all': () => [],
'i18n/t': jest.fn()
}
}
}
}
@@ -27,14 +30,17 @@ describe('component: HarvesterEditExisting', () => {
'name',
])('should emit an update on %p input', (field) => {
const wrapper = mount(HarvesterEditExisting, {
propsData: {
props: {
mode: _EDIT, value: {}, rows: []
},
mocks: {
$store: {
getters: {
'harvester/all': () => [],
'i18n/t': jest.fn()
global: {
mocks: {
$store: {
getters: {
'harvester-common/getFeatureEnabled': () => true,
'harvester/all': () => [],
'i18n/t': jest.fn()
}
}
}
}
@@ -53,14 +59,17 @@ describe('component: HarvesterEditExisting', () => {
'size',
])('should emit an update on %p input and blur', (field) => {
const wrapper = mount(HarvesterEditExisting, {
propsData: {
props: {
mode: _EDIT, value: {}, rows: []
},
mocks: {
$store: {
getters: {
'harvester/all': () => [],
'i18n/t': jest.fn()
global: {
mocks: {
$store: {
getters: {
'harvester-common/getFeatureEnabled': () => true,
'harvester/all': () => [],
'i18n/t': jest.fn()
}
}
}
}
@@ -80,14 +89,17 @@ describe('component: HarvesterEditExisting', () => {
// 'bus', // disabled
])('should emit an update on %p selection change', async(field) => {
const wrapper = mount(HarvesterEditExisting, {
propsData: {
props: {
mode: _EDIT, value: {}, rows: []
},
mocks: {
$store: {
getters: {
'harvester/all': () => [],
'i18n/t': jest.fn()
global: {
mocks: {
$store: {
getters: {
'harvester-common/getFeatureEnabled': () => true,
'harvester/all': () => [],
'i18n/t': jest.fn()
}
}
}
}
@@ -2,23 +2,29 @@ import { mount } from '@vue/test-utils';
import { _CREATE } from '@shell/config/query-params';
import HarvesterEditVMImage from '../vmImage.vue';
// The form emits an update when it is created, so count only the updates that an interaction adds
const updates = (wrapper) => wrapper.emitted('update')?.length ?? 0;
describe('component: HarvesterEditVMImage', () => {
it('should display all the inputs', () => {
const wrapper = mount(HarvesterEditVMImage, {
propsData: {
props: {
mode: _CREATE,
isVirtualType: false,
newCreateId: '123',
validateRequired: true,
idx: 1
},
mocks: {
$store: {
getters: {
'harvester/all': jest.fn(),
'i18n/t': jest.fn(),
}
},
global: {
mocks: {
$store: {
getters: {
'harvester-common/getFeatureEnabled': () => true,
'harvester/all': jest.fn(),
'i18n/t': jest.fn(),
}
},
}
}
});
@@ -31,20 +37,23 @@ describe('component: HarvesterEditVMImage', () => {
'name',
])('should emit an update on %p input', (field) => {
const wrapper = mount(HarvesterEditVMImage, {
propsData: {
props: {
mode: _CREATE,
isVirtualType: false,
newCreateId: '123',
validateRequired: true,
idx: 1
},
mocks: {
$store: {
getters: {
'harvester/all': jest.fn(),
'i18n/t': jest.fn(),
}
},
global: {
mocks: {
$store: {
getters: {
'harvester-common/getFeatureEnabled': () => true,
'harvester/all': jest.fn(),
'i18n/t': jest.fn(),
}
},
}
}
});
const input = wrapper.find(`[data-testid="input-hevi-${ field }"]`).find('input');
@@ -57,31 +66,38 @@ describe('component: HarvesterEditVMImage', () => {
it.each([
'size',
])('should emit an update on %p input blur', (field) => {
])('should emit an update on %p input blur', async(field) => {
const wrapper = mount(HarvesterEditVMImage, {
propsData: {
props: {
mode: _CREATE,
isVirtualType: false,
newCreateId: '123',
validateRequired: true,
idx: 1
},
mocks: {
$store: {
getters: {
'harvester/all': jest.fn(),
'i18n/t': jest.fn(),
}
},
global: {
mocks: {
$store: {
getters: {
'harvester-common/getFeatureEnabled': () => true,
'harvester/all': jest.fn(),
'i18n/t': jest.fn(),
}
},
}
}
});
const input = wrapper.find(`[data-testid="input-hevi-${ field }"]`).find('input');
const newValue = 123;
input.setValue(newValue);
input.trigger('blur');
// setting the value already emits an update, so the blur has to add one on top of that
await input.setValue(newValue);
expect(wrapper.emitted('update')).toHaveLength(1);
const afterSetValue = updates(wrapper);
await input.trigger('blur');
expect(updates(wrapper)).toBeGreaterThan(afterSetValue);
});
it.each([
@@ -90,20 +106,23 @@ describe('component: HarvesterEditVMImage', () => {
'bus',
])('should emit an update on %p selection change', async(field) => {
const wrapper = mount(HarvesterEditVMImage, {
propsData: {
props: {
mode: _CREATE,
isVirtualType: false,
newCreateId: '123',
validateRequired: true,
idx: 1
},
mocks: {
$store: {
getters: {
'harvester/all': jest.fn(),
'i18n/t': jest.fn(),
}
},
global: {
mocks: {
$store: {
getters: {
'harvester-common/getFeatureEnabled': () => true,
'harvester/all': jest.fn(),
'i18n/t': jest.fn(),
}
},
}
}
});
const select = wrapper.find(`[data-testid="input-hevi-${ field }"]`);
@@ -2,18 +2,24 @@ import { mount } from '@vue/test-utils';
import { _EDIT } from '@shell/config/query-params';
import HarvesterEditVolume from '../volume.vue';
// The form emits an update when it is created, so count only the updates that an interaction adds
const updates = (wrapper) => wrapper.emitted('update')?.length ?? 0;
describe('component: HarvesterEditVolume', () => {
it('should display all the inputs', () => {
const wrapper = mount(HarvesterEditVolume, {
propsData: { validateRequired: true },
mocks: {
$store: {
getters: {
'harvester/all': jest.fn(),
'i18n/t': jest.fn(),
'i18n/exists': jest.fn(),
}
},
props: { validateRequired: true },
global: {
mocks: {
$store: {
getters: {
'harvester-common/getFeatureEnabled': () => true,
'harvester/all': jest.fn(),
'i18n/t': jest.fn(),
'i18n/exists': jest.fn(),
}
},
}
}
});
@@ -22,29 +28,56 @@ describe('component: HarvesterEditVolume', () => {
expect(inputWraps).toHaveLength(4);
});
it.each([
'name',
'size',
])('should emit an update on %p input blur', (field) => {
it('should emit an update on name input', async() => {
const wrapper = mount(HarvesterEditVolume, {
propsData: { mode: _EDIT, validateRequired: true },
mocks: {
$store: {
getters: {
'harvester/all': jest.fn(),
'i18n/t': jest.fn(),
'i18n/exists': jest.fn(),
}
},
props: { mode: _EDIT, validateRequired: true },
global: {
mocks: {
$store: {
getters: {
'harvester-common/getFeatureEnabled': () => true,
'harvester/all': jest.fn(),
'i18n/t': jest.fn(),
'i18n/exists': jest.fn(),
}
},
}
}
});
const input = wrapper.find(`[data-testid="input-hev-${ field }"]`).find('input');
const newValue = 123;
const before = updates(wrapper);
const input = wrapper.find('[data-testid="input-hev-name"]').find('input');
input.setValue(newValue);
input.trigger('blur');
await input.setValue(123);
expect(wrapper.emitted('update')).toHaveLength(1);
expect(updates(wrapper)).toBeGreaterThan(before);
});
it('should emit an update on size input blur', async() => {
const wrapper = mount(HarvesterEditVolume, {
props: { mode: _EDIT, validateRequired: true },
global: {
mocks: {
$store: {
getters: {
'harvester-common/getFeatureEnabled': () => true,
'harvester/all': jest.fn(),
'i18n/t': jest.fn(),
'i18n/exists': jest.fn(),
}
},
}
}
});
const input = wrapper.find('[data-testid="input-hev-size"]').find('input');
// setting the value already emits an update, so the blur has to add one on top of that
await input.setValue(123);
const afterSetValue = updates(wrapper);
await input.trigger('blur');
expect(updates(wrapper)).toBeGreaterThan(afterSetValue);
});
it.each([
@@ -52,23 +85,27 @@ describe('component: HarvesterEditVolume', () => {
'bus'
])('should emit an update on %p selection change', async(field) => {
const wrapper = mount(HarvesterEditVolume, {
propsData: { mode: _EDIT, validateRequired: true },
mocks: {
$store: {
getters: {
'harvester/all': jest.fn(),
'i18n/t': jest.fn(),
'i18n/exists': jest.fn(),
}
},
props: { mode: _EDIT, validateRequired: true },
global: {
mocks: {
$store: {
getters: {
'harvester-common/getFeatureEnabled': () => true,
'harvester/all': jest.fn(),
'i18n/t': jest.fn(),
'i18n/exists': jest.fn(),
}
},
}
}
});
const before = updates(wrapper);
const select = wrapper.find(`[data-testid="input-hev-${ field }"]`);
select.find('button').trigger('click');
await wrapper.trigger('keydown.down');
await wrapper.trigger('keydown.enter');
expect(wrapper.emitted('update')).toHaveLength(1);
expect(updates(wrapper)).toBeGreaterThan(before);
});
});
@@ -0,0 +1,323 @@
import { reactive } from 'vue';
import VirtVm from '../kubevirt.io.virtualmachine';
import { POD, PVC } from '@shell/config/types';
import { HCI } from '../../types';
// The Steve base model and the shell resource class pull in most of the dashboard, none of that is needed here
jest.mock('../harvester', () => ({ __esModule: true, default: class {} }));
jest.mock('@shell/plugins/dashboard-store/resource-class', () => ({ colorForState: (state) => (state === 'Running' ? 'text-success' : 'text-info') }));
const VM_ID = 'ns1/vm1';
/**
* Create a VM model instance without the full Steve model plumbing. Only the store getters that the
* VM model reads are mocked, backed by plain lists (like the real store, `all` returns the same array
* that is mutated in place on updates).
*
* The model reads its store either through `$rootGetters` (`harvester/...`) or through `$getters`, the getters of the
* store it lives in. Both are backed by the same data, so the tests do not depend on which of them the model uses.
*/
function createVm({
vm = {}, vmi, pods = [], pvcs = [], restores = [], backups = [], claims = ['disk-pvc'], printableStatus
} = {}) {
const lists = reactive({
[POD]: pods,
[PVC]: pvcs,
[HCI.RESTORE]: restores,
[HCI.BACKUP]: backups,
});
const byId = { [HCI.VMI]: vmi ? [vmi] : [] };
const getters = {
all: (type) => lists[type] || [],
byId: (type, id) => (byId[type] || lists[type] || []).find((x) => x.id === id),
schemaFor: () => undefined,
};
const rootGetters = {
'harvester-common/getFeatureEnabled': () => false,
currentProduct: { inStore: 'harvester' },
'harvester/all': getters.all,
'harvester/byId': getters.byId,
};
const obj = Object.create(VirtVm.prototype);
Object.assign(obj, {
id: VM_ID,
type: HCI.VM,
metadata: {
name: 'vm1', namespace: 'ns1', annotations: {}
},
spec: {
runStrategy: 'Always',
template: {
spec: {
domain: { cpu: { cores: 1 }, resources: { limits: { memory: '1Gi' } } },
volumes: claims.map((c) => ({ name: c, persistentVolumeClaim: { claimName: c } })),
}
}
},
status: {
created: true, ready: true, printableStatus: printableStatus || 'Running', conditions: []
},
...vm,
});
Object.defineProperty(obj, '$rootGetters', { value: rootGetters });
Object.defineProperty(obj, '$getters', { value: getters });
Object.defineProperty(obj, 't', { value: (k) => k });
return { vm: obj, lists };
}
const readyVmi = (extra = {}) => ({
id: VM_ID,
metadata: { name: 'vm1', namespace: 'ns1' },
status: {
phase: 'Running',
conditions: [{ type: 'Ready', status: 'True' }],
...extra
}
});
const pvc = (name, encrypted, ns = 'ns1') => ({
id: `${ ns }/${ name }`,
metadata: { name, namespace: ns },
isEncrypted: encrypted,
});
describe('class VirtVm', () => {
describe('restoreResource', () => {
it('should distinguish snapshots from backups and follow backup updates', () => {
const { vm, lists } = createVm({
vm: {
metadata: {
namespace: 'ns1', name: 'vm1', annotations: { 'restore.harvesterhci.io/name': 'r1' }
}
},
restores: [{
id: 'ns1/r1', isComplete: false, spec: { virtualMachineBackupNamespace: 'ns1', virtualMachineBackupName: 'b1' }
}],
backups: [
{ id: 'ns2/b1', spec: { type: 'snapshot' } },
{ id: 'ns1/b1', spec: { type: 'backup' } },
],
});
expect(vm.restoreResource.fromSnapshot).toBe(false);
expect(vm.restoreState).toBe(false);
lists[HCI.BACKUP][1].spec.type = 'snapshot';
expect(vm.restoreResource.fromSnapshot).toBe(true);
lists[HCI.BACKUP].splice(1, 1);
expect(vm.restoreResource.fromSnapshot).toBe(false);
lists[HCI.RESTORE][0].isComplete = true;
expect(vm.restoreState).toBe(true);
});
});
describe('encryptedVolumeType', () => {
it('should be none without volumes', () => {
const { vm } = createVm({ claims: [] });
expect(vm.encryptedVolumeType).toBe('none');
});
it('should be none if no PVC is encrypted', () => {
const { vm } = createVm({ pvcs: [pvc('disk-pvc', false)] });
expect(vm.encryptedVolumeType).toBe('none');
});
it('should be all if every PVC is encrypted', () => {
const { vm } = createVm({
claims: ['a', 'b'],
pvcs: [pvc('a', true), pvc('b', true), pvc('other', false)]
});
expect(vm.encryptedVolumeType).toBe('all');
});
it('should be partial if some PVCs are encrypted', () => {
const { vm } = createVm({
claims: ['a', 'b'],
pvcs: [pvc('a', true), pvc('b', false)]
});
expect(vm.encryptedVolumeType).toBe('partial');
});
it('should follow PVCs that are added to the store later', () => {
const { vm, lists } = createVm({ claims: ['a'], pvcs: [] });
expect(vm.encryptedVolumeType).toBe('none');
lists[PVC].push(pvc('a', true));
expect(vm.encryptedVolumeType).toBe('all');
});
});
describe('actualState', () => {
it('should be Off if the VM is halted', () => {
const { vm } = createVm({
vm: {
spec: { runStrategy: 'Halted', template: { spec: { volumes: [], domain: {} } } },
status: { created: false, conditions: [] }
}
});
expect(vm.actualState).toBe('Off');
});
it('should be Running if the VMI is running and ready', () => {
const { vm } = createVm({ vmi: readyVmi() });
expect(vm.actualState).toBe('Running');
});
it('should be Not Ready if the VMI is running but not ready', () => {
const { vm } = createVm({ vmi: readyVmi({ conditions: [{ type: 'Ready', status: 'False' }] }) });
expect(vm.actualState).toBe('Not Ready');
});
it('should be Paused if the VMI has the Paused condition', () => {
const { vm } = createVm({ vmi: readyVmi({ conditions: [{ type: 'Ready', status: 'True' }, { type: 'Paused', status: 'True' }] }) });
expect(vm.actualState).toBe('Paused');
});
it('should be Stopping if the VM is not expected to run anymore but the VMI exists', () => {
const { vm } = createVm({
vm: { spec: { runStrategy: 'Halted', template: { spec: { volumes: [], domain: {} } } } },
vmi: readyVmi()
});
expect(vm.actualState).toBe('Stopping');
});
it('should be Starting while the launcher pod is not ready', () => {
const { vm } = createVm({
vmi: readyVmi({ phase: 'Scheduling', conditions: [] }),
pods: [{
metadata: { namespace: 'ns1', ownerReferences: [{ name: 'vm1' }] },
getPodStatus: { status: 'POD_NOT_SCHEDULABLE' }
}]
});
expect(vm.actualState).toBe('Starting');
});
it('should be Unschedulable if the VM is starting and has an unschedulable condition', () => {
const { vm } = createVm({
vmi: readyVmi({ phase: 'Pending', conditions: [] }),
vm: { status: { created: true, conditions: [{ reason: 'Unschedulable', message: 'no node' }] } }
});
expect(vm.actualState).toBe('Unschedulable');
expect(vm.isUnschedulable.message).toBe('no node');
});
it('should not be Unschedulable without an unschedulable condition', () => {
const { vm } = createVm({ vmi: readyVmi() });
expect(vm.isUnschedulable).toBeNull();
expect(vm.actualState).toBe('Running');
});
it('should not be Unschedulable if the VM is neither starting nor stopping', () => {
const { vm } = createVm({
vm: {
spec: { runStrategy: 'Halted', template: { spec: { volumes: [], domain: {} } } },
status: { created: false, conditions: [{ reason: 'Unschedulable' }] }
}
});
expect(vm.isUnschedulable).toBeNull();
expect(vm.actualState).toBe('Off');
});
it('should be Terminating if the VM is being deleted', () => {
const { vm } = createVm({
vmi: readyVmi(),
vm: {
metadata: {
name: 'vm1', namespace: 'ns1', annotations: {}, deletionTimestamp: 'now'
}
}
});
expect(vm.actualState).toBe('Terminating');
});
it('should be VM error if the VM has a failure condition', () => {
const { vm } = createVm({ vm: { status: { created: true, conditions: [{ type: 'Failure', message: 'boom' }] } } });
expect(vm.actualState).toBe('VM error');
});
it('should be Restoring while a restore is in progress', () => {
const { vm } = createVm({
vmi: readyVmi(),
vm: {
metadata: {
name: 'vm1', namespace: 'ns1', annotations: { 'restore.harvesterhci.io/name': 'r1' }
}
},
restores: [{ id: 'ns1/r1', isComplete: false }]
});
expect(vm.actualState).toBe('Restoring');
});
it('should not be Restoring if the restore of the annotation does not exist', () => {
const { vm } = createVm({
vmi: readyVmi(),
vm: {
metadata: {
name: 'vm1', namespace: 'ns1', annotations: { 'restore.harvesterhci.io/name': 'other' }
}
},
restores: [{ id: 'ns1/r1', isComplete: false }]
});
expect(vm.actualState).toBe('Running');
});
});
describe('podResource', () => {
it('should find the launcher pod by owner name', () => {
const pod = { metadata: { namespace: 'ns1', ownerReferences: [{ name: 'vm1' }] } };
const { vm } = createVm({ vmi: readyVmi(), pods: [pod] });
expect(vm.podResource).toStrictEqual(pod);
});
it('should return the last pod if several pods have the same owner', () => {
// e.g. the source and the target pod while the VM migrates
const source = {
metadata: {
name: 'launcher-source', namespace: 'ns1', ownerReferences: [{ name: 'vm1' }]
}
};
const target = {
metadata: {
name: 'launcher-target', namespace: 'ns1', ownerReferences: [{ name: 'vm1' }]
}
};
const { vm } = createVm({ vmi: readyVmi(), pods: [source, target] });
expect(vm.podResource.metadata.name).toBe('launcher-target');
});
});
describe('isRestartRequired', () => {
it('should reflect the RestartRequired condition', () => {
const { vm } = createVm({ vm: { status: { conditions: [{ type: 'RestartRequired', status: 'True' }] } } });
expect(vm.isRestartRequired).toBe(true);
});
});
});
@@ -0,0 +1,57 @@
import { reactive } from 'vue';
import VirtVmInstance from '../kubevirt.io.virtualmachineinstance';
import { HCI } from '../../types';
import { HCI as HCI_ANNOTATIONS } from '../../config/labels-annotations';
jest.mock('../harvester', () => ({ __esModule: true, default: class {} }));
jest.mock('@shell/plugins/dashboard-store/resource-class', () => ({ colorForState: () => 'text-info' }));
function migration(namespace, timestamp, phase = 'Running') {
return {
metadata: { namespace, creationTimestamp: timestamp },
spec: { vmiName: 'vm1' },
status: { phase },
};
}
function createVmi(migrations) {
const list = reactive(migrations);
const vmi = Object.create(VirtVmInstance.prototype);
Object.assign(vmi, {
metadata: {
namespace: 'ns1',
name: 'vm1',
annotations: { [HCI_ANNOTATIONS.MIGRATION_STATE]: 'Migrating' },
},
$getters: { all: (type) => type === HCI.VMIM ? list : [] },
$rootGetters: { 'harvester/all': (type) => type === HCI.VMIM ? list : [] },
});
return { vmi, list };
}
describe('class VirtVmInstance', () => {
it('should follow additions, timestamp changes, phase changes and removals in the same list', () => {
const { vmi, list } = createVmi([]);
expect(vmi.vmimResource).toStrictEqual([]);
expect(vmi.migrationState).toBeNull();
list.push(migration('ns1', '2026-01-01T00:00:00Z'));
expect(vmi.migrationState.status).toBe('Migrating');
list.push(migration('ns1', '2026-01-02T00:00:00Z', 'Failed'));
expect(vmi.migrationState.status).toBe('Failed');
list[0].metadata.creationTimestamp = '2026-01-03T00:00:00Z';
expect(vmi.vmimResource).toBe(list[0]);
expect(vmi.migrationState.status).toBe('Migrating');
list[0].status.phase = 'Failed';
expect(vmi.migrationState.status).toBe('Failed');
list.splice(0, 2);
expect(vmi.migrationState).toBeNull();
});
});
@@ -0,0 +1,37 @@
import { featureEnabled } from '../feature-flags';
describe('featureEnabled', () => {
it('should enable a feature from the version it was released in', () => {
expect(featureEnabled('hotplugNic', 'v1.7.0')).toBe(true);
expect(featureEnabled('hotplugNic', 'v1.8.2')).toBe(true);
});
it('should not enable a feature before the version it was released in', () => {
expect(featureEnabled('hotplugNic', 'v1.6.1')).toBe(false);
expect(featureEnabled('hotplugCdRom', 'v1.7.1')).toBe(false);
});
it('should ignore pre-release suffixes of the server version', () => {
expect(featureEnabled('hotplugCdRom', 'v1.8.0-rc1')).toBe(true);
expect(featureEnabled('hotplugCdRom', 'v1.8.0-dev-20260101')).toBe(true);
});
it('should return false for unknown features', () => {
expect(featureEnabled('doesNotExist', 'v1.8.0')).toBe(false);
});
it('should not enable anything for versions that are no longer supported', () => {
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
expect(featureEnabled('cpuPinning', 'v1.2.0')).toBe(false);
spy.mockRestore();
});
it('should return the same result for repeated calls with different arguments', () => {
expect(featureEnabled('hotplugNic', 'v1.6.0')).toBe(false);
expect(featureEnabled('hotplugNic', 'v1.7.0')).toBe(true);
expect(featureEnabled('hotplugNic', 'v1.6.0')).toBe(false);
expect(featureEnabled('hotplugNic', 'v1.7.0')).toBe(true);
});
});