mirror of
https://github.com/harvester/harvester-ui-extension.git
synced 2026-10-14 10:06:04 +08:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user