From 7da161e275e1c5c3ed8b27c349742f4a9ee3d19a Mon Sep 17 00:00:00 2001 From: Radim Hrazdil Date: Tue, 16 Jul 2019 13:01:11 +0200 Subject: [PATCH] Add VM Wizard test cases for Kubevirt plugin --- frontend/integration-tests/protractor.conf.ts | 3 + .../console-shared/src/test-utils/utils.ts | 145 +++++++++++ .../tests/models/detailView.ts | 50 ++++ .../tests/models/kubevirtDetailView.ts | 73 ++++++ .../tests/models/virtualMachine.ts | 220 ++++++++++++++++ .../integration-tests/tests/models/wizard.ts | 133 ++++++++++ .../integration-tests/tests/utils/consts.ts | 85 ++++++ .../integration-tests/tests/utils/mocks.ts | 243 ++++++++++++++++++ .../integration-tests/tests/utils/types.ts | 41 +++ .../integration-tests/tests/utils/utils.ts | 48 ++++ .../tests/vm.wizard.scenario.ts | 158 ++++++++++++ .../views/kubevirtDetailView.view.ts | 45 ++++ .../views/virtualMachine.view.ts | 68 +++++ .../views/vm.actions.view.ts | 54 ++++ .../integration-tests/views/wizard.view.ts | 90 +++++++ 15 files changed, 1456 insertions(+) create mode 100644 frontend/packages/console-shared/src/test-utils/utils.ts create mode 100644 frontend/packages/kubevirt-plugin/integration-tests/tests/models/detailView.ts create mode 100644 frontend/packages/kubevirt-plugin/integration-tests/tests/models/kubevirtDetailView.ts create mode 100644 frontend/packages/kubevirt-plugin/integration-tests/tests/models/virtualMachine.ts create mode 100644 frontend/packages/kubevirt-plugin/integration-tests/tests/models/wizard.ts create mode 100644 frontend/packages/kubevirt-plugin/integration-tests/tests/utils/consts.ts create mode 100644 frontend/packages/kubevirt-plugin/integration-tests/tests/utils/mocks.ts create mode 100644 frontend/packages/kubevirt-plugin/integration-tests/tests/utils/types.ts create mode 100644 frontend/packages/kubevirt-plugin/integration-tests/tests/utils/utils.ts create mode 100644 frontend/packages/kubevirt-plugin/integration-tests/tests/vm.wizard.scenario.ts create mode 100644 frontend/packages/kubevirt-plugin/integration-tests/views/kubevirtDetailView.view.ts create mode 100644 frontend/packages/kubevirt-plugin/integration-tests/views/virtualMachine.view.ts create mode 100644 frontend/packages/kubevirt-plugin/integration-tests/views/vm.actions.view.ts create mode 100644 frontend/packages/kubevirt-plugin/integration-tests/views/wizard.view.ts diff --git a/frontend/integration-tests/protractor.conf.ts b/frontend/integration-tests/protractor.conf.ts index a3639a34a7c..7834c108241 100644 --- a/frontend/integration-tests/protractor.conf.ts +++ b/frontend/integration-tests/protractor.conf.ts @@ -165,6 +165,9 @@ export const config: Config = { 'tests/monitoring.scenario.ts', 'tests/crd-extensions.scenario.ts', ]), + 'kubevirt-plugin': suite([ + '../packages/kubevirt-plugin/integration-tests/tests/vm.wizard.scenario.ts', + ]), all: suite([ 'tests/crud.scenario.ts', 'tests/overview/overview.scenareio.ts', diff --git a/frontend/packages/console-shared/src/test-utils/utils.ts b/frontend/packages/console-shared/src/test-utils/utils.ts new file mode 100644 index 00000000000..3efb59e2426 --- /dev/null +++ b/frontend/packages/console-shared/src/test-utils/utils.ts @@ -0,0 +1,145 @@ +/* eslint-disable no-await-in-loop, no-console, no-underscore-dangle */ +import { execSync } from 'child_process'; +import { + $, + by, + ElementFinder, + browser, + ExpectedConditions as until, + element, + ElementArrayFinder, +} from 'protractor'; +import { config } from '../../../../integration-tests/protractor.conf'; + +export function resolveTimeout(timeout: number, defaultTimeout: number) { + return timeout !== undefined ? timeout : defaultTimeout; +} + +export function removeLeakedResources(leakedResources: Set) { + const leakedArray: string[] = [...leakedResources]; + if (leakedArray.length > 0) { + console.error(`Leaked ${leakedArray.join()}`); + leakedArray + .map((r) => JSON.parse(r) as { name: string; namespace: string; kind: string }) + .forEach(({ name, namespace, kind }) => { + try { + execSync(`kubectl delete -n ${namespace} --cascade ${kind} ${name}`); + } catch (error) { + console.error(`Failed to delete ${kind} ${name}:\n${error}`); + } + }); + } + leakedResources.clear(); +} + +export function addLeakableResource(leakedResources: Set, resource) { + leakedResources.add( + JSON.stringify({ + name: resource.metadata.name, + namespace: resource.metadata.namespace, + kind: resource.kind, + }), + ); +} + +export function removeLeakableResource(leakedResources: Set, resource) { + leakedResources.delete( + JSON.stringify({ + name: resource.metadata.name, + namespace: resource.metadata.namespace, + kind: resource.kind, + }), + ); +} + +export function createResource(resource) { + execSync(`echo '${JSON.stringify(resource)}' | kubectl create -f -`); +} + +export function createResources(resources) { + resources.forEach(createResource); +} + +export function deleteResource(resource) { + const kind = resource.kind === 'NetworkAttachmentDefinition' ? 'net-attach-def' : resource.kind; + execSync( + `kubectl delete -n ${resource.metadata.namespace} --cascade ${kind} ${resource.metadata.name}`, + ); +} + +export function deleteResources(resources) { + resources.forEach(deleteResource); +} + +export async function withResource( + resourceSet: Set, + resource: any, + callback: Function, + keepResource: boolean = false, +) { + addLeakableResource(resourceSet, resource); + await callback(); + if (!keepResource) { + deleteResource(resource); + removeLeakableResource(resourceSet, resource); + } +} + +export async function click(elem: ElementFinder, timeout?: number) { + const _timeout = resolveTimeout(timeout, config.jasmineNodeOpts.defaultTimeoutInterval); + await browser.wait(until.elementToBeClickable(elem), _timeout); + await elem.click(); +} + +export async function selectDropdownOption(dropdownId: string, option: string) { + await click($(dropdownId)); + await browser.wait(until.presenceOf(element(by.linkText(option)))); + await $(`${dropdownId} + ul`) + .element(by.linkText(option)) + .click(); +} + +export async function getDropdownOptions(dropdownId: string): Promise { + const options = []; + await $(`${dropdownId} + ul`) + .$$('li') + .each((elem) => { + elem + .getText() + .then((text) => { + options.push(text); + }) + .catch((err) => { + return Promise.reject(err); + }); + }); + return options; +} + +export async function asyncForEach(iterable, callback) { + const array = [...iterable]; + for (let index = 0; index < array.length; index++) { + await callback(array[index], index, array); + } +} + +export const waitForCount = (elementArrayFinder: ElementArrayFinder, targetCount: number) => { + return async () => { + const count = await elementArrayFinder.count(); + return count === targetCount; + }; +}; + +export const waitForStringInElement = (elem: ElementFinder, needle: string) => { + return async () => { + const content = await elem.getText(); + return content.includes(needle); + }; +}; + +export const waitForStringNotInElement = (elem: ElementFinder, needle: string) => { + return async () => { + const content = await elem.getText(); + return !content.includes(needle); + }; +}; diff --git a/frontend/packages/kubevirt-plugin/integration-tests/tests/models/detailView.ts b/frontend/packages/kubevirt-plugin/integration-tests/tests/models/detailView.ts new file mode 100644 index 00000000000..2802970da61 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/integration-tests/tests/models/detailView.ts @@ -0,0 +1,50 @@ +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'; + +export class DetailView { + readonly name: string; + + readonly namespace: string; + + readonly kind: string; + + constructor(instance) { + this.name = instance.name; + this.namespace = instance.namespace; + this.kind = instance.kind; + } + + static async getResourceTitle() { + return resourceTitle.getText(); + } + + async navigateToTab(tabName: string) { + if (!(await resourceTitle.isPresent()) || (await resourceTitle.getText()) !== this.name) { + await browser.get(`${appHost}/k8s/ns/${this.namespace}/${this.kind}/${this.name}`); + await isLoaded(); + } + await clickHorizontalTab(tabName); + await isLoaded(); + } + + async navigateToListView() { + const vmsListUrl = (namespace) => `${appHost}/k8s/ns/${namespace}/${this.kind}`; + const currentUrl = await browser.getCurrentUrl(); + if (![vmsListUrl(testName), vmsListUrl('all-namespaces')].includes(currentUrl)) { + await browser.get(vmsListUrl(this.namespace)); + await isLoaded(); + } + } + + asResource() { + return { + kind: this.kind, + metadata: { + namespace: this.namespace, + name: this.name, + }, + }; + } +} diff --git a/frontend/packages/kubevirt-plugin/integration-tests/tests/models/kubevirtDetailView.ts b/frontend/packages/kubevirt-plugin/integration-tests/tests/models/kubevirtDetailView.ts new file mode 100644 index 00000000000..8a04fa14eb4 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/integration-tests/tests/models/kubevirtDetailView.ts @@ -0,0 +1,73 @@ +/* eslint-disable no-await-in-loop */ +import { selectDropdownOption, click } from '../../../../console-shared/src/test-utils/utils'; +import { isLoaded, resourceRows } from '../../../../../integration-tests/views/crud.view'; +import { TABS, diskTabCol, networkTabCol } from '../utils/consts'; +import { StorageResource, NetworkResource } from '../utils/types'; +import { fillInput } from '../utils/utils'; +import * as kubevirtDetailView from '../../views/kubevirtDetailView.view'; +import { confirmAction } from '../../views/vm.actions.view'; +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; + } + + 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; + } + + async addDisk(disk: StorageResource) { + await this.navigateToTab(TABS.DISKS); + await click(kubevirtDetailView.createDisk, 1000); + await fillInput(kubevirtDetailView.diskName, disk.name); + await fillInput(kubevirtDetailView.diskSize, disk.size); + await selectDropdownOption(kubevirtDetailView.diskStorageClassDropdownId, disk.storageClass); + await click(kubevirtDetailView.applyBtn); + await isLoaded(); + } + + async removeDisk(name: string) { + await this.navigateToTab(TABS.DISKS); + await kubevirtDetailView.selectKebabOption(name, 'Delete'); + await confirmAction(); + } + + async addNIC(nic: NetworkResource) { + await this.navigateToTab(TABS.NICS); + await click(kubevirtDetailView.createNic, 1000); + await fillInput(kubevirtDetailView.nicName, nic.name); + await selectDropdownOption(kubevirtDetailView.networkTypeDropdownId, nic.networkDefinition); + await selectDropdownOption(kubevirtDetailView.networkBindingId, nic.binding); + await fillInput(kubevirtDetailView.macAddress, nic.mac); + await click(kubevirtDetailView.applyBtn); + await isLoaded(); + } + + async removeNIC(name: string) { + await this.navigateToTab(TABS.NICS); + await kubevirtDetailView.selectKebabOption(name, 'Delete'); + await confirmAction(); + } +} diff --git a/frontend/packages/kubevirt-plugin/integration-tests/tests/models/virtualMachine.ts b/frontend/packages/kubevirt-plugin/integration-tests/tests/models/virtualMachine.ts new file mode 100644 index 00000000000..e15dda128c1 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/integration-tests/tests/models/virtualMachine.ts @@ -0,0 +1,220 @@ +/* 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 { + 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 { 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, + DASHES, +} from '../utils/consts'; +import { listViewAction } from '../../views/vm.actions.view'; +import { rowForName } from '../../views/kubevirtDetailView.view'; +import { KubevirtDetailView } from './kubevirtDetailView'; +import { Wizard } from './wizard'; + +export class VirtualMachine extends KubevirtDetailView { + constructor(config) { + super({ ...config, kind: 'virtualmachines' }); + } + + async action(action: string, waitForAction?: boolean, timeout?: number) { + await this.navigateToListView(); + + let confirmDialog = true; + if (['Clone'].includes(action)) { + confirmDialog = false; + } + + await listViewAction(this.name)(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); + } + } + } + + 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( + waitForStringNotInElement(vmView.vmDetailNode(this.namespace, this.name), fromNode), + waitForStringNotInElement(vmView.vmDetailNode(this.namespace, this.name), DASHES), + ), + timeout, + ); + } + + async resourceExists(resourceName: string) { + return rowForName(resourceName).isPresent(); + } + + async selectConsole(type: string) { + await selectDropdownOption(vmView.consoleSelectorDropdownId, type); + await isLoaded(); + } + + async getConsoleVmIpAddress(): Promise { + await browser.wait(until.presenceOf(vmView.rdpIpAddress), PAGE_LOAD_TIMEOUT_SECS); + return vmView.rdpIpAddress.getText(); + } + + async getConsoleRdpPort(): Promise { + await browser.wait(until.presenceOf(vmView.rdpPort), PAGE_LOAD_TIMEOUT_SECS); + return vmView.rdpPort.getText(); + } + + async create({ + name, + namespace, + description, + template, + provisionSource, + operatingSystem, + flavor, + workloadProfile, + startOnCreation, + cloudInit, + storageResources, + networkResources, + }: VMConfig) { + const wizard = new Wizard(); + await this.navigateToListView(); + + await wizard.openWizard(); + await wizard.fillName(name); + await wizard.fillDescription(description); + if (!(await browser.getCurrentUrl()).includes(`${testName}/${this.kind}`)) { + await wizard.selectNamespace(namespace); + } + if (template !== undefined) { + await wizard.selectTemplate(template); + } else { + await wizard.selectProvisionSource(provisionSource); + await wizard.selectOperatingSystem(operatingSystem); + await wizard.selectWorkloadProfile(workloadProfile); + } + await wizard.selectFlavor(flavor); + if (startOnCreation) { + await wizard.startOnCreation(); + } + if (cloudInit.useCloudInit) { + if (template !== undefined) { + // TODO: wizard.useCloudInit needs to check state of checkboxes before clicking them to ensure desired state is achieved with specified template + throw new Error('Using cloud init with template not implemented.'); + } + await wizard.useCloudInit(cloudInit); + } + await wizard.next(); + + // Networking + for (const resource of networkResources) { + await wizard.addNIC( + resource.name, + resource.mac, + resource.networkDefinition, + resource.binding, + ); + } + await wizard.next(); + + // Storage + for (const resource of storageResources) { + if (resource.name === 'rootdisk' && provisionSource.method === 'URL') { + // Rootdisk is present by default, only edit specific properties + await wizard.editDiskAttribute(WIZARD_TABLE_FIRST_ROW, 'size', resource.size); + await wizard.editDiskAttribute(WIZARD_TABLE_FIRST_ROW, 'storage', resource.storageClass); + } else if (resource.attached === true) { + await wizard.attachDisk(resource); + } else { + await wizard.addDisk(resource); + } + } + + // Create VM + await wizard.next(); + await wizard.waitForCreation(); + + // Check for errors and close wizard + if (await errorMessage.isPresent()) { + console.error(await errorMessage.getText()); + throw new Error(WIZARD_CREATE_VM_ERROR); + } + await wizard.next(); + + if (startOnCreation === true) { + // If startOnCreation is true, wait for VM to boot up + await this.waitForStatusIcon(vmView.statusIcons.running, VM_BOOTUP_TIMEOUT_SECS); + } + } +} diff --git a/frontend/packages/kubevirt-plugin/integration-tests/tests/models/wizard.ts b/frontend/packages/kubevirt-plugin/integration-tests/tests/models/wizard.ts new file mode 100644 index 00000000000..80ef8dc74aa --- /dev/null +++ b/frontend/packages/kubevirt-plugin/integration-tests/tests/models/wizard.ts @@ -0,0 +1,133 @@ +import { $, browser, ExpectedConditions as until } from 'protractor'; +import { createItemButton, isLoaded } from '../../../../../integration-tests/views/crud.view'; +import { selectDropdownOption, click } from '../../../../console-shared/src/test-utils/utils'; +import { fillInput } from '../utils/utils'; +import { CloudInitConfig, StorageResource } from '../utils/types'; +import { PAGE_LOAD_TIMEOUT_SECS } from '../utils/consts'; +import * as wizardView from '../../views/wizard.view'; + +export class Wizard { + async openWizard() { + await click(createItemButton); + await click(wizardView.createWithWizardLink); + await browser.sleep(500); // wait until the fade in effect is finished + await browser.wait(until.presenceOf(wizardView.nameInput), PAGE_LOAD_TIMEOUT_SECS); + } + + async close() { + await click(wizardView.closeWizard, PAGE_LOAD_TIMEOUT_SECS); + await browser.wait(until.invisibilityOf(wizardView.wizardHeader), PAGE_LOAD_TIMEOUT_SECS); + // Clone VM dialog uses fade in/fade out effect, wait until it disappears + await browser.wait(until.invisibilityOf($('div.fade'))); + } + + async fillName(name: string) { + await fillInput(wizardView.nameInput, name); + } + + async fillDescription(description: string) { + await fillInput(wizardView.descriptionInput, description); + } + + async selectNamespace(namespace: string) { + await selectDropdownOption(wizardView.namespaceDropdownId, namespace); + } + + async selectTemplate(template: string) { + await selectDropdownOption(wizardView.templateDropdownId, template); + } + + async selectOperatingSystem(operatingSystem: string) { + await selectDropdownOption(wizardView.operatingSystemDropdownId, operatingSystem); + } + + async selectFlavor(flavor: string) { + await selectDropdownOption(wizardView.flavorDropdownId, flavor); + } + + async selectWorkloadProfile(workloadProfile: string) { + await selectDropdownOption(wizardView.workloadProfileDropdownId, workloadProfile); + } + + async selectProvisionSource(provisionOptions) { + await selectDropdownOption(wizardView.provisionSourceDropdownId, provisionOptions.method); + if (Object.prototype.hasOwnProperty.call(provisionOptions, 'source')) { + await fillInput( + wizardView.provisionSources[provisionOptions.method], + provisionOptions.source, + ); + } + } + + async startOnCreation() { + await click(wizardView.startVMOnCreation); + } + + async useCloudInit(cloudInitOptions: CloudInitConfig) { + await click(wizardView.useCloudInit); + if (cloudInitOptions.useCustomScript) { + await click(wizardView.useCustomScript); + await fillInput(wizardView.customCloudInitScript, cloudInitOptions.customScript); + } else { + await fillInput(wizardView.cloudInitHostname, cloudInitOptions.hostname); + await fillInput(wizardView.cloudInitSSH, cloudInitOptions.sshKey); + } + } + + async next() { + await isLoaded(); + await click(wizardView.nextButton); + await isLoaded(); + } + + async addNIC(name: string, mac: string, networkDefinition: string, binding: string) { + await click(wizardView.createNIC); + const rowsCount = await this.getTableRowsCount(); + // Dropdown selection needs to be first due to https://github.com/kubevirt/web-ui-components/issues/9 + await wizardView.selectTableDropdownAttribute(rowsCount, 'network', networkDefinition); + await wizardView.selectTableDropdownAttribute(rowsCount, 'binding', binding); + await wizardView.setTableInputAttribute(rowsCount, 'name', name); + await wizardView.setTableInputAttribute(rowsCount, 'mac', mac); + await click(wizardView.apply); + } + + async selectPxeNIC(networkDefinition: string) { + await selectDropdownOption(wizardView.pxeNICDropdownId, networkDefinition); + } + + async getTableRowsCount() { + return wizardView.tableRowsCount(); + } + + async addDisk(disk: StorageResource) { + await click(wizardView.createDisk); + const rowsCount = await this.getTableRowsCount(); + // Dropdown selection needs to be first due to https://github.com/kubevirt/web-ui-components/issues/9 + await wizardView.selectTableDropdownAttribute(rowsCount, 'storage', disk.storageClass); + await wizardView.setTableInputAttribute(rowsCount, 'name', disk.name); + await wizardView.setTableInputAttribute(rowsCount, 'size', disk.size); + await click(wizardView.apply); + } + + async attachDisk(disk: StorageResource) { + await click(wizardView.attachDisk); + const rowsCount = await this.getTableRowsCount(); + await wizardView.selectTableDropdownAttribute(rowsCount, 'name-attach', disk.name); + await click(wizardView.apply); + } + + async editDiskAttribute(rowNumber: number, attribute: string, value: string) { + await wizardView.activateTableRow(rowNumber - 1); + if (attribute === 'storage') { + await wizardView.selectTableDropdownAttribute(rowNumber, attribute, value); + } else { + await wizardView.setTableInputAttribute(rowNumber, attribute, value); + } + await click(wizardView.apply); + } + + async waitForCreation() { + await browser.wait(until.presenceOf(wizardView.provisionResultIcon)); + await browser.wait(until.elementToBeClickable(wizardView.nextButton), PAGE_LOAD_TIMEOUT_SECS); + } +} diff --git a/frontend/packages/kubevirt-plugin/integration-tests/tests/utils/consts.ts b/frontend/packages/kubevirt-plugin/integration-tests/tests/utils/consts.ts new file mode 100644 index 00000000000..d87b86941e5 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/integration-tests/tests/utils/consts.ts @@ -0,0 +1,85 @@ +export const DASHES = '---'; +export const { STORAGE_CLASS = 'nfs-sc' } = process.env; + +// TIMEOUTS +const SEC = 1000; +export const CLONE_VM_TIMEOUT_SECS = 300 * SEC; +export const CLONED_VM_BOOTUP_TIMEOUT_SECS = 150 * SEC; +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_STOP_TIMEOUT_SECS = 10 * SEC; +export const VM_IP_ASSIGNMENT_TIMEOUT_SECS = 180 * SEC; +export const WINDOWS_IMPORT_TIMEOUT_SECS = 150 * SEC; + +export const POD_CREATION_TIMEOUT_SECS = 40 * SEC; +export const POD_TERMINATION_TIMEOUT_SECS = 30 * SEC; +export const POD_CREATE_DELETE_TIMEOUT_SECS = + POD_CREATION_TIMEOUT_SECS + POD_TERMINATION_TIMEOUT_SECS; + +export const NODE_STOP_MAINTENANCE_TIMEOUT = 40 * SEC; + +// Web-UI Exceptions +export const WAIT_TIMEOUT_ERROR = 'Wait Timeout Error.'; +export const WIZARD_CREATE_VM_ERROR = 'Creating VM failed'; +export const WIZARD_CREATE_TEMPLATE_ERROR = 'Creating Template failed'; + +// Framework Exception +export const UNEXPECTED_ACTION_ERROR = 'Received unexpected action.'; + +// Compute Nodes +export const NODE_MAINTENANCE_STATUS = 'Under maintenance'; +export const NODE_STOPPING_MAINTENANCE_STATUS = 'Stopping maintenance'; +export const NODE_READY_STATUS = 'Ready'; + +// Wizard dialog +export const WIZARD_TABLE_FIRST_ROW = 1; + +// Tab names +export const TABS = { + OVERVIEW: 'Overview', + YAML: 'YAML', + CONSOLES: 'Consoles', + EVENTS: 'Events', + DISKS: 'Disks', + NICS: 'Network Interfaces', +}; +Object.freeze(TABS); + +// Network tab columns in VM Wizard +export const networkWizardTabCol = { + name: 0, + mac: 1, + networkDefinition: 2, + binding: 3, +}; +Object.freeze(networkWizardTabCol); + +// Network tab columns in detail view +export const networkTabCol = { + name: 0, + model: 1, + networkDefinition: 2, + binding: 3, + mac: 4, +}; +Object.freeze(networkTabCol); + +// Storage tab columns in VM Wizard +export const diskWizardTabCol = { + name: 0, + size: 1, + storageClass: 2, +}; +Object.freeze(diskWizardTabCol); + +// Network tab columns in detail view +export const diskTabCol = { + name: 0, + size: 1, + interface: 2, + storageClass: 3, +}; +Object.freeze(diskTabCol); diff --git a/frontend/packages/kubevirt-plugin/integration-tests/tests/utils/mocks.ts b/frontend/packages/kubevirt-plugin/integration-tests/tests/utils/mocks.ts new file mode 100644 index 00000000000..707f5140b03 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/integration-tests/tests/utils/mocks.ts @@ -0,0 +1,243 @@ +import { testName } from '../../../../../integration-tests/protractor.conf'; +import { CloudInitConfig } from './types'; +import { STORAGE_CLASS } from './consts'; +import { getRandomMacAddress } from './utils'; + +export const multusNad = { + apiVersion: 'k8s.cni.cncf.io/v1', + kind: 'NetworkAttachmentDefinition', + metadata: { + name: `multus-${testName}`, + namespace: testName, + labels: { automatedTest: testName }, + }, + spec: { + config: '{ "cniVersion": "0.3.1", "type": "cnv-bridge", "bridge": "testbridge", "ipam": {} }', + }, +}; + +export const dataVolumeManifest = ({ name, namespace, sourceURL }) => { + return { + apiVersion: 'cdi.kubevirt.io/v1alpha1', + kind: 'DataVolume', + metadata: { + name, + namespace, + }, + spec: { + pvc: { + accessModes: ['ReadWriteMany'], + dataSource: null, + resources: { + requests: { + storage: '5Gi', + }, + }, + storageClassName: STORAGE_CLASS, + }, + source: { + http: { + url: sourceURL, + }, + }, + }, + }; +}; + +export const basicVmConfig = { + operatingSystem: 'Red Hat Enterprise Linux 7.6', + flavor: 'tiny', + workloadProfile: 'desktop', + sourceURL: 'https://download.cirros-cloud.net/0.4.0/cirros-0.4.0-x86_64-disk.img', + sourceContainer: 'kubevirt/cirros-registry-disk-demo:latest', + cloudInitScript: `#cloud-config\nuser: cloud-user\npassword: atomic\nchpasswd: {expire: False}\nhostname: vm-${testName}.example.com`, +}; + +export const networkInterface = { + name: `nic1-${testName.slice(-5)}`, + mac: getRandomMacAddress(), + binding: 'bridge', + networkDefinition: multusNad.metadata.name, +}; + +export const networkBindingMethods = { + masquerade: 'masquerade', + bridge: 'bridge', + sriov: 'sriov', +}; + +export const rootDisk = { + name: 'rootdisk', + size: '1', + storageClass: `${STORAGE_CLASS}`, +}; + +export const hddDisk = { + name: `disk-${testName.slice(-5)}`, + size: '2', + storageClass: `${STORAGE_CLASS}`, +}; + +export const cloudInitCustomScriptConfig: CloudInitConfig = { + useCloudInit: true, + useCustomScript: true, + customScript: basicVmConfig.cloudInitScript, +}; + +export function getVmManifest( + provisionSource: string, + namespace: string, + name?: string, + cloudinit?: string, +) { + const metadata = { + name: name || `${provisionSource.toLowerCase()}-${namespace.slice(-5)}`, + annotations: { + 'name.os.template.kubevirt.io/rhel7.6': 'Red Hat Enterprise Linux 7.6', + description: namespace, + }, + namespace, + labels: { + app: `vm-${provisionSource.toLowerCase()}-${namespace}`, + 'flavor.template.kubevirt.io/tiny': 'true', + 'os.template.kubevirt.io/rhel7.6': 'true', + 'vm.kubevirt.io/template': 'rhel7-desktop-tiny', + 'vm.kubevirt.io/template-namespace': 'openshift', + 'workload.template.kubevirt.io/desktop': 'true', + }, + }; + const urlSource = { + http: { + url: 'https://download.cirros-cloud.net/0.4.0/cirros-0.4.0-x86_64-disk.img', + }, + }; + const dataVolumeTemplate = { + metadata: { + name: `${metadata.name}-rootdisk`, + }, + spec: { + pvc: { + accessModes: ['ReadWriteMany'], + resources: { + requests: { + storage: '1Gi', + }, + }, + storageClassName: `${rootDisk.storageClass}`, + }, + source: {}, + }, + }; + const dataVolume = { + dataVolume: { + name: `${metadata.name}-rootdisk`, + }, + name: 'rootdisk', + }; + const containerDisk = { + containerDisk: { + image: 'kubevirt/cirros-registry-disk-demo:latest', + }, + name: 'rootdisk', + }; + const cloudInitNoCloud = { + cloudInitNoCloud: { + userData: cloudinit, + }, + name: 'cloudinitdisk', + }; + const rootdisk = { + bootOrder: 1, + disk: { + bus: 'virtio', + }, + name: 'rootdisk', + }; + const cloudinitdisk = { + bootOrder: 3, + disk: { + bus: 'virtio', + }, + name: 'cloudinitdisk', + }; + + const dataVolumeTemplates = []; + const volumes = []; + const disks = []; + + disks.push(rootdisk); + + if (cloudinit) { + volumes.push(cloudInitNoCloud); + disks.push(cloudinitdisk); + } + + switch (provisionSource) { + case 'URL': + dataVolumeTemplate.spec.source = urlSource; + dataVolumeTemplates.push(dataVolumeTemplate); + volumes.push(dataVolume); + break; + case 'PXE': + dataVolumeTemplate.spec.source = { blank: {} }; + dataVolumeTemplates.push(dataVolumeTemplate); + volumes.push(dataVolume); + break; + case 'Container': + volumes.push(containerDisk); + break; + default: + throw Error('Provision source not Implemented'); + } + + const vmResource = { + apiVersion: 'kubevirt.io/v1alpha3', + kind: 'VirtualMachine', + metadata, + spec: { + dataVolumeTemplates, + running: false, + template: { + metadata: { + labels: { + 'vm.kubevirt.io/name': metadata.name, + }, + }, + spec: { + domain: { + cpu: { + cores: 1, + sockets: 1, + threads: 1, + }, + devices: { + disks, + interfaces: [ + { + bootOrder: 2, + masquerade: {}, + name: 'nic0', + }, + ], + rng: {}, + }, + resources: { + requests: { + memory: '1G', + }, + }, + }, + terminationGracePeriodSeconds: 0, + networks: [ + { + name: 'nic0', + pod: {}, + }, + ], + volumes, + }, + }, + }, + }; + return vmResource; +} diff --git a/frontend/packages/kubevirt-plugin/integration-tests/tests/utils/types.ts b/frontend/packages/kubevirt-plugin/integration-tests/tests/utils/types.ts new file mode 100644 index 00000000000..a17336d9af9 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/integration-tests/tests/utils/types.ts @@ -0,0 +1,41 @@ +export type ProvisionOption = { + method: string; + source?: string; +}; + +export type NetworkResource = { + name: string; + mac: string; + binding: string; + networkDefinition: string; +}; + +export type StorageResource = { + name: string; + size: string; + storageClass: string; + attached?: boolean; +}; + +export type CloudInitConfig = { + useCloudInit: boolean; + useCustomScript?: boolean; + customScript?: string; + hostname?: string; + sshKey?: string; +}; + +export type VMConfig = { + name: string; + namespace: string; + description: string; + template?: string; + provisionSource?: ProvisionOption; + operatingSystem?: string; + flavor?: string; + workloadProfile?: string; + startOnCreation: boolean; + cloudInit: CloudInitConfig; + storageResources: StorageResource[]; + networkResources: NetworkResource[]; +}; diff --git a/frontend/packages/kubevirt-plugin/integration-tests/tests/utils/utils.ts b/frontend/packages/kubevirt-plugin/integration-tests/tests/utils/utils.ts new file mode 100644 index 00000000000..1d64f659db7 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/integration-tests/tests/utils/utils.ts @@ -0,0 +1,48 @@ +/* eslint-disable no-await-in-loop */ +import { execSync } from 'child_process'; +import { ElementFinder, browser, ExpectedConditions as until } from 'protractor'; + +export async function fillInput(elem: ElementFinder, value: string) { + // Sometimes there seems to be an issue with clear() method not clearing the input + let attempts = 3; + do { + --attempts; + if (attempts < 0) { + throw Error(`Failed to fill input with value: '${value}'.`); + } + await browser.wait(until.elementToBeClickable(elem)); + // TODO: line below can be removed when pf4 tables in use. + await elem.click(); + await elem.clear(); + await elem.sendKeys(value); + } while ((await elem.getAttribute('value')) !== value && attempts > 0); +} + +export async function getInputValue(elem: ElementFinder) { + return elem.getAttribute('value'); +} + +export function getRandStr(length: number) { + return Math.random() + .toString(36) + .replace(/[.]/g, '') + .substr(1, length); // First char is always 0 +} + +export function getRandomMacAddress() { + const getRandByte = () => { + let byte: string; + do { + byte = Math.random() + .toString(16) + .substr(2, 2); + } while (byte.length !== 2); + return byte; + }; + return `30:24:${getRandByte()}:${getRandByte()}:${getRandByte()}:${getRandByte()}`; +} + +export function getResourceObject(name: string, namespace: string, kind: string) { + const resourceJson = execSync(`oc get -o json -n ${namespace} ${kind} ${name}`).toString(); + return JSON.parse(resourceJson); +} 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 new file mode 100644 index 00000000000..fe328da65cd --- /dev/null +++ b/frontend/packages/kubevirt-plugin/integration-tests/tests/vm.wizard.scenario.ts @@ -0,0 +1,158 @@ +/* eslint-disable max-nested-callbacks */ +import { OrderedMap } from 'immutable'; +import * as _ from 'lodash'; +import { testName } from '../../../../integration-tests/protractor.conf'; +import { + removeLeakedResources, + withResource, + createResources, + deleteResources, +} from '../../../console-shared/src/test-utils/utils'; +import { statusIcons } from '../views/virtualMachine.view'; +import { VirtualMachine } from './models/virtualMachine'; +// eslint-disable-next-line no-unused-vars +import { getResourceObject } from './utils/utils'; +import { VM_BOOTUP_TIMEOUT_SECS, CLONE_VM_TIMEOUT_SECS } from './utils/consts'; +import { + basicVmConfig, + rootDisk, + networkInterface, + multusNad, + hddDisk, + dataVolumeManifest, +} from './utils/mocks'; +import { NetworkResource, StorageResource, ProvisionOption } from './utils/types'; + +describe('Kubevirt create VM using wizard', () => { + const leakedResources = new Set(); + const testDataVolume = dataVolumeManifest({ + name: `toclone-${testName}`, + namespace: testName, + sourceURL: basicVmConfig.sourceURL, + }); + const diskToCloneFrom: StorageResource = { + name: testDataVolume.metadata.name, + size: '1', + storageClass: testDataVolume.spec.pvc.storageClassName, + attached: true, + }; + const commonSettings = { + startOnCreation: true, + cloudInit: { + useCloudInit: false, + }, + namespace: testName, + description: `Default description ${testName}`, + flavor: basicVmConfig.flavor, + operatingSystem: basicVmConfig.operatingSystem, + workloadProfile: basicVmConfig.workloadProfile, + }; + const vmConfig = (name, provisionConfig) => { + return { + ...commonSettings, + name: `${name}-${testName}`, + provisionSource: provisionConfig.provision, + storageResources: provisionConfig.storageResources, + networkResources: provisionConfig.networkResources, + }; + }; + const provisionConfigs = OrderedMap< + string, + { + provision: ProvisionOption; + networkResources: NetworkResource[]; + storageResources: StorageResource[]; + } + >() + .set('URL', { + provision: { + method: 'URL', + source: basicVmConfig.sourceURL, + }, + networkResources: [networkInterface], + storageResources: [rootDisk], + }) + .set('Container', { + provision: { + method: 'Container', + source: basicVmConfig.sourceContainer, + }, + networkResources: [networkInterface], + storageResources: [hddDisk], + }) + .set('PXE', { + provision: { + method: 'PXE', + }, + networkResources: [networkInterface], + storageResources: [rootDisk], + }) + .set('ClonedDisk', { + provision: { + method: 'Cloned Disk', + }, + networkResources: [networkInterface], + storageResources: [diskToCloneFrom], + }); + + beforeAll(async () => { + createResources([multusNad, testDataVolume]); + }); + + afterAll(async () => { + deleteResources([multusNad, testDataVolume]); + }); + + afterEach(() => { + removeLeakedResources(leakedResources); + }); + + provisionConfigs.forEach((provisionConfig, configName) => { + it( + `Create VM using ${configName}.`, + async () => { + const vm = new VirtualMachine(vmConfig(configName.toLowerCase(), provisionConfig)); + await withResource(leakedResources, vm.asResource(), async () => { + await vm.create(vmConfig(configName.toLowerCase(), provisionConfig)); + }); + }, + VM_BOOTUP_TIMEOUT_SECS, + ); + }); + + it( + 'Multiple VMs created using "Cloned Disk" method from single source', + async () => { + const clonedDiskProvisionConfig = provisionConfigs.get('ClonedDisk'); + const vm1Config = vmConfig('vm1', clonedDiskProvisionConfig); + const vm2Config = vmConfig('vm2', clonedDiskProvisionConfig); + vm1Config.startOnCreation = false; + vm1Config.networkResources = []; + const vm1 = new VirtualMachine(vm1Config); + const vm2 = new VirtualMachine(vm2Config); + + await withResource(leakedResources, vm1.asResource(), async () => { + await vm1.create(vm1Config); + // Don't wait for the first VM to be running + await vm1.action('Start', false); + await withResource(leakedResources, vm2.asResource(), async () => { + await vm2.create(vm2Config); + await vm1.waitForStatusIcon(statusIcons.running, VM_BOOTUP_TIMEOUT_SECS); + + // Verify that DV of VM created with Cloned disk method points to correct PVC + const dvResource = getResourceObject( + `${vm1.name}-${testDataVolume.metadata.name}-clone`, + vm1.namespace, + 'dv', + ); + const pvcSource = _.get(dvResource, 'spec.source.pvc', {}); + expect(pvcSource).toEqual({ + name: testDataVolume.metadata.name, + namespace: testDataVolume.metadata.namespace, + }); + }); + }); + }, + CLONE_VM_TIMEOUT_SECS, + ); +}); diff --git a/frontend/packages/kubevirt-plugin/integration-tests/views/kubevirtDetailView.view.ts b/frontend/packages/kubevirt-plugin/integration-tests/views/kubevirtDetailView.view.ts new file mode 100644 index 00000000000..0960efe073d --- /dev/null +++ b/frontend/packages/kubevirt-plugin/integration-tests/views/kubevirtDetailView.view.ts @@ -0,0 +1,45 @@ +import { $, browser, ExpectedConditions as until } from 'protractor'; +import { resourceRows } from '../../../../integration-tests/views/crud.view'; + +export const createNic = $('#create-nic-btn'); +export const createDisk = $('#create-disk-btn'); + +export const nicName = $('#nic-name'); +export const macAddress = $('#nic-mac-address'); +export const networkTypeDropdownId = '#nic-network-type'; +export const networkBindingId = '#nic-binding'; + +export const diskName = $('#disk-name'); +export const diskSize = $('#disk-size'); +export const diskStorageClassDropdownId = '#disk-storage-class'; + +export const cancelBtn = $('button.kubevirt-cancel-accept-buttons.btn-default'); +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) => + resourceRows + .filter((row) => + row + .$$('div') + .first() + .getText() + .then((text) => text === name), + ) + .first(); + +const kebabForName = (name: string) => rowForName(name).$('button.co-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') + .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 new file mode 100644 index 00000000000..90cf1d8a399 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/integration-tests/views/virtualMachine.view.ts @@ -0,0 +1,68 @@ +import { $, $$, by, element } from 'protractor'; + +export const statusIcon = (status) => $(`.kubevirt-status__icon.${status}`); +export const statusLink = $('a.kubevirt-status__link'); + +export const statusIcons = { + starting: 'pficon-in-progress', + migrating: 'pficon-in-progress', + running: 'pficon-ok', + off: 'pficon-off', +}; + +export const consoleSelectorDropdownId = '#console-type-selector'; +export const consoleNetworkInterfaceDropdownId = '#nic-dropdown'; + +const manualConnectionValues = $$('.manual-connection-pf-value'); +export const rdpIpAddress = manualConnectionValues.first(); +export const rdpPort = manualConnectionValues.last(); + +export const overviewIpAddresses = (name: string, namespace: string) => + $(`#${namespace}-${name}-ip-addresses`); + +// VM detail view +export const vmDetailItemId = (namespace, vmName, itemName) => + `#${namespace}-${vmName}-${itemName}`; + +export const vmDetailName = (namespace, vmName) => $(vmDetailItemId(namespace, vmName, 'name')); +export const vmDetailDesc = (namespace, vmName) => + $(vmDetailItemId(namespace, vmName, 'description')); +export const vmDetailOS = (namespace, vmName) => $(vmDetailItemId(namespace, vmName, 'os')); +export const vmDetailIP = (namespace, vmName) => + $(vmDetailItemId(namespace, vmName, 'ip-addresses')); +export const vmDetailWorkloadProfile = (namespace, vmName) => + $(vmDetailItemId(namespace, vmName, 'workload-profile')); +export const vmDetailTemplate = (namespace, vmName) => + $(vmDetailItemId(namespace, vmName, 'template')); +export const vmDetailHostname = (namespace, vmName) => + $(vmDetailItemId(namespace, vmName, 'hostname')); +export const vmDetailNamespace = (namespace, vmName) => + $(vmDetailItemId(namespace, vmName, 'namespace')); +export const vmDetailPod = (namespace, vmName) => $(vmDetailItemId(namespace, vmName, 'pod')); +export const vmDetailNode = (namespace, vmName) => $(vmDetailItemId(namespace, vmName, 'node')); +export const vmDetailFlavor = (namespace, vmName) => $(vmDetailItemId(namespace, vmName, 'flavor')); +export const vmDetailFlavorDropdownId = (namespace, vmName) => + vmDetailItemId(namespace, vmName, 'flavor-dropdown'); +export const vmDetailFlavorDropdown = (namespace, vmName) => + $(vmDetailFlavorDropdownId(namespace, vmName)); +export const vmDetailFlavorDesc = (namespace, vmName) => + $(vmDetailItemId(namespace, vmName, 'flavor-description')); +export const vmDetailFlavorCPU = (namespace, vmName) => + $(vmDetailItemId(namespace, vmName, 'flavor-cpu')); +export const vmDetailFlavorMemory = (namespace, vmName) => + $(vmDetailItemId(namespace, vmName, 'flavor-memory')); +export const vmDetailDescTextarea = (namespace, vmName) => + $(vmDetailItemId(namespace, vmName, 'description-textarea')); +export const vmDetailBootOrder = (namespace, vmName) => + $(vmDetailItemId(namespace, vmName, 'boot-order')) + .$('.kubevirt-boot-order__list') + .$$('li'); + +export const detailViewEditBtn = element(by.buttonText('Edit')); +export const detailViewSaveBtn = element(by.buttonText('Save')); +export const detailViewCancelBtn = element(by.buttonText('Cancel')); + +export const vmDetailServiceItem = (namespace, serviceName) => + `[href="/k8s/ns/${namespace}/services/${serviceName}"]`; +export const vmDetailService = (namespace, serviceName) => + $(vmDetailServiceItem(namespace, serviceName)); 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 new file mode 100644 index 00000000000..9485ff05010 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/integration-tests/views/vm.actions.view.ts @@ -0,0 +1,54 @@ +import { $, element, by, browser, ExpectedConditions as until } from 'protractor'; +import { rowForName, resourceRowsPresent } from '../../../../integration-tests/views/crud.view'; +import { click } from '../../../console-shared/src/test-utils/utils'; + +const dialogOverlay = $('.co-overlay'); + +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 confirmAction = () => + browser + .wait(until.presenceOf($('button[type=submit]'))) + .then(() => $('button[type=submit]').click()) + .then(() => browser.wait(until.not(until.presenceOf(dialogOverlay)))); + +/** + * Selects option button from given dropdown element. + */ +const selectDropdownItem = (getActionsDropdown, getActionsDropdownMenu) => async ( + action: string, +) => { + await browser + .wait(until.elementToBeClickable(getActionsDropdown())) + .then(() => getActionsDropdown().click()); + const option = getActionsDropdownMenu() + .$$('button') + .filter((button) => button.getText().then((text) => text.startsWith(action))) + .first(); + await browser.wait(until.elementToBeClickable(option)).then(() => option.click()); +}; + +/** + * Performs action for VM via list view kebab menu. + */ +export const listViewAction = (name) => async (action: string, confirm?: boolean) => { + await resourceRowsPresent(); + const getActionsDropdown = () => + rowForName(name) + .$$(listViewKebabDropdown) + .first(); + 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(); + } + } +}; diff --git a/frontend/packages/kubevirt-plugin/integration-tests/views/wizard.view.ts b/frontend/packages/kubevirt-plugin/integration-tests/views/wizard.view.ts new file mode 100644 index 00000000000..133464ea045 --- /dev/null +++ b/frontend/packages/kubevirt-plugin/integration-tests/views/wizard.view.ts @@ -0,0 +1,90 @@ +import { $, $$ } from 'protractor'; +import { selectDropdownOption } from '../../../console-shared/src/test-utils/utils'; +import { fillInput } from '../tests/utils/utils'; + +// Wizard Common +export const closeWizard = $('.modal-footer > button.btn-cancel'); +export const wizardContent = $('.wizard-pf-contents'); +export const wizardHeader = $('.modal-header'); +export const provisionResultIcon = wizardContent.$('.pficon-ok'); +export const nextButton = $('.modal-footer > button.btn-primary'); +export const apply = $('.inline-edit-buttons > button:first-child'); +export const cancelButton = $('.inline-edit-buttons > button:last-child'); +export const wizardHelpBlock = $('.modal-body .help-block'); +// Basic Settings tab +export const createWithWizardLink = $('#wizard-link'); +export const createWithYAMLLink = $('#yaml-link'); + +export const nameInput = $('#vm-name'); +export const descriptionInput = $('#vm-description'); + +const provisionSourceURL = $('#provision-source-url'); +const provisionSourceContainerImage = $('#provision-source-container'); +export const provisionSources = { + URL: provisionSourceURL, + Container: provisionSourceContainerImage, +}; + +export const namespaceDropdownId = '#namespace-dropdown'; +export const provisionSourceDropdownId = '#image-source-type-dropdown'; +export const operatingSystemDropdownId = '#operating-system-dropdown'; +export const templateDropdownId = '#template-dropdown'; +export const flavorDropdownId = '#flavor-dropdown'; +export const customFlavorCpus = '#resources-cpu'; +export const customFlavorMemory = '#resources-memory'; +export const workloadProfileDropdownId = '#workload-profile-dropdown'; + +export const startVMOnCreation = $('#start-vm'); +export const useCloudInit = $('#use-cloud-init'); +export const useCustomScript = $('#use-cloud-init-custom-script'); +export const customCloudInitScript = $('#cloud-init-custom-script'); +export const cloudInitHostname = $('#cloud-init-hostname'); +export const cloudInitSSH = $('#cloud-init-ssh'); + +// Networking tab +export const createNIC = $('#create-network-btn'); +export const pxeNICDropdownId = '#pxe-nic-dropdown'; + +// Storage tab +export const attachDisk = $('#attach-disk-btn'); +export const createDisk = $('#create-storage-btn'); + +// Result tab +export const errorMessage = $('.kubevirt-create-vm-wizard__result-tab-row--error'); + +// Tables +export const tableRowsCount = () => $$('.kubevirt-editable-table tbody tr').count(); +export const activateTableRow = (rowNumber: number) => + $$('.kubevirt-editable-table tbody tr') + .get(rowNumber) + .click(); + +/** + * Sets an attribute of a disk (name, size) on a given row. + * @param {number} rowNumber Number of row to select, indexed from 1 for the first row. + * @param {string} attribute Attribute name - size or name. + * @param {string} value Value to set. + * @throws {Error} Will throw an Error when input for selected attribute doesn't exist. + */ +export const setTableInputAttribute = async ( + rowNumber: number, + attribute: string, + value: string, +) => { + await fillInput($(`#${attribute}-edit-${rowNumber}-row`), value); +}; + +/** + * Selects a dropdown attribute of an entity (disk, NIC) on a given row. + * @param {number} rowNumber Number of row to select, indexed from 1 for the first row. + * @param {string} tableType Type of resource table (network, storage, ...). + * @param {string} attribute Attribute name - size, name, mac. + * @param {string} value Value to set. + */ +export const selectTableDropdownAttribute = async ( + rowNumber: number, + tableType: string, + value: string, +) => { + await selectDropdownOption(`#${tableType}-edit-${rowNumber.toString()}-row`, value); +};