diff --git a/awx/ui/client/test/e2e/README.md b/awx/ui/client/test/e2e/README.md new file mode 100644 index 0000000000..5133b9bcdb --- /dev/null +++ b/awx/ui/client/test/e2e/README.md @@ -0,0 +1,22 @@ +## AWX E2E +```shell +# setup +docker exec -i tools_awx_1 sh <<-EOSH + awx-manage createsuperuser --noinput --username=awx-e2e --email=null@ansible.com + awx-manage update_password --username=awx-e2e --password=password +EOSH + +# run with with a live browser +npm --prefix awx/ui run e2e -- --env=debug + +# setup a local webdriver cluster for test development +docker-compose \ + -f awx/ui/client/test/e2e/cluster/docker-compose.yml \ + -f awx/ui/client/test/e2e/cluster/devel-override.yml \ + up --scale chrome=2 --scale firefox=0 + +# run headlessly with multiple workers on the cluster +AWX_E2E_URL='https://awx:8043' AWX_E2E_WORKERS=2 npm --prefix awx/ui run e2e +``` + +**Note:** Unless overridden in [settings](settings.js), tests will run against `localhost:8043`. diff --git a/awx/ui/client/test/e2e/cluster/devel-override.yml b/awx/ui/client/test/e2e/cluster/devel-override.yml new file mode 100644 index 0000000000..dc399d5869 --- /dev/null +++ b/awx/ui/client/test/e2e/cluster/devel-override.yml @@ -0,0 +1,13 @@ +--- +version: '2' +networks: + default: + external: + name: tools_default +services: + chrome: + external_links: + - 'tools_awx_1:awx' + firefox: + external_links: + - 'tools_awx_1:awx' diff --git a/awx/ui/client/test/e2e/cluster/docker-compose.yml b/awx/ui/client/test/e2e/cluster/docker-compose.yml new file mode 100644 index 0000000000..8ac55ce20d --- /dev/null +++ b/awx/ui/client/test/e2e/cluster/docker-compose.yml @@ -0,0 +1,23 @@ +--- +version: '2' +services: + hub: + image: selenium/hub + ports: + - '4444:4444' + chrome: + image: selenium/node-chrome + links: + - hub + volumes: + - /dev/shm:/dev/shm + environment: + HUB_PORT_4444_TCP_ADDR: hub + HUB_PORT_4444_TCP_PORT: 4444 + firefox: + image: selenium/node-firefox + links: + - hub + environment: + HUB_PORT_4444_TCP_ADDR: hub + HUB_PORT_4444_TCP_PORT: 4444 diff --git a/awx/ui/client/test/e2e/commands/inject.js b/awx/ui/client/test/e2e/commands/inject.js new file mode 100644 index 0000000000..50006e57b8 --- /dev/null +++ b/awx/ui/client/test/e2e/commands/inject.js @@ -0,0 +1,21 @@ +exports.command = function(deps, script, callback) { + this.executeAsync(`let args = Array.prototype.slice.call(arguments,0); + return function(deps, done) { + let injector = angular.element('body').injector(); + let loaded = deps.map(d => { + if (typeof(d) === "string") { + return injector.get(d); + } else { + return d; + } + }); + (${script.toString()}).apply(this, loaded).then(done); + }.apply(this, args);`, + [deps], + function(result) { + if(typeof(callback) === "function") { + callback.call(this, result.value); + } + }); + return this; +}; diff --git a/awx/ui/client/test/e2e/commands/login.js b/awx/ui/client/test/e2e/commands/login.js new file mode 100644 index 0000000000..8ae7a4d1ca --- /dev/null +++ b/awx/ui/client/test/e2e/commands/login.js @@ -0,0 +1,48 @@ +import { EventEmitter } from 'events'; +import { inherits } from 'util'; + + +const Login = function() { + EventEmitter.call(this); +} + +inherits(Login, EventEmitter); + + +Login.prototype.command = function(username, password) { + + username = username || this.api.globals.awxUsername; + password = password || this.api.globals.awxPassword; + + const loginPage = this.api.page.login(); + + loginPage + .navigate() + .waitForElementVisible('@submit', this.api.globals.longWait) + .waitForElementNotVisible('div.spinny') + .setValue('@username', username) + .setValue('@password', password) + .click('@submit') + .waitForElementVisible('div.spinny') + .waitForElementNotVisible('div.spinny'); + + // tempoary hack while login issue is resolved + this.api.elements('css selector', '.LoginModal-alert', result => { + let alertVisible = false; + result.value.map(i => i.ELEMENT).forEach(id => { + this.api.elementIdDisplayed(id, ({ value }) => { + if (!alertVisible && value) { + alertVisible = true; + loginPage.setValue('@username', username) + loginPage.setValue('@password', password) + loginPage.click('@submit') + loginPage.waitForElementVisible('div.spinny') + loginPage.waitForElementNotVisible('div.spinny'); + } + }) + }) + this.emit('complete'); + }) +}; + +module.exports = Login; diff --git a/awx/ui/client/test/e2e/commands/waitForAngular.js b/awx/ui/client/test/e2e/commands/waitForAngular.js new file mode 100644 index 0000000000..ee0e337641 --- /dev/null +++ b/awx/ui/client/test/e2e/commands/waitForAngular.js @@ -0,0 +1,20 @@ +exports.command = function(callback) { + let self = this; + this.timeoutsAsyncScript(this.globals.asyncHookTimeout, function() { + this.executeAsync(function(done) { + if(angular && angular.getTestability) { + angular.getTestability(document.body).whenStable(done); + } + else { + done(); + } + }, + [], + function(result) { + if(typeof(callback) === "function") { + callback.call(self, result); + } + }); + }); + return this; +}; diff --git a/awx/ui/client/test/e2e/nightwatch.conf.js b/awx/ui/client/test/e2e/nightwatch.conf.js new file mode 100644 index 0000000000..ed200626e4 --- /dev/null +++ b/awx/ui/client/test/e2e/nightwatch.conf.js @@ -0,0 +1,42 @@ +import path from 'path'; + +import chromedriver from 'chromedriver'; + +import { test_workers } from './settings.js'; + + +const resolve = location => path.resolve(__dirname, location); + + +module.exports = { + src_folders: [resolve('tests')], + output_folder: resolve('reports'), + custom_commands_path: resolve('commands'), + page_objects_path: resolve('objects'), + globals_path: resolve('settings.js'), + test_settings: { + default: { + test_workers, + skip_testcases_on_fail: false, + desiredCapabilities: { + browserName: 'chrome' + } + }, + debug: { + selenium_port: 9515, + selenium_host: 'localhost', + default_path_prefix: '', + test_workers: { enabled: false }, + globals: { + before(done) { + chromedriver.start(); + done(); + }, + after(done) { + chromedriver.stop(); + done(); + } + } + } + } +}; diff --git a/awx/ui/client/test/e2e/objects/activityStream.js b/awx/ui/client/test/e2e/objects/activityStream.js new file mode 100644 index 0000000000..c59a615b9a --- /dev/null +++ b/awx/ui/client/test/e2e/objects/activityStream.js @@ -0,0 +1,10 @@ +module.exports = { + url() { + return `${this.api.globals.awxURL}/#/activity_stream` + }, + elements: { + title: '.List-titleText', + subtitle: '.List-titleLockup', + category: '#stream-dropdown-nav' + } +}; diff --git a/awx/ui/client/test/e2e/objects/credentialTypes.js b/awx/ui/client/test/e2e/objects/credentialTypes.js new file mode 100644 index 0000000000..9b2f4f4385 --- /dev/null +++ b/awx/ui/client/test/e2e/objects/credentialTypes.js @@ -0,0 +1,65 @@ +import actions from './sections/actions.js'; +import breadcrumb from './sections/breadcrumb.js'; +import createFormSection from './sections/createFormSection.js'; +import createTableSection from './sections/createTableSection.js'; +import header from './sections/header.js'; +import search from './sections/search.js'; +import pagination from './sections/pagination.js'; + + +const addEditPanel = { + selector: 'div[ui-view="form"]', + elements: { + title: 'div[class="Form-title"]', + }, + sections: { + details: createFormSection({ + selector: '#credential_type_form', + labels: { + name: "Name", + description: "Description", + inputConfiguration: "Input Configuration", + injectorConfiguration: "Injector Configuration" + }, + strategy: 'legacy' + }) + } +}; + + +const listPanel = { + selector: 'div[ui-view="list"]', + elements: { + add: '.List-buttonSubmit', + badge: 'div[class="List-titleBadge]', + titleText: 'div[class="List-titleText"]', + noitems: 'div[class="List-noItems"]' + }, + sections: { + search, + pagination, + table: createTableSection({ + elements: { + name: 'td[class~="name-column"]', + kind: 'td[class~="kind-column"]', + }, + sections: { + actions + } + }) + } +}; + + +module.exports = { + url() { + return `${this.api.globals.awxURL}/#/credential_types` + }, + sections: { + header, + breadcrumb, + add: addEditPanel, + edit: addEditPanel, + list: listPanel + } +}; diff --git a/awx/ui/client/test/e2e/objects/credentials.js b/awx/ui/client/test/e2e/objects/credentials.js new file mode 100644 index 0000000000..783cd7526e --- /dev/null +++ b/awx/ui/client/test/e2e/objects/credentials.js @@ -0,0 +1,280 @@ +import _ from 'lodash'; + +import actions from './sections/actions.js'; +import breadcrumb from './sections/breadcrumb.js'; +import createFormSection from './sections/createFormSection.js'; +import createTableSection from './sections/createTableSection.js'; +import dynamicSection from './sections/dynamicSection.js'; +import header from './sections/header.js'; +import lookupModal from './sections/lookupModal.js'; +import navigation from './sections/navigation.js'; +import pagination from './sections/pagination.js'; +import permissions from './sections/permissions.js'; +import search from './sections/search.js'; + + +const common = createFormSection({ + selector: 'form', + labels: { + name: "Name", + description: "Description", + organization: "Organization", + type: "Credential type" + } +}); + + +const machine = createFormSection({ + selector: '.at-InputGroup-inset', + labels: { + username: "Username", + password: "Password", + sshKeyData: "SSH Private Key", + sshKeyUnlock: "Private Key Passphrase", + becomeMethod: "Privilege Escalation Method", + becomeUsername: "Privilege Escalation Username", + becomePassword: "Privilege Escalation Password" + } +}); + + +const vault = createFormSection({ + selector: '.at-InputGroup-inset', + labels: { + vaultPassword: "Vault Password", + } +}); + + +const scm = createFormSection({ + selector: '.at-InputGroup-inset', + labels: { + username: "Username", + password: "Password", + sshKeyData: "SCM Private Key", + sshKeyUnlock: "Private Key Passphrase", + } +}); + + +const aws = createFormSection({ + selector: '.at-InputGroup-inset', + labels: { + accessKey: "Access Key", + secretKey: "Secret Key", + securityToken: "STS Token", + } +}); + + +const gce = createFormSection({ + selector: '.at-InputGroup-inset', + labels: { + email: "Service Account Email Address", + project: "Project", + sshKeyData: "RSA Private Key", + } +}); + + +const vmware = createFormSection({ + selector: '.at-InputGroup-inset', + labels: { + host: "VCenter Host", + username: "Username", + password: "Password", + } +}); + + +const azureClassic = createFormSection({ + selector: '.at-InputGroup-inset', + labels: { + subscription: "Subscription ID", + sshKeyData: "Management Certificate", + } +}); + + +const azure = createFormSection({ + selector: '.at-InputGroup-inset', + labels: { + subscription: "Subscription ID", + username: "Username", + password: "Password", + client: "Client ID", + secret: "Client Secret", + tenant: "Tenant ID", + } +}); + + +const openStack = createFormSection({ + selector: '.at-InputGroup-inset', + labels: { + username: "Username", + password: "Password (API Key)", + host: "Host (Authentication URL)", + project: "Project (Tenant Name)", + domain: "Domain Name", + } +}); + + +const rackspace = createFormSection({ + selector: '.at-InputGroup-inset', + labels: { + username: "Username", + password: "Password", + } +}); + + +const cloudForms = createFormSection({ + selector: '.at-InputGroup-inset', + labels: { + host: "Cloudforms URL", + username: "Username", + password: "Password", + } +}); + + +const network = createFormSection({ + selector: '.at-InputGroup-inset', + labels: { + sshKeyData: "SSH Private Key", + sshKeyUnlock: "Private Key Passphrase", + username: "Username", + password: "Password", + authorizePassword: "Authorize Password", + } +}); + +network.elements.authorize = { + locateStrategy: 'xpath', + selector: '//input[../p/text() = "Authorize"]' +}; + + +const sat6 = createFormSection({ + selector: '.at-InputGroup-inset', + labels: { + host: "Satellite 6 URL", + username: "Username", + password: "Password", + } +}); + + +const insights = createFormSection({ + selector: '.at-InputGroup-inset', + labels: { + username: "Username", + password: "Password", + }, +}); + + +const details = _.merge({}, common, { + elements: { + cancel: '.btn[type="cancel"]', + save: '.btn[type="save"]', + }, + sections: { + aws, + azure, + azureClassic, + cloudForms, + dynamicSection, + gce, + insights, + machine, + network, + rackspace, + sat6, + scm, + openStack, + vault, + vmware + }, + commands: [{ + custom({ name, inputs }) { + let labels = {}; + inputs.fields.map(f => labels[f.id] = f.label); + + let selector = '.at-InputGroup-inset'; + let generated = createFormSection({ selector, labels }); + + let params = _.merge({ name }, generated); + return this.section.dynamicSection.create(params); + }, + clear() { + this.clearValue('@name'); + this.clearValue('@organization'); + this.clearValue('@description'); + this.clearValue('@type'); + this.waitForElementNotVisible('.at-InputGroup-inset'); + return this; + }, + clearAndSelectType(type) { + this.clear(); + this.setValue('@type', type); + this.waitForElementVisible('.at-InputGroup-inset'); + return this; + } + }] +}); + + +module.exports = { + url() { + return `${this.api.globals.awxURL}/#/credentials` + }, + sections: { + header, + navigation, + breadcrumb, + lookupModal, + add: { + selector: 'div[ui-view="add"]', + sections: { + details + }, + elements: { + title: 'h3[class="at-Panel-headingTitle"] span' + } + }, + edit: { + selector: 'div[ui-view="edit"]', + sections: { + details, + permissions + }, + elements: { + title: 'h3[class="at-Panel-headingTitle"] span' + } + }, + list: { + selector: 'div[ui-view="list"]', + elements: { + badge: 'span[class~="badge"]', + title: 'div[class="List-titleText"]', + add: 'button[class~="List-buttonSubmit"]' + }, + sections: { + search, + pagination, + table: createTableSection({ + elements: { + name: 'td[class~="name-column"]', + kind: 'td[class~="kind-column"]' + }, + sections: { + actions + } + }) + } + }, + } +}; diff --git a/awx/ui/client/test/e2e/objects/login.js b/awx/ui/client/test/e2e/objects/login.js new file mode 100644 index 0000000000..12bdd45485 --- /dev/null +++ b/awx/ui/client/test/e2e/objects/login.js @@ -0,0 +1,11 @@ +module.exports = { + url() { + return `${this.api.globals.awxURL}/#/login` + }, + elements: { + username: '#login-username', + password: '#login-password', + submit: '#login-button', + logo: '#main_menu_logo' + } +}; diff --git a/awx/ui/client/test/e2e/objects/sections/actions.js b/awx/ui/client/test/e2e/objects/sections/actions.js new file mode 100644 index 0000000000..d99b4521c9 --- /dev/null +++ b/awx/ui/client/test/e2e/objects/sections/actions.js @@ -0,0 +1,16 @@ +const actions = { + selector: 'td[class="List-actionsContainer"]', + elements: { + launch: 'i[class="fa icon-launch"]', + schedule: 'i[class="fa icon-schedule"]', + copy: 'i[class="fa icon-copy"]', + edit: 'i[class="fa icon-pencil"]', + delete: 'i[class="fa icon-trash-o"]', + view: 'i[class="fa fa-search-plus"]', + sync: 'i[class="fa fa-cloud-download"]', + test: 'i[class="fa fa-bell-o' + } +}; + + +module.exports = actions; diff --git a/awx/ui/client/test/e2e/objects/sections/breadcrumb.js b/awx/ui/client/test/e2e/objects/sections/breadcrumb.js new file mode 100644 index 0000000000..62231b95c2 --- /dev/null +++ b/awx/ui/client/test/e2e/objects/sections/breadcrumb.js @@ -0,0 +1,8 @@ +const breadcrumb = { + selector: 'bread-crumb > div', + elements: { + activity: 'i[class$="icon-activity-stream"]' + } +}; + +module.exports = breadcrumb; diff --git a/awx/ui/client/test/e2e/objects/sections/createFormSection.js b/awx/ui/client/test/e2e/objects/sections/createFormSection.js new file mode 100644 index 0000000000..6d53215c1c --- /dev/null +++ b/awx/ui/client/test/e2e/objects/sections/createFormSection.js @@ -0,0 +1,103 @@ +import { merge } from 'lodash'; + + +const translated = "translate(text(), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')"; +const normalized = `normalize-space(${translated})`; + + +const inputContainerElements = { + lookup: 'button > i[class="fa fa-search"]', + error: '.at-InputMessage--rejected', + help: 'i[class$="fa-question-circle"]', + hint: '.at-InputLabel-hint', + label: 'label', + popover: '.at-Popover-container', + yaml: 'input[type="radio", value="yaml"]', + json: 'input[type="radio", value="json"]', + revert: 'a[class~="reset"]', + down: 'span[class^="fa-angle-down"]', + up: 'span[class^="fa-angle-up"]', + prompt: { + locateStrategy: 'xpath', + selector: `.//p[${normalized}='prompt on launch']/preceding-sibling::input` + }, + show: { + locateStrategy: 'xpath', + selector: `.//button[${normalized}='show']` + }, + hide: { + locateStrategy: 'xpath', + selector: `.//button[${normalized}='hide']` + }, + on: { + locateStrategy: 'xpath', + selector: `.//button[${normalized}='on']` + }, + off: { + locateStrategy: 'xpath', + selector: `.//button[${normalized}='off']` + } +}; + + +const legacyContainerElements = merge({}, inputContainerElements, { + prompt: { + locateStrategy: 'xpath', + selector: `.//label[${normalized}='prompt on launch']/input` + }, + error: 'div[class~="error"]', + popover: ':root div[id^="popover"]', +}); + + +const generateInputSelectors = function(label, containerElements) { + // descend until span with matching text attribute is encountered + let span = `.//span[text()="${label}"]`; + // recurse upward until div with form-group in class attribute is encountered + let container = `${span}/ancestor::div[contains(@class, 'form-group')]`; + // descend until element with form-control in class attribute is encountered + let input = `${container}//*[contains(@class, 'form-control')]`; + + let inputContainer = { + locateStrategy: 'xpath', + selector: container, + elements: containerElements + }; + + let inputElement = { + locateStrategy: 'xpath', + selector: input + }; + + return { inputElement, inputContainer }; +}; + + +const generatorOptions = { + default: inputContainerElements, + legacy: legacyContainerElements +}; + + +const createFormSection = function({ selector, labels, strategy }) { + let options = generatorOptions[strategy || 'default']; + + let formSection = { + selector, + sections: {}, + elements: {} + }; + + for (let key in labels) { + let label = labels[key]; + + let { inputElement, inputContainer } = generateInputSelectors(label, options); + + formSection.elements[key] = inputElement; + formSection.sections[key] = inputContainer; + }; + + return formSection; +}; + +module.exports = createFormSection; diff --git a/awx/ui/client/test/e2e/objects/sections/createTableSection.js b/awx/ui/client/test/e2e/objects/sections/createTableSection.js new file mode 100644 index 0000000000..58f134c053 --- /dev/null +++ b/awx/ui/client/test/e2e/objects/sections/createTableSection.js @@ -0,0 +1,63 @@ +import dynamicSection from './dynamicSection.js'; + + +const header = { + selector: 'thead', + sections: { + dynamicSection + }, + commands: [{ + findColumnByText(text) { + return this.section.dynamicSection.create({ + name: `column[${text}]`, + locateStrategy: 'xpath', + selector: `.//*[normalize-space(text())='${text}']/ancestor-or-self::th`, + elements: { + sortable: { + locateStrategy: 'xpath', + selector: './/*[contains(@class, "fa-sort")]' + }, + sorted: { + locateStrategy: 'xpath', + selector: './/*[contains(@class, "fa-sort-")]' + }, + } + }); + } + }] +}; + + +const createTableSection = function({ elements, sections, commands }) { + return { + selector: 'table', + sections: { + header, + dynamicSection + }, + commands: [{ + findRowByText(text) { + return this.section.dynamicSection.create({ + elements, + sections, + commands, + name: `row[${text}]`, + locateStrategy: 'xpath', + selector: `.//tbody/tr/td//*[normalize-space(text())='${text}']/ancestor::tr` + }); + }, + waitForRowCount(count) { + let countReached = `tbody tr:nth-of-type(${count})`; + this.waitForElementPresent(countReached); + + let countExceeded = `tbody tr:nth-of-type(${count + 1})`; + this.waitForElementNotPresent(countExceeded); + + return this; + } + }] + }; +}; + + +module.exports = createTableSection; diff --git a/awx/ui/client/test/e2e/objects/sections/dynamicSection.js b/awx/ui/client/test/e2e/objects/sections/dynamicSection.js new file mode 100644 index 0000000000..d2cc472f2a --- /dev/null +++ b/awx/ui/client/test/e2e/objects/sections/dynamicSection.js @@ -0,0 +1,26 @@ +const dynamicSection = { + selector: '.', + commands: [{ + create({ name, locateStrategy, selector, elements, sections, commands }) { + let Section = this.constructor; + + let options = Object.assign(Object.create(this), { + name, + locateStrategy, + elements, + selector, + sections, + commands + }); + + options.elements.self = { + locateStrategy: 'xpath', + selector: '.' + }; + + return new Section(options); + } + }] +}; + +module.exports = dynamicSection; diff --git a/awx/ui/client/test/e2e/objects/sections/header.js b/awx/ui/client/test/e2e/objects/sections/header.js new file mode 100644 index 0000000000..f0ecb73e2a --- /dev/null +++ b/awx/ui/client/test/e2e/objects/sections/header.js @@ -0,0 +1,12 @@ +const header = { + selector: 'div[class="at-Layout-topNav"]', + elements: { + logo: 'div[class$="logo"] img', + user: 'i[class="fa fa-user"] + span', + documentation: 'i[class="fa fa-book"]', + logout: 'i[class="fa fa-power-off"]', + } +}; + + +module.exports = header; diff --git a/awx/ui/client/test/e2e/objects/sections/lookupModal.js b/awx/ui/client/test/e2e/objects/sections/lookupModal.js new file mode 100644 index 0000000000..e6ac0f9a64 --- /dev/null +++ b/awx/ui/client/test/e2e/objects/sections/lookupModal.js @@ -0,0 +1,27 @@ +import createTableSection from './createTableSection.js'; +import pagination from './pagination.js'; +import search from './search.js'; + + +const lookupModal = { + selector: '#form-modal', + elements: { + close: 'i[class="fa fa-times-circle"]', + title: 'div[class^="Form-title"]', + select: 'button[class*="save"]', + cancel: 'button[class*="cancel"]' + }, + sections: { + search, + pagination, + table: createTableSection({ + elements: { + name: 'td[class~="name-column"]', + selected: 'input[type="radio", value="1"]', + } + + }) + } +}; + +module.exports = lookupModal; diff --git a/awx/ui/client/test/e2e/objects/sections/navigation.js b/awx/ui/client/test/e2e/objects/sections/navigation.js new file mode 100644 index 0000000000..fed756dd11 --- /dev/null +++ b/awx/ui/client/test/e2e/objects/sections/navigation.js @@ -0,0 +1,25 @@ +const navigation = { + selector: 'div[class^="at-Layout-side"]', + elements: { + expand: 'i[class="fa fa-bars"]', + dashboard: 'i[class="fa fa-tachometer"]', + jobs: 'i[class="fa fa-spinner"]', + schedules: 'i[class="fa fa-calendar"]', + portal: 'i[class="fa fa-columns"]', + projects: 'i[class="fa fa-folder-open"]', + credentials: 'i[class="fa fa-key"]', + credentialTypes: 'i[class="fa fa-list-alt"]', + inventories: 'i[class="fa fa-sitemap"]', + templates: 'i[class="fa fa-pencil-square-o"]', + organizations: 'i[class="fa fa-building"]', + users: 'i[class="fa fa-user"]', + teams: 'i[class="fa fa-users"]', + inventoryScripts: 'i[class="fa fa-code"]', + notifications: 'i[class="fa fa-bell"]', + managementJobs: 'i[class="fa fa-wrench"]', + instanceGroups: 'i[class="fa fa-server"]', + } +}; + + +module.exports = navigation; diff --git a/awx/ui/client/test/e2e/objects/sections/pagination.js b/awx/ui/client/test/e2e/objects/sections/pagination.js new file mode 100644 index 0000000000..5973ff2dd4 --- /dev/null +++ b/awx/ui/client/test/e2e/objects/sections/pagination.js @@ -0,0 +1,13 @@ +const pagination = { + selector: 'paginate div', + elements: { + first: 'i[class="fa fa-angle-double-left"]', + previous: 'i[class="fa fa-angle-left"]', + next: 'i[class="fa fa-angle-right"]', + last: 'i[class="fa fa-angle-double-right"]', + pageCount: 'span[class~="pageof"]', + itemCount: 'span[class~="itemsOf"]', + } +}; + +module.exports = pagination; diff --git a/awx/ui/client/test/e2e/objects/sections/permissions.js b/awx/ui/client/test/e2e/objects/sections/permissions.js new file mode 100644 index 0000000000..359206c0d9 --- /dev/null +++ b/awx/ui/client/test/e2e/objects/sections/permissions.js @@ -0,0 +1,30 @@ +import actions from './actions.js'; +import createTableSection from './createTableSection.js'; +import pagination from './pagination.js'; +import search from './search.js'; + + +const permissions = { + selector: 'div[ui-view="related"]', + elements: { + add: 'button[class="btn List-buttonSubmit"]', + badge: 'div[class="List-titleBadge]', + titleText: 'div[class="List-titleText"]', + noitems: 'div[class="List-noItems"]' + }, + sections: { + search, + pagination, + table: createTableSection({ + elements: { + username: 'td[class~="username"]', + roles: 'td role-list:nth-of-type(1)', + teamRoles: 'td role-list:nth-of-type(2)' + }, + sections: { actions } + }) + } +}; + + +module.exports = permissions; diff --git a/awx/ui/client/test/e2e/objects/sections/search.js b/awx/ui/client/test/e2e/objects/sections/search.js new file mode 100644 index 0000000000..f88d1f282b --- /dev/null +++ b/awx/ui/client/test/e2e/objects/sections/search.js @@ -0,0 +1,12 @@ +const search = { + selector: 'smart-search', + elements: { + clearAll: '.SmartSearch-clearAll', + searchButton: '.SmartSearch-searchButton', + input: '.SmartSearch-input', + tags: '.SmartSearch-tagContainer' + } +}; + + +module.exports = search; diff --git a/awx/ui/client/test/e2e/runner.js b/awx/ui/client/test/e2e/runner.js new file mode 100755 index 0000000000..e13cf9b17c --- /dev/null +++ b/awx/ui/client/test/e2e/runner.js @@ -0,0 +1,3 @@ +#!/usr/bin/env node +require('babel-register'); +require('nightwatch/bin/runner.js'); diff --git a/awx/ui/client/test/e2e/settings.js b/awx/ui/client/test/e2e/settings.js new file mode 100644 index 0000000000..6496ba0b75 --- /dev/null +++ b/awx/ui/client/test/e2e/settings.js @@ -0,0 +1,29 @@ +const AWX_E2E_URL = process.env.AWX_E2E_URL || 'https://localhost:8043'; +const AWX_E2E_USERNAME = process.env.AWX_E2E_USERNAME || 'awx-e2e'; +const AWX_E2E_PASSWORD = process.env.AWX_E2E_PASSWORD || 'password'; +const AWX_E2E_SELENIUM_HOST = process.env.AWX_E2E_SELENIUM_HOST || 'localhost'; +const AWX_E2E_SELENIUM_PORT = process.env.AWX_E2E_SELENIUM_PORT || 4444; +const AWX_E2E_TIMEOUT_SHORT = process.env.AWX_E2E_TIMEOUT_SHORT || 1000; +const AWX_E2E_TIMEOUT_MEDIUM = process.env.AWX_E2E_TIMEOUT_MEDIUM || 5000; +const AWX_E2E_TIMEOUT_LONG = process.env.AWX_E2E_TIMEOUT_LONG || 10000; +const AWX_E2E_TIMEOUT_ASYNC = process.env.AWX_E2E_TIMEOUT_ASYNC || 30000; +const AWX_E2E_WORKERS = process.env.AWX_E2E_WORKERS || 0; + + +module.exports = { + awxURL: AWX_E2E_URL, + awxUsername: AWX_E2E_USERNAME, + awxPassword: AWX_E2E_PASSWORD, + asyncHookTimeout: AWX_E2E_TIMEOUT_ASYNC, + longTmeout: AWX_E2E_TIMEOUT_LONG, + mediumTimeout: AWX_E2E_TIMEOUT_MEDIUM, + retryAssertionTimeout: AWX_E2E_TIMEOUT_MEDIUM, + selenium_host: AWX_E2E_SELENIUM_HOST, + selenium_port: AWX_E2E_SELENIUM_PORT, + shortTimeout: AWX_E2E_TIMEOUT_SHORT, + waitForConditionTimeout: AWX_E2E_TIMEOUT_MEDIUM, + test_workers: { + enabled: (AWX_E2E_WORKERS > 0), + workers: AWX_E2E_WORKERS + } +}; diff --git a/awx/ui/client/test/e2e/tests/test-credential-types-add-edit.js b/awx/ui/client/test/e2e/tests/test-credential-types-add-edit.js new file mode 100644 index 0000000000..120d50eb45 --- /dev/null +++ b/awx/ui/client/test/e2e/tests/test-credential-types-add-edit.js @@ -0,0 +1,32 @@ +module.exports = { + before: function(client, done) { + const credentialTypes = client.page.credentialTypes(); + + client.login(); + client.waitForAngular(); + + credentialTypes + .navigate(`${credentialTypes.url()}/add/`) + .waitForElementVisible('div.spinny') + .waitForElementNotVisible('div.spinny'); + + credentialTypes.section.add + .waitForElementVisible('@title', done); + }, + 'expected fields are present and enabled': function(client) { + const credentialTypes = client.page.credentialTypes(); + const details = credentialTypes.section.add.section.details; + + details.expect.element('@name').visible; + details.expect.element('@description').visible; + details.section.inputConfiguration.expect.element('.CodeMirror').visible; + details.section.injectorConfiguration.expect.element('.CodeMirror').visible; + + details.expect.element('@name').enabled; + details.expect.element('@description').enabled; + details.expect.element('@inputConfiguration').enabled; + details.expect.element('@injectorConfiguration').enabled; + + client.end(); + } +}; diff --git a/awx/ui/client/test/e2e/tests/test-credentials-add-edit-aws.js b/awx/ui/client/test/e2e/tests/test-credentials-add-edit-aws.js new file mode 100644 index 0000000000..6d807cdbc1 --- /dev/null +++ b/awx/ui/client/test/e2e/tests/test-credentials-add-edit-aws.js @@ -0,0 +1,135 @@ +import uuid from 'uuid'; + + +let testID = uuid().substr(0,8); + +let store = { + organization: { + name: `org-${testID}` + }, + credential: { + name: `cred-${testID}` + }, +}; + +module.exports = { + before: function(client, done) { + const credentials = client.page.credentials(); + + client.login(); + client.waitForAngular(); + + client.inject([store, 'OrganizationModel'], (store, model) => { + return new model().http.post(store.organization); + }, + ({ data }) => { + store.organization = data; + }); + + credentials.section.navigation + .waitForElementVisible('@credentials') + .click('@credentials'); + + credentials + .waitForElementVisible('div.spinny') + .waitForElementNotVisible('div.spinny'); + + credentials.section.list + .waitForElementVisible('@add') + .click('@add'); + + credentials.section.add.section.details + .waitForElementVisible('@save') + .setValue('@name', store.credential.name) + .setValue('@organization', store.organization.name) + .setValue('@type', 'Amazon Web Services', done); + }, + 'expected fields are visible and enabled': function(client) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + + details.expect.element('@name').visible; + details.expect.element('@description').visible; + details.expect.element('@organization').visible; + details.expect.element('@type').visible; + details.section.aws.expect.element('@accessKey').visible; + details.section.aws.expect.element('@secretKey').visible; + details.section.aws.expect.element('@securityToken').visible; + + details.expect.element('@name').enabled; + details.expect.element('@description').enabled; + details.expect.element('@organization').enabled; + details.expect.element('@type').enabled; + details.section.aws.expect.element('@accessKey').enabled; + details.section.aws.expect.element('@secretKey').enabled; + details.section.aws.expect.element('@securityToken').enabled; + }, + 'required fields display \'*\'': function(client) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + const required = [ + details.section.name, + details.section.type, + details.section.aws.section.accessKey, + details.section.aws.section.secretKey + ] + required.map(s => s.expect.element('@label').text.to.contain('*')); + }, + 'save button becomes enabled after providing required fields': function(client) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + + details + .clearAndSelectType('Amazon Web Services') + .setValue('@name', store.credential.name); + + details.expect.element('@save').not.enabled; + details.section.aws.setValue('@accessKey', 'AAAAAAAAAAAAA'); + details.section.aws.setValue('@secretKey', 'AAAAAAAAAAAAA'); + details.expect.element('@save').enabled; + }, + 'create aws credential': function(client) { + const credentials = client.page.credentials(); + const add = credentials.section.add; + const edit = credentials.section.edit; + + add.section.details + .clearAndSelectType('Amazon Web Services') + .setValue('@name', store.credential.name) + .setValue('@organization', store.organization.name); + + add.section.details.section.aws + .setValue('@accessKey', 'ABCD123456789') + .setValue('@secretKey', '987654321DCBA'); + + add.section.details.click('@save'); + + credentials + .waitForElementVisible('div.spinny') + .waitForElementNotVisible('div.spinny'); + + edit.expect.element('@title').text.equal(store.credential.name); + }, + 'edit details panel remains open after saving': function(client) { + const credentials = client.page.credentials(); + + credentials.section.edit.expect.section('@details').visible; + }, + 'credential is searchable after saving': function(client) { + const credentials = client.page.credentials(); + + const search = credentials.section.list.section.search; + const table = credentials.section.list.section.table; + + search + .waitForElementVisible('@input') + .setValue('@input', `name:${store.credential.name}`) + .click('@searchButton'); + + table.waitForRowCount(1); + table.findRowByText(store.credential.name) + .waitForElementVisible('@self'); + + client.end(); + } +}; diff --git a/awx/ui/client/test/e2e/tests/test-credentials-add-edit-custom.js b/awx/ui/client/test/e2e/tests/test-credentials-add-edit-custom.js new file mode 100644 index 0000000000..0606cc7d39 --- /dev/null +++ b/awx/ui/client/test/e2e/tests/test-credentials-add-edit-custom.js @@ -0,0 +1,176 @@ +import uuid from 'uuid'; + + +let store = { + credentialType: { + name: `credentialType-${uuid().substr(0,8)}`, + description: "custom cloud credential", + kind: "cloud", + inputs: { + fields: [ + { + id: "project", + label: "Project", + type: "string", + help_text: "Name of your project" + }, + { + id: "token", + label: "Token", + secret: true, + type: "string", + help_text: "help" + }, + { + id: "secret_key_data", + label: "Secret Key", + type: "string", + secret: true, + multiline: true, + help_text: "help", + }, + { + id: "public_key_data", + label: "Public Key", + type: "string", + secret: true, + multiline: true, + help_text: "help", + }, + { + id: "secret_key_unlock", + label: "Private Key Passphrase", + type: "string", + secret: true, + //help_text: "help" + }, + { + id: "color", + label: "Favorite Color", + choices: [ + "", + "red", + "orange", + "yellow", + "green", + "blue", + "indigo", + "violet" + ], + help_text: "help", + }, + ], + required: ['project', 'token'] + }, + injectors: { + env: { + CUSTOM_CREDENTIAL_TOKEN: "{{ token }}" + } + } + } +}; + + +const inputs = store.credentialType.inputs; +const fields = store.credentialType.inputs.fields; +const help = fields.filter(f => f.help_text); +const required = fields.filter(f => inputs.required.indexOf(f.id) > -1); +const strings = fields.filter(f => f.type === undefined || f.type === 'string'); + + +const getObjects = function(client) { + let credentials = client.page.credentials(); + let details = credentials.section.add.section.details; + let type = details.custom(store.credentialType); + return { credentials, details, type }; +}; + + +module.exports = { + before: function(client, done) { + const credentials = client.page.credentials(); + + client.login(); + client.waitForAngular(); + + client.inject([store.credentialType, 'CredentialTypeModel'], (data, model) => { + return new model().http.post(data); + }, + ({ data }) => { + store.credentialType.response = data; + }); + + credentials.section.navigation + .waitForElementVisible('@credentials') + .click('@credentials'); + + credentials + .waitForElementVisible('div.spinny') + .waitForElementNotVisible('div.spinny'); + + credentials.section.list + .waitForElementVisible('@add') + .click('@add'); + + credentials.section.add.section.details + .waitForElementVisible('@save') + .setValue('@name', `cred-${uuid()}`) + .setValue('@type', store.credentialType.name, done); + }, + 'all fields are visible': function(client) { + let { type } = getObjects(client); + fields.map(f => type.expect.element(`@${f.id}`).visible); + }, + 'helplinks open popovers showing expected content': function(client) { + let { type } = getObjects(client); + + help.map(f => { + let group = type.section[f.id]; + group.expect.element('@popover').not.visible; + group.click('@help'); + group.expect.element('@popover').visible; + group.expect.element('@popover').text.to.contain(f.help_text); + group.click('@help'); + }); + + help.map(f => { + let group = type.section[f.id]; + group.expect.element('@popover').not.visible; + }); + }, + 'secret field buttons hide and unhide input': function(client) { + let { type } = getObjects(client); + let secrets = strings.filter(f => f.secret && !f.multiline); + + secrets.map(f => { + let group = type.section[f.id]; + let input = `@${f.id}`; + + group.expect.element('@show').visible; + group.expect.element('@hide').not.present; + + type.setValue(input, 'SECRET'); + type.expect.element(input).text.equal(''); + + group.click('@show'); + group.expect.element('@show').not.present; + group.expect.element('@hide').visible; + type.expect.element(input).value.contain('SECRET'); + + group.click('@hide'); + group.expect.element('@show').visible; + group.expect.element('@hide').not.present; + type.expect.element(input).text.equal(''); + }) + }, + 'required fields show * symbol': function(client) { + let { type } = getObjects(client); + + required.map(f => { + let group = type.section[f.id]; + group.expect.element('@label').text.to.contain('*'); + }); + + client.end(); + } +}; diff --git a/awx/ui/client/test/e2e/tests/test-credentials-add-edit-gce.js b/awx/ui/client/test/e2e/tests/test-credentials-add-edit-gce.js new file mode 100644 index 0000000000..92e39fea0f --- /dev/null +++ b/awx/ui/client/test/e2e/tests/test-credentials-add-edit-gce.js @@ -0,0 +1,172 @@ +import uuid from 'uuid'; + + +let testID = uuid().substr(0,8); + +let store = { + organization: { + name: `org-${testID}` + }, + credential: { + name: `cred-${testID}` + }, +}; + +module.exports = { + before: function(client, done) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + + client.login(); + client.waitForAngular(); + + client.inject([store, 'OrganizationModel'], (store, model) => { + return new model().http.post(store.organization); + }, + ({ data }) => { + store.organization = data; + }); + + credentials.section.navigation + .waitForElementVisible('@credentials') + .click('@credentials'); + + credentials + .waitForElementVisible('div.spinny') + .waitForElementNotVisible('div.spinny'); + + credentials.section.list + .waitForElementVisible('@add') + .click('@add'); + + details + .waitForElementVisible('@save') + .setValue('@name', store.credential.name) + .setValue('@organization', store.organization.name) + .setValue('@type', 'Google Compute Engine', done); + }, + 'expected fields are visible and enabled': function(client) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + + details.expect.element('@name').visible; + details.expect.element('@description').visible; + details.expect.element('@organization').visible; + details.expect.element('@type').visible; + details.section.gce.expect.element('@email').visible; + details.section.gce.expect.element('@sshKeyData').visible; + details.section.gce.expect.element('@project').visible; + + details.expect.element('@name').enabled; + details.expect.element('@description').enabled; + details.expect.element('@organization').enabled; + details.expect.element('@type').enabled; + details.section.gce.expect.element('@email').enabled; + details.section.gce.expect.element('@sshKeyData').enabled; + details.section.gce.expect.element('@project').enabled; + + details.section.organization.expect.element('@lookup').visible; + }, + 'required fields display \'*\'': function(client) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + const required = [ + details.section.name, + details.section.type, + details.section.gce.section.email, + details.section.gce.section.sshKeyData + ] + required.map(s => s.expect.element('@label').text.to.contain('*')); + }, + 'save button becomes enabled after providing required fields': function(client) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + + details + .clearAndSelectType('Google Compute Engine') + .setValue('@name', store.credential.name); + + details.expect.element('@save').not.enabled; + + details.section.gce + .setValue('@email', 'abc@123.com') + .sendKeys('@sshKeyData', '-----BEGIN RSA PRIVATE KEY-----') + .sendKeys('@sshKeyData', client.Keys.ENTER) + .sendKeys('@sshKeyData', 'AAAA') + .sendKeys('@sshKeyData', client.Keys.ENTER) + .sendKeys('@sshKeyData', '-----END RSA PRIVATE KEY-----'); + + details.expect.element('@save').enabled; + }, + 'error displayed for invalid ssh key data': function(client) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + const sshKeyData = details.section.gce.section.sshKeyData; + + details + .clearAndSelectType('Google Compute Engine') + .setValue('@name', store.credential.name); + + details.section.gce + .setValue('@email', 'abc@123.com') + .setValue('@sshKeyData', 'invalid'); + + details.click('@save'); + + sshKeyData.expect.element('@error').visible; + sshKeyData.expect.element('@error').text.to.contain('Invalid certificate or key'); + + details.section.gce.clearValue('@sshKeyData'); + + sshKeyData.expect.element('@error').visible; + sshKeyData.expect.element('@error').text.to.contain('Please enter a value'); + + details.section.gce.setValue('@sshKeyData', 'AAAA'); + sshKeyData.expect.element('@error').not.present; + }, + 'create gce credential': function(client) { + const credentials = client.page.credentials(); + const add = credentials.section.add; + const edit = credentials.section.edit; + + add.section.details + .clearAndSelectType('Google Compute Engine') + .setValue('@name', store.credential.name) + .setValue('@organization', store.organization.name); + + add.section.details.section.gce + .setValue('@email', 'abc@123.com') + .sendKeys('@sshKeyData', '-----BEGIN RSA PRIVATE KEY-----') + .sendKeys('@sshKeyData', client.Keys.ENTER) + .sendKeys('@sshKeyData', 'AAAA') + .sendKeys('@sshKeyData', client.Keys.ENTER) + .sendKeys('@sshKeyData', '-----END RSA PRIVATE KEY-----'); + + add.section.details.click('@save'); + + credentials + .waitForElementVisible('div.spinny') + .waitForElementNotVisible('div.spinny'); + + edit.expect.element('@title').text.equal(store.credential.name); + }, + 'edit details panel remains open after saving': function(client) { + const credentials = client.page.credentials(); + + credentials.section.edit.expect.section('@details').visible; + }, + 'credential is searchable after saving': function(client) { + const credentials = client.page.credentials(); + const row = '#credentials_table tbody tr'; + + credentials.section.list.section.search + .waitForElementVisible('@input', client.globals.longWait) + .setValue('@input', `name:${store.credential.name}`) + .click('@searchButton'); + + credentials.waitForElementNotPresent(`${row}:nth-of-type(2)`); + credentials.expect.element(row).text.contain(store.credential.name); + + client.end(); + } +}; diff --git a/awx/ui/client/test/e2e/tests/test-credentials-add-edit-insights.js b/awx/ui/client/test/e2e/tests/test-credentials-add-edit-insights.js new file mode 100644 index 0000000000..97a3eaed10 --- /dev/null +++ b/awx/ui/client/test/e2e/tests/test-credentials-add-edit-insights.js @@ -0,0 +1,134 @@ +import uuid from 'uuid'; + + +let testID = uuid().substr(0,8); + +let store = { + organization: { + name: `org-${testID}` + }, + credential: { + name: `cred-${testID}` + }, +}; + +module.exports = { + before: function(client, done) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + + client.login(); + client.waitForAngular(); + + client.inject([store, 'OrganizationModel'], (store, model) => { + return new model().http.post(store.organization); + }, + ({ data }) => { + store.organization = data; + }); + + credentials.section.navigation + .waitForElementVisible('@credentials') + .click('@credentials'); + + credentials + .waitForElementVisible('div.spinny') + .waitForElementNotVisible('div.spinny'); + + credentials.section.list + .waitForElementVisible('@add') + .click('@add'); + + details + .waitForElementVisible('@save') + .setValue('@name', store.credential.name) + .setValue('@organization', store.organization.name) + .setValue('@type', 'Insights', done); + }, + 'expected fields are visible and enabled': function(client) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + + details.expect.element('@name').visible; + details.expect.element('@description').visible; + details.expect.element('@organization').visible; + details.expect.element('@type').visible; + details.section.insights.expect.element('@username').visible; + details.section.insights.expect.element('@password').visible; + + details.expect.element('@name').enabled; + details.expect.element('@description').enabled; + details.expect.element('@organization').enabled; + details.expect.element('@type').enabled; + details.section.insights.expect.element('@username').enabled; + details.section.insights.expect.element('@password').enabled; + }, + 'required fields display \'*\'': function(client) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + const required = [ + details.section.name, + details.section.type, + details.section.insights.section.username, + details.section.insights.section.password + ] + required.map(s => s.expect.element('@label').text.to.contain('*')); + }, + 'save button becomes enabled after providing required fields': function(client) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + + details + .clearAndSelectType('Insights') + .setValue('@name', store.credential.name); + + details.expect.element('@save').not.enabled; + + details.section.insights + .setValue('@username', 'wrosellini') + .setValue('@password', 'quintus'); + + details.expect.element('@save').enabled; + }, + 'create insights credential': function(client) { + const credentials = client.page.credentials(); + const add = credentials.section.add; + const edit = credentials.section.edit; + + add.section.details + .clearAndSelectType('Insights') + .setValue('@name', store.credential.name) + .setValue('@organization', store.organization.name); + + add.section.details.section.insights + .setValue('@username', 'wrosellini') + .setValue('@password', 'quintus'); + + add.section.details.click('@save'); + + credentials + .waitForElementVisible('div.spinny') + .waitForElementNotVisible('div.spinny'); + + edit.expect.element('@title').text.equal(store.credential.name); + }, + 'edit details panel remains open after saving': function(client) { + const credentials = client.page.credentials(); + + credentials.section.edit.expect.section('@details').visible; + }, + 'credential is searchable after saving': function(client) { + const credentials = client.page.credentials(); + const row = '#credentials_table tbody tr'; + + credentials.section.list.section.search + .waitForElementVisible('@input', client.globals.longWait) + .setValue('@input', `name:${store.credential.name}`) + .click('@searchButton'); + + credentials.waitForElementNotPresent(`${row}:nth-of-type(2)`); + credentials.expect.element(row).text.contain(store.credential.name); + + client.end(); + } +}; diff --git a/awx/ui/client/test/e2e/tests/test-credentials-add-edit-machine.js b/awx/ui/client/test/e2e/tests/test-credentials-add-edit-machine.js new file mode 100644 index 0000000000..22afd7e4cf --- /dev/null +++ b/awx/ui/client/test/e2e/tests/test-credentials-add-edit-machine.js @@ -0,0 +1,183 @@ +import uuid from 'uuid'; + + +let testID = uuid().substr(0,8); + +let store = { + organization: { + name: `org-${testID}` + }, + credential: { + name: `cred-${testID}` + }, +}; + +module.exports = { + before: function(client, done) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + + client.login(); + client.waitForAngular(); + + client.inject([store, 'OrganizationModel'], (store, model) => { + return new model().http.post(store.organization); + }, + ({ data }) => { + store.organization = data; + }); + + credentials.section.navigation + .waitForElementVisible('@credentials') + .click('@credentials'); + + credentials + .waitForElementVisible('div.spinny') + .waitForElementNotVisible('div.spinny'); + + credentials.section.list + .waitForElementVisible('@add') + .click('@add'); + + details.waitForElementVisible('@save', done); + }, + 'common fields are visible and enabled': function(client) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + + details.expect.element('@name').visible; + details.expect.element('@description').visible; + details.expect.element('@organization').visible; + details.expect.element('@type').visible; + + details.expect.element('@name').enabled; + details.expect.element('@description').enabled; + details.expect.element('@organization').enabled; + details.expect.element('@type').enabled; + }, + 'required common fields display \'*\'': function(client) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + + details.section.name.expect.element('@label').text.to.contain('*'); + details.section.type.expect.element('@label').text.to.contain('*'); + }, + 'save button becomes enabled after providing required fields': function(client) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + + details.expect.element('@save').not.enabled; + + details + .setValue('@name', store.credential.name) + .setValue('@organization', store.organization.name) + .setValue('@type', 'Machine'); + + details.expect.element('@save').enabled; + }, + 'machine credential fields are visible after choosing type': function(client) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + const machine = details.section.machine; + + machine.expect.element('@username').visible; + machine.expect.element('@password').visible; + machine.expect.element('@becomeUsername').visible; + machine.expect.element('@becomePassword').visible; + machine.expect.element('@sshKeyData').visible; + machine.expect.element('@sshKeyUnlock').visible; + }, + 'error displayed for invalid ssh key data': function(client) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + const sshKeyData = details.section.machine.section.sshKeyData; + + details + .clearAndSelectType('Machine') + .setValue('@name', store.credential.name); + + details.section.machine.setValue('@sshKeyData', 'invalid'); + + details.click('@save'); + + sshKeyData.expect.element('@error').visible; + sshKeyData.expect.element('@error').text.to.contain('Invalid certificate or key'); + + details.section.machine.clearValue('@sshKeyData'); + sshKeyData.expect.element('@error').not.present; + }, + 'error displayed for unencrypted ssh key with passphrase': function(client) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + const sshKeyUnlock = details.section.machine.section.sshKeyUnlock; + + details + .clearAndSelectType('Machine') + .setValue('@name', store.credential.name); + + details.section.machine + .setValue('@sshKeyUnlock', 'password') + .sendKeys('@sshKeyData', '-----BEGIN RSA PRIVATE KEY-----') + .sendKeys('@sshKeyData', client.Keys.ENTER) + .sendKeys('@sshKeyData', 'AAAA') + .sendKeys('@sshKeyData', client.Keys.ENTER) + .sendKeys('@sshKeyData', '-----END RSA PRIVATE KEY-----'); + + details.click('@save'); + + sshKeyUnlock.expect.element('@error').visible; + sshKeyUnlock.expect.element('@error').text.to.contain('not encrypted'); + + details.section.machine.clearValue('@sshKeyUnlock'); + sshKeyUnlock.expect.element('@error').not.present; + }, + 'create machine credential': function(client) { + const credentials = client.page.credentials(); + const add = credentials.section.add; + const edit = credentials.section.edit; + + add.section.details + .clearAndSelectType('Machine') + .setValue('@name', store.credential.name) + .setValue('@organization', store.organization.name); + + add.section.details.section.machine + .setValue('@username', 'dsarif') + .setValue('@password', 'freneticpny') + .setValue('@becomeMethod', 'sudo') + .setValue('@becomeUsername', 'dsarif') + .setValue('@becomePassword', 'freneticpny') + .sendKeys('@sshKeyData', '-----BEGIN RSA PRIVATE KEY-----') + .sendKeys('@sshKeyData', client.Keys.ENTER) + .sendKeys('@sshKeyData', 'AAAA') + .sendKeys('@sshKeyData', client.Keys.ENTER) + .sendKeys('@sshKeyData', '-----END RSA PRIVATE KEY-----'); + + add.section.details.click('@save'); + + credentials + .waitForElementVisible('div.spinny') + .waitForElementNotVisible('div.spinny'); + + edit.expect.element('@title').text.equal(store.credential.name); + }, + 'edit details panel remains open after saving': function(client) { + const credentials = client.page.credentials(); + + credentials.section.edit.expect.section('@details').visible; + }, + 'credential is searchable after saving': function(client) { + const credentials = client.page.credentials(); + const row = '#credentials_table tbody tr'; + + credentials.section.list.section.search + .waitForElementVisible('@input', client.globals.longWait) + .setValue('@input', `name:${store.credential.name}`) + .click('@searchButton'); + + credentials.waitForElementNotPresent(`${row}:nth-of-type(2)`); + credentials.expect.element(row).text.contain(store.credential.name); + + client.end(); + } +}; diff --git a/awx/ui/client/test/e2e/tests/test-credentials-add-edit-network.js b/awx/ui/client/test/e2e/tests/test-credentials-add-edit-network.js new file mode 100644 index 0000000000..7f71ff8dec --- /dev/null +++ b/awx/ui/client/test/e2e/tests/test-credentials-add-edit-network.js @@ -0,0 +1,208 @@ +import uuid from 'uuid'; + + +let testID = uuid().substr(0,8); + +let store = { + organization: { + name: `org-${testID}` + }, + credential: { + name: `cred-${testID}` + }, +}; + +module.exports = { + before: function(client, done) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + + client.login(); + client.waitForAngular(); + + client.inject([store, 'OrganizationModel'], (store, model) => { + return new model().http.post(store.organization); + }, + ({ data }) => { + store.organization = data; + }); + + credentials.section.navigation + .waitForElementVisible('@credentials') + .click('@credentials'); + + credentials + .waitForElementVisible('div.spinny') + .waitForElementNotVisible('div.spinny'); + + credentials.section.list + .waitForElementVisible('@add') + .click('@add'); + + details.waitForElementVisible('@save', done); + }, + 'common fields are visible and enabled': function(client) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + + details.expect.element('@name').visible; + details.expect.element('@description').visible; + details.expect.element('@organization').visible; + details.expect.element('@type').visible; + + details.expect.element('@name').enabled; + details.expect.element('@description').enabled; + details.expect.element('@organization').enabled; + details.expect.element('@type').enabled; + }, + 'required common fields display \'*\'': function(client) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + + details.section.name.expect.element('@label').text.to.contain('*'); + details.section.type.expect.element('@label').text.to.contain('*'); + }, + 'save button becomes enabled after providing required fields': function(client) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + + details.expect.element('@save').not.enabled; + + details + .setValue('@name', store.credential.name) + .setValue('@organization', store.organization.name) + .setValue('@type', 'Network'); + + details.section.network + .waitForElementVisible('@username') + .setValue('@username', 'sgrimes'); + + details.expect.element('@save').enabled; + }, + 'network credential fields are visible after choosing type': function(client) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + const network = details.section.network; + + network.expect.element('@username').visible; + network.expect.element('@password').visible; + network.expect.element('@authorizePassword').visible; + network.expect.element('@sshKeyData').visible; + network.expect.element('@sshKeyUnlock').visible; + }, + 'error displayed for invalid ssh key data': function(client) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + const sshKeyData = details.section.network.section.sshKeyData; + + details + .clearAndSelectType('Network') + .setValue('@name', store.credential.name); + + details.section.network + .setValue('@username', 'sgrimes') + .setValue('@sshKeyData', 'invalid'); + + details.click('@save'); + + sshKeyData.expect.element('@error').visible; + sshKeyData.expect.element('@error').text.to.contain('Invalid certificate or key'); + + details.section.network.clearValue('@sshKeyData'); + sshKeyData.expect.element('@error').not.present; + }, + 'error displayed for unencrypted ssh key with passphrase': function(client) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + const sshKeyUnlock = details.section.network.section.sshKeyUnlock; + + details + .clearAndSelectType('Network') + .setValue('@name', store.credential.name); + + details.section.network + .setValue('@username', 'sgrimes') + .setValue('@sshKeyUnlock', 'password') + .sendKeys('@sshKeyData', '-----BEGIN RSA PRIVATE KEY-----') + .sendKeys('@sshKeyData', client.Keys.ENTER) + .sendKeys('@sshKeyData', 'AAAA') + .sendKeys('@sshKeyData', client.Keys.ENTER) + .sendKeys('@sshKeyData', '-----END RSA PRIVATE KEY-----'); + + details.click('@save'); + + sshKeyUnlock.expect.element('@error').visible; + sshKeyUnlock.expect.element('@error').text.to.contain('not encrypted'); + + details.section.network.clearValue('@sshKeyUnlock'); + sshKeyUnlock.expect.element('@error').not.present; + }, + 'error displayed for authorize password without authorize enabled': function(client) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + const authorizePassword = details.section.network.section.authorizePassword; + + details + .clearAndSelectType('Network') + .setValue('@name', store.credential.name); + + details.section.network + .setValue('@username', 'sgrimes') + .setValue('@authorizePassword', 'ovid'); + + details.click('@save'); + + let expected = 'cannot be set unless "Authorize" is set'; + authorizePassword.expect.element('@error').visible; + authorizePassword.expect.element('@error').text.to.equal(expected); + + details.section.network.clearValue('@authorizePassword'); + authorizePassword.expect.element('@error').not.present; + }, + 'create network credential': function(client) { + const credentials = client.page.credentials(); + const add = credentials.section.add; + const edit = credentials.section.edit; + + add.section.details + .clearAndSelectType('Network') + .setValue('@name', store.credential.name) + .setValue('@organization', store.organization.name); + + add.section.details.section.network + .setValue('@username', 'sgrimes') + .setValue('@password', 'ovid') + .sendKeys('@sshKeyData', '-----BEGIN RSA PRIVATE KEY-----') + .sendKeys('@sshKeyData', client.Keys.ENTER) + .sendKeys('@sshKeyData', 'AAAA') + .sendKeys('@sshKeyData', client.Keys.ENTER) + .sendKeys('@sshKeyData', '-----END RSA PRIVATE KEY-----'); + + add.section.details.click('@save'); + + credentials + .waitForElementVisible('div.spinny') + .waitForElementNotVisible('div.spinny'); + + edit.expect.element('@title').text.equal(store.credential.name); + }, + 'edit details panel remains open after saving': function(client) { + const credentials = client.page.credentials(); + + credentials.section.edit.expect.section('@details').visible; + }, + 'credential is searchable after saving': function(client) { + const credentials = client.page.credentials(); + const row = '#credentials_table tbody tr'; + + credentials.section.list.section.search + .waitForElementVisible('@input', client.globals.longWait) + .setValue('@input', `name:${store.credential.name}`) + .click('@searchButton'); + + credentials.waitForElementNotPresent(`${row}:nth-of-type(2)`); + credentials.expect.element(row).text.contain(store.credential.name); + + client.end(); + } +}; diff --git a/awx/ui/client/test/e2e/tests/test-credentials-add-edit-scm.js b/awx/ui/client/test/e2e/tests/test-credentials-add-edit-scm.js new file mode 100644 index 0000000000..cdcc438f46 --- /dev/null +++ b/awx/ui/client/test/e2e/tests/test-credentials-add-edit-scm.js @@ -0,0 +1,177 @@ +import uuid from 'uuid'; + + +let testID = uuid().substr(0,8); + +let store = { + organization: { + name: `org-${testID}` + }, + credential: { + name: `cred-${testID}` + }, +}; + +module.exports = { + before: function(client, done) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + + client.login(); + client.waitForAngular(); + + client.inject([store, 'OrganizationModel'], (store, model) => { + return new model().http.post(store.organization); + }, + ({ data }) => { + store.organization = data; + }); + + credentials.section.navigation + .waitForElementVisible('@credentials') + .click('@credentials'); + + credentials + .waitForElementVisible('div.spinny') + .waitForElementNotVisible('div.spinny'); + + credentials.section.list + .waitForElementVisible('@add') + .click('@add'); + + details.waitForElementVisible('@save', done); + }, + 'common fields are visible and enabled': function(client) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + + details.expect.element('@name').visible; + details.expect.element('@description').visible; + details.expect.element('@organization').visible; + details.expect.element('@type').visible; + + details.expect.element('@name').enabled; + details.expect.element('@description').enabled; + details.expect.element('@organization').enabled; + details.expect.element('@type').enabled; + }, + 'required common fields display \'*\'': function(client) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + + details.section.name.expect.element('@label').text.to.contain('*'); + details.section.type.expect.element('@label').text.to.contain('*'); + }, + 'save button becomes enabled after providing required fields': function(client) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + + details.expect.element('@save').not.enabled; + + details + .setValue('@name', store.credential.name) + .setValue('@organization', store.organization.name) + .setValue('@type', 'Source Control'); + + details.expect.element('@save').enabled; + }, + 'scm credential fields are visible after choosing type': function(client) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + + details.section.scm.expect.element('@username').visible; + details.section.scm.expect.element('@password').visible; + details.section.scm.expect.element('@sshKeyData').visible; + details.section.scm.expect.element('@sshKeyUnlock').visible; + }, + 'error displayed for invalid ssh key data': function(client) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + const sshKeyData = details.section.scm.section.sshKeyData; + + details + .clearAndSelectType('Source Control') + .setValue('@name', store.credential.name); + + details.section.scm.setValue('@sshKeyData', 'invalid'); + + details.click('@save'); + + sshKeyData.expect.element('@error').visible; + sshKeyData.expect.element('@error').text.to.contain('Invalid certificate or key'); + + details.section.scm.clearValue('@sshKeyData'); + sshKeyData.expect.element('@error').not.present; + }, + 'error displayed for unencrypted ssh key with passphrase': function(client) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + const sshKeyUnlock = details.section.scm.section.sshKeyUnlock; + + details + .clearAndSelectType('Source Control') + .setValue('@name', store.credential.name); + + details.section.scm + .setValue('@sshKeyUnlock', 'password') + .sendKeys('@sshKeyData', '-----BEGIN RSA PRIVATE KEY-----') + .sendKeys('@sshKeyData', client.Keys.ENTER) + .sendKeys('@sshKeyData', 'AAAA') + .sendKeys('@sshKeyData', client.Keys.ENTER) + .sendKeys('@sshKeyData', '-----END RSA PRIVATE KEY-----'); + + details.click('@save'); + + sshKeyUnlock.expect.element('@error').visible; + sshKeyUnlock.expect.element('@error').text.to.contain('not encrypted'); + + details.section.scm.clearValue('@sshKeyUnlock'); + sshKeyUnlock.expect.element('@error').not.present; + }, + 'create SCM credential': function(client) { + const credentials = client.page.credentials(); + const add = credentials.section.add; + const edit = credentials.section.edit; + + add.section.details + .clearAndSelectType('Source Control') + .setValue('@name', store.credential.name) + .setValue('@organization', store.organization.name); + + add.section.details.section.scm + .setValue('@username', 'gthorpe') + .setValue('@password', 'hydro') + .sendKeys('@sshKeyData', '-----BEGIN RSA PRIVATE KEY-----') + .sendKeys('@sshKeyData', client.Keys.ENTER) + .sendKeys('@sshKeyData', 'AAAA') + .sendKeys('@sshKeyData', client.Keys.ENTER) + .sendKeys('@sshKeyData', '-----END RSA PRIVATE KEY-----'); + + add.section.details.click('@save'); + + credentials + .waitForElementVisible('div.spinny') + .waitForElementNotVisible('div.spinny'); + + edit.expect.element('@title').text.equal(store.credential.name); + }, + 'edit details panel remains open after saving': function(client) { + const credentials = client.page.credentials(); + + credentials.section.edit.expect.section('@details').visible; + }, + 'credential is searchable after saving': function(client) { + const credentials = client.page.credentials(); + const row = '#credentials_table tbody tr'; + + credentials.section.list.section.search + .waitForElementVisible('@input', client.globals.longWait) + .setValue('@input', `name:${store.credential.name}`) + .click('@searchButton'); + + credentials.waitForElementNotPresent(`${row}:nth-of-type(2)`); + credentials.expect.element(row).text.to.contain(store.credential.name); + + client.end(); + } +}; diff --git a/awx/ui/client/test/e2e/tests/test-credentials-add-edit-vault.js b/awx/ui/client/test/e2e/tests/test-credentials-add-edit-vault.js new file mode 100644 index 0000000000..153f4984ce --- /dev/null +++ b/awx/ui/client/test/e2e/tests/test-credentials-add-edit-vault.js @@ -0,0 +1,137 @@ +import uuid from 'uuid'; + + +let testID = uuid().substr(0,8); + +let store = { + organization: { + name: `org-${testID}` + }, + credential: { + name: `cred-${testID}` + }, +}; + +module.exports = { + before: function(client, done) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + + client.login(); + client.waitForAngular(); + + client.inject([store, 'OrganizationModel'], (store, model) => { + return new model().http.post(store.organization); + }, + ({ data }) => { + store.organization = data; + }); + + credentials.section.navigation + .waitForElementVisible('@credentials') + .click('@credentials'); + + credentials + .waitForElementVisible('div.spinny') + .waitForElementNotVisible('div.spinny'); + + credentials.section.list + .waitForElementVisible('@add') + .click('@add'); + + details + .waitForElementVisible('@save') + .setValue('@name', store.credential.name) + .setValue('@organization', store.organization.name) + .setValue('@type', 'Vault', done); + }, + 'expected fields are visible and enabled': function(client) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + + details.expect.element('@name').visible; + details.expect.element('@description').visible; + details.expect.element('@organization').visible; + details.expect.element('@type').visible; + details.section.vault.expect.element('@vaultPassword').visible; + + details.expect.element('@name').enabled; + details.expect.element('@description').enabled; + details.expect.element('@organization').enabled; + details.expect.element('@type').enabled; + details.section.vault.expect.element('@vaultPassword').enabled; + }, + 'required fields display \'*\'': function(client) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + const required = [ + details.section.name, + details.section.type, + details.section.vault.section.vaultPassword, + ] + required.map(s => s.expect.element('@label').text.to.contain('*')); + }, + 'save button becomes enabled after providing required fields': function(client) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + + details + .clearAndSelectType('Vault') + .setValue('@name', store.credential.name); + + details.expect.element('@save').not.enabled; + details.section.vault.setValue('@vaultPassword', 'ch@ng3m3'); + details.expect.element('@save').enabled; + }, + 'vault password field is disabled when prompt on launch is selected': function(client) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + + details + .clearAndSelectType('Vault') + .setValue('@name', store.credential.name); + + details.section.vault.expect.element('@vaultPassword').enabled; + details.section.vault.section.vaultPassword.click('@prompt'); + details.section.vault.expect.element('@vaultPassword').not.enabled; + }, + 'create vault credential': function(client) { + const credentials = client.page.credentials(); + const add = credentials.section.add; + const edit = credentials.section.edit; + + add.section.details + .clearAndSelectType('Vault') + .setValue('@name', store.credential.name) + .setValue('@organization', store.organization.name); + + add.section.details.section.vault.setValue('@vaultPassword', 'ch@ng3m3'); + + add.section.details.click('@save'); + + credentials + .waitForElementVisible('div.spinny') + .waitForElementNotVisible('div.spinny'); + + edit.expect.element('@title').text.equal(store.credential.name); + }, + 'edit details panel remains open after saving': function(client) { + const credentials = client.page.credentials(); + + credentials.section.edit.expect.section('@details').visible; + }, + 'credential is searchable after saving': function(client) { + const credentials = client.page.credentials(); + const row = '#credentials_table tbody tr'; + + credentials.section.list.section.search + .waitForElementVisible('@input', client.globals.longWait) + .setValue('@input', `name:${store.credential.name}`) + .click('@searchButton'); + + credentials.waitForElementNotPresent(`${row}:nth-of-type(2)`); + credentials.expect.element(row).text.contain(store.credential.name); + + client.end(); + } +}; diff --git a/awx/ui/client/test/e2e/tests/test-credentials-lookup-credential-type.js b/awx/ui/client/test/e2e/tests/test-credentials-lookup-credential-type.js new file mode 100644 index 0000000000..7bed8eed7b --- /dev/null +++ b/awx/ui/client/test/e2e/tests/test-credentials-lookup-credential-type.js @@ -0,0 +1,47 @@ +module.exports = { + before: function(client, done) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + + client.login(); + client.waitForAngular(); + + credentials.section.navigation + .waitForElementVisible('@credentials') + .click('@credentials'); + + credentials + .waitForElementVisible('div.spinny') + .waitForElementNotVisible('div.spinny'); + + credentials.section.list + .waitForElementVisible('@add') + .click('@add'); + + details + .waitForElementVisible('@save', done) + }, + 'open the lookup modal': function(client) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + + const modal = 'div[class="modal-body"]'; + const title = 'div[class^="Form-title"]'; + + details.expect.element('@type').visible; + details.expect.element('@type').enabled; + + details.section.type.expect.element('@lookup').visible; + details.section.type.expect.element('@lookup').enabled; + + details.section.type.click('@lookup'); + + client.expect.element(modal).present; + + let expected = 'SELECT CREDENTIAL TYPE'; + client.expect.element(title).visible; + client.expect.element(title).text.equal(expected); + + client.end(); + } +}; diff --git a/awx/ui/client/test/e2e/tests/test-credentials-lookup-organization.js b/awx/ui/client/test/e2e/tests/test-credentials-lookup-organization.js new file mode 100644 index 0000000000..9b8f2ecc6e --- /dev/null +++ b/awx/ui/client/test/e2e/tests/test-credentials-lookup-organization.js @@ -0,0 +1,155 @@ +module.exports = { + before: function(client, done) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + + client.login(); + client.waitForAngular(); + + credentials.section.navigation + .waitForElementVisible('@credentials') + .click('@credentials'); + + credentials + .waitForElementVisible('div.spinny') + .waitForElementNotVisible('div.spinny'); + + credentials.section.list + .waitForElementVisible('@add') + .click('@add'); + + details + .waitForElementVisible('@save', done); + + }, + 'open the lookup modal': function(client) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + const lookupModal = credentials.section.lookupModal; + + details.expect.element('@organization').visible; + details.expect.element('@organization').enabled; + + details.section.organization.expect.element('@lookup').visible; + details.section.organization.expect.element('@lookup').enabled; + + details.section.organization.click('@lookup'); + + credentials.expect.section('@lookupModal').present; + + let expected = 'SELECT ORGANIZATION'; + lookupModal.expect.element('@title').visible; + lookupModal.expect.element('@title').text.equal(expected); + }, + 'select button is disabled until item is selected': function(client) { + const credentials = client.page.credentials(); + const details = credentials.section.add.section.details; + const lookupModal = credentials.section.lookupModal; + const table = lookupModal.section.table; + + details.section.organization.expect.element('@lookup').visible; + details.section.organization.expect.element('@lookup').enabled; + + details.section.organization.click('@lookup'); + + credentials.expect.section('@lookupModal').present; + + table.expect.element('tbody tr:nth-child(1) input[type="radio"]').not.selected; + table.expect.element('tbody tr:nth-child(2) input[type="radio"]').not.selected; + table.expect.element('tbody tr:nth-child(3) input[type="radio"]').not.selected; + table.expect.element('tbody tr:nth-child(4) input[type="radio"]').not.selected; + table.expect.element('tbody tr:nth-child(5) input[type="radio"]').not.selected; + table.expect.element('tbody tr:nth-child(6) input[type="radio"]').not.present; + + lookupModal.expect.element('@select').visible; + lookupModal.expect.element('@select').not.enabled; + + table.click('tbody tr:nth-child(2)'); + table.expect.element('tbody tr:nth-child(2) input[type="radio"]').selected; + + lookupModal.expect.element('@select').visible; + lookupModal.expect.element('@select').enabled; + }, + 'sort and unsort the table by name with an item selected': function(client) { + const credentials = client.page.credentials(); + const lookupModal = credentials.section.lookupModal; + const table = lookupModal.section.table; + + let column = table.section.header.findColumnByText('Name'); + + column.expect.element('@self').visible; + column.expect.element('@sortable').visible; + + column.click('@self'); + credentials.waitForElementVisible('div.spinny'); + credentials.waitForElementNotVisible('div.spinny'); + + table.expect.element('tbody tr:nth-child(1) input[type="radio"]').not.selected; + table.expect.element('tbody tr:nth-child(2) input[type="radio"]').not.selected; + table.expect.element('tbody tr:nth-child(3) input[type="radio"]').not.selected; + table.expect.element('tbody tr:nth-child(4) input[type="radio"]').not.selected; + table.expect.element('tbody tr:nth-child(5) input[type="radio"]').not.selected; + + column.click('@self'); + credentials.waitForElementVisible('div.spinny'); + credentials.waitForElementNotVisible('div.spinny'); + + table.expect.element('tbody tr:nth-child(1) input[type="radio"]').not.selected; + table.expect.element('tbody tr:nth-child(2) input[type="radio"]').selected; + table.expect.element('tbody tr:nth-child(3) input[type="radio"]').not.selected; + table.expect.element('tbody tr:nth-child(4) input[type="radio"]').not.selected; + table.expect.element('tbody tr:nth-child(5) input[type="radio"]').not.selected; + }, + 'use the pagination controls with an item selected': function(client) { + const credentials = client.page.credentials(); + const lookupModal = credentials.section.lookupModal; + const table = lookupModal.section.table; + const pagination = lookupModal.section.pagination; + + pagination.click('@next'); + credentials.waitForElementVisible('div.spinny'); + credentials.waitForElementNotVisible('div.spinny'); + + table.expect.element('tbody tr:nth-child(1) input[type="radio"]').not.selected; + table.expect.element('tbody tr:nth-child(2) input[type="radio"]').not.selected; + table.expect.element('tbody tr:nth-child(3) input[type="radio"]').not.selected; + table.expect.element('tbody tr:nth-child(4) input[type="radio"]').not.selected; + table.expect.element('tbody tr:nth-child(5) input[type="radio"]').not.selected; + + pagination.click('@previous'); + credentials.waitForElementVisible('div.spinny'); + credentials.waitForElementNotVisible('div.spinny'); + + table.expect.element('tbody tr:nth-child(1) input[type="radio"]').not.selected; + table.expect.element('tbody tr:nth-child(2) input[type="radio"]').selected; + table.expect.element('tbody tr:nth-child(3) input[type="radio"]').not.selected; + table.expect.element('tbody tr:nth-child(4) input[type="radio"]').not.selected; + table.expect.element('tbody tr:nth-child(5) input[type="radio"]').not.selected; + + pagination.click('@last'); + credentials.waitForElementVisible('div.spinny'); + credentials.waitForElementNotVisible('div.spinny'); + + pagination.click('@previous'); + credentials.waitForElementVisible('div.spinny'); + credentials.waitForElementNotVisible('div.spinny'); + + table.expect.element('tbody tr:nth-child(1) input[type="radio"]').not.selected; + table.expect.element('tbody tr:nth-child(2) input[type="radio"]').not.selected; + table.expect.element('tbody tr:nth-child(3) input[type="radio"]').not.selected; + table.expect.element('tbody tr:nth-child(4) input[type="radio"]').not.selected; + table.expect.element('tbody tr:nth-child(5) input[type="radio"]').not.selected; + + pagination.click('@first'); + credentials.waitForElementVisible('div.spinny'); + credentials.waitForElementNotVisible('div.spinny'); + + table.expect.element('tbody tr:nth-child(1) input[type="radio"]').not.selected; + table.expect.element('tbody tr:nth-child(2) input[type="radio"]').selected; + table.expect.element('tbody tr:nth-child(3) input[type="radio"]').not.selected; + table.expect.element('tbody tr:nth-child(4) input[type="radio"]').not.selected; + table.expect.element('tbody tr:nth-child(5) input[type="radio"]').not.selected; + + client.end(); + } +}; diff --git a/awx/ui/client/test/e2e/tests/test-credentials-navigation-click-through.js b/awx/ui/client/test/e2e/tests/test-credentials-navigation-click-through.js new file mode 100644 index 0000000000..f9f618756f --- /dev/null +++ b/awx/ui/client/test/e2e/tests/test-credentials-navigation-click-through.js @@ -0,0 +1,32 @@ +module.exports = { + beforeEach: function(client, done) { + const credentials = client.useCss().page.credentials(); + + client.login(); + client.waitForAngular(); + + credentials + .navigate() + .waitForElementVisible('div.spinny') + .waitForElementNotVisible('div.spinny', done); + }, + 'activity link is visible and takes user to activity stream': function(client) { + const credentials = client.page.credentials(); + const activityStream = client.page.activityStream(); + + credentials.expect.section('@breadcrumb').visible; + credentials.section.breadcrumb.expect.element('@activity').visible; + credentials.section.breadcrumb.click('@activity'); + + activityStream + .waitForElementVisible('div.spinny') + .waitForElementNotVisible('div.spinny') + .waitForElementVisible('@title') + .waitForElementVisible('@category'); + + activityStream.expect.element('@title').text.contain('CREDENTIALS'); + activityStream.expect.element('@category').value.contain('credential'); + + client.end(); + } +}; diff --git a/awx/ui/client/test/e2e/tests/test-credentials-read-only.js b/awx/ui/client/test/e2e/tests/test-credentials-read-only.js new file mode 100644 index 0000000000..93d07714a0 --- /dev/null +++ b/awx/ui/client/test/e2e/tests/test-credentials-read-only.js @@ -0,0 +1,97 @@ +import uuid from 'uuid'; + + +let testID = uuid().substr(0,8); + + +let store = { + auditor: { + username: `auditor-${testID}`, + first_name: 'auditor', + last_name: 'last', + email: 'null@ansible.com', + is_superuser: false, + is_system_auditor: true, + password: 'password' + }, + adminCredential: { + name: `adminCredential-${testID}`, + description: `adminCredential-description-${testID}`, + inputs: { + username: 'username', + password: 'password', + security_token: 'AAAAAAAAAAAAAAAAAAAAAAAAAA' + } + }, + created: {} +}; + + +module.exports = { + before: function (client, done) { + const credentials = client.page.credentials(); + + client.login(); + client.waitForAngular(); + + client.inject([store, '$http'], (store, $http) => { + + let { adminCredential, auditor } = store; + + return $http.get('/api/v2/me') + .then(({ data }) => { + let resource = 'Amazon%20Web%20Services+cloud'; + adminCredential.user = data.results[0].id; + + return $http.get(`/api/v2/credential_types/${resource}`); + }) + .then(({ data }) => { + adminCredential.credential_type = data.id; + + return $http.post('/api/v2/credentials/', adminCredential); + }) + .then(({ data }) => { + adminCredential = data; + + return $http.post('/api/v2/users/', auditor); + }) + .then(({ data }) => { + auditor = data; + + return { adminCredential, auditor }; + }); + }, + ({ adminCredential, auditor }) => { + store.created = { adminCredential, auditor }; + done(); + }) + }, + beforeEach: function (client) { + const credentials = client.useCss().page.credentials(); + + credentials + .login(store.auditor.username, store.auditor.password) + .navigate(`${credentials.url()}/${store.created.adminCredential.id}/`) + .waitForElementVisible('div.spinny') + .waitForElementNotVisible('div.spinny'); + }, + 'verify an auditor\'s inputs are read-only': function (client) { + const credentials = client.useCss().page.credentials() + const details = credentials.section.edit.section.details; + + let expected = store.created.adminCredential.name; + + credentials.section.edit + .expect.element('@title').text.contain(expected); + + client.elements('css selector', '.at-Input', inputs => { + inputs.value.map(o => o.ELEMENT).forEach(id => { + client.elementIdAttribute(id, 'disabled', ({ value }) => { + client.assert.equal(value, 'true'); + }); + }); + }); + + client.end(); + } +}; diff --git a/awx/ui/client/test/e2e/tests/test-credentials-search-sort.js b/awx/ui/client/test/e2e/tests/test-credentials-search-sort.js new file mode 100644 index 0000000000..12a8d79ef4 --- /dev/null +++ b/awx/ui/client/test/e2e/tests/test-credentials-search-sort.js @@ -0,0 +1,57 @@ +const columns = ['Name', 'Kind', 'Owners', 'Actions']; +const sortable = ['Name']; +const defaultSorted = ['Name']; + + +module.exports = { + before: function(client, done) { + const credentials = client.page.credentials(); + + client.login(); + client.resizeWindow(1200, 800); + client.waitForAngular(); + + credentials + .navigate() + .waitForElementVisible('div.spinny') + .waitForElementNotVisible('div.spinny'); + + credentials.waitForElementVisible('#credentials_table', done); + }, + 'expected table columns are visible': function(client) { + const credentials = client.page.credentials(); + const table = credentials.section.list.section.table; + + columns.map(label => { + table.section.header.findColumnByText(label) + .expect.element('@self').visible; + }); + }, + 'only fields expected to be sortable show sort icon': function(client) { + const credentials = client.page.credentials(); + const table = credentials.section.list.section.table; + + sortable.map(label => { + table.section.header.findColumnByText(label) + .expect.element('@sortable').visible; + }); + }, + 'sort all columns expected to be sortable': function(client) { + const credentials = client.page.credentials(); + const table = credentials.section.list.section.table; + + sortable.map(label => { + let column = table.section.header.findColumnByText(label); + + column.click('@self'); + + credentials + .waitForElementVisible('div.spinny') + .waitForElementNotVisible('div.spinny'); + + column.expect.element('@sorted').visible; + }); + + client.end(); + } +}; diff --git a/awx/ui/package.json b/awx/ui/package.json index 75cdb334fb..cf61646922 100644 --- a/awx/ui/package.json +++ b/awx/ui/package.json @@ -29,7 +29,8 @@ "lint-dev": "./node_modules/.bin/nodemon --exec \"./node_modules/.bin/eslint -c .eslintrc.js .\" --watch \"client/components/**/*.js\"", "dev": "./node_modules/.bin/webpack --config build/webpack.development.js --progress", "watch": "./node_modules/.bin/webpack-dev-server --config build/webpack.watch.js --progress", - "production": "./node_modules/.bin/webpack --config build/webpack.production.js" + "production": "./node_modules/.bin/webpack --config build/webpack.production.js", + "e2e": "./client/test/e2e/runner.js --config ./client/test/e2e/nightwatch.conf.js" }, "devDependencies": { "angular-mocks": "~1.4.14", @@ -38,6 +39,7 @@ "babel-loader": "^7.1.2", "babel-plugin-istanbul": "^2.0.0", "babel-preset-es2015": "^6.24.1", + "chromedriver": "^2.31.0", "clean-webpack-plugin": "^0.1.16", "copy-webpack-plugin": "^4.0.1", "css-loader": "^0.28.5", @@ -79,6 +81,7 @@ "load-grunt-configs": "^1.0.0", "load-grunt-tasks": "^3.5.0", "minimist": "^1.2.0", + "nightwatch": "^0.9.16", "ngtemplate-loader": "^2.0.1", "phantomjs-prebuilt": "^2.1.12", "script-loader": "^0.7.0", @@ -123,4 +126,4 @@ "select2": "^4.0.2", "sprintf-js": "^1.0.3" } -} \ No newline at end of file +}