diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..733c0ed --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,17 @@ +version: 2 +jobs: + build: + working_directory: ~/workspace + docker: + - image: circleci/node:7-browsers + steps: + - checkout + + - run: export IS_CIRCLE=true + - run: node --version + - run: npm --version + - run: yarn --version + - run: google-chrome --version + + - run: yarn install + - run: yarn run ng e2e protractor.conf.js diff --git a/.gitignore b/.gitignore index 644cadb..7573809 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ /coverage /libpeerconnection.log npm-debug.log +/output/ testem.log /typings yarn-error.log @@ -44,7 +45,6 @@ yarn-error.log # e2e /e2e/*.js /e2e/*.map -/e2e/*-spec.ts # unit tests *.spec.ts \ No newline at end of file diff --git a/README.md b/README.md index 9211ab1..cfcfaaa 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ [![Code Climate](https://codeclimate.com/github/testing-angular-applications/contacts-app-starter/badges/gpa.svg)](https://codeclimate.com/github/testing-angular-applications/contacts-app-starter) [![Dependencies Status](https://david-dm.org/testing-angular-applications/contacts-app-starter/status.svg)](https://david-dm.org/testing-angular-applications/contacts-app-starter) [![devDependencies Status](https://david-dm.org/testing-angular-applications/contacts-app-starter/dev-status.svg)](https://david-dm.org/testing-angular-applications/contacts-app-starter?type=dev) +[![CircleCI Status](https://circleci.com/gh/testing-angular-applications/contacts-app-starter.svg?style=svg)](https://circleci.com/gh/testing-angular-applications/contacts-app-starter) # Contacts App Starter diff --git a/e2e/app.po.ts b/e2e/app.po.ts deleted file mode 100644 index cdf62d6..0000000 --- a/e2e/app.po.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { browser, element, by } from 'protractor'; - -export class ContactsAppStarterPage { - navigateTo() { - return browser.get('/'); - } - - getParagraphText() { - return element(by.css('app-root h1')).getText(); - } -} diff --git a/e2e/contact-list.e2e-spec.ts b/e2e/contact-list.e2e-spec.ts new file mode 100644 index 0000000..9d972c9 --- /dev/null +++ b/e2e/contact-list.e2e-spec.ts @@ -0,0 +1,108 @@ +import { browser, by, element, ElementFinder } from 'protractor'; +import { promise as wdpromise } from 'selenium-webdriver'; + +export interface Contact { + name?: string; + email?: string; + tel?: string; +} + +describe('the contact list', () => { + beforeAll(() => { + browser.get('/'); + }); + + it('with filter: should find existing contact "Craig Service"', () => { + let tbody = element(by.tagName('tbody')); + let trs = tbody.all(by.tagName('tr')); + let craigService = trs.filter(elem => { + // The tds: 0 = mood, 1 = name, 2 = email, 3 = phone number + return elem.all(by.tagName('td')).get(1).getText().then(text => { + return text === 'Craig Service'; + }); + }); + // Nothing happens here until you use it. Although we called getText in the filter function, + // it is not executed until we use it. When you use it, the promises enter the control flow + // and are resolved. This is similar to calling element(), nothing happens until you do + // something like getText(). + expect(craigService.count()).toBeGreaterThan(0); + expect(craigService.all(by.tagName('td')).get(2).getText()) + .toEqual('craig.services@example.com'); + }); + + let expectedContactList: Contact[] = [{ + name: 'Adrian Directive', + email: 'adrian.directive@example.com', + tel: '+1 (703) 555-0123' + }, { + name: 'Rusty Component', + email: 'rusty.component@example.com', + tel: '+1 (441) 555-0122' + }, { + name: 'Jeff Pipe', + email: 'jeff.pipe@example.com', + tel: '+1 (714) 555-0111' + }, { + name: 'Craig Service', + email: 'craig.services@example.com', + tel: '+1 (514) 555-0132' + }]; + + it('with map: should create a map object', () => { + let tbody = element(by.tagName('tbody')); + let trs = tbody.all(by.tagName('tr')); + let contactList = trs.map(elem => { + let contact: Contact = {}; + let tds = elem.all(by.tagName('td')); + // We need to get the values of the contact name and email. Since these are in a couple of + // different promises, we'll create a promise array. + let promises: any[] = []; + + // Getting the text returns a promise of a string then the next function sets the contact's + // name. This function returns void so the final promise saved is of Promise. + // We set the promise array to be of type any since we do not care about the promise type. + promises.push(tds.get(1).getText().then(text=> { + contact.name = text; + })); + promises.push(tds.get(2).getText().then(text => { + contact.email = text; + })); + promises.push(tds.get(3).getText().then(text => { + contact.tel = text; + })); + + // Resolve all the promises and return the contact. + return Promise.all(promises).then(() => { + return contact; + }); + }) + + // Check the results + expect(contactList).toBeDefined(); + contactList.then((contacts: Contact[]) => { + + // Spot check the results + expect(contacts.length).toEqual(4); + expect(contacts[0]).toBeDefined(); + expect(contacts[1].email).toEqual('rusty.component@example.com'); + expect(contacts[2].tel).toEqual('+1 (714) 555-0111'); + expect(contacts[3].name).toEqual('Craig Service'); + + // Check all the contacts match + expect(contacts).toEqual(expectedContactList); + }); + }); + + it('with reduce: get a list of contact names', () => { + let tbody = element(by.tagName('tbody')); + let trs = tbody.all(by.tagName('tr')); + let contacts = trs.reduce((acc, curr) => { + let name = curr.all(by.tagName('td')).get(1); + return name.getText().then(text => { + return acc === '' ? text : acc + ', ' + text; + }); + }, ''); + + expect(contacts).toBe('Adrian Directive, Rusty Component, Jeff Pipe, Craig Service'); + }); +}); diff --git a/e2e/first-test.e2e-spec.ts b/e2e/first-test.e2e-spec.ts new file mode 100644 index 0000000..2c9cb2c --- /dev/null +++ b/e2e/first-test.e2e-spec.ts @@ -0,0 +1,8 @@ +import { browser } from 'protractor'; + +describe('our first Protractor test', () => { + it('should load a page and verify the url', () => { + browser.get('/'); + expect(browser.getCurrentUrl()).toEqual(browser.baseUrl + '/'); + }); +}); \ No newline at end of file diff --git a/e2e/new-contact.e2e-spec.ts b/e2e/new-contact.e2e-spec.ts new file mode 100644 index 0000000..086c0a2 --- /dev/null +++ b/e2e/new-contact.e2e-spec.ts @@ -0,0 +1,110 @@ +import {browser, by, element, ExpectedConditions as EC} from 'protractor'; + +describe('interact with elements', () => { + + describe('for a new valid user', () => { + beforeAll(() => { + browser.get('/'); + }); + + it('should find the add contact button', () => { + element(by.id('add-contact')).click(); + expect(browser.getCurrentUrl()).toEqual(browser.baseUrl + '/add'); + }); + + it('should write a name', () => { + element(by.id('contact-name')).sendKeys('Ada'); + expect(element(by.id('contact-name')).getAttribute('value')).toEqual('Ada'); + }); + + it('should click the create button', () => { + element(by.css('.create-button')).click(); + expect(browser.getCurrentUrl()).toEqual(browser.baseUrl + '/'); + }); + }); + + describe('for another new valid user', () => { + beforeAll(() => { + browser.get('/'); + element(by.id('add-contact')).click(); + element(by.id('contact-name')).sendKeys('Grace'); + }); + + it('should send an email address', () => { + let email = element(by.id('contact-email')); + email.sendKeys('grace@hopper.com'); + expect(email.getAttribute('value')).toEqual('grace@hopper.com'); + }); + + it('should send a phone number', () => { + let tel = element(by.css('input[type="tel"]')); + tel.sendKeys('1234567890'); + expect(tel.getAttribute('value')).toEqual('1234567890'); + }); + }); + + describe('for an invalid email', () => { + beforeEach(() => { + browser.get('/add'); + element(by.id('contact-name')).sendKeys('Bad Email'); + }); + + it('should send an invalid email', () => { + let email = element(by.id('contact-email')); + email.sendKeys('baduser.com'); + element(by.buttonText('Create')).click(); + + let invalidEmailModal = element(by.tagName('app-invalid-email-modal')); + expect(invalidEmailModal.isPresent()).toBe(true); + + let modalButton = invalidEmailModal.element(by.tagName('button')); + modalButton.click(); + + browser.wait(EC.not(EC.presenceOf(invalidEmailModal)), 5000); + expect(invalidEmailModal.isPresent()).toBe(false); + expect(browser.getCurrentUrl()).toEqual(browser.baseUrl + '/add'); + }); + + it('should also send an invalid email', () => { + let email = element(by.id('contact-email')); + email.sendKeys('@baduser.com'); + let invalidEmailModal = element(by.tagName('app-invalid-email-modal')); + expect(invalidEmailModal.isPresent()).toBe(false); + }); + }); + + describe('for an invalid phone number', () => { + beforeEach(() => { + browser.get('/add'); + element(by.id('contact-name')).sendKeys('Bad Tel'); + }); + + it('should send an invalid tel', () => { + let tel = element(by.css('input[type="tel"]')); + tel.sendKeys('123-456-7890'); + element(by.buttonText('Create')).click(); + let invalidTelModal = element(by.tagName('app-invalid-phone-number-modal')); + expect(invalidTelModal.isDisplayed()).toBe(true); + let modalButton = invalidTelModal.element(by.tagName('button')); + modalButton.click(); + + browser.wait(EC.not(EC.presenceOf(invalidTelModal)), 5000); + expect(invalidTelModal.isPresent()).toBe(false); + expect(browser.getCurrentUrl()).toEqual(browser.baseUrl + '/add'); + }); + + it('should also send an invalid tel', () => { + let tel = element(by.css('input[type="tel"]')); + tel.sendKeys('12345678901'); + element(by.buttonText('Create')).click(); + let invalidTelModal = element(by.tagName('app-invalid-phone-number-modal')); + expect(invalidTelModal.isDisplayed()).toBe(true); + let modalButton = invalidTelModal.element(by.tagName('button')); + modalButton.click(); + + browser.wait(EC.not(EC.presenceOf(invalidTelModal)), 5000); + expect(invalidTelModal.isPresent()).toBe(false); + expect(browser.getCurrentUrl()).toEqual(browser.baseUrl + '/add'); + }); + }); +}); \ No newline at end of file diff --git a/e2e/po/base.po.ts b/e2e/po/base.po.ts new file mode 100644 index 0000000..d54683a --- /dev/null +++ b/e2e/po/base.po.ts @@ -0,0 +1,26 @@ +import { browser as globalBrowser, element as globalElement, ElementHelper, + ExpectedConditions as globalExpectedConditions, ProtractorBrowser, + ProtractorExpectedConditions } from 'protractor'; +import { promise as wdpromise } from 'selenium-webdriver'; + +export class PageObject { + browser: ProtractorBrowser; + element: ElementHelper; + expectedConditions: ProtractorExpectedConditions; + + constructor(browser?: ProtractorBrowser) { + if (browser) { + this.browser = browser; + this.element = browser.element; + this.expectedConditions = browser.ExpectedConditions; + } else { + this.browser = globalBrowser; + this.element = globalElement; + this.expectedConditions = globalExpectedConditions; + } + } + + getCurrentUrl() { + return this.browser.getCurrentUrl(); + } +} \ No newline at end of file diff --git a/e2e/po/contact-list.po.ts b/e2e/po/contact-list.po.ts new file mode 100644 index 0000000..0a16ce3 --- /dev/null +++ b/e2e/po/contact-list.po.ts @@ -0,0 +1,91 @@ +import { by, ProtractorBrowser, ElementArrayFinder } from 'protractor'; +import { promise as wdpromise } from 'selenium-webdriver'; +import { PageObject } from './base.po'; +import { NewContactPageObject } from './new-contact.po'; + +export enum COL { + name = 1, + email = 2, + phoneNumber = 3 +} + +export class Contact { + constructor(public name?: string, public email?: string, public tel?: string) {} +} + +export class ContactListPageObject extends PageObject { + static nameCol = 1; + trs: ElementArrayFinder; + + constructor(browser?: ProtractorBrowser) { + super(browser); + + let tbody = this.element(by.tagName('tbody')); + this.trs = tbody.all(by.tagName('tr')); + } + + navigateTo() { + this.browser.get('/'); + } + + clickPlusButton() { + this.element(by.id('add-contact')).click(); + return new NewContactPageObject(); + } + + /** + * Returns the ElementArrayFinder object for the table row. This is an array because there could + * be more than one entry with the same matching name + * @param name + */ + findContact(name: string): ElementArrayFinder { + return this.trs.filter(elem => { + return elem.all(by.tagName('td')).get(COL.name).getText().then(text => { + return text === name; + }); + }); + } + + /** + * Returns a promise of a contact array. + */ + getContacts() { + return this.trs.map(elem => { + let contact: Contact = new Contact(); + let tds = elem.all(by.tagName('td')); + // We need to get the values of the contact name and email. Since these are in a couple of + // different promises, we'll create a promise array. + let promises: any[] = []; + + // Getting the text returns a promise of a string then the next function sets the contact's + // name. This function returns void so the final promise saved is of Promise. + // We set the promise array to be of type any since we do not care about the promise type. + promises.push(tds.get(COL.name).getText().then(text => { + contact.name = text; + })); + promises.push(tds.get(COL.email).getText().then(text => { + contact.email = text; + })); + promises.push(tds.get(COL.phoneNumber).getText().then(text => { + contact.tel = text; + })); + + // Resolve all the promises and return the contact. + return Promise.all(promises).then(() => { + return contact; + }); + }) + } + + /** + * Returns a promise of comma delimited names + */ + getContactNames() { + return this.trs.reduce((acc, curr) => { + let name = curr.all(by.tagName('td')).get(COL.name); + return name.getText().then(text => { + return acc === '' ? text : acc + ', ' + text; + }); + }, ''); + } +} \ No newline at end of file diff --git a/e2e/po/index.ts b/e2e/po/index.ts new file mode 100644 index 0000000..57114c0 --- /dev/null +++ b/e2e/po/index.ts @@ -0,0 +1,2 @@ +export * from './contact-list.po'; +export * from './new-contact.po'; \ No newline at end of file diff --git a/e2e/po/new-contact.po.ts b/e2e/po/new-contact.po.ts new file mode 100644 index 0000000..cb3a1b0 --- /dev/null +++ b/e2e/po/new-contact.po.ts @@ -0,0 +1,52 @@ +import { by, ElementFinder, ProtractorBrowser } from 'protractor'; +import { promise as wdpromise } from 'selenium-webdriver'; +import { PageObject } from './base.po'; +import { ContactListPageObject } from './contact-list.po'; + +export class NewContactPageObject extends PageObject { + inputName: ElementFinder; + inputEmail: ElementFinder; + inputPhone: ElementFinder; + + constructor(browser?: ProtractorBrowser) { + super(browser); + this.inputName = this.element(by.id('contact-name')); + this.inputEmail = this.element(by.id('contact-email')); + this.inputPhone = this.element(by.css('input[type="tel"]')); + } + + /** + * Set extra fields for email and phone number. We should return a promise and since we are doing + * multiple actions, we should keep track of them in a promises array and return all promises + * as a single promise. + * + * @param email + * @param phoneNumber + */ + setContactInfo(name: string, email: string, phoneNumber: string) { + this.inputName.sendKeys(name); + if (email) { + this.inputEmail.sendKeys(email); + } + if (phoneNumber) { + this.inputPhone.sendKeys(phoneNumber); + } + } + + clickCreateButton() { + this.element(by.buttonText('Create')).click(); + return new ContactListPageObject(); + } + + getName() { + return this.inputName.getAttribute('value'); + } + + getEmail() { + return this.inputEmail.getAttribute('value'); + } + + getPhone() { + return this.inputPhone.getAttribute('value'); + } +} \ No newline at end of file diff --git a/e2e/tsconfig.e2e.json b/e2e/tsconfig.e2e.json deleted file mode 100644 index 1d9e5ed..0000000 --- a/e2e/tsconfig.e2e.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extends": "../tsconfig.json", - "compilerOptions": { - "outDir": "../out-tsc/e2e", - "baseUrl": "./", - "module": "commonjs", - "target": "es5", - "types": [ - "jasmine", - "jasminewd2", - "node" - ] - } -} diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 0000000..6478db7 --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "declaration": false, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "lib": [ + "es2016" + ], + "module": "commonjs", + "moduleResolution": "node", + "outDir": "../dist/out-tsc-e2e", + "sourceMap": true, + "target": "es6", + "typeRoots": [ + "../node_modules/@types", + "../../node_modules/@types" + ] + } +} \ No newline at end of file diff --git a/e2e/with-page-objects.e2e-spec.ts b/e2e/with-page-objects.e2e-spec.ts new file mode 100644 index 0000000..277f89c --- /dev/null +++ b/e2e/with-page-objects.e2e-spec.ts @@ -0,0 +1,65 @@ +import { browser, by } from 'protractor'; +import { ContactListPageObject, NewContactPageObject, COL, Contact } from './po'; + +describe('contact list using page objects', () => { + let contactList: ContactListPageObject; + let newContact: NewContactPageObject; + + beforeAll(() => { + // We could also add the browser object here. For example: new ContactListPO(browser). + // This could become important if we fork the browser since the browser object refers to the + // current selenium session. + contactList = new ContactListPageObject(); + }); + + describe('add a new contact', () => { + beforeAll(() => { + contactList.navigateTo(); + }); + + it('should click the + button', () => { + newContact = contactList.clickPlusButton(); + expect(newContact.getCurrentUrl()).toBe(browser.baseUrl + '/add'); + }); + + it('should fill out form for a new contact', () => { + newContact.setContactInfo('Mr. Newton', 'mr.newton@example.com', null); + expect(newContact.getName()).toBe('Mr. Newton'); + expect(newContact.getEmail()).toBe('mr.newton@example.com'); + expect(newContact.getPhone()).toBe(''); + }); + + it('should click the create button', () => { + contactList = newContact.clickCreateButton(); + expect(contactList.getCurrentUrl()).toBe(browser.baseUrl + '/'); + }); + }); + + describe('read contact list', () => { + it('should find the new contact', () => { + let tableRow = contactList.findContact('Mr. Newton').get(0); + let tableData = tableRow.all(by.tagName('td')); + expect(tableData.get(COL.name).getText()).toBe('Mr. Newton'); + expect(tableData.get(COL.email).getText()).toBe('mr.newton@example.com'); + expect(tableData.get(COL.phoneNumber).getText()).toBe(''); + }); + + it('should find the contacts and verify each value', () => { + let contacts = contactList.getContacts(); + contacts.then((contacts: Contact[]) => { + expect(contacts.length).toBe(5); + expect(contacts[0]).toBeDefined(); + expect(contacts[1].email).toBe('rusty.component@example.com'); + expect(contacts[2].tel).toBe('+1 (714) 555-0111'); + expect(contacts[3].name).toBe('Craig Service'); + expect(contacts[4].name).toBe('Mr. Newton'); + }); + }); + + it('should get the contact names so we can print them to console', () => { + let contactNames = contactList.getContactNames(); + let expectedNames = 'Adrian Directive, Rusty Component, Jeff Pipe, Craig Service, Mr. Newton'; + expect(contactNames).toBe(expectedNames); + }); + }); +}); \ No newline at end of file diff --git a/protractor.conf.js b/protractor.conf.js index 7ee3b5e..ccd3541 100644 --- a/protractor.conf.js +++ b/protractor.conf.js @@ -6,23 +6,44 @@ const { SpecReporter } = require('jasmine-spec-reporter'); exports.config = { allScriptsTimeout: 11000, specs: [ - './e2e/**/*.e2e-spec.ts' + 'e2e/**/*.e2e-spec.ts' ], capabilities: { - 'browserName': 'chrome' + browserName: 'chrome', + chromeOptions: { + args: (process.env.IS_CIRCLE ? ['--headless'] : []) + } }, - directConnect: true, + directConnect: !process.env.IS_JENKINS, baseUrl: 'http://localhost:4200/', + + // Jasmine framework: 'jasmine', jasmineNodeOpts: { showColors: true, defaultTimeoutInterval: 30000, print: function() {} }, - onPrepare() { + + // Plugins + beforeLaunch: () => { require('ts-node').register({ - project: 'e2e/tsconfig.e2e.json' + project: 'e2e' }); - jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } })); + }, + onPrepare: ()=> { + if (process.env.IS_JENKINS) { + let jasmineReporters = require('jasmine-reporters'); + let junitReporter = new jasmineReporters.JUnitXmlReporter({ + savePath: 'output/', + consolidateAll: false + }); + jasmine.getEnv().addReporter(junitReporter); + } else { + let specReporter = new SpecReporter({ + spec: { displayStacktrace: true } + }); + jasmine.getEnv().addReporter(specReporter); + } } }; diff --git a/src/app/contacts/contact-list/contact-list.component.html b/src/app/contacts/contact-list/contact-list.component.html index dcf2702..07c1c4d 100644 --- a/src/app/contacts/contact-list/contact-list.component.html +++ b/src/app/contacts/contact-list/contact-list.component.html @@ -44,7 +44,7 @@ Delete All Contacts - +