From 04000c54b7f7c2b5404353c57b2e8295a74b8c2e Mon Sep 17 00:00:00 2001 From: Davert Date: Sun, 22 Nov 2015 03:14:51 +0200 Subject: [PATCH] initial --- .editorconfig | 11 + .eslintrc | 136 ++++++++++ .gitattributes | 1 + .gitignore | 7 + .travis.yml | 5 + Helpers.md/API.md | 430 +++++++++++++++++++++++++++++ LICENSE | 21 ++ README.md | 32 +++ bin/codecept.js | 87 ++++++ docs/API.md | 430 +++++++++++++++++++++++++++++ gulpfile.js | 64 +++++ jsconfig.json | 6 + lib/actor.js | 27 ++ lib/assert.js | 46 ++++ lib/assert/empty.js | 48 ++++ lib/assert/equal.js | 45 +++ lib/assert/error.js | 28 ++ lib/assert/include.js | 84 ++++++ lib/codecept.js | 55 ++++ lib/command/generate.js | 132 +++++++++ lib/command/init.js | 159 +++++++++++ lib/command/interactive.js | 34 +++ lib/config.js | 26 ++ lib/container.js | 75 +++++ lib/event.js | 25 ++ lib/helper.js | 46 ++++ lib/helper/FileSystem.js | 96 +++++++ lib/helper/WebDriverIO.js | 541 +++++++++++++++++++++++++++++++++++++ lib/interfaces/scenario.js | 106 ++++++++ lib/listener/helpers.js | 39 +++ lib/listener/steps.js | 21 ++ lib/output.js | 123 +++++++++ lib/pause.js | 66 +++++ lib/readline.js | 113 ++++++++ lib/recorder.js | 75 +++++ lib/reporter/cli.js | 111 ++++++++ lib/scenario.js | 75 +++++ lib/step.js | 63 +++++ lib/utils.js | 73 +++++ package.json | 51 ++++ 40 files changed, 3613 insertions(+) create mode 100644 .editorconfig create mode 100644 .eslintrc create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 Helpers.md/API.md create mode 100644 LICENSE create mode 100644 README.md create mode 100755 bin/codecept.js create mode 100644 docs/API.md create mode 100644 gulpfile.js create mode 100644 jsconfig.json create mode 100644 lib/actor.js create mode 100644 lib/assert.js create mode 100644 lib/assert/empty.js create mode 100644 lib/assert/equal.js create mode 100644 lib/assert/error.js create mode 100644 lib/assert/include.js create mode 100644 lib/codecept.js create mode 100644 lib/command/generate.js create mode 100644 lib/command/init.js create mode 100644 lib/command/interactive.js create mode 100644 lib/config.js create mode 100644 lib/container.js create mode 100644 lib/event.js create mode 100644 lib/helper.js create mode 100644 lib/helper/FileSystem.js create mode 100644 lib/helper/WebDriverIO.js create mode 100644 lib/interfaces/scenario.js create mode 100644 lib/listener/helpers.js create mode 100644 lib/listener/steps.js create mode 100644 lib/output.js create mode 100644 lib/pause.js create mode 100644 lib/readline.js create mode 100644 lib/recorder.js create mode 100644 lib/reporter/cli.js create mode 100644 lib/scenario.js create mode 100644 lib/step.js create mode 100644 lib/utils.js create mode 100644 package.json diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..beffa3084 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 000000000..4b3c7bc07 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,136 @@ +{ + "extends": "eslint:recommended", + "env": { + "node": true, + "mocha": true, + "es6": true + }, + "ecmaFeatures": { + "jsx": true, + "modules": true + }, + "rules": { + "array-bracket-spacing": [ + 2, + "never" + ], + "brace-style": [ + 2, + "1tbs" + ], + "consistent-return": 0, + "indent": [ + 2, + 2 + ], + "no-multiple-empty-lines": [ + 2, + { + "max": 2 + } + ], + "no-use-before-define": [ + 2, + "nofunc" + ], + "one-var": [ + 2, + "never" + ], + "quote-props": [ + 2, + "as-needed" + ], + "quotes": [ + 2, + "single" + ], + "space-after-keywords": [ + 2, + "always" + ], + "space-before-function-paren": [ + 2, + { + "anonymous": "always", + "named": "never" + } + ], + "space-in-parens": [ + 2, + "never" + ], + "strict": [ + 2, + "global" + ], + "curly": [ + 2, + "all" + ], + "eol-last": 2, + "key-spacing": [ + 2, + { + "beforeColon": false, + "afterColon": true + } + ], + "no-eval": 2, + "no-with": 2, + "space-infix-ops": 2, + "dot-notation": [ + 2, + { + "allowKeywords": true + } + ], + "eqeqeq": 2, + "no-alert": 2, + "no-caller": 2, + "no-empty-label": 2, + "no-extend-native": 2, + "no-extra-bind": 2, + "no-implied-eval": 2, + "no-iterator": 2, + "no-label-var": 2, + "no-labels": 2, + "no-lone-blocks": 2, + "no-loop-func": 2, + "no-multi-spaces": 2, + "no-multi-str": 2, + "no-native-reassign": 2, + "no-new": 2, + "no-new-func": 2, + "no-new-wrappers": 2, + "no-octal-escape": 2, + "no-proto": 2, + "no-return-assign": 2, + "no-script-url": 2, + "no-sequences": 2, + "no-unused-expressions": 2, + "yoda": 2, + "no-shadow": 2, + "no-shadow-restricted-names": 2, + "no-undef-init": 2, + "camelcase": 2, + "comma-spacing": 2, + "new-cap": 2, + "new-parens": 2, + "no-array-constructor": 2, + "no-extra-parens": 2, + "no-new-object": 2, + "no-spaced-func": 2, + "no-trailing-spaces": 2, + "no-underscore-dangle": 2, + "semi": 2, + "semi-spacing": [ + 2, + { + "before": false, + "after": true + } + ], + "space-return-throw-case": 2 + } +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..176a458f9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..edfa0311c --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules +coverage +test +tests +RoboFile.php +.vscode +coverage diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 000000000..991d04b6e --- /dev/null +++ b/.travis.yml @@ -0,0 +1,5 @@ +language: node_js +node_js: + - '0.10' + - '0.12' + - 'iojs' diff --git a/Helpers.md/API.md b/Helpers.md/API.md new file mode 100644 index 000000000..7a50e4b7f --- /dev/null +++ b/Helpers.md/API.md @@ -0,0 +1,430 @@ +# FileSystem + +Helper for testing filesystem. +Can be easily used to check file structures: + +```js +I.amInPath('test'); +I.seeFile('codecept.json'); +I.seeInThisFile('FileSystem'); +I.dontSeeInThisFile("WebDriverIO"); +``` + +## amInPath + +Enters a directory In local filesystem. +Starts from a current directory + +**Parameters** + +- `openPath` + +## dontSeeFileContentsEqual + +Checks that contents of file found by `seeFile` doesn't equal to text. + +**Parameters** + +- `text` +- `encoding` + +## dontSeeInThisFile + +Checks that file found by `seeFile` doesn't include text. + +**Parameters** + +- `text` +- `encoding` + +## seeFile + +Checks that file exists + +**Parameters** + +- `name` + +## seeFileContentsEqual + +Checks that contents of file found by `seeFile` equal to text. + +**Parameters** + +- `text` +- `encoding` + +## seeInThisFile + +Checks that file found by `seeFile` includes a text. + +**Parameters** + +- `text` +- `encoding` + +## writeToFile + +Writes test to file + +**Parameters** + +- `name` +- `text` + +# WebDriverIO + +WebDriverIO helper which wraps [webdriverio](http://webdriver.io/) library to +manipulate browser using Selenium WebDriver or PhantomJS. + +#### Selenium Installation + +1. Download [Selenium Server](http://docs.seleniumhq.org/download/) +2. Launch the daemon: `java -jar selenium-server-standalone-2.xx.xxx.jar` + +#### PhantomJS Installation + +PhantomJS is a headless alternative to Selenium Server that implements [the WebDriver protocol](https://code.google.com/p/selenium/wiki/JsonWireProtocol). +It allows you to run Selenium tests on a server without a GUI installed. + +1. Download [PhantomJS](http://phantomjs.org/download.html) +2. Run PhantomJS in WebDriver mode: `phantomjs --webdriver=4444` + +### Configuration + +This helper should be configured in codecept.json + +- `url` - base url of website to be tested +- `browser` - browser in which perform testing + +Additional configuration params can be used from + +**Parameters** + +- `config` + +## amOnPage + +Opens a web page in a browser. Requires relative or absolute url. +If url starts with `/`, opens a web page of a site defined in `url` config parameter. + +```js +I.amOnPage('/'); // opens main page of website +I.amOnPage('https://github.com'); // opens github +I.amOnPage('/login'); // opens a login page +``` + +**Parameters** + +- `url` + +## attachFile + +Attaches a file to element located by CSS or XPath + +**Parameters** + +- `locator` +- `pathToFile` + +## checkOption + +Selects a checkbox or radio button. +Element is located by label or name or CSS or XPath. + +The second parameter is a context (CSS or XPath locator) to narrow the search. + +```js +I.checkOption('#agree'); +I.checkOption('I Agree to Terms and Conditions'); +``` + +**Parameters** + +- `option` +- `context` + +## click + +Perform a click on a link or a button, given by a locator. +If a fuzzy locator is given, the page will be searched for a button, link, or image matching the locator string. +For buttons, the "value" attribute, "name" attribute, and inner text are searched. For links, the link text is searched. +For images, the "alt" attribute and inner text of any parent links are searched. + +The second parameter is a context (CSS or XPath locator) to narrow the search. + +```js +// simple link +I.click('Logout'); +// button of form +I.click('Submit'); +// CSS button +I.click('#form input[type=submit]'); +// XPath +I.click('//form/*[@type=submit]'); +// link in context +I.click('Logout', '#nav'); +// using strict locator +I.click({css: 'nav a.login'}); +``` + +**Parameters** + +- `link` +- `context` + +## dontSee + +Opposite to `see`. Checks that a text is not present on a page. +Use context parameter to narrow down the search. + +```js +I.dontSee('Login'); // assume we are already logged in +``` + +**Parameters** + +- `text` +- `context` + +## dontSeeElement + +Opposite to `seeElement`. Checks that element is not on page. + +**Parameters** + +- `locator` + +## dontSeeInCurrentUrl + +Checks that current url does not contain a provided fragment. + +**Parameters** + +- `urlFragment` + +## doubleClick + +Performs a double-click on an element matched by CSS or XPath. + +**Parameters** + +- `locator` + +## executeAsyncScript + +Executes async script on page. +Provided function should execute a passed callback (as first argument) to signal it is finished. + +**Parameters** + +- `fn` + +## executeScript + +Executes sync script on a page. +Pass arguments to function as additional parameters. +Will return execution result to a test. +In this case you should use generator and yield to receive results. + +**Parameters** + +- `fn` + +## fillField + +Fills a text field or textarea with the given string. +Field is located by name, label, CSS, or XPath. + +```js +// by label +I.fillField('Email', 'hello@world.com'); +// by name +I.fillField('password', '123456'); +// by CSS +I.fillField('form#login input[name=username]', 'John'); +// or by strict locator +I.fillField({css: 'form#login input[name=username]'}, 'John'); +``` + +**Parameters** + +- `field` +- `value` + +## grabAttribute + +Retrieves an attribute from an element located by CSS or XPath and returns it to test. +Resumes test execution, so **should be used inside a generator with `yield`** operator. + +```js +let hint = yield I.grabAttributeFrom('#tooltip', 'title'); +``` + +**Parameters** + +- `locator` +- `attr` + +## grabTextFrom + +Retrieves a text from an element located by CSS or XPath and returns it to test. +Resumes test execution, so **should be used inside a generator with `yield`** operator. + +```js +let pin = yield I.grabTextFrom('#pin'); +``` + +**Parameters** + +- `locator` + +## grabTitle + +Retrieves a page title and returns it to test. +Resumes test execution, so **should be used inside a generator with `yield`** operator. + +```js +let title = yield I.grabTitle(); +``` + +## grabValueFrom + +Retrieves a value from a form element located by CSS or XPath and returns it to test. +Resumes test execution, so **should be used inside a generator with `yield`** operator. + +```js +let email = yield I.grabValueFrom('input[name=email]'); +``` + +**Parameters** + +- `locator` + +## see + +Checks that a page contains a visible text. +Use context parameter to narrow down the search. + +```js +I.see('Welcome'); // text welcome on a page +I.see('Welcome', '.content'); // text inside .content div +I.see('Register', {css: 'form.register'}); // use strict locator +``` + +**Parameters** + +- `text` +- `context` + +## seeElement + +Checks that element is present on page. +Element is located by CSS or XPath. + +```js +I.seeElement('#modal'); +``` + +**Parameters** + +- `locator` + +## seeInCurrentUrl + +Checks that current url contains a provided fragment. + +```js +I.seeInCurrentUrl('/register'); // we are on registration page +``` + +**Parameters** + +- `urlFragment` + +## seeInTitle + +Checks that title contains text. + +**Parameters** + +- `text` + +## selectOption + +Selects an option in a drop-down select. +Field is siearched by label | name | CSS | XPath. +Option is selected by visible text or by value. + +```js +I.selectOption('Choose Plan', 'Monthly'); // select by label +I.selectOption('subscription', 'Monthly'); // match option by text +I.selectOption('subscription', '0'); // or by value +I.selectOption('//form/select[@name=account]','Permium'); +I.selectOption('form select[name=account]', 'Premium'); +I.selectOption({css: 'form select[name=account]'}, 'Premium'); +``` + +**Parameters** + +- `select` +- `option` + +## wait + +Pauses execution for a number of seconds. + +**Parameters** + +- `sec` + +## waitForElement + +Waits for element to be present on page (by default waits for 1sec). +Element can be located by CSS or XPath. + +**Parameters** + +- `selector` +- `sec` + +## waitForEnabled + +Waits for element to become enabled (by default waits for 1sec). +Element can be located by CSS or XPath. + +**Parameters** + +- `selector` +- `sec` + +## waitForText + +Waits for a text to appear (by default waits for 1sec). +Element can be located by CSS or XPath. +Narrow down search results by providing context. + +**Parameters** + +- `text` +- `sec` +- `context` + +## waitForVisible + +Waits for an element to become visible on a page (by default waits for 1sec). +Element can be located by CSS or XPath. + +**Parameters** + +- `selector` +- `sec` + +## waitUntil + +Waits for a function to return true (waits for 1sec by default). + +**Parameters** + +- `fn` +- `sec` diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..079232e96 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 DavertMik (http://codegyre.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 000000000..baf3b28b7 --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# codeceptjs [![NPM version][npm-image]][npm-url] [![Build Status][travis-image]][travis-url] [![Dependency Status][daviddm-image]][daviddm-url] [![Coverage percentage][coveralls-image]][coveralls-url] +> Modern Era Aceptance Testing Framework for NodeJS + + +## Install + +```sh +$ npm install --save codeceptjs +``` + + +## Usage + +```js +var codeceptjs = require('codeceptjs'); + + +``` + +## License + +MIT © [DavertMik](http://codegyre.com) + + +[npm-image]: https://badge.fury.io/js/codeceptjs.svg +[npm-url]: https://npmjs.org/package/codeceptjs +[travis-image]: https://travis-ci.org/Codeception/codeceptjs.svg?branch=master +[travis-url]: https://travis-ci.org/Codeception/codeceptjs +[daviddm-image]: https://david-dm.org/Codeception/codeceptjs.svg?theme=shields.io +[daviddm-url]: https://david-dm.org/Codeception/codeceptjs +[coveralls-image]: https://coveralls.io/repos/Codeception/codeceptjs/badge.svg +[coveralls-url]: https://coveralls.io/r/Codeception/codeceptjs diff --git a/bin/codecept.js b/bin/codecept.js new file mode 100755 index 000000000..2c4620553 --- /dev/null +++ b/bin/codecept.js @@ -0,0 +1,87 @@ +#!/usr/bin/env node +'use strict'; + +var program = require('commander'); +var path = require('path'); +var Codecept = require('../lib/codecept'); +var Config = require('../lib/config'); +var print = require('../lib/output'); +var fileExists = require('../lib/utils').fileExists; +var fs = require('fs'); + +program.command('init [path]') + .description('Creates dummy config in current dir or [path]') + .action(require('../lib/command/init')); + +program.command('generate test [path]') + .alias('gt') + .description('Generates an empty test') + .action(require('../lib/command/generate').test); + +program.command('generate pageobject [path]') + .alias('gpo') + .description('Generates an empty page object.') + .action(require('../lib/command/generate').pageObject); + +program.command('generate object [path]') + .alias('go') + .option('--type, -t [kind]', 'type of object to be created') + .description('Generates an empty support object (page/step/fragment).') + .action(require('../lib/command/generate').pageObject); + + + +// program.command('g helper [path]') +// .description('Generates a custom Helper') +// .action(require('../lib/command/createHelper')); + +// program.command('g helper [path]') +// .description('Generates a custom Helper') +// .action(require('../lib/command/createHelper')); + +program.command('run [suite] [test]') + .description('Executes tests') + + // codecept-only options + .option('--steps', 'show step-by-step execution') + .option('--debug', 'output additional information') + + // mocha options + .option('-c, --colors', 'force enabling of colors') + .option('-C, --no-colors', 'force disabling of colors') + .option('-G, --growl', 'enable growl notification support') + .option('-O, --reporter-options ', 'reporter-specific options') + .option('-R, --reporter ', 'specify the reporter to use', 'spec') + .option('-S, --sort', "sort test files") + .option('-b, --bail', "bail after first test failure") + .option('-d, --debug', "enable node's debugger, synonym for node --debug") + .option('-g, --grep ', 'only run tests matching ') + .option('-f, --fgrep ', 'only run tests containing ') + .option('-i, --invert', 'inverts --grep and --fgrep matches') + .option('--full-trace', 'display the full stack trace') + .option('--compilers :,...', 'use the given module(s) to compile files') + .option('--debug-brk', "enable node's debugger breaking on the first line") + .option('--inline-diffs', 'display actual/expected differences inline within each string') + .option('--no-exit', 'require a clean shutdown of the event loop: mocha will not call process.exit') + .option('--recursive', 'include sub directories') + .option('--trace', 'trace function calls') + + .action((suite, test, options) => { + console.log('CodeceptJS v'+ Codecept.version()); + let configFile = path.join(process.cwd(), suite || '', 'codecept.json'); + if (!fileExists(configFile)) { + print.error(`Config file not found at ${configFile}`); + console.log('Exiting... '); + return; + } + let config = Config.load(configFile); + let codecept = new Codecept(config, options); + codecept.loadTests(path.join(process.cwd(), suite || ''), config.tests); + codecept.run(test); +}); + +if (process.argv.length <= 2) { + console.log('CodeceptJS v'+ Codecept.version()); + program.outputHelp() +} +program.parse(process.argv); \ No newline at end of file diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 000000000..7a50e4b7f --- /dev/null +++ b/docs/API.md @@ -0,0 +1,430 @@ +# FileSystem + +Helper for testing filesystem. +Can be easily used to check file structures: + +```js +I.amInPath('test'); +I.seeFile('codecept.json'); +I.seeInThisFile('FileSystem'); +I.dontSeeInThisFile("WebDriverIO"); +``` + +## amInPath + +Enters a directory In local filesystem. +Starts from a current directory + +**Parameters** + +- `openPath` + +## dontSeeFileContentsEqual + +Checks that contents of file found by `seeFile` doesn't equal to text. + +**Parameters** + +- `text` +- `encoding` + +## dontSeeInThisFile + +Checks that file found by `seeFile` doesn't include text. + +**Parameters** + +- `text` +- `encoding` + +## seeFile + +Checks that file exists + +**Parameters** + +- `name` + +## seeFileContentsEqual + +Checks that contents of file found by `seeFile` equal to text. + +**Parameters** + +- `text` +- `encoding` + +## seeInThisFile + +Checks that file found by `seeFile` includes a text. + +**Parameters** + +- `text` +- `encoding` + +## writeToFile + +Writes test to file + +**Parameters** + +- `name` +- `text` + +# WebDriverIO + +WebDriverIO helper which wraps [webdriverio](http://webdriver.io/) library to +manipulate browser using Selenium WebDriver or PhantomJS. + +#### Selenium Installation + +1. Download [Selenium Server](http://docs.seleniumhq.org/download/) +2. Launch the daemon: `java -jar selenium-server-standalone-2.xx.xxx.jar` + +#### PhantomJS Installation + +PhantomJS is a headless alternative to Selenium Server that implements [the WebDriver protocol](https://code.google.com/p/selenium/wiki/JsonWireProtocol). +It allows you to run Selenium tests on a server without a GUI installed. + +1. Download [PhantomJS](http://phantomjs.org/download.html) +2. Run PhantomJS in WebDriver mode: `phantomjs --webdriver=4444` + +### Configuration + +This helper should be configured in codecept.json + +- `url` - base url of website to be tested +- `browser` - browser in which perform testing + +Additional configuration params can be used from + +**Parameters** + +- `config` + +## amOnPage + +Opens a web page in a browser. Requires relative or absolute url. +If url starts with `/`, opens a web page of a site defined in `url` config parameter. + +```js +I.amOnPage('/'); // opens main page of website +I.amOnPage('https://github.com'); // opens github +I.amOnPage('/login'); // opens a login page +``` + +**Parameters** + +- `url` + +## attachFile + +Attaches a file to element located by CSS or XPath + +**Parameters** + +- `locator` +- `pathToFile` + +## checkOption + +Selects a checkbox or radio button. +Element is located by label or name or CSS or XPath. + +The second parameter is a context (CSS or XPath locator) to narrow the search. + +```js +I.checkOption('#agree'); +I.checkOption('I Agree to Terms and Conditions'); +``` + +**Parameters** + +- `option` +- `context` + +## click + +Perform a click on a link or a button, given by a locator. +If a fuzzy locator is given, the page will be searched for a button, link, or image matching the locator string. +For buttons, the "value" attribute, "name" attribute, and inner text are searched. For links, the link text is searched. +For images, the "alt" attribute and inner text of any parent links are searched. + +The second parameter is a context (CSS or XPath locator) to narrow the search. + +```js +// simple link +I.click('Logout'); +// button of form +I.click('Submit'); +// CSS button +I.click('#form input[type=submit]'); +// XPath +I.click('//form/*[@type=submit]'); +// link in context +I.click('Logout', '#nav'); +// using strict locator +I.click({css: 'nav a.login'}); +``` + +**Parameters** + +- `link` +- `context` + +## dontSee + +Opposite to `see`. Checks that a text is not present on a page. +Use context parameter to narrow down the search. + +```js +I.dontSee('Login'); // assume we are already logged in +``` + +**Parameters** + +- `text` +- `context` + +## dontSeeElement + +Opposite to `seeElement`. Checks that element is not on page. + +**Parameters** + +- `locator` + +## dontSeeInCurrentUrl + +Checks that current url does not contain a provided fragment. + +**Parameters** + +- `urlFragment` + +## doubleClick + +Performs a double-click on an element matched by CSS or XPath. + +**Parameters** + +- `locator` + +## executeAsyncScript + +Executes async script on page. +Provided function should execute a passed callback (as first argument) to signal it is finished. + +**Parameters** + +- `fn` + +## executeScript + +Executes sync script on a page. +Pass arguments to function as additional parameters. +Will return execution result to a test. +In this case you should use generator and yield to receive results. + +**Parameters** + +- `fn` + +## fillField + +Fills a text field or textarea with the given string. +Field is located by name, label, CSS, or XPath. + +```js +// by label +I.fillField('Email', 'hello@world.com'); +// by name +I.fillField('password', '123456'); +// by CSS +I.fillField('form#login input[name=username]', 'John'); +// or by strict locator +I.fillField({css: 'form#login input[name=username]'}, 'John'); +``` + +**Parameters** + +- `field` +- `value` + +## grabAttribute + +Retrieves an attribute from an element located by CSS or XPath and returns it to test. +Resumes test execution, so **should be used inside a generator with `yield`** operator. + +```js +let hint = yield I.grabAttributeFrom('#tooltip', 'title'); +``` + +**Parameters** + +- `locator` +- `attr` + +## grabTextFrom + +Retrieves a text from an element located by CSS or XPath and returns it to test. +Resumes test execution, so **should be used inside a generator with `yield`** operator. + +```js +let pin = yield I.grabTextFrom('#pin'); +``` + +**Parameters** + +- `locator` + +## grabTitle + +Retrieves a page title and returns it to test. +Resumes test execution, so **should be used inside a generator with `yield`** operator. + +```js +let title = yield I.grabTitle(); +``` + +## grabValueFrom + +Retrieves a value from a form element located by CSS or XPath and returns it to test. +Resumes test execution, so **should be used inside a generator with `yield`** operator. + +```js +let email = yield I.grabValueFrom('input[name=email]'); +``` + +**Parameters** + +- `locator` + +## see + +Checks that a page contains a visible text. +Use context parameter to narrow down the search. + +```js +I.see('Welcome'); // text welcome on a page +I.see('Welcome', '.content'); // text inside .content div +I.see('Register', {css: 'form.register'}); // use strict locator +``` + +**Parameters** + +- `text` +- `context` + +## seeElement + +Checks that element is present on page. +Element is located by CSS or XPath. + +```js +I.seeElement('#modal'); +``` + +**Parameters** + +- `locator` + +## seeInCurrentUrl + +Checks that current url contains a provided fragment. + +```js +I.seeInCurrentUrl('/register'); // we are on registration page +``` + +**Parameters** + +- `urlFragment` + +## seeInTitle + +Checks that title contains text. + +**Parameters** + +- `text` + +## selectOption + +Selects an option in a drop-down select. +Field is siearched by label | name | CSS | XPath. +Option is selected by visible text or by value. + +```js +I.selectOption('Choose Plan', 'Monthly'); // select by label +I.selectOption('subscription', 'Monthly'); // match option by text +I.selectOption('subscription', '0'); // or by value +I.selectOption('//form/select[@name=account]','Permium'); +I.selectOption('form select[name=account]', 'Premium'); +I.selectOption({css: 'form select[name=account]'}, 'Premium'); +``` + +**Parameters** + +- `select` +- `option` + +## wait + +Pauses execution for a number of seconds. + +**Parameters** + +- `sec` + +## waitForElement + +Waits for element to be present on page (by default waits for 1sec). +Element can be located by CSS or XPath. + +**Parameters** + +- `selector` +- `sec` + +## waitForEnabled + +Waits for element to become enabled (by default waits for 1sec). +Element can be located by CSS or XPath. + +**Parameters** + +- `selector` +- `sec` + +## waitForText + +Waits for a text to appear (by default waits for 1sec). +Element can be located by CSS or XPath. +Narrow down search results by providing context. + +**Parameters** + +- `text` +- `sec` +- `context` + +## waitForVisible + +Waits for an element to become visible on a page (by default waits for 1sec). +Element can be located by CSS or XPath. + +**Parameters** + +- `selector` +- `sec` + +## waitUntil + +Waits for a function to return true (waits for 1sec by default). + +**Parameters** + +- `fn` +- `sec` diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 000000000..bf98b2769 --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,64 @@ +var path = require('path'); +var gulp = require('gulp'); +var eslint = require('gulp-eslint'); +var excludeGitignore = require('gulp-exclude-gitignore'); +var mocha = require('gulp-mocha'); +var istanbul = require('gulp-istanbul'); +var nsp = require('gulp-nsp'); +var plumber = require('gulp-plumber'); +var coveralls = require('gulp-coveralls'); +var documentation = require('gulp-documentation'); + +gulp.task('docs', function () { + + gulp.src('./lib/helper/*.js') + .pipe(documentation({ format: 'md' })) + .pipe(gulp.dest('Helpers.md')); + +}); + +gulp.task('static', function () { + return gulp.src('**/*.js') + .pipe(excludeGitignore()) + .pipe(eslint()) + .pipe(eslint.format()) + .pipe(eslint.failAfterError()); +}); + +gulp.task('nsp', function (cb) { + nsp('package.json', cb); +}); + +gulp.task('pre-test', function () { + return gulp.src('lib/**/*.js') + .pipe(istanbul({ + includeUntested: true + })) + .pipe(istanbul.hookRequire()); +}); + +gulp.task('test', function (cb) { + var mochaErr; + + gulp.src('test/**/*.js') + // .pipe(plumber()) + .pipe(mocha({reporter: 'spec'})); + // .on('error', function (err) { + // mochaErr = err; + // }) + // .on('end', function () { + // cb(mochaErr); + // }); +}); + +gulp.task('coveralls', ['test'], function () { + if (!process.env.CI) { + return; + } + + return gulp.src(path.join(__dirname, 'coverage/lcov.info')) + .pipe(coveralls()); +}); + +gulp.task('prepublish', ['nsp']); +gulp.task('default', ['static', 'test', 'coveralls']); diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 000000000..ebc090d22 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "target": "ES6", + "module": "commonjs" + } +} \ No newline at end of file diff --git a/lib/actor.js b/lib/actor.js new file mode 100644 index 000000000..4f1a8a904 --- /dev/null +++ b/lib/actor.js @@ -0,0 +1,27 @@ +'use strict'; +let Step = require('./step'); +let container = require('./container'); +let methodsOfObject = require('./utils').methodsOfObject; +let recorder = require('./recorder'); + +module.exports = function(obj) { + obj = obj || {}; + + let helpers = container.getHelpers(); + Object.keys(helpers) + .map(function (key) { return helpers[key]}) + .forEach((helper) => { + methodsOfObject(helper, 'Helper') + .filter((method) => { + return method != 'constructor' && method[0] != '_'}) + .forEach((action) => { + obj[action] = function() { + let args = arguments; + recorder.addStep(new Step(helper, action), args); + return recorder.promise; + }; + }); + }); + + return obj; +} \ No newline at end of file diff --git a/lib/assert.js b/lib/assert.js new file mode 100644 index 000000000..fb6a18308 --- /dev/null +++ b/lib/assert.js @@ -0,0 +1,46 @@ +'use strict'; + +let output = require('./output'); +let AssertionFailedError = require('./assert/error'); +let subs = require('./utils').template; + +class Assertion { + + constructor(comparator, params) { + this.comparator = comparator; + this.params = params || {}; + } + + assert() { + this.addAssertParams.apply(this, arguments); + let result = this.comparator.apply(this.params, arguments); + if (result) return; // should increase global assetion counter + throw this.getFailedAssertion(); + } + + negate() { + this.addAssertParams.apply(this, arguments); + let result = this.comparator.apply(this.params, arguments); + if (!result) return; // should increase global assetion counter + throw this.getFailedNegation(); + } + + addAssertParams() {} + + getException() { + return new AssertionFailedError(this.params, ''); + } + + getFailedNegation() { + let err = this.getException(); + err.params.type = 'not ' + err.params.type; + return err; + } + + getFailedAssertion() { + return this.getException(); + } + +} + +module.exports = Assertion; \ No newline at end of file diff --git a/lib/assert/empty.js b/lib/assert/empty.js new file mode 100644 index 000000000..b6a8237bc --- /dev/null +++ b/lib/assert/empty.js @@ -0,0 +1,48 @@ +'use strict'; +let Assertion = require('../assert'); +let AssertionFailedError = require('./error'); +let template = require('../utils').template; +let output = require('../output'); + +class EmptinessAssertion extends Assertion { + + constructor(params) { + super(function(value) { + if (Array.isArray(value)) { + return value.length === 0; + } + return !value; + }, params); + this.params.type = 'to be empty'; + } + + getException() { + if (Array.isArray(this.params.value)) { + this.params.value = '[' + this.params.value.join(', ')+']'; + } + let err = new AssertionFailedError(this.params, "{{customMessage}}expected {{subject}} {{value}} {{type}}"); + err.actual = this.params.value; + err.expected = []; + + err.cliMessage = () => { + let msg = err.message + .replace('{{value}}', output.colors.yellow.bold('{{value}}')) + .replace('{{subject}}', output.colors.bold('{{subject}}')); + return template(msg, this.params); + } + return err; + } + + + addAssertParams() { + this.params.value = arguments[0]; + this.params.customMessage = arguments[1] ? arguments[1]+"\n\n" : ''; + } +} + +module.exports = { + Assertion: EmptinessAssertion, + empty: (subject) => { + return new EmptinessAssertion({subject}); + } +} \ No newline at end of file diff --git a/lib/assert/equal.js b/lib/assert/equal.js new file mode 100644 index 000000000..a166e485f --- /dev/null +++ b/lib/assert/equal.js @@ -0,0 +1,45 @@ +'use strict'; +let Assertion = require('../assert'); +let AssertionFailedError = require('./error'); +let template = require('../utils').template; +let output = require('../output'); + +class EqualityAssertion extends Assertion { + + constructor(comparator, params) { + super(comparator, params); + this.params.type = 'to equal'; + } + + getException() { + let params = this.params; + params.jar = template(params.jar, params); + let err = new AssertionFailedError(params, "{{customMessage}}expected {{jar}} {{type}}"); + err.actual = this.params.actual; + err.expected = this.params.expected; + + err.cliMessage = () => { + let msg = err.message + .replace('{{jar}}', output.colors.bold('{{jar}}')); + return template(msg, this.params); + } + return err; + } + + addAssertParams() { + this.params.expected = arguments[0]; + this.params.actual = arguments[1]; + this.params.customMessage = arguments[2] ? arguments[2]+"\n\n" : ''; + } +} + +module.exports = { + Assertion: EqualityAssertion, + fileEquals: (file) => { + return new EqualityAssertion( + function(expected, actual) { + return expected == actual; + }, { file, jar: "contents of {{file}}" } + ); + } +} \ No newline at end of file diff --git a/lib/assert/error.js b/lib/assert/error.js new file mode 100644 index 000000000..be5881fcc --- /dev/null +++ b/lib/assert/error.js @@ -0,0 +1,28 @@ +'use strict'; +let subs = require('../utils').template; + +function AssertionFailedError(params, message) { + this.params = params; + this.message = message || 'AssertionFailedError'; + let stack = new Error().stack; + // this.showDiff = true; + stack = stack.split("\n").filter((line) => { + // @todo cut assert things nicer + return line.indexOf('lib/assert') < 0; + }); + this.stack = stack.join("\n"); + + this.inspect = () => { + let params = this.params || {}; + let msg = params.customMessage || ''; + return msg + subs(this.message, params); + } + + this.cliMessage = () => { + return this.inspect(); + } +} + +AssertionFailedError.prototype = Object.create(Error.prototype); + +module.exports = AssertionFailedError; \ No newline at end of file diff --git a/lib/assert/include.js b/lib/assert/include.js new file mode 100644 index 000000000..fc08c95cf --- /dev/null +++ b/lib/assert/include.js @@ -0,0 +1,84 @@ +'use strict'; +let Assertion = require('../assert'); +let AssertionFailedError = require('./error'); +let template = require('../utils').template; +let output = require('../output'); + +const MAX_LINES = 10; + +class InclusionAssertion extends Assertion { + + constructor(params) { + params.jar = params.jar || 'string'; + let comparator = function(needle, haystack) { + if (Array.isArray(haystack)) { + return haystack.filter(part => part.indexOf(needle) >= 0).length > 0; + } + return haystack.indexOf(needle) >= 0; + }; + super(comparator, params); + this.params.type = 'to include'; + } + + getException() { + let params = this.params; + params.jar = template(params.jar, params); + let err = new AssertionFailedError(params, "{{customMessage}}expected {{jar}} {{type}} {{needle}}"); + err.expected = params.needle; + err.actual = params.haystack; + if (Array.isArray(this.params.haystack)) { + this.params.haystack = this.params.haystack.join("\n___(next element)___\n"); + } + err.cliMessage = () => { + let msg = err.message + .replace('{{needle}}', output.colors.yellow.bold('{{needle}}')) + .replace('{{jar}}', output.colors.bold('{{jar}}')); + return template(msg, this.params); + } + return err; + } + + getFailedAssertion() { + let err = this.getException(); + let lines = this.params.haystack.split("\n"); + if (lines.length > MAX_LINES) { + let more = lines.length - MAX_LINES; + err.actual = lines.slice(0,MAX_LINES).join("\n")+`\n--( ${more} lines more )---`; + } + return err; + } + + getFailedNegation() { + this.params.type = 'not to include'; + let err = this.getException(); + let pattern = new RegExp("^.*?\n?^.*?\n?^.*?" + escapeRegExp(this.params.needle) + ".*?$\n?.*$\n?.*$", "m"); + err.actual = this.params.haystack.match(pattern)[0].replace(this.params.needle, output.colors.bold(this.params.needle)); + err.actual = `------\n${err.actual}\n------`; + return err; + } + + addAssertParams() { + this.params.needle = arguments[0]; + this.params.haystack = arguments[1]; + this.params.customMessage = arguments[2] ? arguments[2]+"\n\n" : ''; + } +} + +module.exports = { + Assertion: InclusionAssertion, + includes: (needleType) => { + needleType = needleType || 'string'; + return new InclusionAssertion({jar: needleType}); + }, + fileIncludes: (file) => { + return new InclusionAssertion( + function(needle, haystack) { + return haystack.indexOf(needle) >= 0; + }, { file, jar: "file {{file}}" } + ); + } +} + +function escapeRegExp(str) { + return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"); +} \ No newline at end of file diff --git a/lib/codecept.js b/lib/codecept.js new file mode 100644 index 000000000..fa0a703ac --- /dev/null +++ b/lib/codecept.js @@ -0,0 +1,55 @@ +'use strict'; + +let Mocha = require('mocha/lib/mocha'); +let fsPath = require('path'); +let readFileSync = require('fs').readFileSync; +let readdirSync = require('fs').readdirSync; +let statSync = require('fs').statSync; +let container = require('./container'); +let reporter = require('./reporter/cli'); +let event = require('../lib/event'); +let glob = require('glob'); +const scenarioUi = fsPath.join(__dirname, './interfaces/scenario.js'); + +class Codecept { + + constructor(config, opts) { + this.opts = opts; + this.config = config; + this.mocha = new Mocha(Object.assign(config.mocha, opts)); + this.mocha.ui(scenarioUi); + this.mocha.reporter(reporter, opts); + this.testFiles = []; + } + + loadTests(dir, pattern) { + global.codecept_dir = dir; + container.create(this.config); + listen(); + glob.sync(fsPath.join(dir, pattern)).forEach((file) => { + this.testFiles.push(fsPath.resolve(file)); + }); + + } + + run(test) { + this.mocha.files = this.testFiles; + if (test) { + this.mocha.files = this.mocha.files.filter((t) => fsPath.basename(t, '_test.js') == test || fsPath.basename(t, '.js') == test || fsPath.basename(t) == test); + } + this.mocha.run(() => { + event.dispatcher.emit(event.all.result, this); + }); + } + + static version() { + return JSON.parse(readFileSync(__dirname + '/../package.json', 'utf8')).version; + } +} + +module.exports = Codecept; + +function listen() { + require('./listener/steps'); + require('./listener/helpers'); +} diff --git a/lib/command/generate.js b/lib/command/generate.js new file mode 100644 index 000000000..56075eb92 --- /dev/null +++ b/lib/command/generate.js @@ -0,0 +1,132 @@ +'use strict'; +let output = require("../output"); +let inquirer = require("inquirer"); +let fs = require('fs'); +let path = require('path'); +let colors = require('colors'); +let fileExists = require('../utils').fileExists; +let ucfirst = require('../utils').ucfirst; + +function getTestRoot(currentPath) { + let testsPath = path.join(process.cwd(), currentPath || './test'); + + if (!path) { + output.print(`Test root is assumed to be ${colors.yellow.bold(testsPath)}`); + } else { + output.print(`Using test root ${colors.bold(testsPath)}`); + } + return testsPath; +} + +function getConfig(testsPath) { + let configFile = path.join(testsPath, 'codecept.json'); + if (!fileExists(configFile)) { + output.error(`No config file at ${configFile}`); + return; + } + return JSON.parse(fs.readFileSync(configFile, 'utf8')); +} + +function updateConfig(testsPath, config) { + let configFile = path.join(testsPath, 'codecept.json'); + return fs.writeFileSync(configFile, JSON.stringify(config, null, 2)); +} + +let testTemplate = ` +Feature('{{feature}}'); + +Scenario('test something', (I) => { + +}); +`; + +// generates empty test +module.exports.test = function(args) { + let testsPath = getTestRoot(args.path); + let config = getConfig(testsPath); + if (!config) return; + + output.print(`Creating a new test...`) + output.print('----------------------'); + + let defaultExt = config.tests.match(/\*(.*?)$/)[1] || '_test.js'; + + inquirer.prompt([ + { + type: 'input', + message: 'Filename of a test', + name: 'filename' + }, + { + type: 'input', + name: 'feature', + message: 'Feature which is being tested', + default: function(answers) { + return ucfirst(answers.filename).replace('_',' ').replace('-', ' '); + } + } + ], (result) => { + let testFilePath = path.dirname(path.join(testsPath, config.tests)); + let testFile = path.join(testFilePath, result.filename); + let ext = path.extname(testFile); + if (!ext) testFile += defaultExt; + let dir = path.dirname(testFile); + if (!fileExists(dir)) fs.mkdirSync(dir); + + let testContent = testTemplate.replace('{{feature}}', result.feature); + fs.writeFileSync(testFile, testContent); + output.success(`Test for ${result.filename} was created in ${testFile}`); + }); +} + +let pageObjectTemplate = ` +'use strict'; + +let I; + +exports = { + + _init: () => { + I = require('{{actor}}')(); + } + + // insert your locators and methods here +} +`; + +module.exports.pageObject = function(args) { + let testsPath = getTestRoot(args.path); + let config = getConfig(testsPath); + let kind = args.kind || 'page'; + if (!config) return; + + output.print(`Creating a new page object`) + output.print('--------------------------'); + + inquirer.prompt([{ + type: 'input', + name: 'name', + message: `Name of a ${kind} object` + }, { + type: 'input', + name: 'filename', + message: 'Where should it be stored', + default: (answers) => { + return `./${kind}s/${answers.name}.js` + } + }], (result) => { + let pageObjectFile = path.join(testsPath, result.filename); + let dir = path.dirname(pageObjectFile); + if (!fileExists(dir)) fs.mkdirSync(dir); + + let actor = config.include.I || 'codeceptjs/actor'; + if (actor.charAt(0) == '.') { // relative path + let relativePath = path.relative(dir, path.dirname(path.join(testsPath, config.include.I))); // get an upper level + actor = relativePath + actor.substring(1); + } + fs.writeFileSync(pageObjectFile, pageObjectTemplate.replace('{{actor}}', actor)); + config.include[result.name] = result.filename; + updateConfig(testsPath, config); + output.success(`Page object for ${result.name} was created in ${pageObjectFile}`); + }); +} \ No newline at end of file diff --git a/lib/command/init.js b/lib/command/init.js new file mode 100644 index 000000000..e03aeb56b --- /dev/null +++ b/lib/command/init.js @@ -0,0 +1,159 @@ +'use strict'; +let print = require('../output').print; +let success = require('../output').success; +let error = require('../output').error; +let colors = require('colors'); +let fs = require('fs'); +let path = require('path'); +let fileExists = require('../utils').fileExists; +var inquirer = require("inquirer"); + +let defaultConfig = { + "tests": "./*_test.js", + "timeout": 10000, + "output": null, + "helpers": { + }, + "include": { + }, + "mocha": { + } +} + +let defaultActor = ` +'use strict'; +// in this file you can append custom step methods to 'I' object + +exports = function() { + return require('./lib/actor')({ + + // Define custom steps here, use 'this' to access default methods of I. + // It is recommended to place a general 'login' function here. + + }); +} + + +`; + +module.exports = function(args, cb) { + let testsPath = path.join(process.cwd(), args.path || './test'); + + print(); + print(` Welcome to ${colors.magenta.bold('CodeceptJS')} initialization tool`); + print(" It will prepare and configure a test environment for you"); + print(); + + + if (!args.path) { + print(`No test root specified.`); + print(`Test root is assumed to be ${colors.yellow.bold(testsPath)}`); + print('----------------------------------'); + } else { + print(`Installing to ${colors.bold(testsPath)}`); + } + + if (!fileExists(testsPath)) { + print(`Directory ${testsPath} does not exist, creating...`); + fs.mkdirSync(testsPath); + } + + let configFile = path.join(testsPath, 'codecept.json'); + if (fileExists(configFile)) { + error(`Config is already created at ${configFile}`); + return; + } + + + inquirer.prompt( + [ + { + name: "tests", + type: "input", + default: "./*_test.js", + message: "Where are your tests located?" + }, + { + name: "helpers", + type: "checkbox", + choices: ["WebDriverIO", "FileSystem"], + message: "What helpers do you want to use?" + }, + { + name: "output", + default: "./output", + message: "Where should logs, screenshots, and reports to be stored?" + }, + { + name: "steps", + type: "confirm", + message: "Would you like to extend I object with custom steps?", + default: true + }, + { + name: "steps_file", + type: "input", + message: "Where would you like to place custom steps?", + default: "./steps_file.js", + when: function(answers) { + return answers.steps; + } + } + ], (result) => { + let config = defaultConfig; + config.name = testsPath.split(path.sep).pop(); + config.output = result.output; + result.helpers.forEach((helper) => config.helpers[helper] = {}); + if (result.steps_file) config.include.I = result.steps_file; + + let helperConfigs = []; + + result.helpers.forEach((helperName) => { + try { + let Helper = require('../helper/'+helperName); + if (!Helper._config) return; + helperConfigs = helperConfigs.concat(Helper._config().map((config) => { + config.message = `[${helperName}] ${config.message}`; + config.name = `${helperName}_${config.name}`; + config.type = config.type || 'input'; + return config; + })); + } catch (err) { + error(err); + } + }); + + if (result.helpers.length) { + print("Configure helpers..."); + } + + inquirer.prompt(helperConfigs, (helperResult) => { + + Object.keys(helperResult).forEach((key) => { + let helperName, configName; + let parts = key.split('_'); + helperName = parts[0]; + configName = parts[1]; + if (!configName) return; + config.helpers[helperName][configName] = helperResult[key]; + }) + + if (result.steps_file) { + let stepFile = path.join(testsPath, result.steps_file); + if (!fileExists(path.dirname(stepFile))) fs.mkdirSync(path.dirname(stepFile)); + fs.writeFileSync(stepFile, defaultActor); + config.include.I = result.steps_file; + success('Steps file created at '+stepFile); + } + + fs.writeFileSync(configFile, JSON.stringify(config, null, 2)); + success(`Config created at ${configFile}`); + + + if (config.output) { + fs.mkdirSync(path.join(testsPath, config.output)); + success("Directory for temporaty output files created at `_output`"); + } + }); + }); +} \ No newline at end of file diff --git a/lib/command/interactive.js b/lib/command/interactive.js new file mode 100644 index 000000000..c154909a1 --- /dev/null +++ b/lib/command/interactive.js @@ -0,0 +1,34 @@ +'use strict'; + +var Codecept = require('./../codecept'); +let container = require('./../container'); +let getParamNames = require('./../utils').getParamNames; + +let objects = container.support(); + +let I = objects.I; +let params; + +// vorpal +// .mode('repl') +// .description('Enters the user into a REPL session.') +// .delimiter('repl:') +// .action(function(command, callback) { +// eval('I.'+command); +// }); + +for (let method in I) { + params = getParamNames(I[method]); + + vorpal.command(`${method} [params...]`).action(function(args, callback) { + I[method].apply(I, args.params).then(() => { + return callback(); + }); + }); +} + + +module.exports = function() { + + vorpal.show(); +} \ No newline at end of file diff --git a/lib/config.js b/lib/config.js new file mode 100644 index 000000000..236c660b0 --- /dev/null +++ b/lib/config.js @@ -0,0 +1,26 @@ +'use strict'; +let fs = require('fs'); + +let defaultConfig = { + output: './_output', + helpers: {}, + include: {}, + mocha: {} +}; + +let config = {}; + +class Config { + + static load(configFile, force) { + if (Object.keys(config).length > 0 && !force) { + return config; + } + config = JSON.parse(fs.readFileSync(configFile, 'utf8')); + config = Object.assign(defaultConfig, config); + return config; + } +} + +module.exports = Config; + diff --git a/lib/container.js b/lib/container.js new file mode 100644 index 000000000..7e51cbeff --- /dev/null +++ b/lib/container.js @@ -0,0 +1,75 @@ +'use strict'; +let path = require('path'); + +function createHelpers(config) { + let helpers = {}; + let helperModule; + for (let helperName in config) { + try { + let HelperClass = require(helperModule = config.require || './helper/'+helperName); + helpers[helperName] = new HelperClass(config[helperName]); + } catch (err) { + throw new Error(`Could not load helper ${helperName} from module '${helperModule}'\n${err.message}`); + } + } + + for (let name in helpers) { + if (helpers[name]._init) helpers[name]._init(); + } + return helpers; +} + +function createSupportObjects(config) { + let objects = {}; + for (let name in config) { + let module = config[name]; + if (module.charAt(0) == '.') { + module = path.join(global.codecept_dir, module); + } + try { + objects[name] = require(module); + } catch (err) { + throw new Error(`Could not include object ${name} from module '${module}'\n${err.message}`); + } + try { + if (typeof(objects[name]) == 'function') { + objects[name] = objects[name](); + } else if (objects[name]._init) { + objects[name]._init(); + } + } catch (err) { + throw new Error(`Initialization failed for ${objects[name]}\n${err.message}`); + } + } + if (!objects.I) { + objects.I = require('./actor')(); + } + + return objects; +} + +let helpers = {}; +let support = {}; + +module.exports = { + + create: (config) => { + helpers = createHelpers(config.helpers || {}); + support = createSupportObjects(config.include || {}); + }, + + support: (name) => { + if (!name) { + return support; + } + return support[name]; + }, + + getHelpers: (name) => { + if (!name) { + return helpers; + } + return helpers[name]; + } + +} \ No newline at end of file diff --git a/lib/event.js b/lib/event.js new file mode 100644 index 000000000..d8be47eb7 --- /dev/null +++ b/lib/event.js @@ -0,0 +1,25 @@ +var events = require('events'); +var dispatcher = new events.EventEmitter(); + +module.exports = { + dispatcher, + test: { + before: 'test.before', + after: 'test.after', + failed: 'test.failed', + }, + suite: { + before: 'suite.before', + after: 'suite.after' + }, + step: { + init: 'step.init', + before: 'step.before', + after: 'step.after' + }, + all: { + before: 'global.before', + after: 'global.after', + result: 'global.result' + } +} \ No newline at end of file diff --git a/lib/helper.js b/lib/helper.js new file mode 100644 index 000000000..e310c452d --- /dev/null +++ b/lib/helper.js @@ -0,0 +1,46 @@ +'use strict'; + +let container = require('./container'); +let debug = require('./output').debug; + +class Helper { + + construtor(config) { + this.config = config; + } + + _init() { + + } + + _before() { + + } + + _after() { + + } + + _beforeStep() { + + } + + _afterStep() { + + } + + get helpers() { + return container.helpers; + } + + debug(msg) { + debug(msg); + } + + debugSection(section, msg) { + debug(`[${section}] ${msg}`); + } + +} + +module.exports = Helper; \ No newline at end of file diff --git a/lib/helper/FileSystem.js b/lib/helper/FileSystem.js new file mode 100644 index 000000000..186ec9e52 --- /dev/null +++ b/lib/helper/FileSystem.js @@ -0,0 +1,96 @@ +'use strict'; +let Helper = require('../helper'); +let fileExists = require('../utils').fileExists; +let fileIncludes = require('../assert/include').fileIncludes; +let fileEquals = require('../assert/equal').fileEquals; +let assert = require('assert'); +let path = require('path'); +let fs = require('fs'); +let colors = require('colors'); + +/** + * Helper for testing filesystem. + * Can be easily used to check file structures: + * + * ```js + * I.amInPath('test'); + * I.seeFile('codecept.json'); + * I.seeInThisFile('FileSystem'); + * I.dontSeeInThisFile("WebDriverIO"); + * ``` + */ +class FileSystem extends Helper { + + _before() { + this.dir = process.cwd(); + this.file = null; + this.debugSection('Dir', this.dir); + } + + /** + * Enters a directory In local filesystem. + * Starts from a current directory + */ + amInPath(openPath) { + this.dir = path.join(process.cwd(), openPath); + this.debugSection('Dir', this.dir); + } + + /** + * Writes test to file + */ + writeToFile(name, text) { + fs.writeFileSync(path.join(this.dir, name), text); + } + + /** + * Checks that file exists + */ + seeFile(name) { + this.file = path.join(this.dir, name); + this.debugSection('File', this.file); + assert.ok(fileExists(this.file), `File ${name} not found in ${this.dir}`); + } + + /** + * Checks that file found by `seeFile` includes a text. + */ + seeInThisFile(text, encoding) { + let content = getFileContents(this.file, encoding); + fileIncludes(this.file).assert(text, content); + } + + /** + * Checks that file found by `seeFile` doesn't include text. + */ + dontSeeInThisFile(text, encoding) { + let content = getFileContents(this.file, encoding); + fileIncludes(this.file).negate(text, content); + } + + /** + * Checks that contents of file found by `seeFile` equal to text. + */ + seeFileContentsEqual(text, encoding) { + let content = getFileContents(this.file, encoding); + fileEquals(this.file).assert(text, content); + } + + /** + * Checks that contents of file found by `seeFile` doesn't equal to text. + */ + dontSeeFileContentsEqual(text, encoding) { + let content = getFileContents(this.file, encoding); + fileEquals(this.file).negate(text, content); + } + +} + +function getFileContents(file, encoding) { + if (!file) assert.fail(`No files were opened, please use seeFile action`); + encoding = encoding || 'utf8'; + return fs.readFileSync(file, 'utf8'); + +} + +module.exports = FileSystem; \ No newline at end of file diff --git a/lib/helper/WebDriverIO.js b/lib/helper/WebDriverIO.js new file mode 100644 index 000000000..2688dc1f3 --- /dev/null +++ b/lib/helper/WebDriverIO.js @@ -0,0 +1,541 @@ +'use strict'; +let Helper = require('../helper'); +let webdriverio = require('webdriverio'); +let stringIncludes = require('../assert/include').includes; +let empty = require('../assert/empty').empty; +let xpathLocator = require('../utils').xpathLocator; + +/** + * WebDriverIO helper which wraps [webdriverio](http://webdriver.io/) library to + * manipulate browser using Selenium WebDriver or PhantomJS. + * + * #### Selenium Installation + * + * 1. Download [Selenium Server](http://docs.seleniumhq.org/download/) + * 2. Launch the daemon: `java -jar selenium-server-standalone-2.xx.xxx.jar` + * + * + * #### PhantomJS Installation + * + * PhantomJS is a headless alternative to Selenium Server that implements [the WebDriver protocol](https://code.google.com/p/selenium/wiki/JsonWireProtocol). + * It allows you to run Selenium tests on a server without a GUI installed. + * + * 1. Download [PhantomJS](http://phantomjs.org/download.html) + * 2. Run PhantomJS in WebDriver mode: `phantomjs --webdriver=4444` + * + * ### Configuration + * + * This helper should be configured in codecept.json + * + * * `url` - base url of website to be tested + * * `browser` - browser in which perform testing + * + * Additional configuration params can be used from http://webdriver.io/guide/getstarted/configuration.html + * + */ +class WebDriverIO extends Helper { + + constructor(config) { + super(config); + this.options = config; + if (!this.options.desiredCapailities) this.options.desiredCapailities = {}; + this.options.desiredCapailities.browserName = config.browser; + this.options.baseUrl = config.url; + } + + static _config() { + return [ + { name: 'url', message: "Base url of site to be tested", default: 'http://localhost' }, + { name: 'browser', message: 'Browser in which testing will be performed', default: 'firefox'} + ]; + } + + _before() { + return this.browser = webdriverio.remote(this.options).init(); + } + + _after() { + return this.browser.end(); + } + + /** + * Opens a web page in a browser. Requires relative or absolute url. + * If url starts with `/`, opens a web page of a site defined in `url` config parameter. + * + * ```js + * I.amOnPage('/'); // opens main page of website + * I.amOnPage('https://github.com'); // opens github + * I.amOnPage('/login'); // opens a login page + * ``` + */ + amOnPage(url) { + return this.browser.url(url).url((err, res) => { + this.debugSection('Url', res.value); + }); + } + + /** + * Perform a click on a link or a button, given by a locator. + * If a fuzzy locator is given, the page will be searched for a button, link, or image matching the locator string. + * For buttons, the "value" attribute, "name" attribute, and inner text are searched. For links, the link text is searched. + * For images, the "alt" attribute and inner text of any parent links are searched. + * + * The second parameter is a context (CSS or XPath locator) to narrow the search. + * + * ```js + * // simple link + * I.click('Logout'); + * // button of form + * I.click('Submit'); + * // CSS button + * I.click('#form input[type=submit]'); + * // XPath + * I.click('//form/*[@type=submit]'); + * // link in context + * I.click('Logout', '#nav'); + * // using strict locator + * I.click({css: 'nav a.login'}); + * ``` + */ + click(link, context) { + let client = this.browser; + let clickMethod = this.browser.isMobile ? 'touchClick' : 'elementIdClick'; + if (context) { + client = client.element(context); + } + return findClickable(client, link).then(function(res) { + if (!res.value || res.value.length === 0) { + throw new Error(`Clickable element ${link} was not found by text|CSS|XPath`); + }; + let elem = res.value[0]; + return this[clickMethod](elem.ELEMENT); + }); + } + + /** + * Performs a double-click on an element matched by CSS or XPath. + */ + doubleClick(locator) { + return this.browser.doubleClick(withStrictLocator(locator)); + } + + /** + * Fills a text field or textarea with the given string. + * Field is located by name, label, CSS, or XPath. + * + * ```js + * // by label + * I.fillField('Email', 'hello@world.com'); + * // by name + * I.fillField('password', '123456'); + * // by CSS + * I.fillField('form#login input[name=username]', 'John'); + * // or by strict locator + * I.fillField({css: 'form#login input[name=username]'}, 'John'); + * ``` + */ + fillField(field, value) { + return findFields(this.browser, field).then(function(res) { + if (!res.value || res.value.length === 0) { + throw new Error(`Field ${field} not found by name|text|CSS|XPath`); + }; + let elem = res.value[0]; + return this.elementIdClear(elem.ELEMENT).elementIdValue(elem.ELEMENT, value); + }); + } + + /** + * Selects an option in a drop-down select. + * Field is siearched by label | name | CSS | XPath. + * Option is selected by visible text or by value. + * + * ```js + * I.selectOption('Choose Plan', 'Monthly'); // select by label + * I.selectOption('subscription', 'Monthly'); // match option by text + * I.selectOption('subscription', '0'); // or by value + * I.selectOption('//form/select[@name=account]','Permium'); + * I.selectOption('form select[name=account]', 'Premium'); + * I.selectOption({css: 'form select[name=account]'}, 'Premium'); + * ``` + */ + selectOption(select, option) { + return findFields(this.browser, select).then(function(res) { + if (!res.value || res.value.length === 0) { + throw new Error(`Selectable field ${select} not found by name|text|CSS|XPath`); + }; + let elem = res.value[0]; + let normalized = `[normalize-space(.) = "${option.trim()}"]`; + let byVisibleText = `./option${normalized}|./optgroup/option${normalized}`; + + // try by visible text + return this.elementIdElements(elem.ELEMENT, byVisibleText).then(function(res) { + if (res.value && res.value.length) { + return this.elementIdClick(res.value[0].ELEMENT); + } + // try by value + let normalized = `[normalize-space(@value) = "${option.trim()}"]`; + let byValue = `./option${normalized}|./optgroup/option${normalized}`; + return this.elementIdElements(elem.ELEMENT, byValue).then(function(res) { + if (!res.value || res.value.length === 0) { + throw new Error(`Option ${option} in ${select} was found neither by visible text not by value`); + }; + return this.elementIdClick(res.value[0].ELEMENT); + }); + }); + }); + } + + /** + * Attaches a file to element located by CSS or XPath + */ + attachFile(locator, pathToFile) { + return this.browser.chooseFile(withStrictLocator(locator), pathToFile); + } + + /** + * Selects a checkbox or radio button. + * Element is located by label or name or CSS or XPath. + * + * The second parameter is a context (CSS or XPath locator) to narrow the search. + * + * ```js + * I.checkOption('#agree'); + * I.checkOption('I Agree to Terms and Conditions'); + * ``` + */ + checkOption(option, context) { + let client = this.browser; + let clickMethod = this.browser.isMobile ? 'touchClick' : 'elementIdClick'; + if (context) { + client = client.element(withStrictLocator(context)); + } + return findCheckable(client, option).then((res) => { + if (!res.value || res.value.length === 0) { + throw new Error(`Checkable ${option} can't be located by name|text|CSS|XPath`); + } + let elem = res.value[0]; + return client.elementIdSelected(elem.ELEMENT).then(function(isSelected) { + if (isSelected.value) return true; + return this[clickMethod](elem.ELEMENT); + }); + }); + } + + /** + * Retrieves a text from an element located by CSS or XPath and returns it to test. + * Resumes test execution, so **should be used inside a generator with `yield`** operator. + * + * ```js + * let pin = yield I.grabTextFrom('#pin'); + * ``` + */ + grabTextFrom(locator) { + return this.browser.getText(withStrictLocator(locator)).then(function(text) { + return text; + }); + } + + /** + * Retrieves a value from a form element located by CSS or XPath and returns it to test. + * Resumes test execution, so **should be used inside a generator with `yield`** operator. + * + * ```js + * let email = yield I.grabValueFrom('input[name=email]'); + * ``` + */ + grabValueFrom(locator) { + return this.browser.getValue(withStrictLocator(locator)).then(function(text) { + return text; + }); + } + + /** + * Retrieves an attribute from an element located by CSS or XPath and returns it to test. + * Resumes test execution, so **should be used inside a generator with `yield`** operator. + * + * ```js + * let hint = yield I.grabAttributeFrom('#tooltip', 'title'); + * ``` + */ + grabAttribute(locator, attr) { + return this.browser.getAttribute(withStrictLocator(locator), attr).then(function(text) { + return text; + }); + } + + /** + * Checks that title contains text. + */ + seeInTitle(text) { + return this.browser.getTitle().then((title) => { + return stringIncludes('web page title').assert(text, title); + }); + } + + /** + * Retrieves a page title and returns it to test. + * Resumes test execution, so **should be used inside a generator with `yield`** operator. + * + * ```js + * let title = yield I.grabTitle(); + * ``` + */ + grabTitle() { + return this.browser.getTitle().then((title) => { + this.debugSection('Title', title); + return title; + }); + } + + /** + * Checks that a page contains a visible text. + * Use context parameter to narrow down the search. + * + * ```js + * I.see('Welcome'); // text welcome on a page + * I.see('Welcome', '.content'); // text inside .content div + * I.see('Register', {css: 'form.register'}); // use strict locator + * ``` + */ + see(text, context) { + return proceedSee.call(this, 'assert', text,context); + } + + /** + * Opposite to `see`. Checks that a text is not present on a page. + * Use context parameter to narrow down the search. + * + * ```js + * I.dontSee('Login'); // assume we are already logged in + * ``` + */ + dontSee(text, context) { + return proceedSee.call(this, 'negate', text,context); + } + + /** + * Checks that element is present on page. + * Element is located by CSS or XPath. + * + * ```js + * I.seeElement('#modal'); + * ``` + */ + seeElement(locator) { + return this.browser.elements(withStrictLocator(locator)).then(function(res) { + return empty('elements').negate(res.value); + }); + } + + /** + * Opposite to `seeElement`. Checks that element is not on page. + */ + dontSeeElement(locator) { + return this.browser.elements(withStrictLocator(locator)).then(function(res) { + return empty('elements').assert(res.value); + }); + } + + /** + * Checks that current url contains a provided fragment. + * + * ```js + * I.seeInCurrentUrl('/register'); // we are on registration page + * ``` + */ + seeInCurrentUrl(urlFragment) { + return this.browser.url().then(function(res) { + return stringIncludes('url').assert(urlFragment, res.value); + }); + } + + /** + * Checks that current url does not contain a provided fragment. + */ + dontSeeInCurrentUrl(urlFragment) { + return this.browser.url().then(function(res) { + return stringIncludes('url').negate(urlFragment, res.value); + }); + } + + /** + * Executes sync script on a page. + * Pass arguments to function as additional parameters. + * Will return execution result to a test. + * In this case you should use generator and yield to receive results. + */ + executeScript(fn) { + return this.browser.execute.apply(this.browser, arguments); + } + + /** + * Executes async script on page. + * Provided function should execute a passed callback (as first argument) to signal it is finished. + */ + executeAsyncScript(fn) { + return this.browser.executeAsync.apply(this.browser, arguments); + } + + /** + * Pauses execution for a number of seconds. + */ + wait(sec) { + return this.browser.pause(sec * 1000); + } + + /** + * Waits for element to become enabled (by default waits for 1sec). + * Element can be located by CSS or XPath. + */ + waitForEnabled(selector, sec) { + sec = sec || 1; + return this.browser.waitForEnabled(withStrictLocator(selector), sec*1000); + } + + /** + * Waits for element to be present on page (by default waits for 1sec). + * Element can be located by CSS or XPath. + */ + waitForElement(selector, sec) { + sec = sec || 1; + return this.browser.waitForExist(withStrictLocator(selector), sec*1000); + } + + /** + * Waits for a text to appear (by default waits for 1sec). + * Element can be located by CSS or XPath. + * Narrow down search results by providing context. + */ + waitForText(text, sec, context) { + sec = sec || 1; + context = context || 'body'; + return this.browser.waitUntil(function() { + this.getText(context).then(function(source) { + if(Array.isArray(source)) { + return source.filter(part => part.indexOf(text) >= 0).length > 0 + } + return source.indexOf(text) >= 0; + }); + }, sec*1000); + } + + /** + * Waits for an element to become visible on a page (by default waits for 1sec). + * Element can be located by CSS or XPath. + */ + waitForVisible(selector, sec) { + sec = sec || 1; + return this.browser.waitForVisible(withStrictLocator(selector), sec*1000); + } + + /** + * Waits for a function to return true (waits for 1sec by default). + */ + waitUntil(fn, sec) { + sec = sec || 1; + return this.browser.waitUntil(fn, sec); + } +} + +function proceedSee(assertType, text, context) { + let description = 'element '+context; + if (!context) { + context = 'body'; + description = 'web page'; + } + return this.browser.getText(withStrictLocator(context)).then(function(source) { + return stringIncludes(description)[assertType](text, source); + }); +} + +function findClickable(client, locator) { + if (typeof(locator) === 'object') return client.elements(withStrictLocator(locator)); + if (isCSSorXPathLocator(locator)) return client.elements(locator); + + let literal = xpathLocator.literal(locator); + + let narrowLocator = xpathLocator.combine([ + `//a[normalize-space(.)=${literal}]`, + `//button[normalize-space(.)=${literal}]`, + `//a/img[normalize-space(@alt)=${literal}]/ancestor::a`, + `//input[./@type = 'submit' or ./@type = 'image' or ./@type = 'button'][normalize-space(@value)=${literal}]` + ]); + return client.elements(narrowLocator).then(function(els) { + if (els.value.length) { + return els; + } + let wideLocator = xpathLocator.combine([ + `//a[./@href][((contains(normalize-space(string(.)), ${literal})) or .//img[contains(./@alt, ${literal})])]`, + `//input[./@type = 'submit' or ./@type = 'image' or ./@type = 'button'][contains(./@value, ${literal})]`, + `//input[./@type = 'image'][contains(./@alt, ${literal})]`, + `//button[contains(normalize-space(string(.)), ${literal})]`, + `//input[./@type = 'submit' or ./@type = 'image' or ./@type = 'button'][./@name = ${literal}]`, + `//button[./@name = ${literal}]` + ]); + return this.elements(wideLocator).then(function(els) { + if (els.value.length) { + return els; + } + return this.elements(locator); // by css or xpath + }); + }); +} + +function findFields(client, locator) { + if (typeof(locator) === 'object') return client.elements(strictLocator(locator)); + if (isCSSorXPathLocator(locator)) return client.elements(locator); + + let literal = xpathLocator.literal(locator); + let byText = xpathLocator.combine([ + `//*[self::input | self::textarea | self::select][not(./@type = 'submit' or ./@type = 'image' or ./@type = 'hidden')][(((./@name = ${literal}) or ./@id = //label[contains(normalize-space(string(.)), ${literal})]/@for) or ./@placeholder = ${literal})]`, + `//label[contains(normalize-space(string(.)), ${literal})]//.//*[self::input | self::textarea | self::select][not(./@type = 'submit' or ./@type = 'image' or ./@type = 'hidden')]` + ]); + return client.elements(byText).then(function(els) { + if (els.value.length) return els; + let byName = `//*[self::input | self::textarea | self::select][@name = ${literal}]` + return this.elements(byName).then(function(els) { + if (els.value.length) return els; + return this.elements(locator); // by css or xpath + }); + }); +} + +function findCheckable(client, locator) { + let literal = xpathLocator.literal(locator); + + let byText = xpathLocator.combine([ + `//input[@type = 'checkbox' or @type = 'radio'][(@id = //label[contains(normalize-space(string(.)), ${literal})]/@for) or @placeholder = ${literal}]`, + `//label[contains(normalize-space(string(.)), ${literal})]//input[@type = 'radio' or @type = 'checkbox']` + ]); + return client.elements(byText).then(function(els) { + if (els.value.length) { + return els; + } + return this.elements(locator); // by css or xpath + }); +} + +function isCSSorXPathLocator(locator) { + if (locator[0] == '#' || locator[0] == '.') { + return true; + } + if (locator.substr(0,2) == '//') { + return true; + } + return false; +} + +function withStrictLocator(locator) { + if (typeof(locator) !== 'object') return locator; + let key = Object.keys(locator)[0]; + let value = locator[key]; + switch (key) { + case 'by': + case 'xpath': + case 'css': return value; + case 'id': return '#'+value; + case 'name': return `[name="value"]`; + } +} + +module.exports = WebDriverIO; \ No newline at end of file diff --git a/lib/interfaces/scenario.js b/lib/interfaces/scenario.js new file mode 100644 index 000000000..d6c8e43af --- /dev/null +++ b/lib/interfaces/scenario.js @@ -0,0 +1,106 @@ +/** + * Module dependencies. + */ + +var Suite = require('mocha/lib/suite'); +var Test = require('mocha/lib/test'); +var event = require('../event'); +var scenario = require('../scenario'); + +/** + * Codecept-style interface: + * + * Feature('login') + * Scenario('login as regular user', (I) { + * I.fillField(); + * I.click() + * I.see('Hello, '+data.login); + * }); + * + * @param {Suite} suite Root suite. + */ +module.exports = function(suite) { + var suites = [suite]; + var beforeHooks = []; + var afterHooks = []; + + suite.on('pre-require', function(context, file, mocha) { + var common = require('mocha/lib/interfaces/common')(suites, context); + + common.before('codeceptjs event', () => { + event.dispatcher.emit(event.suite.before); + }); + + common.after('codeceptjs event', () => { + event.dispatcher.emit(event.suite.after); + }); + + // create dispatcher + + context.BeforeAll = common.before; + context.AfterAll = common.after; + context.Before = common.beforeEach; + context.After = common.afterEach; + context.run = mocha.options.delay && common.runWithSuite(suite); + /** + * Describe a "suite" with the given `title` + * and callback `fn` containing nested suites + * and/or tests. + */ + + context.Feature = function(title) { + var suite = Suite.create(suites[0], title); + suite.file = file; + suites.unshift(suite); + return suite; + }; + + /** + * Describe a specification or test-case + * with the given `title` and callback `fn` + * acting as a thunk. + */ + + context.Scenario = function(title, fn) { + var suite = suites[0]; + if (suite.pending) { + fn = null; + } + suite.timeout(0); + + // parse fn params + // grab params from container + // relace fn with new + // + // params.push( = ... containter.get() + // fn = function() { + // fn.apply(context, params); + // } + // also it should wrap test in co + + var test = new Test(title, fn); + test.file = file; + test.async = true; + test.timeout(0); + suite.addTest(scenario(test)); + return test; + }; + + /** + * Exclusive test-case. + */ + context.Scenario.only = function(title, fn) { + var test = context.it(title, fn); + var reString = '^' + escapeRe(test.fullTitle()) + '$'; + mocha.grep(new RegExp(reString)); + return test; + }; + + /** + * Pending test case. + */ + context.xScenario = context.Scenario.skip = function(title) { + context.Scenario(title); + }; + }); +}; diff --git a/lib/listener/helpers.js b/lib/listener/helpers.js new file mode 100644 index 000000000..e74b9eda6 --- /dev/null +++ b/lib/listener/helpers.js @@ -0,0 +1,39 @@ +'use strict'; + +let container = require('../container'); +let event = require('../event'); +let recorder = require('../recorder'); + +let helpers = container.getHelpers(); + +let runHelpersHook = (hook, param) => { + Object.keys(helpers).forEach((key) => { + helpers[key][hook](param); + }); +} + +let runAsyncHelpersHook = (hook, param) => { + Object.keys(helpers).forEach((key) => { + recorder.add(() => helpers[key][hook](param)); + }); +} + +event.dispatcher.on(event.all.before, function() { + runHelpersHook('_init'); +}); + +event.dispatcher.on(event.test.before, function(test) { + runAsyncHelpersHook('_before', test); +}); + +event.dispatcher.on(event.test.after, function(test) { + runAsyncHelpersHook('_after', test); +}); + +event.dispatcher.on(event.step.before, function(step) { + runHelpersHook('_beforeStep', step); +}); + +event.dispatcher.on(event.step.after, function(step) { + runHelpersHook('_afterStep', step); +}); \ No newline at end of file diff --git a/lib/listener/steps.js b/lib/listener/steps.js new file mode 100644 index 000000000..657d9ea8b --- /dev/null +++ b/lib/listener/steps.js @@ -0,0 +1,21 @@ +'use strict'; + +let event = require('../event'); + +let currentTest; +let steps; + +event.dispatcher.on(event.test.before, function(test) { + steps = []; + currentTest = test; +}); + +event.dispatcher.on(event.test.after, function(test) { + test.steps = steps; + currentTest = null; +}); + +event.dispatcher.on(event.step.before, function(step) { + if (!currentTest || !currentTest.steps) return; + steps.push(step); +}); diff --git a/lib/output.js b/lib/output.js new file mode 100644 index 000000000..678219c7a --- /dev/null +++ b/lib/output.js @@ -0,0 +1,123 @@ +'use strict'; + +let colors = require('colors'); +let print = console.log; +let symbols = require('mocha/lib/reporters/base').symbols; + +let debugEnabled = false; + +let styles = { + error: colors.bgRed.white.bold, + success: colors.bgGreen.white.bold, + scenario: colors.magenta.bold, + basic: colors.white, + debug: colors.cyan +}; + +module.exports = { + colors, + styles, + print, + + enableDebug(opt) { + debugEnabled = opt; + }, + + debug: (msg) => { + if (debugEnabled) print(styles.debug(" > " + msg)); + }, + + error: (msg) => { + print(styles.error(msg)); + }, + + success: (msg) => { + print(styles.success(msg)); + }, + + step: (step) => { + let sym = process.platform === 'win32' ? '*' : '•'; + print(` ${sym} ${step.toString()}`); + }, + + suite: { + started: (suite) => { + if (!suite.title) return; + print(colors.bold(suite.title) + ' --'); + } + }, + + test: { + started: (test) => { + print(` ${colors.magenta.bold(test.title)}`) + }, + passed: (test) => { + print(` ${colors.green.bold(symbols.ok)} ${test.title} ${colors.grey('in '+test.duration+'ms')}`); + }, + failed: (test) => { + print(` ${colors.red.bold(symbols.err)} ${test.title} ${colors.grey('in '+test.duration+'ms')}`); + }, + skipped: (test) => { + print(` ${colors.yellow.bold('S')} ${test.title}`); + } + }, + + scenario: { + started: (test) => { + + }, + passed: (test) => { + print(' '+colors.green.bold(`${symbols.ok} OK`) +' '+colors.grey(`in ${test.duration}ms`)); + print(); + }, + failed: (test) => { + print(' '+colors.red.bold(`${symbols.err} FAILED`) +' '+colors.grey(`in ${test.duration}ms`)); + print(); + } + + + }, + + say: (message) => { + print(` ${colors.cyan.bold(message)}`) + }, + + result: (passed, failed, pending, duration) => { + let style = colors.bgGreen; + let msg = ` ${passed || 0} passed`; + let status = style.bold(` OK `); + if (failed) { + style = style.bgRed; + status = style.bold(` FAIL `); + msg += `, ${failed} failed`; + } + status += style.grey(` |`); + + if (pending) { + if (!failed) style = style.bgYellow; + msg += `, ${pending} pending`; + } + msg += " "; + print(status + style(msg) + colors.grey(` // ${duration}`)); + } + + // result: { + // passed: (num, duration) => { + // print(ind()+colors.green.bold(num) + ' passed ' + colors.grey(`(${duration})`)); + // }, + // failed: (num) => { + // if (!num) return; + // print(ind()+colors.red.bold(`${num} failed`)); + // }, + // pending: (num) => { + // if (!num) return; + // print(ind()+colors.yellow.bold(`${num} pending`)); + // } + // } + +} + + +function ind() { + return " "; +} \ No newline at end of file diff --git a/lib/pause.js b/lib/pause.js new file mode 100644 index 000000000..b8152d5b6 --- /dev/null +++ b/lib/pause.js @@ -0,0 +1,66 @@ +'use strict'; +let container = require('./container'); +let recorder = require('./recorder'); +let output = require('./output'); +let methodsOfObject = require('./utils').methodsOfObject; + +let readline = require('readline') +let util = require('util') +let colors = require('colors') // npm install colors +let rl; + +function completer(line) { + let I = container.support('I'); + var completions = methodsOfObject(I); + var hits = completions.filter(function(c) { + if (c.indexOf(line) == 0) { + // console.log('bang! ' + c); + return c; + } + }); + return [hits && hits.length ? hits : completions, line]; +} + +function askForStep(done) { + return new Promise(function(resolve) { + let I = container.support('I'); + + rl.setPrompt(' I.', 3); + rl.prompt(); + rl.on('line', function(cmd) { + if (!cmd) { + done(); + recorder.session.restore(); + rl.close(); + return resolve(); + } + try { + eval('I.'+cmd); + } catch (err) { + output.print(output.styles.error(" ERROR "), err.message); + } + recorder.session.catch(function(err) { + let msg = err.cliMessage ? err.cliMessage() : err.message; + return output.print(output.styles.error(" FAIL "), msg); + }); + recorder.add(() => askForStep(done)); + resolve(); + }) + }); +} + +module.exports = function() { + recorder.add(function() { + recorder.session.start(); + output.print(colors.yellow(" Interative debug session started")); + output.print(colors.yellow(" Use JavaScript syntax to try steps in action")); + output.print(colors.yellow(` Press ${colors.bold('ENTER')} to resume test`)); + rl = readline.createInterface(process.stdin, process.stdout, completer); + rl.on('close', function() { + console.log('Resuming test execution....'); + }) + return new Promise(function(resolve) { + return askForStep(resolve); + }); + }); +} \ No newline at end of file diff --git a/lib/readline.js b/lib/readline.js new file mode 100644 index 000000000..9a26d8fb8 --- /dev/null +++ b/lib/readline.js @@ -0,0 +1,113 @@ + +var readline = require('readline') + , util = require('util') + , colors = require('colors') // npm install colors + , rl = readline.createInterface(process.stdin, process.stdout, completer) + + , help = [ '.help ' + 'display this message.'.grey + , '.error ' + 'display an example error'.grey + , '.q[uit] ' + 'exit console.'.grey + ].join('\n') + ; + +// This should work now, thanks to @josher19 +function completer(line) { + var completions = '.help .error .exit .quit .q'.split(' ') + var hits = completions.filter(function(c) { + if (c.indexOf(line) == 0) { + // console.log('bang! ' + c); + return c; + } + }); + return [hits && hits.length ? hits : completions, line]; +} + +function welcome() { + util.puts([ "= readline-demo " + , "= Welcome, enter .help if you're lost." + , "= Try counting from 1 to 5!" + ].join('\n').grey); + prompt(); +} + +function prompt() { + var arrow = '> ' + , length = arrow.length + ; + + rl.setPrompt(arrow.grey, length); + rl.prompt(); +} + +var state = 1; +function exec(command) { + var num = parseInt(command, 10); + if (1 <= num && num <= 5) { + if (state === num) { + state++; + console.log('WIN'.green); + } else { + console.log(('Try entering a different number, like ' + + state + ' for example').red); + } + if (state === 6) { + console.log('WOW YOU ROCKS A LOT!'.rainbow); + process.exit(0); + } + + } else if (command[0] === '.') { + + switch (command.slice(1)) { + case 'help': + util.puts(help.yellow); + break; + case 'error': + console.log("Here's what an error might look like"); + JSON.parse('{ a: "bad JSON" }'); + break; + case 'exit': + case 'quit': + case 'q': + process.exit(0); + break; + } + } else { + // only print if they typed something + if (command !== '') { + console.log(('\'' + command + + '\' is not a command dude, sorryz').yellow); + } + } + prompt(); +} + +// +// Set things up +// +rl.on('line', function(cmd) { + exec(cmd.trim()); +}).on('close', function() { + // only gets triggered by ^C or ^D + util.puts('goodbye!'.green); + process.exit(0); +}); + +process.on('uncaughtException', function(e) { + util.puts(e.stack.red); + rl.prompt(); +}); + +welcome(); + +// Helpful thing I didn't get around to using: +// Make sure the buffer is flushed before +// we display the prompt. +function flush(callback) { + if (process.stdout.write('')) { + callback(); + } else { + process.stdout.once('drain', function() { + callback(); + }); + } +}; \ No newline at end of file diff --git a/lib/recorder.js b/lib/recorder.js new file mode 100644 index 000000000..f0aeee0dc --- /dev/null +++ b/lib/recorder.js @@ -0,0 +1,75 @@ +'use strict'; + +let promise, oldpromise; +let running = false; +let finishFn, errFn; +let next; + +module.exports = { + start(err, finish) { + finishFn = finish; + errFn = err; + running = true; + this.reset(); + }, + + session: { + start(errFn) { + oldpromise = promise; + promise = Promise.resolve(); + }, + + restore() { + promise = promise.then(() => oldpromise); + }, + + catch(errFn) { + promise = promise.catch(errFn); + } + + }, + + + reset() { + if (promise) this.catch(); + promise = Promise.resolve(); + }, + + add(fn) { + if (!running) { + return promise = Promise.resolve().then(fn).then(() => promise); + } + return promise = promise.then(fn); + }, + + addStep(step, args) { + this.add(() => step.run.apply(step, args)); + }, + + catch() { + return promise = promise.catch((err) => { + errFn(err); + finishFn(err); + this.stop(); + }) + }, + + finalize() { + if (!running) return; + this.catch(); + this.add(() => { if (running) finishFn(); }); + this.add(() => this.stop()); + }, + + throw(err) { + return this.add(function() { + throw err; + }); + }, + + stop() { + running = false; + }, + + promise +} \ No newline at end of file diff --git a/lib/reporter/cli.js b/lib/reporter/cli.js new file mode 100644 index 000000000..d74ee3c7b --- /dev/null +++ b/lib/reporter/cli.js @@ -0,0 +1,111 @@ +'use strict'; + +let Base = require('mocha/lib/reporters/base'); +let ms = require('mocha/lib/ms'); +let event = require('../event'); +let AssertionFailedError = require('../assert/error'); +let output = require('../output'); +let tests = []; +let currentTest; +var cursor = Base.cursor; +var color = Base.color; + + +class Cli extends Base { + constructor(runner, opts) { + super(runner); + let showSteps = opts.steps || opts.debug || false; + output.enableDebug(opts.debug); + + let indents = 0; + function indent() { + return Array(indents).join(' '); + } + + + runner.on('start', function () { + console.log(); + }); + + runner.on('suite', function (suite) { + output.suite.started(suite); + }); + + runner.on('fail', function(test, err) { + if (showSteps && test.steps) { + return output.scenario.failed(test); + } + cursor.CR(); + output.test.failed(test); + }); + + runner.on('pending', function(test) { + cursor.CR(); + output.test.skipped(test); + }); + + runner.on('pass', function (test) { + if (showSteps && test.steps) { + return output.scenario.passed(test); + } + cursor.CR(); + output.test.passed(test); + }); + + if (showSteps) { + runner.on('test', function (test) { + if (test.steps) { + output.test.started(test); + } + }); + + event.dispatcher.on(event.step.before, function (step) { + output.step(step) + }); + } + + runner.on('end', this.result.bind(this)); + } + + result() { + let stats = this.stats; + console.log(); + + // passes + if (stats.failures) { + output.print('-- FAILURES:'); + } + + // failures + if (stats.failures) { + + // append step traces + this.failures.forEach((test) => { + + let err = test.err; + let msg = err.message; + if (err instanceof AssertionFailedError) { + msg = err.message = err.cliMessage(); + } + let steps = test.steps; + if (steps && steps.length) { + let scenarioTrace = ""; + steps.reverse().forEach((step, i) => { + let line = `${steps.length - i}) ${step.toCode()}`; + if (step.status == 'failed') line = output.colors.white.bgRed(line); + scenarioTrace += "\n" + line; + }); + msg += `\n\n${output.colors.white('Scenario Steps:')} ${scenarioTrace}`; + } + err.stack = msg + "\n\n" + output.colors.grey(err.stack); + }); + Base.list(this.failures); + console.log(); + } + + output.result(stats.passes, stats.failures, stats.pending, ms(stats.duration)); + } +} +module.exports = function (runner, opts) { + return new Cli(runner, opts); +}; \ No newline at end of file diff --git a/lib/scenario.js b/lib/scenario.js new file mode 100644 index 000000000..841d29d7e --- /dev/null +++ b/lib/scenario.js @@ -0,0 +1,75 @@ +'use strict'; +let event = require('./event'); +let container = require('./container'); +let recorder = require('./recorder'); +let getParamNames = require('./utils').getParamNames; + +global.pause = require('./pause'); + +let scenario = (test) => { + let testArguments = []; + let testFn = test.fn; + if (!testFn) { + return test; + } + let params = getParamNames(testFn) || []; + let objects = container.support(); + for (var key in params) { + let obj = params[key]; + // @todo rebuild support container per test + if (!objects[obj]) { + throw new Error(`Object of type ${obj} is not defined in container`); + } + testArguments.push(container.support(obj)); + } + test.steps = []; + test.fn = function(done) { + + function errTest(err){ + event.dispatcher.emit(event.test.failed, test, err); + } + + function finishTest(err) { + event.dispatcher.emit(event.test.after, test); + done(err); + } + + recorder.start(errTest, finishTest); + try { + event.dispatcher.emit(event.test.before, test); + let res = testFn.apply(test, testArguments); + + if (isGenerator(testFn)) { + res.next(); // running test + recorder.catch(); // catching possible errors in promises + let resumeTest = function() { + recorder.add(function(data) { + recorder.reset(); // creating a new promise chain + try { + let resume = res.next(data); + resume.done ? recorder.finalize() : resumeTest(); + } catch (err) { + recorder.throw(err); + } + }); + }; + resumeTest(); + } + } catch (err) { + recorder.throw(err); + } finally { + if (!isGenerator(testFn)) { + recorder.finalize(); + } + } + } + return test; +} + + + +function isGenerator(fn) { + return fn.constructor.name == 'GeneratorFunction'; +} + +module.exports = scenario; \ No newline at end of file diff --git a/lib/step.js b/lib/step.js new file mode 100644 index 000000000..3eda1bf17 --- /dev/null +++ b/lib/step.js @@ -0,0 +1,63 @@ +'use strict'; + +let container = require('./container'); +let event = require('./event'); + +class Step { + + constructor(helper, name) { + this.helper = helper; + this.name = name; + this.status = 'pending'; + } + + run() { + this.args = Array.prototype.slice.call(arguments); + event.dispatcher.emit(event.step.init, this); + this.status = 'queued'; + event.dispatcher.emit(event.step.before, this); + let result; + try { + result = this.helper[this.name].apply(this.helper, this.args); + this.status = 'success'; + } catch (err) { + this.status = 'failed'; + throw err; + } finally { + event.dispatcher.emit(event.step.after, this); + } + return result; + } + + humanize() { + return this.name + // insert a space before all caps + .replace(/([A-Z])/g, ' $1') + // uppercase the first character + .replace(/^(.)|\s(.)/g, function ( $1 ) { return $1.toLowerCase ( ); } ); + } + + humanizeArgs() { + return this.args.map((arg) => { + if (typeof(arg) == "string" ) { + return `"${arg}"`; + } else if (typeof(arg) == "function" ) { + return "func()"; + } else if (arg.toString) { + return arg.toString(); + } + return arg; + }).join(', '); + } + + toString() { + return `I ${this.humanize()} ${this.humanizeArgs()}`; + } + + toCode() { + return `I.${this.name}(${this.humanizeArgs()})`; + } + +} + +module.exports = Step; \ No newline at end of file diff --git a/lib/utils.js b/lib/utils.js new file mode 100644 index 000000000..a0bd210bf --- /dev/null +++ b/lib/utils.js @@ -0,0 +1,73 @@ +var fs = require('fs'); + +module.exports.fileExists = function (filePath) { + try { + fs.statSync(filePath); + } catch(err) { + if (err.code == 'ENOENT') return false; + } + return true; +}; + +module.exports.getParamNames = function(fn) { + var funStr = fn.toString(); + return funStr.slice(funStr.indexOf('(') + 1, funStr.indexOf(')')).match(/([^\s,]+)/g); +} + +module.exports.methodsOfObject = function (obj, className) { + var methods = []; + + const standard = [ + 'constructor', + 'toString', + 'toLocaleString', + 'valueOf', + 'hasOwnProperty', + 'isPrototypeOf', + 'propertyIsEnumerable' + ] + + while (obj.constructor.name != className) { + Object.getOwnPropertyNames(obj).forEach((prop) => { + if (typeof(obj[prop]) != 'function') return; + if (standard.indexOf(prop) >= 0) return; + if (prop.indexOf('_') === 0) return; + methods.push(prop); + }); + obj = obj.__proto__; + + if (!obj || !obj.constructor) break; + } + return methods; +} + +module.exports.template = function (template, data) { + return template.replace(/{{([^{}]*)}}/g, function (a, b) { + var r = data[b]; + return typeof r === 'string' ? r : a; + }); +}; + +module.exports.ucfirst = function(str) { + return str.charAt(0).toUpperCase() + str.substr(1); +} + +module.exports.lcfirst = function(str) { + return str.charAt(0).toLowerCase() + str.substr(1); +} + +module.exports.xpathLocator = { + literal: (string) => { + if (string.indexOf("'") > -1) { + string = string.split("'", -1).map(function (substr) { + return "'" + substr + "'"; + }).join(',"\'",'); + return "concat(" + string + ")"; + } else { + return "'" + string + "'"; + } + }, + combine: (locators) => { + return locators.join(' | '); + } +} diff --git a/package.json b/package.json new file mode 100644 index 000000000..3404ce4d9 --- /dev/null +++ b/package.json @@ -0,0 +1,51 @@ +{ + "name": "codeceptjs", + "version": "0.1.0", + "description": "Modern Era Aceptance Testing Framework for NodeJS", + "homepage": "http://codecept.io", + "repository": "Codeception/codeceptjs", + "es6": true, + "author": { + "name": "DavertMik", + "email": "davert@codegyre.com", + "url": "http://codegyre.com" + }, + "files": [ + "bin", + "lib" + ], + "main": "lib/codecept.js", + "keywords": [ + "tdd", + "bdd", + "testing", + "acceptance" + ], + "dependencies": { + "colors": "^1.1.2", + "commander": "^2.9.0", + "glob": "^6.0.1", + "inquirer": "^0.11.0", + "inquirer-autocomplete-prompt": "^0.1.3", + "mocha": "^2.3.3", + "readline2": "^1.0.1", + "webdriverio": "^3.2.5" + }, + "devDependencies": { + "documentation": "^3.0.4", + "gulp": "^3.6.0", + "gulp-coveralls": "^0.1.0", + "gulp-documentation": "^2.0.1", + "gulp-eslint": "^1.0.0", + "gulp-exclude-gitignore": "^1.0.0", + "gulp-istanbul": "^0.9.0", + "gulp-mocha": "^2.0.0", + "gulp-nsp": "^0.4.5", + "gulp-plumber": "^1.0.0" + }, + "scripts": { + "prepublish": "gulp prepublish", + "test": "gulp" + }, + "license": "MIT" +}