diff --git a/awx/ui/client/test/e2e/README.md b/awx/ui/client/test/e2e/README.md index 8cd57f38cd..b4c1a0aab8 100644 --- a/awx/ui/client/test/e2e/README.md +++ b/awx/ui/client/test/e2e/README.md @@ -17,7 +17,7 @@ docker-compose \ 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 +AWX_E2E_LAUNCH_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/api.js b/awx/ui/client/test/e2e/api.js new file mode 100644 index 0000000000..464401cb24 --- /dev/null +++ b/awx/ui/client/test/e2e/api.js @@ -0,0 +1,104 @@ +import https from 'https'; + +import axios from 'axios'; + +import { + awxURL, + awxUsername, + awxPassword +} from './settings.js'; + + +let authenticated; + +const session = axios.create({ + baseURL: awxURL, + xsrfHeaderName: 'X-CSRFToken', + xsrfCookieName: 'csrftoken', + httpsAgent: new https.Agent({ + rejectUnauthorized: false + }) +}); + + +const endpoint = function(location) { + + if (location.indexOf('/api/v') === 0) { + return location; + } + + if (location.indexOf('://') > 0) { + return location; + } + + return `${awxURL}/api/v2${location}`; +}; + + +const authenticate = function() { + if (authenticated) { + return Promise.resolve(); + } + + let uri = endpoint('/authtoken/'); + + let credentials = { + username: awxUsername, + password: awxPassword + }; + + return session.post(uri, credentials).then(res => { + session.defaults.headers.Authorization = `Token ${res.data.token}`; + authenticated = true; + return res + }); +}; + + +const request = function(method, location, data) { + let uri = endpoint(location); + let action = session[method.toLowerCase()]; + + return authenticate().then(() => action(uri, data)).then(res => { + console.log([ + res.config.method.toUpperCase(), + uri, + res.status, + res.statusText + ].join(' ')); + + return res; + }); +}; + + +const get = function(endpoint, data) { + return request('GET', endpoint, data); +}; + +const options = function(endpoint) { + return request('OPTIONS', endpoint); +}; + +const post = function(endpoint, data) { + return request('POST', endpoint, data); +}; + +const patch = function(endpoint, data) { + return request('PATCH', endpoint, data) +}; + +const put = function(endpoint, data) { + return request('PUT', endpoint, data); +}; + + +module.exports = { + get, + options, + post, + patch, + put, + all: axios.all, + spread: axios.spread +}; diff --git a/awx/ui/client/test/e2e/commands/login.js b/awx/ui/client/test/e2e/commands/login.js index 8ae7a4d1ca..ddf753c496 100644 --- a/awx/ui/client/test/e2e/commands/login.js +++ b/awx/ui/client/test/e2e/commands/login.js @@ -26,7 +26,7 @@ Login.prototype.command = function(username, password) { .waitForElementVisible('div.spinny') .waitForElementNotVisible('div.spinny'); - // tempoary hack while login issue is resolved + // temporary 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 => { diff --git a/awx/ui/client/test/e2e/fixtures.js b/awx/ui/client/test/e2e/fixtures.js new file mode 100644 index 0000000000..0ef3a080f6 --- /dev/null +++ b/awx/ui/client/test/e2e/fixtures.js @@ -0,0 +1,263 @@ +import uuid from 'uuid'; + +import { + all, + get, + post, + spread +} from './api.js'; + + +const sid = uuid().substr(0,8); + +let store = {}; + + +const getOrCreate = function(endpoint, data) { + let identifier = Object.keys(data).find(key => ['name', 'username'].includes(key)); + + if (identifier === undefined) { + throw new Error('A unique key value must be provided.'); + } + + let identity = data[identifier]; + + if (store[endpoint] && store[endpoint][identity]) { + return store[endpoint][identity].then(created => created.data); + } + + if (!store[endpoint]) { + store[endpoint] = {}; + } + + let query = { params: { [identifier]: identity } }; + + store[endpoint][identity] = get(endpoint, query).then(res => { + + if (res.data.results.length > 1) { + return Promise.reject(new Error('More than one matching result.')); + } + + if (res.data.results.length === 1) { + return get(res.data.results[0].url); + } + + if (res.data.results.length === 0) { + return post(endpoint, data); + } + + return Promise.reject(new Error(`unexpected response: ${res}`)); + }); + + return store[endpoint][identity].then(created => created.data); +}; + + +const getOrganization = function() { + return getOrCreate('/organizations/', { + name: `e2e-organization-${sid}` + }); +}; + + +const getInventory = function() { + return getOrganization().then(organization => { + return getOrCreate('/inventories/', { + name: `e2e-inventory-${sid}`, + organization: organization.id + }); + }); +}; + + +const getInventoryScript = function() { + return getOrganization().then(organization => { + return getOrCreate('/inventory_scripts/', { + name: `e2e-inventory-script-${sid}`, + organization: organization.id, + script: '#!/usr/bin/env python' + }); + }); +}; + + +const getAdminAWSCredential = function() { + return all([ + get('/me/'), + getOrCreate('/credential_types/', { + name: "Amazon Web Services" + }) + ]) + .then(spread((me, credentialType) => { + let admin = me.data.results[0]; + return getOrCreate('/credentials/', { + name: `e2e-aws-credential-${sid}`, + credential_type: credentialType.id, + user: admin.id, + inputs: { + username: 'admin', + password: 'password', + security_token: 'AAAAAAAAAAAAAAAA' + } + }); + })); +}; + + +const getAdminMachineCredential = function() { + return all([ + get('/me/'), + getOrCreate('/credential_types/', { name: "Machine" }) + ]) + .then(spread((me, credentialType) => { + let admin = me.data.results[0]; + return getOrCreate('/credentials/', { + name: `e2e-machine-credential-${sid}`, + credential_type: credentialType.id, + user: admin.id + }); + })); +}; + + +const getTeam = function() { + return getOrganization().then(organization => { + return getOrCreate('/teams/', { + name: `e2e-team-${sid}`, + organization: organization.id, + }); + }); +}; + + +const getSmartInventory = function() { + return getOrganization().then(organization => { + return getOrCreate('/inventories/', { + name: `e2e-smart-inventory-${sid}`, + organization: organization.id, + host_filter: 'search=localhost', + kind: 'smart' + }); + }); +}; + + +const getNotificationTemplate = function() { + return getOrganization().then(organization => { + return getOrCreate('/notification_templates/', { + name: `e2e-notification-template-${sid}`, + organization: organization.id, + notification_type: 'slack', + notification_configuration: { + token: '54321GFEDCBAABCDEFG12345', + channels: ['awx-e2e'] + } + }); + }); +}; + + +const getProject = function() { + return getOrganization().then(organization => { + return getOrCreate('/projects/', { + name: `e2e-project-${sid}`, + organization: organization.id, + scm_url: 'https://github.com/ansible/ansible-tower-samples', + scm_type: 'git' + }); + }); +}; + + +const waitForJob = function(endpoint) { + const interval = 2000; + const statuses = ['successful', 'failed', 'error', 'canceled']; + + let attempts = 20; + + return new Promise((resolve, reject) => { + (function pollStatus() { + get(endpoint).then(update => { + let completed = statuses.indexOf(update.data.status) > -1; + if (completed) return resolve(); + if (--attempts <= 0) return reject('Retry limit exceeded.'); + setTimeout(pollStatus, interval); + }); + })(); + }); +}; + + +const getUpdatedProject = function() { + return getProject().then(project => { + let updateURL = project.related.current_update; + if (updateURL) { + return waitForJob(updateURL).then(() => project); + } + return project; + }); +}; + + +const getJobTemplate = function() { + return all([ + getInventory(), + getAdminMachineCredential(), + getUpdatedProject() + ]) + .then(spread((inventory, credential, project) => { + return getOrCreate('/job_templates', { + name: `e2e-job-template-${sid}`, + inventory: inventory.id, + credential: credential.id, + project: project.id, + playbook: 'hello_world.yml' + }); + })); +}; + + +const getAuditor = function() { + return getOrganization().then(organization => { + return getOrCreate('/users/', { + organization: organization.id, + username: `e2e-auditor-${sid}`, + first_name: 'auditor', + last_name: 'last', + email: 'null@ansible.com', + is_superuser: false, + is_system_auditor: true, + password: 'password' + }) + }); +}; + + +const getUser = function() { + return getOrCreate('/users/', { + username: `e2e-user-${sid}`, + first_name: `user-${sid}-first`, + last_name: `user-${sid}-last`, + email: `null-${sid}@ansible.com`, + is_superuser: false, + is_system_auditor: false, + password: 'password' + }); +}; + + +module.exports = { + getAdminAWSCredential, + getAdminMachineCredential, + getAuditor, + getInventory, + getInventoryScript, + getJobTemplate, + getNotificationTemplate, + getOrCreate, + getOrganization, + getSmartInventory, + getTeam, + getUpdatedProject, + getUser +}; diff --git a/awx/ui/client/test/e2e/objects/activityStream.js b/awx/ui/client/test/e2e/objects/activityStream.js index c59a615b9a..b122c02c68 100644 --- a/awx/ui/client/test/e2e/objects/activityStream.js +++ b/awx/ui/client/test/e2e/objects/activityStream.js @@ -1,6 +1,6 @@ module.exports = { url() { - return `${this.api.globals.awxURL}/#/activity_stream` + return `${this.api.globals.launch_url}/#/activity_stream` }, elements: { title: '.List-titleText', diff --git a/awx/ui/client/test/e2e/objects/credentialTypes.js b/awx/ui/client/test/e2e/objects/credentialTypes.js index 9b2f4f4385..4584309543 100644 --- a/awx/ui/client/test/e2e/objects/credentialTypes.js +++ b/awx/ui/client/test/e2e/objects/credentialTypes.js @@ -53,7 +53,7 @@ const listPanel = { module.exports = { url() { - return `${this.api.globals.awxURL}/#/credential_types` + return `${this.api.globals.launch_url}/#/credential_types` }, sections: { header, diff --git a/awx/ui/client/test/e2e/objects/credentials.js b/awx/ui/client/test/e2e/objects/credentials.js index 783cd7526e..7cb0a6de78 100644 --- a/awx/ui/client/test/e2e/objects/credentials.js +++ b/awx/ui/client/test/e2e/objects/credentials.js @@ -229,7 +229,7 @@ const details = _.merge({}, common, { module.exports = { url() { - return `${this.api.globals.awxURL}/#/credentials` + return `${this.api.globals.launch_url}/#/credentials` }, sections: { header, diff --git a/awx/ui/client/test/e2e/objects/inventories.js b/awx/ui/client/test/e2e/objects/inventories.js new file mode 100644 index 0000000000..bbef08ff2e --- /dev/null +++ b/awx/ui/client/test/e2e/objects/inventories.js @@ -0,0 +1,113 @@ +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 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 standardInvDetails = createFormSection({ + selector: 'form', + props: { + formElementSelectors: [ + '#inventory_form .Form-textInput', + '#inventory_form select.Form-dropDown', + '#inventory_form .Form-textArea', + '#inventory_form input[type="checkbox"]', + '#inventory_form .ui-spinner-input', + '#inventory_form .ScheduleToggle-switch' + ] + } +}); + +const smartInvDetails = createFormSection({ + selector: 'form', + props: { + formElementSelectors: [ + '#smartinventory_form input.Form-textInput', + '#smartinventory_form textarea.Form-textArea', + '#smartinventory_form .Form-lookupButton', + '#smartinventory_form #InstanceGroups' + ] + } +}); + +module.exports = { + url() { + return `${this.api.globals.launch_url}/#/inventories`; + }, + sections: { + header, + navigation, + breadcrumb, + lookupModal, + addStandardInventory: { + selector: 'div[ui-view="form"]', + sections: { + standardInvDetails + }, + elements: { + title: 'div[class^="Form-title"]' + } + }, + editStandardInventory: { + selector: 'div[ui-view="form"]', + sections: { + standardInvDetails, + permissions + }, + elements: { + title: 'div[class^="Form-title"]' + } + }, + addSmartInventory: { + selector: 'div[ui-view="form"]', + sections: { + smartInvDetails + }, + elements: { + title: 'div[class^="Form-title"]' + } + }, + editSmartInventory: { + selector: 'div[ui-view="form"]', + sections: { + smartInvDetails, + permissions + }, + elements: { + title: 'div[class^="Form-title"]' + } + }, + list: { + selector: 'div[ui-view="list"]', + elements: { + badge: 'span[class~="badge"]', + title: 'div[class="List-titleText"]', + add: 'button[class~="List-dropdownButton"]' + }, + sections: { + search, + pagination, + table: createTableSection({ + elements: { + status: 'td[class~="status-column"]', + name: 'td[class~="name-column"]', + kind: 'td[class~="kind-column"]', + organization: 'td[class~="organization-column"]' + }, + sections: { + actions + } + }) + } + } + }, + elements: { + cancel: 'button[class*="Form-cancelButton"]', + save: 'button[class*="Form-saveButton"]' + } +}; diff --git a/awx/ui/client/test/e2e/objects/inventoryScripts.js b/awx/ui/client/test/e2e/objects/inventoryScripts.js new file mode 100644 index 0000000000..3a9da46f00 --- /dev/null +++ b/awx/ui/client/test/e2e/objects/inventoryScripts.js @@ -0,0 +1,77 @@ +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 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 details = createFormSection({ + selector: 'form', + props: { + formElementSelectors: [ + '#inventory_script_form .Form-textInput', + '#inventory_script_form .Form-textArea', + '#inventory_script_form .Form-lookupButton' + ] + } +}); + +module.exports = { + url() { + return `${this.api.globals.launch_url}/#/inventory_scripts`; + }, + sections: { + header, + navigation, + breadcrumb, + lookupModal, + add: { + selector: 'div[ui-view="form"]', + sections: { + details + }, + elements: { + title: 'div[class^="Form-title"]' + } + }, + edit: { + selector: 'div[ui-view="form"]', + sections: { + details, + permissions + }, + elements: { + title: 'div[class^="Form-title"]' + } + }, + 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"]', + organization: 'td[class~="organization-column"]', + }, + sections: { + actions + } + }) + } + } + }, + elements: { + cancel: 'button[class*="Form-cancelButton"]', + save: 'button[class*="Form-saveButton"]' + } +}; diff --git a/awx/ui/client/test/e2e/objects/login.js b/awx/ui/client/test/e2e/objects/login.js index 12bdd45485..7613ee855b 100644 --- a/awx/ui/client/test/e2e/objects/login.js +++ b/awx/ui/client/test/e2e/objects/login.js @@ -1,6 +1,6 @@ module.exports = { url() { - return `${this.api.globals.awxURL}/#/login` + return `${this.api.globals.launch_url}/#/login` }, elements: { username: '#login-username', diff --git a/awx/ui/client/test/e2e/objects/notificationTemplates.js b/awx/ui/client/test/e2e/objects/notificationTemplates.js new file mode 100644 index 0000000000..72ebd53045 --- /dev/null +++ b/awx/ui/client/test/e2e/objects/notificationTemplates.js @@ -0,0 +1,82 @@ +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 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 details = createFormSection({ + selector: 'form', + props: { + formElementSelectors: [ + '#notification_template_form .Form-textInput', + '#notification_template_form select.Form-dropDown', + '#notification_template_form input[type="checkbox"]', + '#notification_template_form input[type="radio"]', + '#notification_template_form .ui-spinner-input', + '#notification_template_form .Form-textArea', + '#notification_template_form .ScheduleToggle-switch', + '#notification_template_form .Form-lookupButton' + ] + } +}); + +module.exports = { + url() { + return `${this.api.globals.launch_url}/#/notification_templates`; + }, + sections: { + header, + navigation, + breadcrumb, + lookupModal, + add: { + selector: 'div[ui-view="form"]', + sections: { + details + }, + elements: { + title: 'div[class^="Form-title"]' + } + }, + edit: { + selector: 'div[ui-view="form"]', + sections: { + details, + permissions + }, + elements: { + title: 'div[class^="Form-title"]' + } + }, + 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"]', + organization: 'td[class~="organization-column"]', + }, + sections: { + actions + } + }) + } + } + }, + elements: { + cancel: 'button[class*="Form-cancelButton"]', + save: 'button[class*="Form-saveButton"]' + } +}; diff --git a/awx/ui/client/test/e2e/objects/organizations.js b/awx/ui/client/test/e2e/objects/organizations.js new file mode 100644 index 0000000000..b17c1d55f1 --- /dev/null +++ b/awx/ui/client/test/e2e/objects/organizations.js @@ -0,0 +1,66 @@ +import breadcrumb from './sections/breadcrumb.js'; +import createFormSection from './sections/createFormSection.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 details = createFormSection({ + selector: 'form', + props: { + formElementSelectors: [ + '#organization_form input.Form-textInput', + '#organization_form .Form-lookupButton', + '#organization_form #InstanceGroups' + ] + } +}); + +module.exports = { + url() { + return `${this.api.globals.launch_url}/#/organizations`; + }, + sections: { + header, + navigation, + breadcrumb, + lookupModal, + add: { + selector: 'div[ui-view="form"]', + sections: { + details + }, + elements: { + title: 'div[class^="Form-title"]' + } + }, + edit: { + selector: 'div[ui-view="form"]', + sections: { + details, + permissions + }, + elements: { + title: 'div[class^="Form-title"]' + } + }, + list: { + selector: '#organizations', + elements: { + badge: 'span[class~="badge"]', + title: 'div[class="List-titleText"]', + add: 'button[class~="List-buttonSubmit"]' + }, + sections: { + search, + pagination + } + } + }, + elements: { + cancel: 'button[class*="Form-cancelButton"]', + save: 'button[class*="Form-saveButton"]' + } +}; diff --git a/awx/ui/client/test/e2e/objects/projects.js b/awx/ui/client/test/e2e/objects/projects.js new file mode 100644 index 0000000000..e1956a8e83 --- /dev/null +++ b/awx/ui/client/test/e2e/objects/projects.js @@ -0,0 +1,80 @@ +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 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 details = createFormSection({ + selector: 'form', + props: { + formElementSelectors: [ + '#project_form .Form-textInput', + '#project_form select.Form-dropDown', + '#project_form input[type="checkbox"]', + '#project_form .ui-spinner-input', + ] + } +}); + +module.exports = { + url() { + return `${this.api.globals.launch_url}/#/projects`; + }, + sections: { + header, + navigation, + breadcrumb, + lookupModal, + add: { + selector: 'div[ui-view="form"]', + sections: { + details + }, + elements: { + title: 'div[class^="Form-title"]' + } + }, + edit: { + selector: 'div[ui-view="form"]', + sections: { + details, + permissions + }, + elements: { + title: 'div[class^="Form-title"]' + } + }, + 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: { + status: 'td[class~="status-column"]', + name: 'td[class~="name-column"]', + scm_type: 'td[class~="scm_type-column"]', + last_updated: 'td[class~="last_updated-column"]' + }, + sections: { + actions + } + }) + } + } + }, + elements: { + cancel: 'button[class*="Form-cancelButton"]', + save: 'button[class*="Form-saveButton"]' + } +}; diff --git a/awx/ui/client/test/e2e/objects/sections/createFormSection.js b/awx/ui/client/test/e2e/objects/sections/createFormSection.js index 6d53215c1c..1bdeeb0c54 100644 --- a/awx/ui/client/test/e2e/objects/sections/createFormSection.js +++ b/awx/ui/client/test/e2e/objects/sections/createFormSection.js @@ -73,19 +73,42 @@ const generateInputSelectors = function(label, containerElements) { }; +const checkAllFieldsDisabled = function() { + let client = this.client.api; + + let selectors = this.props.formElementSelectors ? this.props.formElementSelectors : [ + '.at-Input' + ]; + + selectors.forEach(function(selector) { + client.elements('css selector', selector, inputs => { + inputs.value.map(o => o.ELEMENT).forEach(id => { + client.elementIdAttribute(id, 'disabled', ({ value }) => { + client.assert.equal(value, 'true'); + }); + }); + }); + }); +}; + + const generatorOptions = { default: inputContainerElements, legacy: legacyContainerElements }; -const createFormSection = function({ selector, labels, strategy }) { +const createFormSection = function({ selector, labels, strategy, props }) { let options = generatorOptions[strategy || 'default']; let formSection = { selector, sections: {}, - elements: {} + elements: {}, + commands: [{ + checkAllFieldsDisabled: checkAllFieldsDisabled + }], + props: props }; for (let key in labels) { @@ -95,7 +118,7 @@ const createFormSection = function({ selector, labels, strategy }) { formSection.elements[key] = inputElement; formSection.sections[key] = inputContainer; - }; + } return formSection; }; diff --git a/awx/ui/client/test/e2e/objects/teams.js b/awx/ui/client/test/e2e/objects/teams.js new file mode 100644 index 0000000000..245ee2eba2 --- /dev/null +++ b/awx/ui/client/test/e2e/objects/teams.js @@ -0,0 +1,76 @@ +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 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 details = createFormSection({ + selector: 'form', + props: { + formElementSelectors: [ + '#team_form input.Form-textInput', + '#team_form .Form-lookupButton' + ] + } +}); + +module.exports = { + url() { + return `${this.api.globals.launch_url}/#/teams`; + }, + sections: { + header, + navigation, + breadcrumb, + lookupModal, + add: { + selector: 'div[ui-view="form"]', + sections: { + details + }, + elements: { + title: 'div[class^="Form-title"]' + } + }, + edit: { + selector: 'div[ui-view="form"]', + sections: { + details, + permissions + }, + elements: { + title: 'div[class^="Form-title"]' + } + }, + 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"]', + organization: 'td[class~="organization-column"]' + }, + sections: { + actions + } + }) + } + } + }, + elements: { + cancel: 'button[class*="Form-cancelButton"]', + save: 'button[class*="Form-saveButton"]' + } +}; diff --git a/awx/ui/client/test/e2e/objects/templates.js b/awx/ui/client/test/e2e/objects/templates.js new file mode 100644 index 0000000000..4e3b69cc4f --- /dev/null +++ b/awx/ui/client/test/e2e/objects/templates.js @@ -0,0 +1,99 @@ +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 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 details = createFormSection({ + selector: 'form', + props: { + formElementSelectors: [ + '#job_template_form .Form-textInput', + '#job_template_form select.Form-dropDown', + '#job_template_form .Form-textArea', + '#job_template_form input[type="checkbox"]', + '#job_template_form .ui-spinner-input', + '#job_template_form .ScheduleToggle-switch' + ] + } +}); + +module.exports = { + url() { + return `${this.api.globals.launch_url}/#/templates`; + }, + sections: { + header, + navigation, + breadcrumb, + lookupModal, + addJobTemplate: { + selector: 'div[ui-view="form"]', + sections: { + details + }, + elements: { + title: 'div[class^="Form-title"]' + } + }, + editJobTemplate: { + selector: 'div[ui-view="form"]', + sections: { + details, + permissions + }, + elements: { + title: 'div[class^="Form-title"]' + } + }, + addWorkflowJobTemplate: { + selector: 'div[ui-view="form"]', + sections: { + details + }, + elements: { + title: 'div[class^="Form-title"]' + } + }, + editWorkflowJobTemplate: { + selector: 'div[ui-view="form"]', + sections: { + details, + permissions + }, + elements: { + title: 'div[class^="Form-title"]' + } + }, + 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~="type-column"]' + }, + sections: { + actions + } + }) + } + } + }, + elements: { + cancel: 'button[class*="Form-cancelButton"]', + save: 'button[class*="Form-saveButton"]' + } +}; diff --git a/awx/ui/client/test/e2e/objects/users.js b/awx/ui/client/test/e2e/objects/users.js new file mode 100644 index 0000000000..9d8cdc0eeb --- /dev/null +++ b/awx/ui/client/test/e2e/objects/users.js @@ -0,0 +1,77 @@ +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 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 details = createFormSection({ + selector: 'form', + props: { + formElementSelectors: [ + '#user_form .Form-textInput', + '#user_form select.Form-dropDown' + ] + } +}); + +module.exports = { + url() { + return `${this.api.globals.launch_url}/#/users`; + }, + sections: { + header, + navigation, + breadcrumb, + lookupModal, + add: { + selector: 'div[ui-view="form"]', + sections: { + details + }, + elements: { + title: 'div[class^="Form-title"]' + } + }, + edit: { + selector: 'div[ui-view="form"]', + sections: { + details, + permissions + }, + elements: { + title: 'div[class^="Form-title"]' + } + }, + 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: { + username: 'td[class~="username-column"]', + first_name: 'td[class~="first_name-column"]', + last_name: 'td[class~="last_name-column"]' + }, + sections: { + actions + } + }) + } + } + }, + elements: { + cancel: 'button[class*="Form-cancelButton"]', + save: 'button[class*="Form-saveButton"]' + } +}; diff --git a/awx/ui/client/test/e2e/settings.js b/awx/ui/client/test/e2e/settings.js index 6496ba0b75..0a5c892461 100644 --- a/awx/ui/client/test/e2e/settings.js +++ b/awx/ui/client/test/e2e/settings.js @@ -3,6 +3,7 @@ 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_LAUNCH_URL = process.env.AWX_E2E_LAUNCH_URL || AWX_E2E_URL; 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; @@ -20,6 +21,7 @@ module.exports = { retryAssertionTimeout: AWX_E2E_TIMEOUT_MEDIUM, selenium_host: AWX_E2E_SELENIUM_HOST, selenium_port: AWX_E2E_SELENIUM_PORT, + launch_url: AWX_E2E_LAUNCH_URL, shortTimeout: AWX_E2E_TIMEOUT_SHORT, waitForConditionTimeout: AWX_E2E_TIMEOUT_MEDIUM, test_workers: { diff --git a/awx/ui/client/test/e2e/tests/test-auditor-read-only-forms.js b/awx/ui/client/test/e2e/tests/test-auditor-read-only-forms.js new file mode 100644 index 0000000000..e0646fe718 --- /dev/null +++ b/awx/ui/client/test/e2e/tests/test-auditor-read-only-forms.js @@ -0,0 +1,186 @@ +import { all } from '../api.js'; + +import { + getAdminAWSCredential, + getAdminMachineCredential, + getAuditor, + getInventory, + getInventoryScript, + getNotificationTemplate, + getOrCreate, + getOrganization, + getSmartInventory, + getTeam, + getUpdatedProject, + getUser +} from '../fixtures.js'; + + + +let data = {}; + +let credentials, + inventoryScripts, + templates, + notificationTemplates, + organizations, + projects, + users, + inventories, + teams; + + +function navigateAndWaitForSpinner(client, url) { + client + .url(url) + .waitForElementVisible('div.spinny') + .waitForElementNotVisible('div.spinny'); +} + + +module.exports = { + before: function (client, done) { + all([ + getAuditor().then(obj => data.auditor = obj), + getOrganization().then(obj => data.organization = obj), + getInventory().then(obj => data.inventory = obj), + getInventoryScript().then(obj => data.inventoryScript = obj), + getAdminAWSCredential().then(obj => data.adminAWSCredential = obj), + getAdminMachineCredential().then(obj => data.adminMachineCredential = obj), + getSmartInventory().then(obj => data.smartInventory = obj), + getTeam().then(obj => data.team = obj), + getUser().then(obj => data.user = obj), + getNotificationTemplate().then(obj => data.notificationTemplate = obj), + getUpdatedProject().then(obj => data.project = obj) + ]) + .then(() => { + client.useCss(); + + credentials = client.page.credentials(); + inventoryScripts = client.page.inventoryScripts(); + templates = client.page.templates(); + notificationTemplates = client.page.notificationTemplates(); + organizations = client.page.organizations(); + projects = client.page.projects(); + users = client.page.users(); + inventories = client.page.inventories(); + teams = client.page.teams(); + + client.login(data.auditor.username, data.auditor.password); + client.waitForAngular(); + + done(); + }); + }, + 'verify an auditor\'s credentials inputs are read-only': function (client) { + navigateAndWaitForSpinner(client, `${credentials.url()}/${data.adminAWSCredential.id}/`); + + credentials.section.edit + .expect.element('@title').text.contain(data.adminAWSCredential.name); + + credentials.section.edit.section.details.checkAllFieldsDisabled(); + }, + 'verify an auditor\'s inventory scripts inputs are read-only': function (client) { + navigateAndWaitForSpinner(client, `${inventoryScripts.url()}/${data.inventoryScript.id}/`); + + inventoryScripts.section.edit + .expect.element('@title').text.contain(data.inventoryScript.name); + + inventoryScripts.section.edit.section.details.checkAllFieldsDisabled(); + }, + 'verify save button hidden from auditor on inventory scripts form': function () { + inventoryScripts.expect.element('@save').to.not.be.visible; + }, + // TODO: re-enable these tests when JT edit has been re-factored to reliably show/remove the loading spinner + // only one time. Without this, we can't tell when all the requisite data is available. + // 'verify an auditor\'s job template inputs are read-only': function (client) { + // navigateAndWaitForSpinner(client, `${templates.url()}/job_template/${data.jobTemplate.id}/`); + // + // templates.section.editJobTemplate + // .expect.element('@title').text.contain(data.jobTemplate.name); + // + // templates.section.edit.section.details.checkAllFieldsDisabled(); + // }, + // 'verify save button hidden from auditor on job templates form': function () { + // templates.expect.element('@save').to.not.be.visible; + // }, + 'verify an auditor\'s notification templates inputs are read-only': function (client) { + navigateAndWaitForSpinner(client, `${notificationTemplates.url()}/${data.notificationTemplate.id}/`); + + notificationTemplates.section.edit + .expect.element('@title').text.contain(data.notificationTemplate.name); + + notificationTemplates.section.edit.section.details.checkAllFieldsDisabled(); + }, + 'verify save button hidden from auditor on notification templates page': function () { + notificationTemplates.expect.element('@save').to.not.be.visible; + }, + 'verify an auditor\'s organizations inputs are read-only': function (client) { + navigateAndWaitForSpinner(client, `${organizations.url()}/${data.organization.id}/`); + + organizations.section.edit + .expect.element('@title').text.contain(data.organization.name); + + organizations.section.edit.section.details.checkAllFieldsDisabled(); + }, + 'verify save button hidden from auditor on organizations form': function () { + organizations.expect.element('@save').to.not.be.visible; + }, + 'verify an auditor\'s smart inventory inputs are read-only': function (client) { + navigateAndWaitForSpinner(client, `${inventories.url()}/smart/${data.smartInventory.id}/`); + + inventories.section.editSmartInventory + .expect.element('@title').text.contain(data.smartInventory.name); + + inventories.section.editSmartInventory.section.smartInvDetails.checkAllFieldsDisabled(); + }, + 'verify save button hidden from auditor on smart inventories form': function () { + inventories.expect.element('@save').to.not.be.visible; + }, + 'verify an auditor\'s project inputs are read-only': function (client) { + navigateAndWaitForSpinner(client, `${projects.url()}/${data.project.id}/`); + + projects.section.edit + .expect.element('@title').text.contain(data.project.name); + + projects.section.edit.section.details.checkAllFieldsDisabled(); + }, + 'verify save button hidden from auditor on projects form': function () { + projects.expect.element('@save').to.not.be.visible; + }, + 'verify an auditor\'s standard inventory inputs are read-only': function (client) { + navigateAndWaitForSpinner(client, `${inventories.url()}/inventory/${data.inventory.id}/`); + + inventories.section.editStandardInventory + .expect.element('@title').text.contain(data.inventory.name); + + inventories.section.editStandardInventory.section.standardInvDetails.checkAllFieldsDisabled(); + }, + 'verify save button hidden from auditor on standard inventory form': function () { + inventories.expect.element('@save').to.not.be.visible; + }, + 'verify an auditor\'s teams inputs are read-only': function (client) { + navigateAndWaitForSpinner(client, `${teams.url()}/${data.team.id}/`); + + teams.section.edit + .expect.element('@title').text.contain(data.team.name); + + teams.section.edit.section.details.checkAllFieldsDisabled(); + }, + 'verify save button hidden from auditor on teams form': function () { + teams.expect.element('@save').to.not.be.visible; + }, + 'verify an auditor\'s user inputs are read-only': function (client) { + navigateAndWaitForSpinner(client, `${users.url()}/${data.user.id}/`); + + users.section.edit + .expect.element('@title').text.contain(data.user.username); + + users.section.edit.section.details.checkAllFieldsDisabled(); + }, + 'verify save button hidden from auditor on users form': function (client) { + users.expect.element('@save').to.not.be.visible; + + 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 deleted file mode 100644 index 93d07714a0..0000000000 --- a/awx/ui/client/test/e2e/tests/test-credentials-read-only.js +++ /dev/null @@ -1,97 +0,0 @@ -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/package.json b/awx/ui/package.json index 505a27810b..3cb89bcec5 100644 --- a/awx/ui/package.json +++ b/awx/ui/package.json @@ -33,6 +33,7 @@ }, "devDependencies": { "angular-mocks": "~1.4.14", + "axios": "^0.16.2", "babel-core": "^6.26.0", "babel-istanbul": "^0.12.2", "babel-loader": "^7.1.2",