diff --git a/frontend/integration-tests/protractor.conf.ts b/frontend/integration-tests/protractor.conf.ts index bc438cbc31b..610f55f8646 100644 --- a/frontend/integration-tests/protractor.conf.ts +++ b/frontend/integration-tests/protractor.conf.ts @@ -168,6 +168,9 @@ export const config: Config = { ]), 'kubevirt-plugin': suite([ '../packages/kubevirt-plugin/integration-tests/tests/vm.wizard.scenario.ts', + '../packages/kubevirt-plugin/integration-tests/tests/vm.actions.scenario.ts', + '../packages/kubevirt-plugin/integration-tests/tests/vm.migration.scenario.ts', + '../packages/kubevirt-plugin/integration-tests/tests/vm.resources.scenario.ts', ]), all: suite([ 'tests/crud.scenario.ts', diff --git a/frontend/packages/console-shared/src/test-utils/utils.ts b/frontend/packages/console-shared/src/test-utils/utils.ts index 3efb59e2426..fa6214e5cf6 100644 --- a/frontend/packages/console-shared/src/test-utils/utils.ts +++ b/frontend/packages/console-shared/src/test-utils/utils.ts @@ -143,3 +143,16 @@ export const waitForStringNotInElement = (elem: ElementFinder, needle: string) = return !content.includes(needle); }; }; + +/** + * Search YAML manifest for a given string. Return true if found. + * @param {string} needle String to search in YAML. + * @param {string} name Name of the resource. + * @param {string} namespace Namespace of the resource. + * @param {string} kind Kind of the resource. + * @returns {boolean} True if found, false otherwise. + */ +export function searchYAML(needle: string, name: string, namespace: string, kind: string): boolean { + const result = execSync(`kubectl get -o yaml -n ${namespace} ${kind} ${name}`).toString(); + return result.search(needle) >= 0; +} diff --git a/frontend/packages/kubevirt-plugin/integration-tests/tests/models/detailView.ts b/frontend/packages/kubevirt-plugin/integration-tests/tests/models/detailView.ts index 2802970da61..646f543cdad 100644 --- a/frontend/packages/kubevirt-plugin/integration-tests/tests/models/detailView.ts +++ b/frontend/packages/kubevirt-plugin/integration-tests/tests/models/detailView.ts @@ -2,6 +2,7 @@ import { browser } from 'protractor'; import { appHost, testName } from '../../../../../integration-tests/protractor.conf'; import { clickHorizontalTab } from '../../../../../integration-tests/views/horizontal-nav.view'; import { isLoaded, resourceTitle } from '../../../../../integration-tests/views/crud.view'; +import { activeTab } from '../../views/detailView.view'; export class DetailView { readonly name: string; @@ -25,12 +26,15 @@ export class DetailView { await browser.get(`${appHost}/k8s/ns/${this.namespace}/${this.kind}/${this.name}`); await isLoaded(); } - await clickHorizontalTab(tabName); - await isLoaded(); + if ((await activeTab.getText()) !== tabName) { + await clickHorizontalTab(tabName); + await isLoaded(); + } } async navigateToListView() { - const vmsListUrl = (namespace) => `${appHost}/k8s/ns/${namespace}/${this.kind}`; + const vmsListUrl = (namespace) => + `${appHost}/k8s/${namespace === 'all-namespaces' ? '' : 'ns/'}${namespace}/${this.kind}`; const currentUrl = await browser.getCurrentUrl(); if (![vmsListUrl(testName), vmsListUrl('all-namespaces')].includes(currentUrl)) { await browser.get(vmsListUrl(this.namespace)); diff --git a/frontend/packages/kubevirt-plugin/integration-tests/tests/models/kubevirtDetailView.ts b/frontend/packages/kubevirt-plugin/integration-tests/tests/models/kubevirtDetailView.ts index 8a04fa14eb4..cc37ad34d2b 100644 --- a/frontend/packages/kubevirt-plugin/integration-tests/tests/models/kubevirtDetailView.ts +++ b/frontend/packages/kubevirt-plugin/integration-tests/tests/models/kubevirtDetailView.ts @@ -1,7 +1,12 @@ /* eslint-disable no-await-in-loop */ -import { selectDropdownOption, click } from '../../../../console-shared/src/test-utils/utils'; +import { browser, ExpectedConditions as until } from 'protractor'; +import { + selectDropdownOption, + click, + waitForCount, +} from '../../../../console-shared/src/test-utils/utils'; import { isLoaded, resourceRows } from '../../../../../integration-tests/views/crud.view'; -import { TABS, diskTabCol, networkTabCol } from '../utils/consts'; +import { TABS, diskTabCol, networkTabCol, PAGE_LOAD_TIMEOUT_SECS } from '../utils/consts'; import { StorageResource, NetworkResource } from '../utils/types'; import { fillInput } from '../utils/utils'; import * as kubevirtDetailView from '../../views/kubevirtDetailView.view'; @@ -11,31 +16,29 @@ import { DetailView } from './detailView'; export class KubevirtDetailView extends DetailView { async getAttachedDisks(): Promise { await this.navigateToTab(TABS.DISKS); - const resources = []; - for (const row of await resourceRows) { - const cells = row.$$('div'); - resources.push({ - name: await cells.get(diskTabCol.name).getText(), - size: (await cells.get(diskTabCol.size).getText()).match(/^\d*/)[0], - storageClass: await cells.get(diskTabCol.storageClass).getText(), - }); - } - return resources; + const rows = await kubevirtDetailView.tableRows(); + return rows.map((line) => { + const cols = line.split(/\s+/); + return { + name: cols[diskTabCol.name], + size: cols[diskTabCol.size].slice(0, -2), + storageClass: cols[diskTabCol.storageClass], + }; + }); } async getAttachedNICs(): Promise { await this.navigateToTab(TABS.NICS); - const resources = []; - for (const row of await resourceRows) { - const cells = row.$$('div'); - resources.push({ - name: await cells.get(networkTabCol.name).getText(), - mac: await cells.get(networkTabCol.mac).getText(), - networkDefinition: await cells.get(networkTabCol.networkDefinition).getText(), - binding: await cells.get(networkTabCol.binding).getText(), - }); - } - return resources; + const rows = await kubevirtDetailView.tableRows(); + return rows.map((line) => { + const cols = line.split(/\s+/); + return { + name: cols[networkTabCol.name], + mac: cols[networkTabCol.mac], + networkDefinition: cols[networkTabCol.networkDefinition], + binding: cols[networkTabCol.binding], + }; + }); } async addDisk(disk: StorageResource) { @@ -50,8 +53,10 @@ export class KubevirtDetailView extends DetailView { async removeDisk(name: string) { await this.navigateToTab(TABS.DISKS); + const count = await resourceRows.count(); await kubevirtDetailView.selectKebabOption(name, 'Delete'); await confirmAction(); + await browser.wait(until.and(waitForCount(resourceRows, count - 1)), PAGE_LOAD_TIMEOUT_SECS); } async addNIC(nic: NetworkResource) { @@ -67,7 +72,9 @@ export class KubevirtDetailView extends DetailView { async removeNIC(name: string) { await this.navigateToTab(TABS.NICS); + const count = await resourceRows.count(); await kubevirtDetailView.selectKebabOption(name, 'Delete'); await confirmAction(); + await browser.wait(until.and(waitForCount(resourceRows, count - 1)), PAGE_LOAD_TIMEOUT_SECS); } } diff --git a/frontend/packages/kubevirt-plugin/integration-tests/tests/models/virtualMachine.ts b/frontend/packages/kubevirt-plugin/integration-tests/tests/models/virtualMachine.ts index e15dda128c1..25bd6da799e 100644 --- a/frontend/packages/kubevirt-plugin/integration-tests/tests/models/virtualMachine.ts +++ b/frontend/packages/kubevirt-plugin/integration-tests/tests/models/virtualMachine.ts @@ -1,110 +1,58 @@ /* eslint-disable no-await-in-loop, no-console */ import { browser, ExpectedConditions as until } from 'protractor'; import { testName } from '../../../../../integration-tests/protractor.conf'; -import { resourceTitle, isLoaded } from '../../../../../integration-tests/views/crud.view'; +import { isLoaded } from '../../../../../integration-tests/views/crud.view'; import { selectDropdownOption, waitForStringNotInElement, - resolveTimeout, } from '../../../../console-shared/src/test-utils/utils'; import * as vmView from '../../views/virtualMachine.view'; -import { nameInput, errorMessage } from '../../views/wizard.view'; +import { errorMessage } from '../../views/wizard.view'; import { VMConfig } from '../utils/types'; import { PAGE_LOAD_TIMEOUT_SECS, VM_BOOTUP_TIMEOUT_SECS, - VM_STOP_TIMEOUT_SECS, - VM_ACTIONS_TIMEOUT_SECS, WIZARD_CREATE_VM_ERROR, - UNEXPECTED_ACTION_ERROR, - TABS, WIZARD_TABLE_FIRST_ROW, + TABS, DASHES, } from '../utils/consts'; -import { listViewAction } from '../../views/vm.actions.view'; -import { rowForName } from '../../views/kubevirtDetailView.view'; -import { KubevirtDetailView } from './kubevirtDetailView'; +import { detailViewAction } from '../../views/vm.actions.view'; +import { tableRowForName } from '../../views/kubevirtDetailView.view'; import { Wizard } from './wizard'; +import { KubevirtDetailView } from './kubevirtDetailView'; +import { VirtualMachineInstance } from './virtualMachineInstance'; export class VirtualMachine extends KubevirtDetailView { constructor(config) { super({ ...config, kind: 'virtualmachines' }); } + async navigateToVMI(vmiTab: string): Promise { + await this.navigateToTab(TABS.OVERVIEW); + const vmPodName = await vmView + .vmDetailPod(this.namespace, this.name) + .$('a') + .getText(); + const vmi = new VirtualMachineInstance({ name: vmPodName, namespace: testName }); + await vmi.navigateToTab(vmiTab); + return vmi; + } + async action(action: string, waitForAction?: boolean, timeout?: number) { - await this.navigateToListView(); + await this.navigateToTab(TABS.OVERVIEW); let confirmDialog = true; if (['Clone'].includes(action)) { confirmDialog = false; } - await listViewAction(this.name)(action, confirmDialog); + await detailViewAction(action, confirmDialog); if (waitForAction !== false) { - switch (action) { - case 'Start': - await this.waitForStatusIcon( - vmView.statusIcons.running, - resolveTimeout(timeout, VM_BOOTUP_TIMEOUT_SECS), - ); - break; - case 'Restart': - await this.waitForStatusIcon( - vmView.statusIcons.starting, - resolveTimeout(timeout, VM_BOOTUP_TIMEOUT_SECS), - ); - await this.waitForStatusIcon( - vmView.statusIcons.running, - resolveTimeout(timeout, VM_BOOTUP_TIMEOUT_SECS), - ); - break; - case 'Stop': - await this.waitForStatusIcon( - vmView.statusIcons.off, - resolveTimeout(timeout, VM_STOP_TIMEOUT_SECS), - ); - break; - case 'Clone': - await browser.wait( - until.presenceOf(nameInput), - resolveTimeout(timeout, PAGE_LOAD_TIMEOUT_SECS), - ); - await browser.sleep(500); // Wait until the fade in effect is finished, otherwise we may misclick - break; - case 'Migrate': - await this.waitForStatusIcon( - vmView.statusIcons.migrating, - resolveTimeout(timeout, PAGE_LOAD_TIMEOUT_SECS), - ); - await this.waitForStatusIcon( - vmView.statusIcons.running, - resolveTimeout(timeout, VM_ACTIONS_TIMEOUT_SECS), - ); - break; - case 'Cancel': - await this.waitForStatusIcon( - vmView.statusIcons.running, - resolveTimeout(timeout, PAGE_LOAD_TIMEOUT_SECS), - ); - break; - case 'Delete': - // wait for redirect - await browser.wait( - until.textToBePresentInElement(resourceTitle, 'Virtual Machines'), - resolveTimeout(timeout, PAGE_LOAD_TIMEOUT_SECS), - ); - break; - default: - throw Error(UNEXPECTED_ACTION_ERROR); - } + await vmView.waitForActionFinished(action, timeout); } } - async waitForStatusIcon(statusIcon: string, timeout: number) { - await this.navigateToTab(TABS.OVERVIEW); - await browser.wait(until.presenceOf(vmView.statusIcon(statusIcon)), timeout); - } - async waitForMigrationComplete(fromNode: string, timeout: number) { await browser.wait( until.and( @@ -116,7 +64,7 @@ export class VirtualMachine extends KubevirtDetailView { } async resourceExists(resourceName: string) { - return rowForName(resourceName).isPresent(); + return tableRowForName(resourceName).isPresent(); } async selectConsole(type: string) { @@ -214,7 +162,7 @@ export class VirtualMachine extends KubevirtDetailView { if (startOnCreation === true) { // If startOnCreation is true, wait for VM to boot up - await this.waitForStatusIcon(vmView.statusIcons.running, VM_BOOTUP_TIMEOUT_SECS); + await vmView.waitForStatusIcon(vmView.statusIcons.running, VM_BOOTUP_TIMEOUT_SECS); } } } diff --git a/frontend/packages/kubevirt-plugin/integration-tests/tests/models/virtualMachineInstance.ts b/frontend/packages/kubevirt-plugin/integration-tests/tests/models/virtualMachineInstance.ts new file mode 100644 index 00000000000..9da2709aa97 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/integration-tests/tests/models/virtualMachineInstance.ts @@ -0,0 +1,22 @@ +/* eslint-disable no-unused-vars, no-undef, no-await-in-loop, no-console */ +import { volumeRows } from '../../views/virtualMachineInstance.view'; +import { DetailView } from './detailView'; + +export class VirtualMachineInstance extends DetailView { + constructor(vmiConfig) { + super({ ...vmiConfig, kind: 'pods' }); + } + + async getVolumes() { + const disks = []; + for (const row of await volumeRows) { + disks.push( + await row + .$$('td') + .first() + .getText(), + ); + } + return disks; + } +} diff --git a/frontend/packages/kubevirt-plugin/integration-tests/tests/utils/consts.ts b/frontend/packages/kubevirt-plugin/integration-tests/tests/utils/consts.ts index d87b86941e5..cd8176deb94 100644 --- a/frontend/packages/kubevirt-plugin/integration-tests/tests/utils/consts.ts +++ b/frontend/packages/kubevirt-plugin/integration-tests/tests/utils/consts.ts @@ -9,7 +9,7 @@ export const PAGE_LOAD_TIMEOUT_SECS = 15 * SEC; export const TEMPLATE_ACTIONS_TIMEOUT_SECS = 90 * SEC; export const VM_ACTIONS_TIMEOUT_SECS = 250 * SEC; export const VM_BOOTUP_TIMEOUT_SECS = 200 * SEC; -export const VM_MIGRATION_TIMEOUT_SECS = 150 * SEC; +export const VM_MIGRATION_TIMEOUT_SECS = 190 * SEC; export const VM_STOP_TIMEOUT_SECS = 10 * SEC; export const VM_IP_ASSIGNMENT_TIMEOUT_SECS = 180 * SEC; export const WINDOWS_IMPORT_TIMEOUT_SECS = 150 * SEC; @@ -48,6 +48,17 @@ export const TABS = { }; Object.freeze(TABS); +// Tab names +export const VMACTIONS = { + START: 'Start', + STOP: 'Stop', + CLONE: 'Clone', + RESTART: 'Restart', + MIGRATE: 'Events', + DELETE: 'Delete', +}; +Object.freeze(VMACTIONS); + // Network tab columns in VM Wizard export const networkWizardTabCol = { name: 0, diff --git a/frontend/packages/kubevirt-plugin/integration-tests/tests/utils/utils.ts b/frontend/packages/kubevirt-plugin/integration-tests/tests/utils/utils.ts index 1d64f659db7..65befca16b1 100644 --- a/frontend/packages/kubevirt-plugin/integration-tests/tests/utils/utils.ts +++ b/frontend/packages/kubevirt-plugin/integration-tests/tests/utils/utils.ts @@ -43,6 +43,6 @@ export function getRandomMacAddress() { } export function getResourceObject(name: string, namespace: string, kind: string) { - const resourceJson = execSync(`oc get -o json -n ${namespace} ${kind} ${name}`).toString(); + const resourceJson = execSync(`kubectl get -o json -n ${namespace} ${kind} ${name}`).toString(); return JSON.parse(resourceJson); } diff --git a/frontend/packages/kubevirt-plugin/integration-tests/tests/vm.actions.scenario.ts b/frontend/packages/kubevirt-plugin/integration-tests/tests/vm.actions.scenario.ts new file mode 100644 index 00000000000..3fc775d64db --- /dev/null +++ b/frontend/packages/kubevirt-plugin/integration-tests/tests/vm.actions.scenario.ts @@ -0,0 +1,122 @@ +import { browser, ExpectedConditions as until } from 'protractor'; +import { appHost, testName } from '../../../../integration-tests/protractor.conf'; +import { + resourceRows, + resourceRowsPresent, + isLoaded, + textFilter, +} from '../../../../integration-tests/views/crud.view'; +import { listViewAction } from '../views/vm.actions.view'; +import { waitForActionFinished } from '../views/virtualMachine.view'; +import { + addLeakableResource, + createResource, + removeLeakedResources, + removeLeakableResource, + waitForCount, +} from '../../../console-shared/src/test-utils/utils'; +import { getVmManifest } from './utils/mocks'; +import { fillInput } from './utils/utils'; +import { + VM_BOOTUP_TIMEOUT_SECS, + VM_ACTIONS_TIMEOUT_SECS, + PAGE_LOAD_TIMEOUT_SECS, + VMACTIONS, + TABS, +} from './utils/consts'; +import { VirtualMachine } from './models/virtualMachine'; + +describe('Test VM actions', () => { + const leakedResources = new Set(); + const testVm = getVmManifest('Container', testName); + + afterAll(async () => { + removeLeakedResources(leakedResources); + }); + + describe('Test VM list view kebab actions', () => { + const vmName = `vm-list-actions-${testName}`; + + beforeAll(async () => { + testVm.metadata.name = vmName; + createResource(testVm); + addLeakableResource(leakedResources, testVm); + + // Navigate to Virtual Machines page + await browser.get(`${appHost}/k8s/ns/${testName}/virtualmachines`); + await isLoaded(); + await fillInput(textFilter, vmName); + await resourceRowsPresent(); + }); + + it('Starts VM', async () => { + await listViewAction(vmName)(VMACTIONS.START, true); + await fillInput(textFilter, vmName); + await waitForActionFinished(VMACTIONS.START); + }); + + it( + 'Restarts VM', + async () => { + await listViewAction(vmName)(VMACTIONS.RESTART, true); + await fillInput(textFilter, vmName); + await waitForActionFinished(VMACTIONS.RESTART); + }, + VM_ACTIONS_TIMEOUT_SECS, + ); + + it('Stops VM', async () => { + await listViewAction(vmName)(VMACTIONS.STOP, true); + await fillInput(textFilter, vmName); + await waitForActionFinished(VMACTIONS.STOP); + }); + + it('Deletes VM', async () => { + await listViewAction(vmName)(VMACTIONS.DELETE, true); + await isLoaded(); + await fillInput(textFilter, vmName); + await browser.wait(until.and(waitForCount(resourceRows, 0)), PAGE_LOAD_TIMEOUT_SECS); + removeLeakableResource(leakedResources, testVm); + }); + }); + + describe('Test VM detail view actions dropdown', () => { + const vmName = `vm-detail-actions-${testName}`; + const vm = new VirtualMachine({ name: vmName, namespace: testName }); + + beforeAll(async () => { + testVm.metadata.name = vmName; + createResource(testVm); + addLeakableResource(leakedResources, testVm); + await vm.navigateToTab(TABS.OVERVIEW); + }); + + it( + 'Starts VM', + async () => { + await vm.action(VMACTIONS.START); + }, + VM_BOOTUP_TIMEOUT_SECS, + ); + + it( + 'Restarts VM', + async () => { + await vm.action(VMACTIONS.RESTART); + }, + VM_ACTIONS_TIMEOUT_SECS, + ); + + it('Stops VM', async () => { + await vm.action(VMACTIONS.STOP); + }); + + it('Deletes VM', async () => { + await vm.action(VMACTIONS.DELETE); + await isLoaded(); + await fillInput(textFilter, vmName); + await browser.wait(until.and(waitForCount(resourceRows, 0)), PAGE_LOAD_TIMEOUT_SECS); + removeLeakableResource(leakedResources, testVm); + }); + }); +}); diff --git a/frontend/packages/kubevirt-plugin/integration-tests/tests/vm.migration.scenario.ts b/frontend/packages/kubevirt-plugin/integration-tests/tests/vm.migration.scenario.ts new file mode 100644 index 00000000000..3e07968c159 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/integration-tests/tests/vm.migration.scenario.ts @@ -0,0 +1,112 @@ +import { browser } from 'protractor'; +import { testName } from '../../../../integration-tests/protractor.conf'; +import { getDetailActionDropdownOptions } from '../views/vm.actions.view'; +import { + statusIcon, + statusIcons, + vmDetailNode, + waitForStatusIcon, +} from '../views/virtualMachine.view'; +import { + createResource, + deleteResource, + waitForStringInElement, +} from '../../../console-shared/src/test-utils/utils'; +import { getRandStr } from './utils/utils'; +import { getVmManifest } from './utils/mocks'; +import { + VM_BOOTUP_TIMEOUT_SECS, + VM_ACTIONS_TIMEOUT_SECS, + VM_MIGRATION_TIMEOUT_SECS, + PAGE_LOAD_TIMEOUT_SECS, + TABS, +} from './utils/consts'; +import { VirtualMachine } from './models/virtualMachine'; + +describe('Test VM Migration', () => { + const testVm = getVmManifest('Container', testName); + let vm: VirtualMachine; + + const MIGRATE_VM = 'Migrate Virtual Machine'; + const CANCEL_MIGRATION = 'Cancel Virtual Machine Migration'; + const VM_BOOT_AND_MIGRATE_TIMEOUT = VM_BOOTUP_TIMEOUT_SECS + VM_MIGRATION_TIMEOUT_SECS; + + beforeEach(() => { + testVm.metadata.name = `migrationvm-${getRandStr(4)}`; + vm = new VirtualMachine(testVm.metadata); + createResource(testVm); + }); + + afterEach(() => { + deleteResource(testVm); + }); + + it( + 'Migrate VM action button is displayed appropriately', + async () => { + await vm.navigateToTab(TABS.OVERVIEW); + expect(await getDetailActionDropdownOptions()).not.toContain(MIGRATE_VM); + expect(await getDetailActionDropdownOptions()).not.toContain(CANCEL_MIGRATION); + + await vm.action('Start'); + expect(await getDetailActionDropdownOptions()).toContain(MIGRATE_VM); + expect(await getDetailActionDropdownOptions()).not.toContain(CANCEL_MIGRATION); + + await vm.action('Migrate', false); + await waitForStatusIcon(statusIcons.migrating, PAGE_LOAD_TIMEOUT_SECS); + expect(await getDetailActionDropdownOptions()).not.toContain(MIGRATE_VM); + expect(await getDetailActionDropdownOptions()).toContain(CANCEL_MIGRATION); + }, + VM_BOOTUP_TIMEOUT_SECS, + ); + + it( + 'Migrate VM', + async () => { + await vm.action('Start'); + const sourceNode = await vmDetailNode(vm.namespace, vm.name).getText(); + + await vm.action('Migrate'); + await vm.waitForMigrationComplete(sourceNode, VM_MIGRATION_TIMEOUT_SECS); + expect(statusIcon(statusIcons.running).isPresent()).toBeTruthy(); + }, + VM_BOOT_AND_MIGRATE_TIMEOUT, + ); + + it( + 'Migrate already migrated VM', + async () => { + await vm.action('Start'); + let sourceNode = await vmDetailNode(vm.namespace, vm.name).getText(); + + await vm.action('Migrate'); + await vm.waitForMigrationComplete(sourceNode, VM_MIGRATION_TIMEOUT_SECS); + sourceNode = await vmDetailNode(vm.namespace, vm.name).getText(); + + await vm.action('Migrate'); + await vm.waitForMigrationComplete(sourceNode, VM_MIGRATION_TIMEOUT_SECS); + expect(statusIcon(statusIcons.running).isPresent()).toBeTruthy(); + }, + VM_BOOT_AND_MIGRATE_TIMEOUT * 2, + ); + + it( + 'Cancel ongoing VM migration', + async () => { + await vm.action('Start'); + const sourceNode = await vmDetailNode(vm.namespace, vm.name).getText(); + + // Start migration without waiting for it to finish + await vm.action('Migrate', false); + await waitForStatusIcon(statusIcons.migrating, VM_MIGRATION_TIMEOUT_SECS); + + await vm.action('Cancel', false); + await waitForStatusIcon(statusIcons.running, VM_BOOTUP_TIMEOUT_SECS); + await browser.wait( + waitForStringInElement(vmDetailNode(vm.namespace, vm.name), sourceNode), + VM_MIGRATION_TIMEOUT_SECS, + ); + }, + VM_ACTIONS_TIMEOUT_SECS, + ); +}); diff --git a/frontend/packages/kubevirt-plugin/integration-tests/tests/vm.resources.scenario.ts b/frontend/packages/kubevirt-plugin/integration-tests/tests/vm.resources.scenario.ts new file mode 100644 index 00000000000..1c4a9e0433f --- /dev/null +++ b/frontend/packages/kubevirt-plugin/integration-tests/tests/vm.resources.scenario.ts @@ -0,0 +1,113 @@ +import * as _ from 'lodash'; +import { $, browser, ExpectedConditions as until } from 'protractor'; +import { testName } from '../../../../integration-tests/protractor.conf'; +import { createNic, networkTypeDropdownId } from '../views/kubevirtDetailView.view'; +import { + click, + createResources, + deleteResources, + searchYAML, + getDropdownOptions, +} from '../../../console-shared/src/test-utils/utils'; +import { getInterfaces } from '../../src/selectors/vm/selectors'; +import { multusNad, hddDisk, networkInterface, getVmManifest } from './utils/mocks'; +import { getResourceObject } from './utils/utils'; +import { VM_BOOTUP_TIMEOUT_SECS, VM_ACTIONS_TIMEOUT_SECS, TABS } from './utils/consts'; +import { VirtualMachine } from './models/virtualMachine'; + +describe('Add/remove disks and NICs on respective VM pages', () => { + const testVm = getVmManifest('Container', testName, `vm-disk-nic-${testName}`); + const vm = new VirtualMachine(testVm.metadata); + + beforeAll(async () => { + createResources([multusNad, testVm]); + await vm.action('Start'); + }, VM_BOOTUP_TIMEOUT_SECS); + + afterAll(() => { + deleteResources([multusNad, testVm]); + }); + + it( + 'Add/remove disk on VM disks page', + async () => { + await vm.addDisk(hddDisk); + expect(await vm.getAttachedDisks()).toContain(hddDisk); + + let vmi = await vm.navigateToVMI(TABS.OVERVIEW); + expect((await vmi.getVolumes()).includes(hddDisk.name)).toBe(false); + + await vm.action('Restart'); + + vmi = await vm.navigateToVMI(TABS.OVERVIEW); + expect((await vmi.getVolumes()).includes(hddDisk.name)).toBe(true); + + await vm.removeDisk(hddDisk.name); + expect(await vm.getAttachedDisks()).not.toContain(hddDisk); + + await vm.action('Restart'); + + vmi = await vm.navigateToVMI(TABS.OVERVIEW); + expect((await vmi.getVolumes()).includes(hddDisk.name)).toBe(false); + }, + VM_ACTIONS_TIMEOUT_SECS * 2, // VM is restarted twice + ); + + xit( + 'BZ(1732598) Add/remove nic on VM Network Interfaces page', + async () => { + await vm.addNIC(networkInterface); + + expect(searchYAML(networkInterface.networkDefinition, vm.name, vm.namespace, 'vmi')).toBe( + false, + ); + + await vm.action('Restart'); + expect(searchYAML(networkInterface.networkDefinition, vm.name, vm.namespace, 'vmi')).toBe( + true, + ); + + await vm.removeNIC(networkInterface.name); + expect((await vm.getAttachedNICs()).includes(networkInterface)).toBe(false); + + await vm.action('Restart'); + + expect(searchYAML(networkInterface.networkDefinition, vm.name, vm.namespace, 'vmi')).toBe( + false, + ); + }, + VM_ACTIONS_TIMEOUT_SECS * 2, // VM is restarted twice + ); + + it('NIC cannot be added twice using one net-attach-def', async () => { + await vm.navigateToTab(TABS.NICS); + if ( + (await vm.getAttachedNICs()).filter((nic) => nic.name === networkInterface.name).length === 0 + ) { + await vm.addNIC(networkInterface); + } + + // Verify the NIC is added in VM Manifest + const resource = getResourceObject(vm.name, vm.namespace, vm.kind); + const nic = _.find(getInterfaces(resource), (o) => o.name === networkInterface.name); + expect(nic).not.toBe(undefined); + + // Try to add the NIC again + await click(createNic, 1000); + await browser.sleep(1000).then(() => browser.wait(until.presenceOf($(networkTypeDropdownId)))); + + // The network dropdown should be either empty (disabled) or not containing the already used net-attach-def + await browser.wait( + until.or( + async () => { + return !(await $(networkTypeDropdownId).isEnabled()); + }, + async () => { + return !(await getDropdownOptions(networkTypeDropdownId)).includes( + networkInterface.networkDefinition, + ); + }, + ), + ); + }); +}); diff --git a/frontend/packages/kubevirt-plugin/integration-tests/tests/vm.wizard.scenario.ts b/frontend/packages/kubevirt-plugin/integration-tests/tests/vm.wizard.scenario.ts index 23cad16e779..ae7d5d87b1e 100644 --- a/frontend/packages/kubevirt-plugin/integration-tests/tests/vm.wizard.scenario.ts +++ b/frontend/packages/kubevirt-plugin/integration-tests/tests/vm.wizard.scenario.ts @@ -7,10 +7,11 @@ import { createResources, deleteResources, } from '../../../console-shared/src/test-utils/utils'; -import { statusIcons } from '../views/virtualMachine.view'; +import { statusIcons, waitForStatusIcon } from '../views/virtualMachine.view'; import { VirtualMachine } from './models/virtualMachine'; import { getResourceObject } from './utils/utils'; -import { VM_BOOTUP_TIMEOUT_SECS, CLONE_VM_TIMEOUT_SECS } from './utils/consts'; +import { VM_BOOTUP_TIMEOUT_SECS, CLONE_VM_TIMEOUT_SECS, TABS } from './utils/consts'; +import { StorageResource, NetworkResource, ProvisionOption } from './utils/types'; import { basicVmConfig, rootDisk, @@ -19,7 +20,6 @@ import { hddDisk, dataVolumeManifest, } from './utils/mocks'; -import { NetworkResource, StorageResource, ProvisionOption } from './utils/types'; describe('Kubevirt create VM using wizard', () => { const leakedResources = new Set(); @@ -135,7 +135,8 @@ describe('Kubevirt create VM using wizard', () => { await vm1.action('Start', false); await withResource(leakedResources, vm2.asResource(), async () => { await vm2.create(vm2Config); - await vm1.waitForStatusIcon(statusIcons.running, VM_BOOTUP_TIMEOUT_SECS); + await vm1.navigateToTab(TABS.OVERVIEW); + await waitForStatusIcon(statusIcons.running, VM_BOOTUP_TIMEOUT_SECS); // Verify that DV of VM created with Cloned disk method points to correct PVC const dvResource = getResourceObject( diff --git a/frontend/packages/kubevirt-plugin/integration-tests/views/detailView.view.ts b/frontend/packages/kubevirt-plugin/integration-tests/views/detailView.view.ts new file mode 100644 index 00000000000..6c966509340 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/integration-tests/views/detailView.view.ts @@ -0,0 +1,3 @@ +import { $ } from 'protractor'; + +export const activeTab = $('.co-m-horizontal-nav-item--active'); diff --git a/frontend/packages/kubevirt-plugin/integration-tests/views/kubevirtDetailView.view.ts b/frontend/packages/kubevirt-plugin/integration-tests/views/kubevirtDetailView.view.ts index 0960efe073d..7a9c07ecc2a 100644 --- a/frontend/packages/kubevirt-plugin/integration-tests/views/kubevirtDetailView.view.ts +++ b/frontend/packages/kubevirt-plugin/integration-tests/views/kubevirtDetailView.view.ts @@ -19,26 +19,28 @@ export const applyBtn = $('button.kubevirt-cancel-accept-buttons.btn-primary'); // Used to determine presence of a new row by looking for confirmation buttons export const newResourceRow = $('.kubevirt-vm-create-device-row__confirmation-buttons'); -export const rowForName = (name: string) => +const tableContent = $('.ReactVirtualized__VirtualGrid.ReactVirtualized__List'); +export const tableRows = () => tableContent.getText().then((text) => text.split('\n')); +export const tableRowForName = (name: string) => resourceRows .filter((row) => row - .$$('div') + .$$('td') .first() .getText() .then((text) => text === name), ) .first(); -const kebabForName = (name: string) => rowForName(name).$('button.co-kebab__button'); +const kebabForName = (name: string) => tableRowForName(name).$('[data-test-id=kebab-button]'); export const selectKebabOption = async (name: string, option: string) => { await browser.wait(until.presenceOf(kebabForName(name))); // open kebab dropdown await kebabForName(name).click(); // select given option from opened dropdown - await rowForName(name) - .$('.co-kebab__dropdown') - .$$('a') + await tableRowForName(name) + .$('.dropdown-menu-right') + .$$('button') .filter((link) => link.getText().then((text) => text.startsWith(option))) .first() .click(); diff --git a/frontend/packages/kubevirt-plugin/integration-tests/views/virtualMachine.view.ts b/frontend/packages/kubevirt-plugin/integration-tests/views/virtualMachine.view.ts index 90cf1d8a399..c496328579f 100644 --- a/frontend/packages/kubevirt-plugin/integration-tests/views/virtualMachine.view.ts +++ b/frontend/packages/kubevirt-plugin/integration-tests/views/virtualMachine.view.ts @@ -1,4 +1,14 @@ -import { $, $$, by, element } from 'protractor'; +import { $, $$, by, browser, element, ExpectedConditions as until } from 'protractor'; +import { resolveTimeout } from '../../../console-shared/src/test-utils/utils'; +import { + VM_BOOTUP_TIMEOUT_SECS, + VM_STOP_TIMEOUT_SECS, + PAGE_LOAD_TIMEOUT_SECS, + VM_ACTIONS_TIMEOUT_SECS, + UNEXPECTED_ACTION_ERROR, +} from '../tests/utils/consts'; +import { resourceTitle } from '../../../../integration-tests/views/crud.view'; +import { nameInput } from './wizard.view'; export const statusIcon = (status) => $(`.kubevirt-status__icon.${status}`); export const statusLink = $('a.kubevirt-status__link'); @@ -8,6 +18,7 @@ export const statusIcons = { migrating: 'pficon-in-progress', running: 'pficon-ok', off: 'pficon-off', + error: 'pficon-error-circle-o', }; export const consoleSelectorDropdownId = '#console-type-selector'; @@ -66,3 +77,57 @@ export const vmDetailServiceItem = (namespace, serviceName) => `[href="/k8s/ns/${namespace}/services/${serviceName}"]`; export const vmDetailService = (namespace, serviceName) => $(vmDetailServiceItem(namespace, serviceName)); + +export async function waitForStatusIcon(icon: string, timeout: number) { + await browser.wait(until.presenceOf(statusIcon(icon)), timeout); +} + +export async function waitForActionFinished(action: string, timeout?: number) { + switch (action) { + case 'Start': + await waitForStatusIcon(statusIcons.running, resolveTimeout(timeout, VM_BOOTUP_TIMEOUT_SECS)); + break; + case 'Restart': + await browser.wait( + until.or( + until.presenceOf(statusIcon(statusIcons.starting)), + until.presenceOf(statusIcon(statusIcons.error)), + ), + resolveTimeout(timeout, VM_BOOTUP_TIMEOUT_SECS), + ); + await waitForStatusIcon(statusIcons.running, resolveTimeout(timeout, VM_BOOTUP_TIMEOUT_SECS)); + break; + case 'Stop': + await waitForStatusIcon(statusIcons.off, resolveTimeout(timeout, VM_STOP_TIMEOUT_SECS)); + break; + case 'Clone': + await browser.wait( + until.presenceOf(nameInput), + resolveTimeout(timeout, PAGE_LOAD_TIMEOUT_SECS), + ); + await browser.sleep(500); // Wait until the fade in effect is finished, otherwise we may misclick + break; + case 'Migrate': + await waitForStatusIcon( + statusIcons.migrating, + resolveTimeout(timeout, PAGE_LOAD_TIMEOUT_SECS), + ); + await waitForStatusIcon( + statusIcons.running, + resolveTimeout(timeout, VM_ACTIONS_TIMEOUT_SECS), + ); + break; + case 'Cancel': + await waitForStatusIcon(statusIcons.running, resolveTimeout(timeout, PAGE_LOAD_TIMEOUT_SECS)); + break; + case 'Delete': + // wait for redirect + await browser.wait( + until.textToBePresentInElement(resourceTitle, 'Virtual Machines'), + resolveTimeout(timeout, PAGE_LOAD_TIMEOUT_SECS), + ); + break; + default: + throw Error(UNEXPECTED_ACTION_ERROR); + } +} diff --git a/frontend/packages/kubevirt-plugin/integration-tests/views/virtualMachineInstance.view.ts b/frontend/packages/kubevirt-plugin/integration-tests/views/virtualMachineInstance.view.ts new file mode 100644 index 00000000000..fe2e1f7aa7f --- /dev/null +++ b/frontend/packages/kubevirt-plugin/integration-tests/views/virtualMachineInstance.view.ts @@ -0,0 +1,22 @@ +import { $$ } from 'protractor'; + +export const volumeRowsTable = $$('.co-m-pane__body') + .filter((table) => + table + .$('.co-section-heading') + .getText() + .then((text) => text === 'Volumes'), + ) + .first(); + +export const volumeRows = volumeRowsTable.$$('[data-test-rows="resource-row"]'); +export const volumeRowByName = (name: string) => + volumeRows + .filter((row) => + row + .$$('td') + .first() + .getText() + .then((text) => text === name), + ) + .first(); diff --git a/frontend/packages/kubevirt-plugin/integration-tests/views/vm.actions.view.ts b/frontend/packages/kubevirt-plugin/integration-tests/views/vm.actions.view.ts index 9485ff05010..46908a6edfc 100644 --- a/frontend/packages/kubevirt-plugin/integration-tests/views/vm.actions.view.ts +++ b/frontend/packages/kubevirt-plugin/integration-tests/views/vm.actions.view.ts @@ -1,19 +1,26 @@ -import { $, element, by, browser, ExpectedConditions as until } from 'protractor'; +import { $, $$, browser, ExpectedConditions as until } from 'protractor'; import { rowForName, resourceRowsPresent } from '../../../../integration-tests/views/crud.view'; -import { click } from '../../../console-shared/src/test-utils/utils'; +import { waitForCount } from '../../../console-shared/src/test-utils/utils'; -const dialogOverlay = $('.co-overlay'); +const disabledDropdownButtons = $$('.pf-m-disabled'); const listViewKebabDropdown = '.pf-c-dropdown__toggle'; const listViewKebabDropdownMenu = '.pf-c-dropdown__menu'; -export const detailViewDropdown = '#action-dropdown'; -export const detailViewDropdownMenu = '.dropdown-menu-right'; +export const detailViewDropdown = '[data-test-id=actions-menu-button]'; +export const detailViewDropdownMenu = '[data-test-id=action-items]'; -export const confirmAction = () => - browser - .wait(until.presenceOf($('button[type=submit]'))) - .then(() => $('button[type=submit]').click()) - .then(() => browser.wait(until.not(until.presenceOf(dialogOverlay)))); +export async function confirmAction() { + const dialogOverlay = $('.co-overlay'); + const confirmActionButton = $('#confirm-action'); + await browser.wait( + until.and( + until.presenceOf(confirmActionButton), + until.elementToBeClickable(confirmActionButton), + ), + ); + await confirmActionButton.click(); + await browser.wait(until.not(until.presenceOf(dialogOverlay))); +} /** * Selects option button from given dropdown element. @@ -24,6 +31,7 @@ const selectDropdownItem = (getActionsDropdown, getActionsDropdownMenu) => async await browser .wait(until.elementToBeClickable(getActionsDropdown())) .then(() => getActionsDropdown().click()); + await browser.wait(waitForCount(disabledDropdownButtons, 0)); const option = getActionsDropdownMenu() .$$('button') .filter((button) => button.getText().then((text) => text.startsWith(action))) @@ -43,12 +51,51 @@ export const listViewAction = (name) => async (action: string, confirm?: boolean const getActionsDropdownMenu = () => rowForName(name).$(listViewKebabDropdownMenu); await selectDropdownItem(getActionsDropdown, getActionsDropdownMenu)(action); if (confirm === true) { - if (action === 'Migrate') { - // TODO: WA for BZ(1719227), remove when resolved - await click(element(by.buttonText('Migrate'))); - await browser.wait(until.not(until.presenceOf($('.co-overlay')))); - } else { - await confirmAction(); - } + await confirmAction(); } }; + +/** + * Performs action for VM on its detail page. + */ +export const detailViewAction = async (action, confirm?) => { + const getActionsDropdown = () => $(detailViewDropdown); + const getActionsDropdownMenu = () => $(detailViewDropdownMenu); + await selectDropdownItem(getActionsDropdown, getActionsDropdownMenu)(action); + await browser.wait(until.not(until.presenceOf($(detailViewDropdownMenu)))); + if (confirm === true) { + await confirmAction(); + } +}; + +/** + * Returns available options from Action. + * Temporary method that should be superseded by more general getDropdownOptions method in utils.ts + * once https://github.com/openshift/console/issues/1492 is resolved + */ +export async function getDetailActionDropdownOptions(): Promise { + const getActionsDropdown = () => $$(detailViewDropdown).first(); + await browser + .wait(until.elementToBeClickable(getActionsDropdown())) + .then(() => getActionsDropdown().click()); + + const options = []; + await $('ul.dropdown-menu-right') + .$$('li') + .each(async (elem) => { + elem + .getText() + .then((text) => { + options.push(text); + }) + .catch((error) => { + // eslint-disable-next-line no-console + console.error(error); + }); + }); + + await browser + .wait(until.elementToBeClickable(getActionsDropdown())) + .then(() => getActionsDropdown().click()); + return options; +}