├── .editorconfig ├── .eslintrc ├── .gitignore ├── .gitmodules ├── .jsdoc ├── CONTRIBUTING.md ├── Changelog.md ├── README.md ├── circle.yml ├── example ├── DuckDuckGo - advanced matchers │ ├── 1 - SearchScenario.js │ ├── 2 - ExistenceMatcherScenario.js │ ├── 3 - RegExpMatcherScenario.js │ ├── 4 - FunctionMatcherScenario.js │ ├── 5 - IgnoredScenario.js │ ├── SearchBarComponent.js │ ├── ZeroClickComponent.js │ ├── ZeroClickFixture.js │ └── config.js └── DuckDuckGo │ ├── 1 - ZeroClickScenario.js │ ├── SearchBarComponent.js │ ├── ZeroClickComponent.js │ ├── ZeroClickFixture.js │ └── config.js ├── license.AGPL.txt ├── npm-shrinkwrap.json ├── package.json ├── src ├── Watai.js ├── config.json ├── controller │ ├── Runner.js │ ├── SetupLoader.js │ └── SuiteLoader.js ├── errors.json ├── index.js ├── lib │ ├── Preprocessor.js │ ├── cli-animator-posix.js │ ├── cli-animator-windows.js │ ├── cli-animator.js │ ├── desiredCapabilities.json │ └── mootools-additions.js ├── model │ ├── Component.js │ ├── Locator.js │ ├── Scenario.js │ └── steps │ │ ├── AbstractStep.js │ │ ├── FunctionalStep.js │ │ ├── StateStep.js │ │ ├── index.js │ │ └── state │ │ ├── AbstractMatcher.js │ │ ├── ContentMatcher.js │ │ ├── ContentRegExpMatcher.js │ │ ├── FunctionMatcher.js │ │ ├── VisibilityMatcher.js │ │ └── index.js ├── plugins │ ├── config │ │ └── index.js │ ├── help │ │ └── index.js │ ├── installed │ │ └── index.js │ ├── setup │ │ └── index.js │ └── version │ │ └── index.js ├── setup.json └── view │ ├── Matcher │ └── Verbose.js │ ├── PromiseView.js │ ├── Runner │ ├── CLI.js │ ├── Dots.js │ ├── Growl.js │ ├── Instafail.js │ ├── PageDump.js │ ├── SauceLabs.js │ └── Verbose.js │ ├── Scenario │ ├── CLI.js │ ├── Dots.js │ ├── Instafail.js │ └── Verbose.js │ └── Step │ ├── CLI.js │ ├── Instafail.js │ └── Verbose.js └── test ├── config.js ├── functional └── ComponentScenarioTest.js ├── integration ├── exitCodesTest.js ├── ignoreScenariosTest.js ├── noServerTest.js ├── optionConfigTest.js └── optionSetupTest.js ├── mocha.opts ├── resources ├── BadConfigSuite │ └── config.js ├── EmptySuite │ └── .gitkeep ├── FailingSuite │ ├── 1 - FailingScenario.js │ ├── 2 - SucceedingScenario.js │ └── TestComponent.js ├── SucceedingSuite │ ├── 1 - SucceedingScenario.js │ └── TestComponent.js ├── config.json ├── page.html └── page_with_missing_element.html └── unit ├── controller └── RunnerTest.js ├── helpers ├── StdoutSpy.js ├── driver.js ├── subject.js └── testComponent.js ├── lib └── mootools-additions.js ├── model ├── ComponentTest.js ├── LocatorTest.js ├── ScenarioTest.js └── scenario │ ├── FunctionalStepTest.js │ ├── StateStepTest.js │ └── state │ ├── ContentMatcherTest.js │ ├── ContentRegExpMatcherTest.js │ ├── FunctionMatcherTest.js │ └── VisibilityMatcherTest.js ├── setup.js └── view ├── SauceLabsRunnerViewTest.js ├── StepVerboseViewTest.js └── ViewsTest.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 4 8 | indent_style = tab 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | [{package.json,npm-shrinkwrap.json}] 16 | indent_size = 2 17 | indent_style = space 18 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | env: 2 | node: true 3 | rules: 4 | array-bracket-spacing: [ 2, always ] 5 | curly: [ 2, multi-or-nest ] 6 | eqeqeq: 0 7 | indent: [ 2, tab, { SwitchCase: 1 } ] 8 | linebreak-style: [ 2, unix ] 9 | no-extra-boolean-cast: 2 10 | no-extra-semi: 2 11 | no-implicit-coercion: 2 12 | no-multiple-empty-lines: [ 2, { max: 3, maxEOF: 1 } ] 13 | no-unreachable: 2 14 | quotes: [ 2, single, avoid-escape ] 15 | semi: 2 16 | semi-spacing: [ 2, { before: false, after: true } ] 17 | space-before-blocks: 2 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS stuff 2 | .DS_Store 3 | 4 | # logs 5 | log/* 6 | chromedriver.log 7 | npm-debug.log 8 | 9 | # generated stuff 10 | node_modules 11 | coverage 12 | doc/api 13 | 14 | # IDEs 15 | *.sublime-project 16 | *.sublime-workspace 17 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "doc"] 2 | path = doc 3 | url = git@github.com:MattiSG/Watai.wiki.git 4 | -------------------------------------------------------------------------------- /.jsdoc: -------------------------------------------------------------------------------- 1 | { 2 | "source": { 3 | "includePattern": ".+\\.js(doc)?$" 4 | }, 5 | "plugins": [ "plugins/markdown" ], 6 | "tags": { 7 | "allowUnknownTags": false 8 | }, 9 | "templates": { 10 | "cleverLinks": true, 11 | "default": { 12 | "outputSourceFiles": true 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | How to contribute to Watai 2 | ========================== 3 | 4 | If you want to improve Watai, this document will guide you through the necessary environment and provided helpers. 5 | 6 | But first of all, thanks for wanting to contribute! :) 7 | 8 | 9 | Fork & clone 10 | ------------ 11 | 12 | Simply [fork the project and clone the repository](https://help.github.com/articles/fork-a-repo) on your machine, and install the developer dependencies. 13 | 14 | ```shell 15 | cd path/to/watai/clone 16 | npm install --no-shrinkwrap 17 | ``` 18 | 19 | 20 | Code conventions 21 | ---------------- 22 | 23 | Please read the [Code conventions](https://github.com/MattiSG/Watai/wiki/Code-conventions) prior to coding. An [`editorconfig`](http://editorconfig.org/) file is provided, as well as an automated style checker with `npm run lint`. 24 | 25 | 26 | Build tool 27 | ---------- 28 | 29 | Build processes are mostly automated through [npm scripts](https://www.npmjs.org/doc/cli/npm-run-script.html). 30 | 31 | Most are documented here, and you can get a full list by running `npm run`. 32 | 33 | 34 | ### Test 35 | 36 | Test runner: [Mocha](http://visionmedia.github.com/mocha/). 37 | 38 | npm run test-unit test/unit test/functional 39 | 40 | You can execute a subset of tests only if you're working on a specific part. For example, considering you're adding tests to controllers: 41 | 42 | npm run test-unit [--watch] test/unit/controller 43 | 44 | 45 | #### Coverage 46 | 47 | Coverage information is provided by [Istanbul](https://github.com/gotwarlost/istanbul). It is always output after tests. You can check the thresholds with: 48 | 49 | npm run test-coverage 50 | 51 | 52 | #### Exhaustive mode 53 | 54 | Run all Watai tests, including CLI options and exit codes checks, which are much longer as they imply several browser startups and kills: 55 | 56 | npm test 57 | 58 | 59 | ### Documentation generation 60 | 61 | Documentation generator and syntax: [JSdoc](http://usejsdoc.org). 62 | 63 | npm run doc 64 | 65 | You will find the generated documentation as HTML files in the `doc/api` folder. Open `doc/api/index.html` to start. 66 | 67 | If you're hacking on the core of Watai rather than a plugin, you can use: 68 | 69 | npm run doc-private 70 | 71 | 72 | Packaging 73 | --------- 74 | 75 | ### Changelog 76 | 77 | Please update the `Changelog.md` at the root of the project for every change you bring. 78 | Remember we use [SemVer](http://semver.org). 79 | 80 | 81 | ### Shrinkwrap 82 | 83 | If you ever update or add a module, you will need to update the [NPM shrinkwrap](https://npmjs.org/doc/shrinkwrap.html) file to lock down dependencies versions (Ruby folks, this is the same as a `Gemfile.lock`). 84 | 85 | To do so, simply `cd` to your Watai clone and type: 86 | 87 | npm shrinkwrap 88 | 89 | However, when committing, you will notice in the diff that many dependencies are added, not only the ones you added yourself. These are the developer dependencies, and **should never be committed**. Discard the hunk before committing the `npm-shrinkwrap.json` file. 90 | 91 | 92 | Merging your changes 93 | -------------------- 94 | 95 | Once your changes are ready, i.e. you made sure: 96 | 97 | 1. You didn't break anything, tested properly, and respected the styleguide (`npm test`). 98 | 2. You cleanly documented the new code (`npm run doc-private`). 99 | 100 | …you may open a [pull request](https://help.github.com/articles/using-pull-requests) to ask your new code to be merged in the baseline. 101 | 102 | Please make sure the reason why you made these changes is clear in the pull request comments, and please reference any linked issue in these same comments. 103 | 104 | Your pull request will then be reviewed, and eventually added to the mainline. Thanks for helping to improve Watai! :) 105 | 106 | 107 | Distribution 108 | ------------ 109 | 110 | ### Versioning 111 | 112 | We use [Semantic Versioning](http://semver.org) to convey compatibility information through version numbers. If merged changes in `master` impact the end user in any way, the version number should be updated as specified by [SemVer](http://semver.org). 113 | 114 | To ensure quality, we use field testing before publishing official versions. Thus, when the version number is updated, it should be sufficed by `-alpha` or `-beta` upon merge, with a numeric indicator incremented on each later merge. The switch from alpha to beta happens on feature freeze, decided by the maintainers. The switch from beta to official release happens after 2 weeks without issues. 115 | 116 | > This means there could be a time within which both a beta and an alpha for the next version are both available. 117 | 118 | `master` is always the edge version. You can access stable versions through tags, as listed in the [Releases](https://github.com/MattiSG/Watai/releases) tab. Unstable versions are distributed under the `edge` NPM [tag](https://docs.npmjs.com/cli/dist-tag). 119 | 120 | 121 | ### Publishing 122 | 123 | ```shell 124 | npm version # remember to use semver.org 125 | npm publish 126 | npm pack 127 | ``` 128 | 129 | Copy the changelog entries and upload the resulting pack to the [Releases](https://github.com/MattiSG/Watai/releases) tab of GitHub. 130 | -------------------------------------------------------------------------------- /Changelog.md: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | This document logs all user-impacting changes between officially-published, NPM-available, stable versions of Watai. 5 | On development branches (i.e. not `master`), it may also log changes between development versions, which will be concatenated on publication. 6 | 7 | To get all details, and changes at all versions, including development ones, use `git tag -n20` or use GitHub’s [releases](https://github.com/MattiSG/Watai/releases). 8 | 9 | ### Versioning 10 | 11 | You are reminded that Watai uses [SemVer](http://semver.org), which means upgrades that have only a patch number (last digit) change are backwards-compatible, and versions with a minor number (second digit) are API-breaking while in `0` major versions. 12 | 13 | 14 | 15 | v0.8 [WIP] 16 | ---- 17 | 18 | ### v0.8.0 19 | 20 | #### Breaking changes 21 | 22 | - Drop support for Node 0.6 to allow updating dependencies. 23 | 24 | #### Minor changes 25 | 26 | - Update all dependencies. 27 | 28 | 29 | v0.7 30 | ---- 31 | 32 | ### v0.7.0 33 | 34 | #### Breaking changes 35 | 36 | An [upgrade guide](https://github.com/MattiSG/Watai/wiki/Upgrading-from-v0-6-to-v0-7) is available to help you update your tests through breaking changes. 37 | 38 | - Renamed following concepts ([#116](https://github.com/MattiSG/Watai/issues/116)): 39 | - `Feature` to `Scenario`. 40 | - `Scenario` to `Steps` (inside a feature/scenario) 41 | - `Flow` to `Verbose`. 42 | - `Data` to `Fixture`. 43 | - `Hook` to `Locator`. 44 | - `Widget` to `Component`. 45 | 46 | Backward compatibility is kept for this version, but support for previous names is deprecated and will be removed in a later version. Feedback on these names welcome. 47 | 48 | #### Minor changes 49 | 50 | - Added compatibility with NPM 3. 51 | - Update documentation generation tool from JSdoc 2 to [JSdoc 3](http://usejsdoc.org). 52 | 53 | 54 | v0.6 55 | ---- 56 | 57 | ### v0.6.2 58 | 59 | #### New features 60 | 61 | - [`ignore`](https://github.com/MattiSG/Watai/wiki/Configuration#wiki-ignore) config option has been added ([#111](https://github.com/MattiSG/Watai/pull/111), thanks @debona). 62 | - [SauceLabs](https://github.com/MattiSG/Watai/wiki/Configuration#wiki-views) view is now available, transmitting test status as pass/fail and outputting a direct link to the job as well as an estimate of how many minutes are left on your account ([#87](https://github.com/MattiSG/Watai/issues/87)). 63 | 64 | #### Minor changes 65 | 66 | - Upgraded obsolete object getters and setters syntax ([#107](https://github.com/MattiSG/Watai/pull/107)). 67 | - Updated `wd` to v0.2.6. [Some incompatibilities](https://github.com/admc/wd/blob/master/doc/release-notes.md#022) were introduced in v0.2.2. They are in advanced monkeypatching usage, you probably didn't do any with Watai. 68 | - Added an [`editorconfig`](http://editorconfig.org/) file to help contributors ([#118](https://github.com/MattiSG/Watai/pull/118), thanks @GillesFabio). 69 | - Added [`jscs`](https://github.com/mdevils/node-jscs) to unify syntax and guide contributors ([#119](https://github.com/MattiSG/Watai/pull/119), thanks @GillesFabio). 70 | 71 | #### Bugfixes 72 | 73 | - Errors appearing on suite load are now visible ([#112](https://github.com/MattiSG/Watai/pull/112), thanks @debona). 74 | 75 | 76 | ### v0.6.1 77 | 78 | #### Minor changes 79 | 80 | - Async configuration entries can now [use a promise](https://github.com/MattiSG/Watai/wiki/Configuration#wiki-async-config) instead of a callback. This allows for async errors to be detected. 81 | - Improve display of unknown errors. 82 | - Important improvements in development tools. 83 | - Use native `Q`'s long stack traces instead of `longjohn` module. 84 | 85 | 86 | ### v0.6.0 87 | 88 | #### New features 89 | 90 | - Automatic file upload support: if you set a file field to a local file path, the file will be [sent to the Selenium server](http://sauceio.com/index.php/2012/03/selenium-tips-uploading-files-in-remote-webdriver/), making test assets available anywhere automatically. 91 | - Add [support for (async) functions in configuration](https://github.com/MattiSG/Watai/wiki/Configuration#async-config). 92 | - Add [`bail` config key](https://github.com/MattiSG/Watai/wiki/Configuration#wiki-bail): if set to `true`, stops a test after the first failing feature. 93 | - Almost all errors now have a proper description (server not reachable, elements not found in state assertions, widget actions failures…). 94 | - Authentication data is now taken from [`seleniumServerURL`](https://github.com/MattiSG/Watai/wiki/Configuration#wiki-seleniumserverurl), allowing distant services such as SauceLabs to be used. 95 | - [Metadata fields](https://github.com/MattiSG/Watai/wiki/Configuration#metadata) `name`, `tags` and `build` are now parsed in config files and sent to the Selenium server. 96 | - There is now a default hook type: `css` ([#92](https://github.com/MattiSG/Watai/pull/92)). If you target an element with only a String, it will be considered as a CSS selector. 97 | 98 | #### Breaking changes 99 | 100 | An [upgrade guide](https://github.com/MattiSG/Watai/wiki/Upgrading-from-v0-5-to-v0-6) is available to help you update your tests through breaking changes. 101 | 102 | - Widgets' elements and actions are now declared in a single hash ([#94](https://github.com/MattiSG/Watai/pull/94)). Your existing Widgets will need to have their `elements` key removed. 103 | - Widgets and Features don't need an enclosing curly braces anymore ([#91](https://github.com/MattiSG/Watai/pull/91)). Your existing Widgets and Features will need to have their enclosing curly braces removed. 104 | - Textual content matches against Strings or RegExps only, not Numbers anymore (reserving for later use) nor any other Object (preventing errors). 105 | - `Runner.driverInit` event is not fired anymore. API clients should use the `Runner.start` event instead. 106 | - `Runner.restart` event is not fired anymore. API clients should use the `Runner.start` event instead. 107 | 108 | #### Minor changes 109 | 110 | - Switch to [WD](https://github.com/admc/wd) as the underlying library ([#89](https://github.com/MattiSG/Watai/pull/89)). 111 | - Growl view gives much more details. 112 | - Explicit setters (`set`) now have precedence over magic setters. 113 | - `--installed` exits with `1` instead of `3` if Watai is not installed properly. 114 | 115 | #### Bugfixes 116 | 117 | - Compatibility with Node 0.10 has improved ([#90](https://github.com/MattiSG/Watai/pull/90)). 118 | 119 | 120 | v0.5 121 | ---- 122 | 123 | ### v0.5.2 124 | 125 | #### New features 126 | 127 | - Config may be set through (a)sync functions. 128 | 129 | #### Minor changes 130 | 131 | - Q promises updated to 0.9.6, bringing many new possibilities for API clients. 132 | 133 | 134 | ### v0.5.1 135 | 136 | #### New features 137 | 138 | - Add a `--config` option to override config at run time. 139 | - Add a `--setup` option to override setup at run time. 140 | - URLs in config (base, selenium) may be provided as URL objects instead of pure strings, allowing for specific overrides. Compatibility with strings is still offered, and will be maintained. 141 | - Add a `browser` config shortcut with usual defaults for `desiredCapabilities`. 142 | - Default view now includes "Instafail". 143 | 144 | #### Minor changes 145 | 146 | - Failure reports now give the exact spent time, not the expected timeout. 147 | - Setup options are now loaded from setup files. This is not considered breaking since loading them from config never worked. 148 | - Minor visual improvements to the Flow view. 149 | 150 | #### Bugfixes 151 | 152 | - Fix default config values not being loaded in some cases. 153 | 154 | 155 | ### v0.5.0 156 | 157 | #### New features 158 | 159 | - Add "Flow" view, a more detailed step-by-step view of all actions, non-interactive for compatibility with headless environments. 160 | - Add "PageDump" view: if activated, a failure in the first feature will trigger a page source dump. Useful in headless environments. 161 | - Report failures in real-time. 162 | - Show feature ID for easier identification. 163 | - Warn when no features are found in a suite. 164 | - Add magic for "Option" elements. 165 | 166 | #### Breaking changes 167 | 168 | - Only one suite may be loaded at a time, no more CLI varargs. 169 | 170 | #### Minor changes 171 | 172 | - Much improved tests speed. 173 | - "test" is now a valid suite name. 174 | - Remove the need for log-level config tweaking. 175 | - Made magic methods much more resilient. 176 | - Correct a minor Dots view summary phrasing inconsistency. 177 | - Improve missing elements tests performance. 178 | 179 | 180 | v0.4 181 | ---- 182 | 183 | ### v0.4.5 184 | 185 | #### New features 186 | 187 | - Add 'Instafail' view. 188 | 189 | #### Minor changes 190 | 191 | - Improve CLI animator and view management system. 192 | 193 | 194 | ### v0.4.4 195 | 196 | #### Minor changes 197 | 198 | - Prevent magically-added shortcuts from being referenced in state assertions by mistake. 199 | 200 | 201 | ### v0.4.3 202 | 203 | #### Minor changes 204 | 205 | - Improve Function matchers output in case of failure. 206 | 207 | #### Bugfixes 208 | 209 | - Ensure cursor is redrawn even after a failure. 210 | 211 | 212 | ### v0.4.2 213 | 214 | #### New features 215 | 216 | - User-provided functions may be used in state descriptions. 217 | 218 | #### Bugfixes 219 | 220 | - Fix DuckDuckGo examples for non-English systems. 221 | 222 | 223 | ### v0.4.1 224 | 225 | #### New features 226 | 227 | - RegExp matchers may now be used on value attributes. 228 | - Add magic setters to send keystrokes to elements: `set(input)`. These wrap WebDriver failures, unlike assignment setters. 229 | 230 | #### Breaking changes 231 | 232 | - Change syntax for action calls: call them as if they were immediate functions. 233 | 234 | 235 | ### From the 0.3 series 236 | 237 | #### New features 238 | 239 | - Return status code 1 on tests fail. 240 | - Add `--version` option. 241 | - Scenarios warn if an undefined step is used. 242 | - Offer a "dots" view for non-interactive environments. 243 | 244 | #### Breaking changes 245 | 246 | - Boolean state descriptors now describe visibility instead of DOM existence. 247 | - The obsolete `Widget.has` syntax is removed. 248 | - Scenario functions now have their parameters passed directly as array elements, not embedded in another array. 249 | 250 | #### Minor changes 251 | 252 | - `--help` exits with 0. 253 | - Dots view logs browser readiness. 254 | - All feature scenario steps now respect a timeout, even if WebDriver raises errors when executing one. 255 | 256 | #### Bugfixes 257 | 258 | - Fixed Dots view crash on error reports. 259 | 260 | 261 | v0.2 262 | ---- 263 | 264 | ### v0.2.9 265 | 266 | First public, and last stable version, in the 0.2 series. 267 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Watai 2 | ===== 3 | 4 | Watai _(Web Application Testing Automation Infrastructure)_ is a **declarative web integration testing** framework. 5 | 6 | [![Latest version](https://img.shields.io/github/release/MattiSG/Watai.svg)](https://github.com/MattiSG/Watai/releases) 7 | [![Code Climate](https://codeclimate.com/github/MattiSG/Watai.png)](https://codeclimate.com/github/MattiSG/Watai) 8 | [![Inline docs](http://inch-ci.org/github/MattiSG/Watai.svg?branch=master)](http://inch-ci.org/github/MattiSG/Watai) 9 | 10 | It is both a test runner engine and a set of architectural patterns that help [front-end ops](http://www.smashingmagazine.com/2013/06/front-end-ops/)-conscious developers write **maintainable** and **solid** end-to-end tests. These tests automate web application navigation through actual browsers, checking whether such interactions present the expected information to the end users along the way, including through asynchronous operations. 11 | 12 | This allows to automate demonstration of features, to detect regressions when updating code and, since the same tests can be run in almost any browser, to easily check cross-browser functional support. 13 | 14 | ➥ Read more about [what is Watai](https://github.com/MattiSG/Watai/wiki/Definition). 15 | 16 | ➥ Or watch a 4-minutes introduction: 17 | [![We're bad at web integration testing](http://img.youtube.com/vi/fLP3NKUsx3k/3.jpg)](https://youtu.be/fLP3NKUsx3k?t=17s) 18 | 19 | 20 | What a test looks like 21 | ---------------------- 22 | 23 | Have a look at a [simple example](https://github.com/MattiSG/Watai/tree/master/example/DuckDuckGo) or an [advanced example](https://github.com/MattiSG/Watai/tree/master/example/DuckDuckGo%20-%20advanced%20matchers)… or look at [real-world users](https://github.com/MattiSG/Watai/wiki/Examples)! 24 | 25 | > Our 10-minutes [tutorial](https://github.com/MattiSG/Watai/wiki/Tutorial) walks you through an example to introduce all concepts. You don't even need to install the software to do it. 26 | 27 | 28 | Installing 29 | ---------- 30 | 31 | npm install --global selenium-server watai 32 | 33 | > If you're not familiar with `npm install`, read the full [installation guide](https://github.com/MattiSG/Watai/wiki/Installing) :) 34 | 35 | Let’s make sure you’re fully ready to use Watai by typing: 36 | 37 | watai --installed 38 | 39 | ### Then, [get started with Watai](https://github.com/MattiSG/Watai/wiki/Tutorial)! 40 | 41 | 42 | Strengths 43 | --------- 44 | 45 | - Enforcement of a highly decoupled, [component](http://addyosmani.com/blog/the-webs-declarative-composable-future/)-based architecture for maintainable tests. 46 | - Out-of-the-box support for async operations (AJAX…), as well as much more resilience than WebDriver, whose failures are wrapped. 47 | - Simple cascading configuration makes sharing tests across environments (dev, CI, prod) very easy. 48 | - Aiming for helpful error messages. 49 | - [High quality code](https://codeclimate.com/github/MattiSG/Watai) and [developer documentation](http://inch-ci.org/github/MattiSG/Watai), so that you can _actually_ fix things or add functionality without depending on the original developer. 50 | 51 | ➥ Read more about [how Watai is different](https://github.com/MattiSG/Watai/wiki/Comparison) from other integration testing tools. 52 | 53 | - - - - - - - 54 | 55 | License 56 | ------- 57 | 58 | The code for this software is distributed under an [AGPL license](http://www.gnu.org/licenses/agpl.html). That means you may use it to test any type of software, be it free or proprietary. But if you make any changes to the test engine itself (i.e. this library), even if it is not redistributed and provided only as a webservice, you have to share them back with the community. Sounds fair? :) 59 | 60 | Contact the author if you have any specific need or question regarding licensing. 61 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | node: 3 | version: 0.12 4 | 5 | dependencies: 6 | post: 7 | - npm install --global selenium-server 8 | - npm install saucelabs@0.1.1 # allow testing SauceLabs view 9 | 10 | test: 11 | pre: 12 | - selenium > $CIRCLE_ARTIFACTS/selenium_log.txt: 13 | background: true 14 | override: 15 | - npm run lint 16 | - npm run test-examples 17 | - npm run test-unit test/unit test/functional 18 | - npm run test-integration 19 | - npm run test-coverage 20 | - npm run test-security 21 | -------------------------------------------------------------------------------- /example/DuckDuckGo - advanced matchers/1 - SearchScenario.js: -------------------------------------------------------------------------------- 1 | description: 'Look up an ambiguous term', 2 | 3 | steps: [ 4 | SearchBarComponent.searchFor(query) 5 | ] 6 | -------------------------------------------------------------------------------- /example/DuckDuckGo - advanced matchers/2 - ExistenceMatcherScenario.js: -------------------------------------------------------------------------------- 1 | description: 'Make sure the Zero Click Info box exists with booleans', 2 | 3 | steps: [ 4 | { 5 | 'ZeroClickComponent.meanings': true // prove that the element exists 6 | } 7 | ] 8 | -------------------------------------------------------------------------------- /example/DuckDuckGo - advanced matchers/3 - RegExpMatcherScenario.js: -------------------------------------------------------------------------------- 1 | description: 'Match the Zero Click Info box header with RegExps', 2 | 3 | steps: [ 4 | { 5 | 'ZeroClickComponent.meanings': expandedAcronym, // match the textual content with a static regexp… 6 | 'SearchBarComponent.field': new RegExp('^' + query + '$') // …or with a dynamic one 7 | // notice how we can match both the textual content of an element or the value of a field with the same syntax 8 | } 9 | ] 10 | -------------------------------------------------------------------------------- /example/DuckDuckGo - advanced matchers/4 - FunctionMatcherScenario.js: -------------------------------------------------------------------------------- 1 | description: 'Check an HTML attribute on an element with custom matcher functions', 2 | 3 | steps: [ 4 | { 5 | 'ZeroClickComponent.meanings': function isAlwaysValid(header) { // providing a good name (i.e. prefixed with `is`) for the function will allow for better reports 6 | /** 7 | * You can do whatever you please in a function. 8 | * If it throws, it will be considered a failure. 9 | */ 10 | assert.ok(true, 'This is always valid'); // Node's [assert](http://nodejs.org/api/assert.html) default library is injected, so you can use it instead of `throw`ing yourself. 11 | 12 | }, 13 | 'SearchBarComponent.field': function hasNoAutocompletion(searchField) { // custom matchers are always passed a WD reference to the element they evaluate; see API for such objects at 14 | return searchField.getAttribute('autocomplete') // notice that if you're using a promise, you should **always** `return` it 15 | .then(function(attribute) { // a promise-returning matcher will be evaluated based on whether it was resolved or rejected 16 | assert.equal(attribute, 'off', 'Expected autocompletion to be off'); 17 | }); 18 | } 19 | } 20 | ] 21 | -------------------------------------------------------------------------------- /example/DuckDuckGo - advanced matchers/5 - IgnoredScenario.js: -------------------------------------------------------------------------------- 1 | description: 'This scenario would fail, but it is ignored in the config', 2 | 3 | steps: [ 4 | { 5 | 'ZeroClickComponent.meanings': false 6 | } 7 | ] 8 | -------------------------------------------------------------------------------- /example/DuckDuckGo - advanced matchers/SearchBarComponent.js: -------------------------------------------------------------------------------- 1 | field: { name: 'q' }, 2 | submitButton: '#search_button_homepage', 3 | 4 | searchFor: function searchFor(term) { 5 | return this.setField(term)() // (yes, this syntax is ugly and will be fixed in an upcoming release, see issue #99) 6 | .then(this.submit()); 7 | } 8 | -------------------------------------------------------------------------------- /example/DuckDuckGo - advanced matchers/ZeroClickComponent.js: -------------------------------------------------------------------------------- 1 | meanings: '.zci__body' 2 | -------------------------------------------------------------------------------- /example/DuckDuckGo - advanced matchers/ZeroClickFixture.js: -------------------------------------------------------------------------------- 1 | query = 'TTIP' 2 | expandedAcronym = /Transatlantic Trade/ 3 | -------------------------------------------------------------------------------- /example/DuckDuckGo - advanced matchers/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | baseURL: { 3 | protocol: 'https', 4 | hostname: 'duckduckgo.com', 5 | query: { kad:'en_GB' } // passing the language code is used only because DuckDuckGo adjusts to your default language, which may make the test fail 6 | }, 7 | 8 | browser: 'chrome', // this is a shortcut, relying on chromedriver having been installed, and on browsers being in their default paths 9 | // for more information, see the [documentation](https://github.com/MattiSG/Watai/wiki/Testing-with-Chrome) or check out the DuckDuckGo simple example 10 | 11 | views: [ 'Verbose', 'PageDump', 'Growl' ], // you may specify any combination of views you want; see more at 12 | 13 | name: function() { // any configuration element may also be a function 14 | return 'Advanced stuff'; // …either synchronously returning the value to use in its place… 15 | }, 16 | 17 | build: function(promise) { // …or asynchronous, in which case it takes a Q deferred object as its first parameter 18 | require('child_process').exec('git rev-parse HEAD', // set the build number to be the SHA of the Git repo available in the current working directory 19 | function(err, stdout, stderr) { 20 | if (err) return promise.reject(err); 21 | promise.resolve(stdout.trim()); 22 | } 23 | ); 24 | 25 | return promise.promise; 26 | }, 27 | 28 | ignore: [ 5 ] // list indices of scenarios to be ignored; best used at the command-line 29 | } 30 | -------------------------------------------------------------------------------- /example/DuckDuckGo/1 - ZeroClickScenario.js: -------------------------------------------------------------------------------- 1 | description: 'Looking up an ambiguous term should make a Zero Click Info box appear.', 2 | 3 | steps: [ 4 | SearchBarComponent.searchFor(query), 5 | { 6 | 'ZeroClickComponent.meanings': expandedAcronym 7 | } 8 | ] 9 | -------------------------------------------------------------------------------- /example/DuckDuckGo/SearchBarComponent.js: -------------------------------------------------------------------------------- 1 | field: 'input[name=q]', 2 | submitButton: '#search_button_homepage', 3 | 4 | searchFor: function searchFor(term) { 5 | return this.setField(term)() // (yes, this syntax is ugly and will be fixed in an upcoming release, see issue #99) 6 | .then(this.submit()); 7 | } 8 | -------------------------------------------------------------------------------- /example/DuckDuckGo/ZeroClickComponent.js: -------------------------------------------------------------------------------- 1 | meanings: '.zci__body' 2 | -------------------------------------------------------------------------------- /example/DuckDuckGo/ZeroClickFixture.js: -------------------------------------------------------------------------------- 1 | query = 'TTIP' 2 | expandedAcronym = /Transatlantic Trade/ 3 | -------------------------------------------------------------------------------- /example/DuckDuckGo/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | baseURL: 'https://duckduckgo.com', 3 | 4 | browser: 'firefox', // if you’d rather test with Chrome, you will need to install chromedriver 5 | // read more at https://github.com/MattiSG/Watai/wiki/Testing-with-Chrome 6 | 7 | // The above "browser" key is a shortcut, relying on browsers being installed in their default paths, and on a "standard" setup. 8 | // If you want to set your browsers more specifically, you will need to fill the following "driverCapabilities" hash. 9 | driverCapabilities: { // see all allowed values at http://code.google.com/p/selenium/wiki/DesiredCapabilities 10 | // browserName: 'firefox', 11 | // javascriptEnabled: false, // defaults to true when using the "browser" shortcut 12 | 13 | // If your browsers are placed in a “non-default” path, set the paths to the **binaries** in the following keys: 14 | // firefox_binary: '/Applications/Firefox.app/Contents/MacOS/firefox', 15 | // 'chrome.binary': '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "watai", 3 | "version": "0.8.0-alpha.1", 4 | "description": "Integration testing for the modern web", 5 | "license": "AGPL-3.0", 6 | "keywords": [ 7 | "test", 8 | "testing", 9 | "components", 10 | "webcomponents", 11 | "integration", 12 | "functional", 13 | "validation", 14 | "acceptance", 15 | "behavior", 16 | "behaviour", 17 | "BDD", 18 | "browser", 19 | "cross-browser", 20 | "selenium", 21 | "webdriver" 22 | ], 23 | "homepage": "https://github.com/MattiSG/Watai/", 24 | "bugs": "https://github.com/MattiSG/Watai/issues", 25 | "author": "Matti Schneider (http://mattischneider.fr)", 26 | "contributors": [ 27 | "Nicolas Dupont (http://ontherailsagain.com)", 28 | "Thomas De Bona (https://github.com/debona)", 29 | "Gilles Fabio (http://gillesfabio.com)" 30 | ], 31 | "files": [ 32 | "src", 33 | "doc", 34 | "README.md", 35 | "Changelog.md", 36 | "npm-shrinkwrap.json", 37 | "license.AGPL.txt" 38 | ], 39 | "main": "src/Watai.js", 40 | "bin": "src/index.js", 41 | "directories": { 42 | "lib": "./src", 43 | "doc": "./doc", 44 | "example": "./example" 45 | }, 46 | "repository": { 47 | "type": "git", 48 | "url": "https://github.com/MattiSG/Watai.git" 49 | }, 50 | "scripts": { 51 | "test": "npm run test-examples && npm run test-unit test/unit test/functional && npm run test-integration && npm run test-coverage && npm run test-security && npm run lint && npm run doc-private", 52 | "test-unit": "istanbul cover _mocha", 53 | "test-coverage": "istanbul check-coverage --statements 70 --branches 59", 54 | "test-integration": "mocha test/integration", 55 | "test-examples": "find example -mindepth 1 -maxdepth 1 -type d | xargs -I suite ./src/index.js suite", 56 | "test-examples-parallel": "find example -mindepth 1 -maxdepth 1 -type d | xargs -I suite -P 4 ./src/index.js suite", 57 | "test-security": "retire --node --package", 58 | "lint": "eslint src && eslint --env mocha test/{unit,functional,integration}", 59 | "doc": "jsdoc --configure .jsdoc --recurse src --destination doc/api README.md", 60 | "doc-private": "jsdoc --configure .jsdoc --recurse src --destination doc/api --private README.md", 61 | "prepublish": "git submodule update --init && git archive -9 --output=doc/tutorials/Watai-DuckDuckGo-example.zip HEAD example/DuckDuckGo/" 62 | }, 63 | "engines": { 64 | "node": ">=0.8 <1", 65 | "npm": "> 1.1" 66 | }, 67 | "dependencies": { 68 | "mattisg.configloader": "~1.0.0", 69 | "mootools": "~1.5.2", 70 | "q": "^1.4.1", 71 | "wd": "~0.3.12", 72 | "winston": "^2.1.0" 73 | }, 74 | "bundledDependencies": [ 75 | "mattisg.configloader", 76 | "mootools", 77 | "q", 78 | "wd", 79 | "winston" 80 | ], 81 | "optionalDependencies": { 82 | "growl": "^1.8.1" 83 | }, 84 | "devDependencies": { 85 | "eslint": "^1.10.3", 86 | "istanbul": "^0.4.1", 87 | "jsdoc": "^3.3.3", 88 | "mocha": "^2.3.3", 89 | "retire": "^1.1.1", 90 | "saucelabs": "^1.0.1", 91 | "should": "^8.0.0" 92 | }, 93 | "preferGlobal": true 94 | } 95 | -------------------------------------------------------------------------------- /src/Watai.js: -------------------------------------------------------------------------------- 1 | /* This library depends on [MooTools 1.4+](http://mootools.net). 2 | * Since MooTools augments prototypes, requiring it here is enough. 3 | */ 4 | require('mootools'); 5 | /* Object property paths manipulation. 6 | */ 7 | require('./lib/mootools-additions'); 8 | 9 | 10 | /** This module simply exports all public classes, letting you namespace them as you wish. 11 | *@example 12 | * var Watai = require('Watai'); 13 | * var myComponent = new Watai.Component(…); 14 | *@example 15 | * var TR = require('Watai'); 16 | * var myComponent = new TR.Component(…); 17 | *@namespace 18 | */ 19 | var Watai = { 20 | /**@see Component 21 | */ 22 | Component: require('./model/Component'), 23 | /**@see Scenario 24 | */ 25 | Scenario: require('./model/Scenario'), 26 | /**@see Runner 27 | */ 28 | Runner: require('./controller/Runner'), 29 | /**@see SuiteLoader 30 | */ 31 | SuiteLoader: require('./controller/SuiteLoader'), 32 | /**@see Locator 33 | *@protected 34 | */ 35 | Locator: require('./model/Locator'), 36 | /**@see SetupLoader 37 | */ 38 | setup: require('./controller/SetupLoader'), 39 | steps: require('./model/steps'), 40 | matchers: require('./model/steps/state') 41 | }; 42 | 43 | 44 | Watai.setup.reload(); // ensure setup is initialized at least once 45 | 46 | 47 | module.exports = Watai; // CommonJS export 48 | -------------------------------------------------------------------------------- /src/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseURL" : "http://0.0.0.0", 3 | "browser" : "firefox", 4 | "timeout" : 5000, 5 | "quit" : "always", 6 | "views" : ["CLI", "Growl"], 7 | "seleniumServerURL" : "http://127.0.0.1:4444/wd/hub", 8 | "ignore" : [] 9 | } 10 | -------------------------------------------------------------------------------- /src/controller/Runner.js: -------------------------------------------------------------------------------- 1 | var url = require('url'); 2 | 3 | 4 | var webdriver = require('wd'), 5 | promises = require('q'); 6 | 7 | 8 | var Runner = new Class( /** @lends Runner# */ { 9 | 10 | Extends: require('events').EventEmitter, 11 | 12 | /** The promise object for results, resolved when all scenarios of this Runner have been evaluated. 13 | *@type {QPromise} 14 | */ 15 | promise: null, 16 | 17 | /** A hash mapping all failed scenarios to their reasons for rejection. 18 | *If empty, the run was successful. 19 | *@type {Object.} 20 | *@private 21 | */ 22 | failures: {}, 23 | 24 | /** The list of all scenarios to evaluate with this configuration. 25 | *@type {Array.} 26 | *@private 27 | */ 28 | scenarios: [], 29 | 30 | /** Index of the currently evaluated scenario. 31 | *@type {integer} 32 | *@private 33 | */ 34 | currentScenario: 0, 35 | 36 | /** Promise for the driver to be initialized. 37 | * 38 | *@type {QPromise} 39 | *@private 40 | */ 41 | initialized: null, 42 | 43 | /** Promise for the baseURL to be loaded. 44 | * 45 | *@type {QPromise} 46 | *@private 47 | */ 48 | baseUrlLoaded: null, 49 | 50 | /** The promise controller (deferred object) for results, resolved when all scenarios of this Runner have been evaluated. 51 | *@type {q.deferred} 52 | *@private 53 | */ 54 | deferred: null, 55 | 56 | 57 | /**@class Manages a set of scenarios and the driver in which they are run. 58 | * 59 | * A `Runner` is mostly set up through a configuration object. 60 | * Such an object MUST contain the following items: 61 | * - `baseURL`: the URL at which the driver should start; 62 | * It SHOULD contain: 63 | * - `driverCapabilities`: an object that will be passed straight to the WebDriver instance, that describes the browser on which the tests should be run. 64 | * It MAY contain: 65 | * - `name`: the name of the suite. Defaults to the name of the containing folder. 66 | * 67 | *@constructs 68 | *@param {Object} config A configuration object, as defined above. 69 | */ 70 | initialize: function init(config) { 71 | this.config = this.parseConfig(config); 72 | 73 | this.initDriver(); 74 | }, 75 | 76 | /** Checks the passed configuration hash for any missing mandatory definitions. 77 | * 78 | *@param {Object} config The configuration object to check (may not be defined, which will return an error). 79 | *@throws {Error} An error object describing the encountered problem. 80 | *@see {@link initialize} For details on the configuration object. 81 | */ 82 | parseConfig: function parseConfig(config) { 83 | if (! config) 84 | throw new Error('You need to provide a configuration to create a Runner!'); 85 | 86 | config.baseURL = this.formatURL(config, 'baseURL'); 87 | config.seleniumServerURL = this.formatURL(config, 'seleniumServerURL'); 88 | 89 | return config; 90 | }, 91 | 92 | /** Checks the presence and formats the element stored in the given config, expecting an URL. 93 | * 94 | *@param {Object} config The configuration to examine. 95 | *@param {String} key The configuration key that holds the URL to check and reformat. 96 | *@throws {Error} If the given value is not a valid URL. 97 | *@returns {String} The URL stored in the given config, normalized. 98 | */ 99 | formatURL: function formatURL(config, key) { 100 | if (! config[key]) 101 | throw new Error('No ' + key + ' was found in the given config'); 102 | 103 | try { 104 | var result = url.format(config[key]); // allow taking objects describing the URL 105 | 106 | if (! result) 107 | throw 'parsed value is empty'; // [RETROCOMPATIBILITY] Node < 0.10 throws if `format`'s parameter is undefined, but ≥ 0.10 does not; to factor behavior, we'll throw ourselves 108 | 109 | return result; 110 | } catch (err) { 111 | throw new Error('The given ' + key + ' ("' + config[key] + '") is unreadable (' + err.message + ')'); 112 | } 113 | }, 114 | 115 | /** Initializes the underlying driver of this Runner. 116 | * 117 | *@return {QPromise} A promise for the driver to be initialized. 118 | *@private 119 | */ 120 | initDriver: function initDriver() { 121 | if (! this.initialized) 122 | this.initialized = this.buildDriverFrom(this.config); 123 | 124 | return this.initialized; 125 | }, 126 | 127 | /** Navigates to the base page for this runner. 128 | * 129 | *@return {QPromise} A promise for the base page to be loaded. 130 | *@private 131 | */ 132 | loadBaseURL: function loadBaseURL() { 133 | this.loaded = this.driver.get(this.config.baseURL); 134 | 135 | return this.loaded.then(this.onReady.bind(this)); 136 | }, 137 | 138 | /** Constructs a new WebDriver instance based on the given configuration. 139 | * 140 | *@param {Object} config The configuration object based on which the driver will be built. 141 | *@return {QPromise} The promise for the `driver` instance variable to be ready. 142 | *@see {@link initialize} For details on the configuration object. 143 | *@private 144 | */ 145 | buildDriverFrom: function buildDriverFrom(config) { 146 | this.driver = webdriver.promiseRemote(url.parse(config.seleniumServerURL)); // TODO: get the URL already parsed from the config instead of serializing it at each step 147 | 148 | return this.driver.init(Object.merge(config.driverCapabilities, { 149 | name : config.name, // TODO: find a better way to pass config elements instead of whitelisting 150 | tags : config.tags, 151 | build : config.build 152 | })); 153 | }, 154 | 155 | /** Tells whether the underlying driver of this Runner has loaded the base page or not. 156 | * 157 | *@return {Boolean} `true` if the page has been loaded, `false` otherwise. 158 | */ 159 | isReady: function isReady() { 160 | return Boolean(this.loaded && this.loaded.isFulfilled()); 161 | }, 162 | 163 | /** Emits the "ready" event. 164 | * 165 | *@private 166 | */ 167 | onReady: function onReady() { 168 | this.emit('ready', this); 169 | }, 170 | 171 | /** Adds the given Scenario to the list of those that this Runner will evaluate. 172 | * 173 | *@param {Scenario} scenario A Scenario for this Runner to evaluate. 174 | *@return This Runner, for chaining. 175 | */ 176 | addScenario: function addScenario(scenario) { 177 | this.scenarios.push(scenario); 178 | 179 | return this; 180 | }, 181 | 182 | /** Returns the WebDriver instance this Runner created for the current run. 183 | * 184 | *@returns WebDriver 185 | */ 186 | getDriver: function getDriver() { 187 | return this.driver; 188 | }, 189 | 190 | /** Evaluates all scenarios added to this Runner. 191 | * Emits the "start" event. 192 | * 193 | *@returns {QPromise} A promise for results, resolved if all scenarios pass (param: this Runner), rejected otherwise (param: hash mapping failed scenarios to their reasons for rejection, or an Error if an error appeared in the runner itself or the evaluation was cancelled). 194 | *@see addScenario 195 | */ 196 | test: function test() { 197 | this.deferred = promises.defer(); 198 | var promise = this.promise = this.deferred.promise; 199 | 200 | this.emit('start', this); 201 | 202 | return this.initDriver() 203 | .then(this.loadBaseURL.bind(this)) 204 | .then(this.start.bind(this), 205 | this.deferred.reject) // ensure failures in driver init are propagated 206 | .finally(function() { return promise; }); 207 | }, 208 | 209 | /** Actually starts the evaluation process. 210 | *@returns {QPromise} The promise for this run to be finished. 211 | * 212 | *@private 213 | */ 214 | start: function start() { 215 | this.failures = {}; 216 | this.currentScenario = -1; 217 | this.startNextScenario(); 218 | 219 | return this.promise; 220 | }, 221 | 222 | /** Increments the scenario index, starts evaluation of the next scenario, and quits the driver if all scenarios were evaluated. 223 | * 224 | *@private 225 | */ 226 | startNextScenario: function startNextScenario() { 227 | this.currentScenario++; 228 | 229 | if (this.currentScenario >= this.scenarios.length 230 | || (this.config.bail && Object.getLength(this.failures))) 231 | this.finish(); 232 | else 233 | this.evaluateScenario(this.scenarios[this.currentScenario]); 234 | }, 235 | 236 | /** Prepares and triggers the evaluation of the given scenario. 237 | * Emits "scenario". 238 | * 239 | *@private 240 | */ 241 | evaluateScenario: function evaluateScenario(scenario) { 242 | this.emit('scenario', scenario); 243 | 244 | scenario.test() 245 | .fail(this.storeFailure.bind(this, scenario)) // leave last arg to pass failure description 246 | .done(this.startNextScenario.bind(this)); // TODO: make startNextScenario return a promise for the next scenario to be evaluated, and orchestrate flow around it with an array reduce 247 | }, 248 | 249 | /** Callback handler upon scenario evaluation. Flags failures and prepares final error report. 250 | * 251 | *@private 252 | */ 253 | storeFailure: function storeFailure(scenario, message) { 254 | this.failures[scenario] = message; 255 | this.failed = true; 256 | }, 257 | 258 | /** Informs of the end result and cleans the instance up after tests runs. 259 | * 260 | *@private 261 | */ 262 | finish: function finish() { 263 | var resolve = this.deferred.resolve.bind(this.deferred, this), 264 | reject = this.deferred.reject.bind(this.deferred, this.failures), 265 | fulfill = resolve, 266 | quitBrowser = this.quitBrowser.bind(this), 267 | precondition = (this.config.quit == 'always' 268 | ? quitBrowser 269 | : promises); // Q without params simply returns a fulfilled promise 270 | 271 | if (Object.getLength(this.failures) > 0) 272 | fulfill = reject; 273 | else if (this.config.quit == 'on success') 274 | precondition = quitBrowser; 275 | 276 | precondition().done(fulfill, reject); 277 | }, 278 | 279 | /** Quits the managed browser. 280 | * 281 | *@return {QPromise} A promise resolved once the browser has been properly quit. 282 | */ 283 | quitBrowser: function quitBrowser() { 284 | var quit = this._quitBrowser.bind(this); 285 | return this.initialized ? this.initialized.then(quit, quit) : promises(); 286 | }, 287 | 288 | /** Quits the managed browser immediately, ignoring its availability. 289 | * 290 | *@return {QPromise} A promise resolved once the browser has been quit. 291 | *@private 292 | */ 293 | _quitBrowser: function _quitBrowser() { 294 | return this.driver.quit().then(function() { 295 | this.initialized = null; 296 | }.bind(this)); 297 | }, 298 | 299 | toString: function toString() { 300 | return this.config.name; 301 | } 302 | }); 303 | 304 | module.exports = Runner; // CommonJS export 305 | -------------------------------------------------------------------------------- /src/controller/SetupLoader.js: -------------------------------------------------------------------------------- 1 | var pathsUtils = require('path'); // 2 | 3 | 4 | var winston = require('winston'), // logging lib: 5 | ConfigLoader = require('mattisg.configloader'); // 6 | 7 | 8 | /** Name of the setup file to be loaded in cascade. 9 | * 10 | *@type {String} 11 | *@private 12 | */ 13 | var SETUP_FILE = 'setup'; 14 | 15 | 16 | /** The setup hash. 17 | * 18 | *@type {Object} 19 | *@private 20 | */ 21 | var setup = {}; 22 | 23 | /** Loads test runner setup configuration. 24 | * Differs from loading a suite in that the "setup" is needed for application bootstrapping. 25 | */ 26 | var SetupLoader = { 27 | 28 | /** Reloads the setup data. 29 | * 30 | *@param {Hash} [override] If set, the setup data loaded from setup files will be overridden by the data contained in that parameter. 31 | */ 32 | reload: function reloadSetup(override) { 33 | setup = new ConfigLoader({ 34 | from : pathsUtils.dirname(module.parent.parent.filename), // required by Watai, which is itself required either by the main CLI entry point, or by the test setup, or by any client… and we want to load from that client, not locally 35 | appName : 'watai', 36 | override: override 37 | }).load(SETUP_FILE); 38 | 39 | require('q').longStackSupport = true; // log stack traces across async calls: 40 | 41 | this.initLoggers(setup.log); 42 | }, 43 | 44 | /** Initializes loggers. 45 | * These loggers are global, and managed by the `winston` module, acting as a singleton. 46 | * 47 | *@param {Hash} config 48 | *@private 49 | */ 50 | initLoggers: function initLoggers(config) { 51 | Object.each(config, function(options, name) { 52 | winston.loggers.close(name); 53 | winston.loggers.add(name, options); 54 | }); 55 | } 56 | }; 57 | 58 | 59 | module.exports = SetupLoader; // CommonJS export 60 | -------------------------------------------------------------------------------- /src/errors.json: -------------------------------------------------------------------------------- 1 | { 2 | "ECONNREFUSED": { 3 | "exitCode": 4, 4 | "title": "The Selenium server could not be reached", 5 | "help": "You should start it up with a command like `java -jar selenium-server-standalone.jar`" 6 | }, 7 | "BAD_CONFIG": { 8 | "exitCode": 16, 9 | "title": "An error occurred while executing the configuration file for this suite", 10 | "help": "Check the Configuration Files reference: " 11 | }, 12 | "NO_SCENARIOS": { 13 | "exitCode": 16, 14 | "title": "No scenario was found in this suite", 15 | "help": "Check the Scenario Files reference: " 16 | }, 17 | "EACCES": { 18 | "exitCode": 16, 19 | "title": "Some files in the given suite are not readable", 20 | "help": "" 21 | }, 22 | "SCENARIOS_NOT_FOUND": { 23 | "exitCode": 2, 24 | "title": "Some of the scenarios you specified were not found", 25 | "help": "Double-check the path to your suite, and the numerical indices you specified" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /* 4 | * Here is the main CLI entry point. 5 | */ 6 | 7 | var path = require('path'); 8 | 9 | 10 | var logger = require('winston'); 11 | 12 | 13 | var Watai = require('./Watai'), 14 | Preprocessor = require('./lib/Preprocessor'), 15 | /** Path to the directory containing option-callable scripts. 16 | *@type {String} 17 | *@see preProcessArguments 18 | *@private 19 | */ 20 | OPTIONS_HANDLERS_DIR = path.join(__dirname, 'plugins'), 21 | ERRORS_LIST = require('./errors'); 22 | 23 | 24 | var argsProcessor = new Preprocessor(OPTIONS_HANDLERS_DIR); 25 | 26 | var args = argsProcessor.processArgv(); 27 | 28 | var suites = args.remaining; 29 | 30 | validateParams(suites); 31 | 32 | if (args.setup) 33 | Watai.setup.reload(args.setup); 34 | 35 | 36 | var suitePath = suites[0], 37 | suite = new Watai.SuiteLoader(suitePath, args.config), 38 | statusCode = 0; 39 | 40 | suite.getRunner() 41 | .then(function(runner) { 42 | return runner.test(); 43 | }, function(err) { 44 | var logger = require('winston').loggers.get('load'); 45 | logger.debug(err.stack); 46 | 47 | var errorDescription = ERRORS_LIST[err && err.code]; 48 | if (errorDescription) { 49 | logger.error(errorDescription.title); 50 | logger.info(errorDescription.help); 51 | } 52 | throw err; 53 | }).fail(function(err) { 54 | var error = ERRORS_LIST[err && err.code] || { exitCode: 1 }; 55 | statusCode = error.exitCode; 56 | if (err.stack) // TODO: improve detection of what is an actual uncaught exception (We currently have an ambiguity: is the promise rejected because the test failed or because an error occurred? If error, we should tell the user. This is (badly) approximated by the reason for rejection having a stack trace or not.) 57 | console.error(err.stack); 58 | }).done(); // ensure any uncaught exception gets rethrown 59 | 60 | 61 | process.on('exit', function() { 62 | process.exit(statusCode); 63 | }); 64 | 65 | 66 | 67 | function validateParams(params) { 68 | var showHelpAndExit = require(path.join(OPTIONS_HANDLERS_DIR, 'help')); 69 | 70 | if (params.length == 0) { 71 | logger.error('Oops, you didn’t provide any test suite to execute!'); 72 | showHelpAndExit(2); 73 | } 74 | 75 | if (params.length > 1) { 76 | logger.error('Too many arguments! (' + params + ')'); 77 | showHelpAndExit(2); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/lib/Preprocessor.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | 3 | /** A parser for arguments that loads “plugins”, i.e. Node modules, based on options passed in UNIX long style (i.e. with two dashes). 4 | * 5 | *@class Loads plugins based on options passed in UNIX long style. 6 | */ 7 | var Preprocessor = function Preprocessor(pluginsDir) { 8 | this.pluginsDir = pluginsDir; 9 | }; 10 | 11 | /** Loads plugins based on arguments passed to the main Node process. 12 | * 13 | *@return {Hash} A hash whose keys are the processed plugins, mapped to their result; plus a magic key 'remaining' containing all non-processed args. 14 | *@see processAll 15 | */ 16 | Preprocessor.prototype.processArgv = function processArgv() { 17 | var args = process.argv.slice(2); // extract CLI arguments, see http://docs.nodejitsu.com/articles/command-line/how-to-parse-command-line-arguments 18 | 19 | return this.processAll(args); 20 | }; 21 | 22 | /** Loads plugins based on any passed options. 23 | * More precisely: any option prefixed with `--` will be recognized as a plugin, and if a matching file is found, the corresponding argument will be removed from the passed in array. 24 | * The convention is for such plugins to reside in a `plugins//index.js` file, and to export a function that define their expected arguments. The corresponding number of arguments will be popped from the arguments list. 25 | * The loaded plugin function may return an array of values, which will be added in the resulting arguments array. 26 | * 27 | *@return {Hash} A hash whose keys are the processed plugins, mapped to their result; plus a magic key 'remaining' containing all non-processed args. 28 | */ 29 | Preprocessor.prototype.processAll = function processAll(args) { 30 | var result = { 31 | remaining: [] 32 | }; 33 | 34 | for (var i = 0; i < args.length; i++) { 35 | var optionMatch = args[i].match(/^--([^-].*)/); 36 | 37 | if (! optionMatch) { // this is not an option 38 | result.remaining.push(args[i]); // pass it without doing anything on it 39 | continue; 40 | } 41 | 42 | var pluginName = optionMatch[1], // [1] is the value of the first capturing parentheses 43 | plugin; 44 | 45 | try { 46 | plugin = require(path.join(this.pluginsDir, pluginName)); 47 | } catch (e) { // no matching plugin to handle this option 48 | result.remaining.push(args[i]); // pass it without doing anything on it 49 | continue; // go straight to the next option 50 | } 51 | 52 | var params = args.slice(i + 1, i + 1 + plugin.length); // extract the required arguments from the CLI 53 | i += plugin.length; // update the options pointer: params for this option will be ignored 54 | 55 | try { 56 | var optionResults = plugin.apply(null, params); 57 | } catch (err) { 58 | err.message += ' (when trying to parse option "--' + pluginName + '"'; 59 | 60 | if (params.length) 61 | err.message += ' with params "' + params.join('", "') + '"'; 62 | 63 | err.message += ')'; 64 | 65 | throw err; 66 | } 67 | 68 | if (optionResults) 69 | result[pluginName] = optionResults; 70 | } 71 | 72 | return result; 73 | }; 74 | 75 | 76 | module.exports = Preprocessor; // CommonJS export 77 | -------------------------------------------------------------------------------- /src/lib/cli-animator-posix.js: -------------------------------------------------------------------------------- 1 | /**A helper to ease CLI animation. 2 | * Most of this code is extracted from TJ Holowaychuk’s Mocha. 3 | * 4 | *@namespace 5 | */ 6 | var CLIanimator = {}; 7 | 8 | 9 | /** Presents the given information to the user. 10 | *@param {String} prefix A symbol to prepend to the message. 11 | *@param {String} type The type of information to present (i.e. "debug", "info", "warn"…). 12 | *@param {String} message The actual content to present to the user. 13 | *@param {String} [messageType] The type of the actual content, for different colouration. 14 | *@param {Stream} [out] The stream to which the content should be written. Defaults to process.stdout. 15 | */ 16 | CLIanimator.log = function log(prefix, type, message, messageType, out) { 17 | out = out || process.stdout; 18 | 19 | stop(); 20 | out.write(makeLine(prefix, typeToColorCode[type], message + '\n', typeToColorCode[messageType])); 21 | }; 22 | 23 | /** Erases the current line. 24 | */ 25 | CLIanimator.clear = function clear() { 26 | process.stdout.write('\r'); 27 | }; 28 | 29 | /** Hides the cursor. 30 | */ 31 | CLIanimator.hideCursor = function hideCursor() { 32 | process.stdout.write('\033[?25l'); 33 | }; 34 | 35 | /** Shows the cursor. 36 | */ 37 | CLIanimator.showCursor = function showCursor() { 38 | process.stdout.write('\033[?25h'); 39 | }; 40 | 41 | /** Does a spinner animation with the given message. 42 | */ 43 | CLIanimator.spin = function spin(message) { 44 | stop(); 45 | play(makeFrames(message)); 46 | }; 47 | 48 | /** Creates a coloured line out of the given pieces. 49 | *@return String 50 | *@private 51 | */ 52 | function makeLine(prefix, colorCode, message, messageColorCode) { 53 | messageColorCode = messageColorCode || 0; 54 | return '\r\033[' + colorCode + 'm' + prefix + ' \033[' + messageColorCode + 'm ' + message + '\033[0m'; 55 | } 56 | 57 | /** Generates a series of line “frames” to be played with a spinner animation. 58 | *@return Array 59 | *@private 60 | */ 61 | function makeFrames(msg) { 62 | return [ 63 | makeLine('◜', 96, msg, 90), 64 | makeLine('◠', 96, msg, 90), 65 | makeLine('◝', 96, msg, 90), 66 | makeLine('◞', 96, msg, 90), 67 | makeLine('◡', 96, msg, 90), 68 | makeLine('◟', 96, msg, 90) 69 | ]; 70 | } 71 | 72 | /** Maps types to the corresponding ANSI color code. 73 | *@type {Object.} 74 | *@private 75 | */ 76 | var typeToColorCode = { 77 | debug : 34, 78 | cyan : 36, 79 | purple : 35, 80 | info : 32, 81 | error : 31, 82 | warn : 31, 83 | yellow : 33, 84 | grey : 90, 85 | gray : 90 86 | }; 87 | 88 | /** Plays the given array of strings. 89 | *@private 90 | *@author TJ Holowaychuk (Mocha) 91 | */ 92 | 93 | function play(frames, interval) { 94 | CLIanimator.hideCursor(); 95 | 96 | var len = frames.length, 97 | interval = interval || 100, 98 | i = 0; 99 | 100 | play.timer = setInterval(function() { 101 | var str = frames[i++ % len]; 102 | process.stdout.write('\r' + str); 103 | }, interval); 104 | } 105 | 106 | /**Stop play()ing. 107 | *@private 108 | *@author TJ Holowaychuk (Mocha) 109 | */ 110 | function stop() { 111 | clearInterval(play.timer); 112 | CLIanimator.showCursor(); 113 | } 114 | 115 | /* 116 | * Ensure we don't mess the user's prompt up. 117 | */ 118 | 119 | process.on('SIGINT', function() { 120 | CLIanimator.showCursor(); 121 | process.stdout.write('\n'); 122 | process.exit(); 123 | }); 124 | 125 | process.addListener('uncaughtException', function(e) { 126 | CLIanimator.showCursor(); // ensure the prompt is always restored, even if the process crashes 127 | throw e; 128 | }); 129 | 130 | module.exports = CLIanimator; // CommonJS export 131 | -------------------------------------------------------------------------------- /src/lib/cli-animator-windows.js: -------------------------------------------------------------------------------- 1 | /** A fake animation helper for the Windows console. 2 | * Has much less functionality than the POSIX one, but offers easy compatibility. 3 | * 4 | * @namespace 5 | */ 6 | var WindowsCLI = {}; 7 | 8 | var logger = require('winston'); 9 | 10 | 11 | /** Presents the given information to the user. 12 | *@param {String} prefix A symbol to prepend to the message. 13 | *@param {String} type The type of information to present (i.e. "debug", "info", "warn"…). 14 | *@param {String} message The actual content to present to the user. 15 | *@param {String} [messageType] The type of the actual content, for different colouration. 16 | */ 17 | WindowsCLI.log = function log(prefix, type, message, messageType) { 18 | logger[method](prefix + ' ' + message); 19 | }; 20 | 21 | /** Erases the current line. 22 | */ 23 | WindowsCLI.clear = function clear() { 24 | process.stdout.write('\r'); 25 | }; 26 | 27 | /** Hides the cursor. 28 | * Does nothing on Windows. 29 | */ 30 | WindowsCLI.hideCursor = function hideCursor() { 31 | // do nothing on Windows 32 | }; 33 | 34 | /** Shows the cursor. 35 | * Does nothing on Windows. 36 | */ 37 | WindowsCLI.showCursor = function showCursor() { 38 | // do nothing on Windows 39 | }; 40 | 41 | /** Logs the given message. 42 | */ 43 | WindowsCLI.spin = function spin(message) { 44 | logger.verbose(message); 45 | }; 46 | 47 | module.exports = WindowsCLI; // CommonJS export 48 | -------------------------------------------------------------------------------- /src/lib/cli-animator.js: -------------------------------------------------------------------------------- 1 | /** Indirection to get the CLI animator most fit for the execution platform. 2 | * 3 | *@ignore 4 | */ 5 | module.exports = require(process.platform.win32 6 | ? './cli-animator-windows' 7 | : './cli-animator-posix'); 8 | -------------------------------------------------------------------------------- /src/lib/desiredCapabilities.json: -------------------------------------------------------------------------------- 1 | { 2 | "firefox": { 3 | "browserName" : "firefox", 4 | "platform" : "ANY", 5 | "javascriptEnabled" : true 6 | }, 7 | 8 | "ie": { 9 | "browserName" : "internet explorer", 10 | "platform" : "WINDOWS", 11 | "javascriptEnabled" : true 12 | }, 13 | 14 | "chrome": { 15 | "browserName" : "chrome", 16 | "platform" : "ANY", 17 | "javascriptEnabled" : true 18 | }, 19 | 20 | "opera" : { 21 | "browserName" : "opera", 22 | "platform" : "ANY", 23 | "javascriptEnabled" : true 24 | }, 25 | 26 | "safari": { 27 | "browserName" : "safari", 28 | "platform" : "MAC", 29 | "javascriptEnabled" : true 30 | }, 31 | 32 | "aurora": { 33 | "browserName" : "firefox", 34 | "firefox_binary" : "/Applications/FirefoxAurora.app/Contents/MacOS/firefox-bin", 35 | "platform" : "ANY", 36 | "javascriptEnabled" : true 37 | }, 38 | 39 | "htmlunit": { 40 | "browserName" : "htmlunit", 41 | "platform" : "ANY" 42 | }, 43 | 44 | "htmlunitwithjs": { 45 | "browserName" : "htmlunit", 46 | "version" : "firefox", 47 | "platform" : "ANY", 48 | "javascriptEnabled" : true 49 | }, 50 | 51 | "iphone": { 52 | "browserName" : "iPhone", 53 | "platform" : "MAC", 54 | "javascriptEnabled" : true 55 | }, 56 | 57 | "ipad" : { 58 | "browserName" : "iPad", 59 | "platform" : "MAC", 60 | "javascriptEnabled" : true 61 | }, 62 | 63 | "android": { 64 | "browserName" : "android", 65 | "platform" : "ANDROID", 66 | "javascriptEnabled" : true 67 | }, 68 | 69 | "phantomjs": { 70 | "browserName" :"phantomjs", 71 | "platform" : "ANY", 72 | "javascriptEnabled" : true 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/lib/mootools-additions.js: -------------------------------------------------------------------------------- 1 | var hasOwnProperty = Object.prototype.hasOwnProperty; 2 | 3 | Object.extend( /* @lends Object */ { 4 | /** Tells whether the given property path (a string delimiting nested properties with a dot) is available, without accessing the last property. 5 | *@param {Object} source The object in which the given property path should be looked up. 6 | *@param {String} parts A path of properties to walk, delimited by dots. 7 | *@returns {Boolean} 8 | *@memberOf Object 9 | */ 10 | hasPropertyPath: function hasPropertyPath(source, parts) { 11 | if (typeof parts == 'string') 12 | parts = parts.split('.'); 13 | 14 | for (var i = 0, l = parts.length; i < l - 1; i++) { 15 | if (hasOwnProperty.call(source, parts[i])) 16 | source = source[parts[i]]; 17 | else 18 | return false; 19 | } 20 | 21 | return hasOwnProperty.call(source, parts[i]); 22 | }, 23 | 24 | /** Tells whether the given property path (a string delimiting nested properties with a dot) points at a getter. 25 | *@param {Object} source The object in which the given getter path should be looked up. 26 | *@param {String} parts A path of properties to walk, delimited by dots. 27 | *@returns {Boolean} 28 | *@memberOf Object 29 | */ 30 | hasGetter: function hasGetter(source, parts) { 31 | if (typeof parts == 'string') 32 | parts = parts.split('.'); 33 | 34 | for (var i = 0, l = parts.length; i < l - 1; i++) { 35 | if (hasOwnProperty.call(source, parts[i])) 36 | source = source[parts[i]]; 37 | else 38 | return false; 39 | } 40 | 41 | return Object.prototype.__lookupGetter__.call(source, parts[i]); 42 | }, 43 | 44 | /** Returns the property at the end of the given property path (a string delimiting nested properties with a dot). 45 | * Part of MooTools-more. 46 | * 47 | *@param {Object} source The object in which the given property path should be looked up. 48 | *@param {String} parts A path of properties to walk, delimited by dots. 49 | *@returns The pointed property, or `null` if any of the sub-paths is incorrect. 50 | *@memberOf Object 51 | */ 52 | getFromPath: function getFromPath(source, parts) { 53 | if (typeof parts == 'string') 54 | parts = parts.split('.'); 55 | 56 | for (var i = 0, l = parts.length; i < l; i++) { 57 | if (hasOwnProperty.call(source, parts[i])) 58 | source = source[parts[i]]; 59 | else 60 | return null; 61 | } 62 | 63 | return source; 64 | } 65 | }); 66 | 67 | String.implement( /* @lends String */ { 68 | 69 | /** A naive approach to pluralization: postfixes this string with an 's' if needed, and prefixes it with the given amount. 70 | * 71 | *@param {Number} count The count of items represented by this string. 72 | *@param {String} [postfix] The String to append in plural form. Defaults to 's'. 73 | *@returns {String} This string, pluralized based on the count. 74 | */ 75 | count: function count(count, postfix) { 76 | return count + ' ' + this + (count == 1 ? '' : postfix || 's'); 77 | }, 78 | 79 | /** Transforms this camel-cased or hyphenated string into a spaced string. 80 | * 81 | *@returns {String} This string, with capitals replaced by a space and a small letter. 82 | *@example humanize('areCookiesGood') // 'are cookies good' 83 | */ 84 | humanize: function humanize() { 85 | return this.hyphenate().replace(/[-_]/g, ' ').clean(); 86 | } 87 | }); 88 | -------------------------------------------------------------------------------- /src/model/Component.js: -------------------------------------------------------------------------------- 1 | var promises = require('q'); 2 | 3 | var Locator = require('./Locator'); 4 | 5 | 6 | var Component = new Class( /** @lends Component# */ { 7 | 8 | Extends: require('events').EventEmitter, 9 | 10 | /** The name of this component. 11 | * Automatically set to the name of its containing file upon parsing. 12 | *@type {String} 13 | *@private 14 | */ 15 | name: '', 16 | 17 | /**@class Models a set of controls on a website. 18 | * 19 | *@constructs 20 | *@param {String} name User-visible name of this component. 21 | *@param {Hash} values A hash describing a component's elements and actions. Keys will be made available directly on the resulting test component, and associated values can be locators for elements or functions for actions. 22 | *@param {WebDriver} driver The WebDriver instance in which this component should look for its elements. 23 | */ 24 | initialize: function init(name, values, driver) { 25 | this.name = name; 26 | 27 | var component = this, 28 | elementsAndActions = this.extractElementsAndActions(values); 29 | 30 | Object.each(elementsAndActions.elements, function(typeAndSelector, key) { 31 | Locator.addLocator(component, key, typeAndSelector, driver); 32 | component.addMagic(key); 33 | }); 34 | 35 | Object.each(elementsAndActions.actions, function(method, key) { 36 | component[key] = function() { 37 | var args = Array.prototype.slice.call(arguments); // make an array of prepared arguments 38 | 39 | // in order to present a meaningful test report, we need to have actions provide as much description elements as possible 40 | // in user-provided functions, the function's name takes this place 41 | // however, when wrapping these names, we can't assign to a Function's name, and dynamically creating its name means creating it through evaluation, which means we'd first have to extract its arguments' names, which is getting very complicated 42 | var action = function() { 43 | return method.apply(component, args); 44 | }; 45 | 46 | action.component = component; 47 | action.reference = key; 48 | action.title = method.name; 49 | action.args = args; 50 | 51 | return action; 52 | }; 53 | }); 54 | }, 55 | 56 | /** Extract elements and actions from the given parameter 57 | * 58 | *@param {Hash} values A hash describing a component's elements and actions. Keys will be made available directly on the resulting test component, and associated values can be locators for elements or functions for actions. 59 | *@return {Hash} A hash containing the following keys: 60 | * - `elements`: A hash mapping all locator names to their description. 61 | * - `actions`: A hash mapping all method names to the actual function. 62 | *@see Locator 63 | *@private 64 | */ 65 | extractElementsAndActions: function extractElementsAndActions(values) { 66 | var result = { 67 | elements: {}, 68 | actions: {} 69 | }; 70 | 71 | Object.each(values, function(value, key) { 72 | if (typeof value != 'function') 73 | result.elements[key] = value; 74 | else 75 | result.actions[key] = value; 76 | }); 77 | 78 | return result; 79 | }, 80 | 81 | /** Add magic actions on specially-formatted elements. 82 | *@example addMagic("loginLink") // makes the `loginLink` element available to the component, but also generates the `login()` method, which automagically calls `click` on `loginLink` 83 | * 84 | *@param {String} key The key that should be considered for adding magic elements. 85 | *@see Component.magic 86 | *@private 87 | */ 88 | addMagic: function addMagic(key) { 89 | var component = this; 90 | 91 | Object.each(Component.magic, function(matcher, method) { 92 | var matches = matcher.exec(key); 93 | 94 | if (! matches) // no match, hence no magic to add 95 | return; 96 | 97 | var basename = matches[1], 98 | type = matches[2]; // for example "Link", "Button"… 99 | 100 | component[basename] = function() { // wrapping to allow immediate calls in scenario steps // TODO: rather return an object with methods, and leave preparation for scenarios to the Component constructor 101 | var args = Array.prototype.slice.call(arguments); // make an array of prepared arguments 102 | 103 | var action = function() { // no immediate access to avoid calling the getter, which would trigger a Selenium access 104 | return component[key].then(function(element) { 105 | return element[method].apply(element, args); 106 | }); 107 | }; 108 | 109 | action.component = component; 110 | action.reference = basename; 111 | action.title = basename; 112 | action.args = args; 113 | 114 | return action; 115 | }; 116 | }); 117 | }, 118 | 119 | /** Returns the user-provided name of this component. 120 | * 121 | *@returns {String} 122 | *@see name 123 | */ 124 | toString: function toString() { 125 | return this.name; 126 | } 127 | }); 128 | 129 | /** Maps magic element regexps from the action that should be generated. 130 | * _Example: "loginLink" makes the `loginLink` element available to the component, but also generates the `login()` method, which automagically calls `click` on `loginLink`._ 131 | * 132 | * Keys are names of the actions that should be added to the element, and values are regexps that trigger the magic. 133 | * The name of the generated member is the content of the first capturing parentheses match in the regexp. 134 | * 135 | *@see RegExp#exec 136 | *@private 137 | */ 138 | Component.magic = { 139 | click: /(.+)(Link|Button|Checkbox|Option|Radio)$/i 140 | }; 141 | 142 | module.exports = Component; // CommonJS export 143 | -------------------------------------------------------------------------------- /src/model/Locator.js: -------------------------------------------------------------------------------- 1 | var promises = require('q'); 2 | 3 | /** Mapping from short selector types to WebDriver's fully qualified selector types. 4 | * 5 | *@see {@link http://code.google.com/p/selenium/wiki/JsonWireProtocol#POST_/session/:sessionId/element|JsonWire sessionId} 6 | */ 7 | var WATAI_SELECTOR_TYPES_TO_WEBDRIVER_TYPES = { 8 | css : 'css selector', 9 | xpath : 'xpath', 10 | a : 'partial link text', 11 | linkText: 'link text', 12 | id : 'id', 13 | name : 'name', 14 | 'class' : 'class name', 15 | tag : 'tag name' 16 | }; 17 | 18 | /** Default Watai selector type 19 | * 20 | *@type {String} 21 | */ 22 | var DEFAULT_SELECTOR_TYPE = 'css'; 23 | 24 | /**@class A Locator allows one to target a specific element on a web page. 25 | * It is a wrapper around both a selector and its type (css, xpath, id…). 26 | * 27 | *@param {Object} locator A single value-pair hash whose key may be one of `css`, `id`, or any other value of Selenium's `By` class; and whose value must be a string of the matching form. 28 | *@param {WebDriver} driver The WebDriver instance in which the described elements are to be sought. 29 | */ 30 | var Locator = function Locator(locator, driver) { 31 | if (typeof locator == 'string') { 32 | this.type = DEFAULT_SELECTOR_TYPE; 33 | this.selector = locator; 34 | } else { 35 | this.type = Object.getOwnPropertyNames(locator)[0]; 36 | this.selector = locator[this.type]; 37 | } 38 | 39 | this.driver = driver; 40 | 41 | /** Returns the element this locator points to in the given driver, as an object with all WebDriver methods. 42 | * 43 | *@see {@link http://seleniumhq.org/docs/03_webdriver.html|WebDriver} 44 | *@private 45 | */ 46 | this.toSeleniumElement = function toSeleniumElement() { 47 | return this.driver.element(WATAI_SELECTOR_TYPES_TO_WEBDRIVER_TYPES[this.type] || this.type, this.selector); 48 | }; 49 | 50 | /** Sends the given sequence of keystrokes to the element pointed by this locator. 51 | * 52 | *@param {String} input A string that will be sent to this element. 53 | *@returns {QPromise} A promise, resolved when keystrokes have been received, rejected in case of a failure. 54 | *@see {@link http://seleniumhq.org/docs/03_webdriver.html#sendKeys|WebDriver.sendKeys} 55 | *@private 56 | */ 57 | this.handleInput = function handleInput(input) { 58 | var element; 59 | 60 | return this.toSeleniumElement().then(function(elm) { 61 | element = elm; 62 | return elm.clear(); 63 | }).then(function() { 64 | return element.type(input); 65 | }).then(function() { 66 | return element; // allow easier chaining 67 | }); 68 | }; 69 | }; 70 | 71 | /** Adds a getter and a setter to the given Object, allowing access to the Selenium element corresponding to the given locator description. 72 | * The getter dynamically retrieves the Selenium element pointed at by the given selector description. 73 | * The setter will pass the value to the `Locator.handleInput` method. 74 | * 75 | *@param {Object} target The Object to which the getter and setter will be added. 76 | *@param {String} key The name of the property to add to the target object. 77 | *@param {Object} typeAndSelector A locator descriptor, as defined in the Locator constructor. 78 | *@param {WebDriver} driver The WebDriver instance in which the described elements are to be sought. 79 | */ 80 | Locator.addLocator = function addLocator(target, key, typeAndSelector, driver) { 81 | var locator = new Locator(typeAndSelector, driver); 82 | 83 | var inputHandler = function handleInputAndEmit(input) { 84 | target.emit('action', key, 'write', [ input ]); 85 | 86 | return locator.handleInput(input); 87 | }; 88 | 89 | var propertyDescriptor = {}; 90 | 91 | propertyDescriptor[key] = { 92 | get: function() { 93 | target.emit('access', key); 94 | return locator.toSeleniumElement(locator); 95 | }, 96 | set: inputHandler // TODO: remove in v0.7, deprecated since v0.6 97 | }; 98 | 99 | Object.defineProperties(target, propertyDescriptor); 100 | 101 | var setterName = 'set' + key.capitalize(); 102 | 103 | target[setterName] = function(input) { // wrapping to allow call-like syntax in scenarios 104 | var setter = inputHandler.bind(null, input); 105 | 106 | setter.component = target; 107 | setter.reference = setterName; 108 | setter.title = setterName.humanize(); 109 | setter.args = [ input ]; 110 | 111 | return setter; 112 | }; 113 | }; 114 | 115 | module.exports = Locator; // CommonJS export 116 | -------------------------------------------------------------------------------- /src/model/Scenario.js: -------------------------------------------------------------------------------- 1 | var promises = require('q'), 2 | assert = require('assert'), 3 | winston = require('winston'); 4 | 5 | var steps = require('./steps'); 6 | 7 | 8 | var Scenario = new Class( /** @lends Scenario# */ { 9 | 10 | Extends: require('events').EventEmitter, 11 | 12 | /** Constructed after the scenario for this scenario. 13 | * 14 | *@type {Array.} 15 | *@private 16 | */ 17 | steps: [], 18 | 19 | /** The reasons for failures of the steps. 20 | * 21 | *@type {Array.} 22 | *@private 23 | */ 24 | reasons: [], 25 | 26 | /** A hash with all components accessible to this Scenario, indexed on their names. 27 | * 28 | *@type {Object.} 29 | *@see Component 30 | *@private 31 | */ 32 | components: {}, 33 | 34 | /** The user-provided scenario name. 35 | * 36 | *@type {String} 37 | */ 38 | description: '', 39 | 40 | /** A numerical identifier that the user can easily identify. 41 | * 42 | *@type {Number} 43 | */ 44 | id: 0, 45 | 46 | promise: null, 47 | 48 | /**@class A Scenario models a sequence of actions to be executed through Components. 49 | * 50 | * A scenario description file contains a simple descriptive array listing component methods to execute and component state descriptors to assert. 51 | * More formally, such an array is ordered and its members may be: 52 | * - a closure; 53 | * - an object whose keys are some components' attributes identifiers (ex: "MyComponent.myAttr"), pointing at a string that contains the expected text content of the HTML element represented by the `myAttr` locator in `MyComponent`. 54 | * 55 | * Upon instantiation, a Scenario translates this array into an array of promises: 56 | * - closures are executed directly, either as promises if they are so themselves, or as basic functions; 57 | * - a component state describing hash maps each of its members to an assertion inside a promise, evaluating all of them asynchronously. 58 | * All those promises are then evaluated sequentially upon calling the `test` method of a Scenario. 59 | * 60 | *@constructs 61 | *@param {String} description A plain text description of the scenario, advised to be written in a BDD fashion. 62 | *@param {Array} stepsArray An array that describes states and transitions. See class documentation for formatting. 63 | *@param {Object.} components A hash listing all components accessible to this Scenario, indexed on their names. 64 | *@param {Hash} config The test-suite-level configuration elements. 65 | *@param {Number} [id] The numerical identifier of this scenario. 66 | */ 67 | initialize: function init(description, stepsArray, components, config, id) { 68 | this.description = description; 69 | this.id = id || 0; 70 | this.config = config; 71 | this.components = components; // TODO: transform so that they can be referred to with the "Component" suffix optional? 72 | 73 | this.steps = this.loadSteps(stepsArray); 74 | }, 75 | 76 | /** Parses an array that describes states and transitions and transforms it into a sequence of promises to be evaluated. 77 | * 78 | *@param {Array} stepsArray An array that describes states and transitions. See class documentation for formatting. 79 | *@returns {Array.} An array of promises representing the given steps. 80 | *@private 81 | */ 82 | loadSteps: function loadSteps(stepsArray) { 83 | var result = []; 84 | 85 | for (var stepIndex = 0; stepIndex < stepsArray.length; stepIndex++) { 86 | var sourceStep = stepsArray[stepIndex], 87 | step = null; // this is going to be an actual AbstractStep-inheriting instance 88 | 89 | winston.loggers.get('load').silly('Loading step ' + stepIndex + ' (source type: ' + typeof sourceStep + ', full source: ' + sourceStep + ')…'); 90 | 91 | switch (typeof sourceStep) { 92 | case 'function': 93 | step = new steps.FunctionalStep(sourceStep); 94 | break; 95 | case 'object': // if this is a Hash, it is a state description 96 | if (! sourceStep) // yep, typeof null == 'object' :) 97 | break; // do nothing, but make sure step is not defined, and that we eliminated any risk of using an illegal sourceStep 98 | 99 | step = new steps.StateStep(sourceStep, this.components); 100 | break; 101 | case 'string': 102 | case 'number': 103 | winston.loggers.get('load').debug('Oops, encoutered "' + sourceStep + '" as a free step!' // TODO: remove this hint after v0.4 104 | + '\n' 105 | + 'Maybe your test is still using pre-0.4 syntax? :)' 106 | + '\n' 107 | + 'Since v0.4, action parameters are passed directly as a function call.' 108 | + '\n' 109 | + 'For more details, see the Scenarios syntax reference: https://github.com/MattiSG/Watai/wiki/Scenarios'); 110 | break; 111 | } 112 | 113 | if (! step) 114 | this.notifySyntaxError('step value ("' + sourceStep + '") is illegal!', stepIndex); 115 | 116 | winston.loggers.get('load').silly('Loaded step ' + stepIndex + ' as ' + step); 117 | 118 | result.push(step); 119 | } 120 | 121 | return result; 122 | }, 123 | 124 | /** Notifies the user that there was a syntax error in the scenario description file. 125 | * 126 | *@param {String} message A description of the syntax error that was detected 127 | *@param {Number} [stepIndex] The scenario step (0-based) at which the syntax error was detected. If not defined, the syntax error will be described as global to the scenario file. 128 | */ 129 | notifySyntaxError: function notifySyntaxError(message, stepIndex) { 130 | throw new SyntaxError( 131 | 'Scenario "' + this.description + '"' 132 | + (typeof stepIndex != 'undefined' // we can't simply test for falsiness, since the stepIndex could be 0 133 | ? ', at step ' + (stepIndex + 1) 134 | : '') 135 | + (message 136 | ? ': ' + message 137 | : '') 138 | ); 139 | }, 140 | 141 | /** Asynchronously evaluates the scenario given to this scenario. 142 | * 143 | *@returns {QPromise} A promise that will be either: 144 | * - rejected if any assertion or action fails, passing an array of strings that describe reason(s) for failure(s) (one reason per item in the array). 145 | * - resolved if all assertions pass, with this scenario as a parameter. 146 | */ 147 | test: function test() { 148 | var deferred = promises.defer(), 149 | stepIndex = -1, 150 | evaluateNext; 151 | 152 | evaluateNext = (function evalNext() { 153 | stepIndex++; 154 | 155 | if (stepIndex == this.steps.length) { 156 | if (this.reasons.length) 157 | return deferred.reject(this.reasons); 158 | 159 | return deferred.resolve(this); 160 | } 161 | 162 | var step = this.steps[stepIndex]; 163 | 164 | this.emit('step', step, stepIndex); 165 | 166 | step.test(this.config.timeout) 167 | .fail(this.reasons.push.bind(this.reasons)) 168 | .fin(process.nextTick.bind(process, evaluateNext)) 169 | .done(); 170 | }).bind(this); 171 | 172 | this.promise = deferred.promise; 173 | 174 | this.emit('start', this); 175 | 176 | process.nextTick(evaluateNext); // all other steps will be async, decrease discrepancies and give control back ASAP 177 | 178 | return this.promise; 179 | }, 180 | 181 | toString: function toString() { 182 | return this.description; 183 | } 184 | }); 185 | 186 | 187 | module.exports = Scenario; // CommonJS export 188 | -------------------------------------------------------------------------------- /src/model/steps/FunctionalStep.js: -------------------------------------------------------------------------------- 1 | var promises = require('q'); 2 | 3 | /**@class A step that parses and evaluates a function in a scenario. 4 | * A function may be a component action, or a user-provided function. 5 | * 6 | *@extends steps.AbstractStep 7 | *@memberOf steps 8 | */ 9 | var FunctionalStep = new Class(/** @lends steps.FunctionalStep# */{ 10 | Extends: require('./AbstractStep'), 11 | 12 | type: 'functional', 13 | 14 | /** A function, promise-returning or not, to be executed. If it throws, or returns a rejected promise, this step will fail. Otherwise, it will succeed. 15 | * 16 | *@type {Function} 17 | *@private 18 | */ 19 | action: null, 20 | 21 | /** 22 | *@param {Function} action A function, promise-returning or not, to be executed. If it throws, or returns a rejected promise, this step will fail. Otherwise, it will succeed. 23 | *@constructs 24 | */ 25 | initialize: function init(action) { 26 | this.action = action; 27 | }, 28 | 29 | start: function start() { 30 | promises.fcall(this.action) 31 | .done(this.succeed.bind(this), this.fail.bind(this)); 32 | }, 33 | 34 | formatFailure: function formatFailure(report) { 35 | return (this 36 | + ' failed' 37 | + (report 38 | ? ', returning "' + report + '"' 39 | : '') 40 | ); 41 | }, 42 | 43 | toString: function toString() { 44 | if (this.action.component) // this is a Component action 45 | return this.describeAction(); 46 | 47 | if (this.action.name) { // this is a custom user function, hopefully the user provided a good name for it 48 | var humanized = this.action.name.humanize(); 49 | 50 | if (humanized != this.action.name) 51 | humanized += ' (as ' + this.action.name + ')'; 52 | 53 | return humanized; 54 | } 55 | 56 | return '[unnamed action]'; 57 | }, 58 | 59 | /** Tries to describe the wrapped step, assuming it is a Component-generated action. 60 | * 61 | *@returns {String} A user-presentable action description. 62 | *@see Component 63 | */ 64 | describeAction: function describeAction() { 65 | var humanizedAction = (this.action.title || this.action.reference).humanize(); // makes naming functions themselves optional, but let them have higher precedence over component key: users can thus provide more details in function names without making it long to access them in tests 66 | 67 | return this.action.component 68 | + ' ' 69 | + humanizedAction 70 | + (this.action.args.length 71 | ? ' with "' + this.action.args.join('", "') + '"' 72 | : '') 73 | + (humanizedAction != this.action.reference // make it easier to locate source 74 | ? ' (as ' + this.action.component + '.' + this.action.reference + ')' 75 | : ''); 76 | } 77 | }); 78 | 79 | 80 | module.exports = FunctionalStep; // CommonJS export 81 | -------------------------------------------------------------------------------- /src/model/steps/StateStep.js: -------------------------------------------------------------------------------- 1 | var promises = require('q'); 2 | 3 | var stateMatchers = require('./state'); 4 | 5 | /**@class A step that parses and evaluates a component state assertions. 6 | * 7 | *@extends steps.AbstractStep 8 | *@memberOf steps 9 | */ 10 | var StateStep = new Class(/** @lends steps.StateStep# */{ 11 | Extends: require('./AbstractStep'), 12 | 13 | type: 'state', 14 | 15 | /** The dictionary of all components within which the given state assertion should be understood. 16 | * 17 | *@type {Hash.} 18 | *@private 19 | */ 20 | components: {}, 21 | 22 | /** The original, user-provided state description hash. 23 | * 24 | *@type {Hash.} 25 | *@private 26 | */ 27 | descriptors: {}, 28 | 29 | /** The assertions corresponding to the given description hash. 30 | * An array of promise-returning functions. 31 | * 32 | *@type {Array.} 33 | *@private 34 | */ 35 | assertions: [], 36 | 37 | /** 38 | * 39 | *@param {Hash.} description A state assertion hash. 40 | *@param {Hash.} components The dictionary of all components within which the given state assertion should be understood. 41 | *@constructs 42 | */ 43 | initialize: function init(description, components) { 44 | this.components = components; 45 | 46 | description = this.removeOptions(description); 47 | this.sanityCheck(description); 48 | 49 | this.descriptors = description; 50 | 51 | Object.each(description, function(expected, elementName) { 52 | this.assertions.push(this.generateAssertion(elementName, expected)); 53 | }, this); 54 | }, 55 | 56 | /** 57 | *@see AbstractStep#start 58 | */ 59 | start: function start() { 60 | var assertionsPromises = this.assertions.map(function(assertion) { 61 | return assertion(); 62 | }); 63 | 64 | promises.allSettled(assertionsPromises) 65 | .done(this.onAllDescriptorsDone.bind(this)); 66 | }, 67 | 68 | /** Parses local options (i.e. the ones specific to this state assertion) and removes them from the given description. 69 | * 70 | *@param {Hash.} description A state assertion hash. 71 | *@returns {Hash.} The same assertion hash, with its options removed. 72 | */ 73 | removeOptions: function removeOptions(description) { 74 | if (description.hasOwnProperty('timeout')) { 75 | this.timeout = description.timeout; 76 | delete description.timeout; // we don't want to iterate over this property! 77 | } 78 | 79 | return description; 80 | }, 81 | 82 | /** Checks that the given state assertions are valid. 83 | * 84 | *@throws {ReferenceError} 85 | */ 86 | sanityCheck: function sanityCheck(description) { 87 | Object.each(description, function(expected, attribute) { 88 | // unfortunately, we can't cache elements, since WebDriverJS matches elements to the current page once and for all. We'll have to ask access on the page on which the assertion will take place. 89 | if (! Object.hasPropertyPath(this.components, attribute)) { // the user referenced a non-existing element 90 | throw new ReferenceError( 91 | 'Could not find "' + attribute + '" in available components.\n' 92 | + 'Are you sure you spelled the property path properly?' 93 | ); 94 | } 95 | 96 | if (! Object.hasGetter(this.components, attribute)) { // the user referenced a magically-added attribute, not an element 97 | throw new ReferenceError( 98 | '"' + attribute + '" is a shortcut, not an element. It can not be used in an assertion description.\n' 99 | + 'You should target a key referenced in the `elements` hash of the "' + attribute.split('.')[0] + '" component.' 100 | ); 101 | } 102 | }, this); 103 | }, 104 | 105 | /** 106 | *@param {String} elementName The component element whose content is to be evaluated. 107 | *@param {Object} expected The expected value for the element content. 108 | *@returns {Function} A promise-returning function. 109 | *@private 110 | */ 111 | generateAssertion: function generateAssertion(elementName, expected) { 112 | var deferred = promises.defer(), 113 | MatcherClass = stateMatchers.forValue(expected); 114 | 115 | if (! MatcherClass) 116 | throw new TypeError('No matcher found for the given value type.\nHad to check for "' + expected + '", which is of type ' + typeof expected + '.'); 117 | 118 | var matcher = new MatcherClass(expected, elementName, this.components); 119 | 120 | return function evaluateStateDescriptorMatcher() { 121 | this.emit('matcher', matcher); 122 | 123 | matcher.test(this.timeout) 124 | .done(deferred.resolve, deferred.reject); 125 | 126 | return deferred.promise; 127 | }.bind(this); 128 | }, 129 | 130 | /** Extracts failures from descriptor promises and calls either `succeed` or `fail` based on this information. 131 | * 132 | *@param {Array.} promiseSnapshots The snapshots of promises for each state descriptor. 133 | *@see Q.allSettled 134 | */ 135 | onAllDescriptorsDone: function onAllDescriptorsDone(promiseSnapshots) { 136 | var failures = []; 137 | 138 | promiseSnapshots.each(function(promiseSnapshot) { 139 | if (promiseSnapshot.state == 'rejected') 140 | failures = failures.concat(promiseSnapshot.reason); // as passed when rejecting a promise from #generateAssertion, this is an array containing reasons of rejection for each matcher 141 | }); 142 | 143 | if (failures.length > 0) 144 | this.fail(failures); 145 | else 146 | this.succeed(); 147 | }, 148 | 149 | /** 150 | *@see AbstractStep#formatFailure 151 | */ 152 | formatFailure: function formatFailure(failures) { 153 | return '- ' + failures.join('\n- '); 154 | }, 155 | 156 | toString: function toString() { 157 | return 'State assertion'; 158 | } 159 | }); 160 | 161 | 162 | module.exports = StateStep; // CommonJS export 163 | -------------------------------------------------------------------------------- /src/model/steps/index.js: -------------------------------------------------------------------------------- 1 | /** All public scenario steps. 2 | * A _step_ is an object that handles an element in a Scenario array. 3 | * You can access them through this hash. 4 | * 5 | *@namespace 6 | */ 7 | var steps = { 8 | FunctionalStep: require('./FunctionalStep'), 9 | StateStep: require('./StateStep') 10 | }; 11 | 12 | module.exports = steps; 13 | -------------------------------------------------------------------------------- /src/model/steps/state/AbstractMatcher.js: -------------------------------------------------------------------------------- 1 | var promises = require('q'); 2 | 3 | 4 | /**@class Abstract class from which all content matchers inherit. 5 | * 6 | * A _matcher_ is an object that handles state descriptors, i.e. each key/value pair in a state description object from a Scenario. 7 | * Implementing a new matcher, i.e. offering a new content comparison type, is very easy: simply extend this class, taking example on the existing concrete matchers, and redefine at least `onElementFound`, calling `this.succeed`, `this.fail` and/or `this.compare`. 8 | * 9 | *@extends steps.AbstractStep 10 | *@memberOf matchers 11 | */ 12 | var AbstractMatcher = new Class( /** @lends matchers.AbstractMatcher# */ { 13 | 14 | Extends: require('../AbstractStep'), 15 | 16 | /** The type of content this matcher can match an element on. 17 | */ 18 | type: 'abstract matcher', 19 | 20 | /** The value this matcher should look for. 21 | */ 22 | expected: undefined, 23 | 24 | /** The components in which elements should be looked for. 25 | *@type {Object.} 26 | *@private 27 | */ 28 | components: [], 29 | 30 | /** A component element selector, describing the element whose content is to be matched. 31 | *@type {String} 32 | *@private 33 | */ 34 | selector: '', 35 | 36 | 37 | /** Creates a matcher, ready to be evaluated. 38 | * 39 | *@param {*} expected Any kind of content this matcher should look for. 40 | *@param {String} selector The element selector to look for in this instance's referenced components. 41 | *@param {Object.} [components] The components in which elements should be looked for. 42 | *@constructs 43 | *@see test 44 | *@see addComponents 45 | */ 46 | initialize: function init(expected, selector, components) { 47 | this.addComponents(components); 48 | this.selector = selector; 49 | this.expected = expected; 50 | 51 | this.compare = this.compare.bind(this); 52 | this.succeed = this.succeed.bind(this); 53 | this.fail = this.fail.bind(this); 54 | }, 55 | 56 | /** Adds the given components to the ones that are available to this matcher to seek elements in. 57 | * 58 | *@param {Object.} components The components in which elements should be looked for. 59 | *@returns this For chaining. 60 | */ 61 | addComponents: function addComponents(components) { 62 | Object.append(this.components, components); 63 | return this; 64 | }, 65 | 66 | /** Starts an actual match, by trying to obtain the element pointed by this instance's selector. 67 | * 68 | *@private 69 | */ 70 | start: function start() { 71 | Object.getFromPath(this.components, this.selector) 72 | .done(this.onElementFound.bind(this), // this wrapping is needed because the promise from `getFromPath` is a WebDriver promise, so we can't add failure handlers only, we need to set all handlers at once through the `then` method 73 | this.onElementMissing.bind(this)); 74 | }, 75 | 76 | /** Handler called when the selected element is found. To be redefined by subclasses. 77 | * 78 | *@param {WD.Element} element The WebDriver element pointed by the selector of this instance. 79 | */ 80 | onElementFound: function onElementFound(element) { 81 | throw new Error('AbstractMatcher should never be used as an instance! onElementFound() has to be redefined.'); 82 | }, 83 | 84 | /** Handler for missing element. May be redefined by subclasses. 85 | * Defaults to failing. 86 | * 87 | *@param {Error} error The error raised by WebDriver. 88 | */ 89 | onElementMissing: function onElementMissing(error) { 90 | this.fail(error); 91 | }, 92 | 93 | /** Compares the given value to the expected value, using the `match` method, and fails or succeeds the match automatically. 94 | * 95 | *@param {*} actual The value that should be compared against this instance's expected value. 96 | *@see match 97 | */ 98 | compare: function compare(actual) { 99 | if (this.match(actual, this.expected)) 100 | this.succeed(); 101 | else 102 | this.fail(actual); 103 | }, 104 | 105 | /** Compares the equality of the two given values. 106 | * To be modified by inheriting classes. Defaults to testing loose equality with `==`. 107 | * 108 | *@param {*} actual The value that should be compared against the expected value. 109 | *@param {*} expected The value to compare against. 110 | */ 111 | match: function match(actual, expected) { 112 | return actual == expected; 113 | }, 114 | 115 | /** Formats the message displayed to the user in case of a failure. 116 | * May be redefined by children classes. 117 | * May be prefixed by timeout information when actually shown to the user. 118 | * 119 | *@param {*} actual The actual value that was encountered. 120 | */ 121 | formatFailure: function formatFailure(actual) { 122 | if (typeof actual == 'undefined') { 123 | return 'could not determine the ' 124 | + this.type 125 | + ' from element ' 126 | + this.selector 127 | + '.'; 128 | } 129 | 130 | return this.selector 131 | + "'s " 132 | + this.type 133 | + ' was' 134 | + (this.timeout > 0 ? ' still "' : ' "') 135 | + actual 136 | + '" instead of "' 137 | + this.expected 138 | + '".'; 139 | }, 140 | 141 | toString: function toString() { 142 | return 'Match ' 143 | + this.selector + '’s ' 144 | + (this.attribute || this.type) 145 | + ' against "' 146 | + this.expected 147 | + '"'; 148 | }, 149 | 150 | /** Formats a "NoSuchElement" JsonWire error. 151 | * 152 | *@param {JsonWireError} error The error to format. 153 | *@returns {String} The formatted error. 154 | */ 155 | formatJsonWireError7: function formatJsonWireError7(error) { 156 | return this.selector + ' was not found.'; 157 | } 158 | }); 159 | 160 | 161 | module.exports = AbstractMatcher; // CommonJS export 162 | -------------------------------------------------------------------------------- /src/model/steps/state/ContentMatcher.js: -------------------------------------------------------------------------------- 1 | /**@class A matcher that tests its element's content, without prior knowledge of whether this content is to be obtained through inner text or element value. 2 | *@extends matchers.AbstractMatcher 3 | *@memberOf matchers 4 | */ 5 | var ContentMatcher = new Class( /** @lends matchers.ContentMatcher# */ { 6 | Extends: require('./AbstractMatcher'), 7 | 8 | type: 'content', 9 | 10 | onElementFound: function(element) { 11 | this.textPromise = element.text(); 12 | this.valuePromise = element.getAttribute('value'); 13 | 14 | this.textComparedPromise = this.textPromise.then(this.compare, this.fail); 15 | this.valueComparedPromise = this.valuePromise.then(this.compare, this.fail); 16 | }, 17 | 18 | compare: function compare(actual) { 19 | if (this.match(actual, this.expected)) 20 | return this.succeed(); 21 | else if (this.valueComparedPromise.isRejected() || this.textComparedPromise.isRejected()) 22 | return this.fail(); 23 | else 24 | throw new Error('"' + actual + '"" does not match "' + expected + '"'); // reject the comparison promise 25 | }, 26 | 27 | formatFailure: function formatFailure(failure) { 28 | var text = this.textPromise.inspect().value, 29 | value = this.valuePromise.inspect().value; 30 | 31 | return 'Found text "' 32 | + text 33 | + '" and ' 34 | + (value 35 | ? 'value "' + value + '"' 36 | : 'no value') 37 | + ' in ' 38 | + this.selector 39 | + ', which does not match ' 40 | + (this.expected 41 | ? '"' + this.expected + '"' 42 | : 'the empty string') 43 | + '.'; 44 | } 45 | }); 46 | 47 | module.exports = ContentMatcher; // CommonJS export 48 | -------------------------------------------------------------------------------- /src/model/steps/state/ContentRegExpMatcher.js: -------------------------------------------------------------------------------- 1 | /**@class A matcher that tests its element's content against a regular expression, without prior knowledge of whether this content is to be obtained through inner text or element value. 2 | *@extends matchers.ContentMatcher 3 | *@memberOf matchers 4 | */ 5 | var ContentRegExpMatcher = new Class( /** @lends matchers.ContentRegExpMatcher# */ { 6 | Extends: require('./ContentMatcher'), 7 | 8 | match: function match(actual, expected) { 9 | return expected.exec(actual); 10 | } 11 | }); 12 | 13 | module.exports = ContentRegExpMatcher; // CommonJS export 14 | -------------------------------------------------------------------------------- /src/model/steps/state/FunctionMatcher.js: -------------------------------------------------------------------------------- 1 | var promises = require('q'); 2 | 3 | 4 | /**@class A matcher that tests its element against a user-provided function. 5 | *@extends matchers.AbstractMatcher 6 | *@memberOf matchers 7 | */ 8 | var FunctionMatcher = new Class( /** @lends matchers.FunctionMatcher# */ { 9 | Extends: require('./AbstractMatcher'), 10 | 11 | type: 'function', 12 | 13 | onElementFound: function(element) { 14 | this.compare(element); 15 | }, 16 | 17 | compare: function compare(element) { 18 | promises.fcall(this.expected, element) 19 | .done(this.succeed, this.fail); 20 | }, 21 | 22 | formatFailure: function formatFailure(actual) { 23 | return this.selector 24 | + ' had its testing function fail, saying "' + actual + '":\n' 25 | + this.expected; 26 | }, 27 | 28 | toString: function toString() { 29 | return 'Evaluation of ' + (this.expected.name || 'an unnamed function') + ' on ' + this.selector; 30 | } 31 | }); 32 | 33 | module.exports = FunctionMatcher; // CommonJS export 34 | -------------------------------------------------------------------------------- /src/model/steps/state/VisibilityMatcher.js: -------------------------------------------------------------------------------- 1 | /**@class A matcher that tests for its element's visibility in the DOM. 2 | *@extends matchers.AbstractMatcher 3 | *@memberOf matchers 4 | */ 5 | var VisibilityMatcher = new Class( /** @lends matchers.VisibilityMatcher# */ { 6 | Extends: require('./AbstractMatcher'), 7 | 8 | type: 'visibility', 9 | 10 | onElementFound: function(element) { 11 | element.isDisplayed() 12 | .done(this.compare, 13 | this.fail); 14 | }, 15 | 16 | onElementMissing: function() { 17 | this.compare(false); 18 | }, 19 | 20 | formatFailure: function formatFailure(actual) { 21 | if (actual) { 22 | return 'element ' 23 | + this.selector 24 | + ' was visible on the page while it should not have.'; 25 | } else { 26 | return 'element ' 27 | + this.selector 28 | + ' was not visible on the page while it should have.'; 29 | } 30 | }, 31 | 32 | toString: function toString() { 33 | return 'Visibility of ' + this.selector; 34 | } 35 | }); 36 | 37 | module.exports = VisibilityMatcher; // CommonJS export 38 | -------------------------------------------------------------------------------- /src/model/steps/state/index.js: -------------------------------------------------------------------------------- 1 | /** All public content matchers. 2 | * A _matcher_ is an object that handles state descriptors, i.e. each key/value pair in a state description object from a Scenario. 3 | * You can access them through this hash. 4 | * 5 | *@namespace 6 | */ 7 | var matchers = { 8 | VisibilityMatcher : require('./VisibilityMatcher'), 9 | ContentMatcher : require('./ContentMatcher'), 10 | ContentRegExpMatcher: require('./ContentRegExpMatcher'), 11 | FunctionMatcher : require('./FunctionMatcher') 12 | }; 13 | 14 | /** Returns the matcher class that is able to test for the given expected value. 15 | * 16 | *@param {*} expected Any value that matchers are to be found for. 17 | *@returns {AbstractMatcher|undefined} A *class*, to be initialized, or nothing if no matcher can be used for the given value. 18 | */ 19 | matchers.forValue = function matcherForValue(expected) { 20 | if (typeof expected == 'boolean') // TODO: make matchers responsible for defining which value types they can handle instead of this horrendous switch 21 | return matchers.VisibilityMatcher; 22 | else if (typeof expected == 'function') 23 | return matchers.FunctionMatcher; 24 | else if (expected.constructor && expected.constructor.name === 'RegExp') // since elements are loaded in a separate context, the `instanceof` fails, as it compares constructors references 25 | return matchers.ContentRegExpMatcher; 26 | else if (typeof expected == 'string') 27 | return matchers.ContentMatcher; 28 | }; 29 | 30 | module.exports = matchers; 31 | -------------------------------------------------------------------------------- /src/plugins/config/index.js: -------------------------------------------------------------------------------- 1 | /** Parses an option hash given through the CLI. 2 | * 3 | *@param {String} data A JSON-encoded series of config options. 4 | *@returns {Hash} The config options, unserialized. 5 | */ 6 | function parseOptions(data) { 7 | return JSON.parse(data); 8 | } 9 | 10 | 11 | module.exports = parseOptions; // CommonJS export 12 | -------------------------------------------------------------------------------- /src/plugins/help/index.js: -------------------------------------------------------------------------------- 1 | /** Prints CLI synopsis. 2 | * 3 | *@param {Number|false} [exitCode] The exit code with which the process should exit. Pass `false` to not exit. Defaults to exiting with status code 0. 4 | */ 5 | function showHelp(exitCode) { 6 | var packageDescription = require('../../../package.json'); 7 | 8 | console.log([ 9 | 'Usage: watai [--config \'{"json":"hash"}\'] path/to/suite/description/folder', 10 | ' watai --version', 11 | ' watai --installed', 12 | 'Get more information at <' + packageDescription.homepage + '>' 13 | ].join('\n')); 14 | 15 | if (typeof exitCode === false) 16 | return; 17 | 18 | process.exit(exitCode || 0); 19 | } 20 | 21 | 22 | module.exports = showHelp; // CommonJS export 23 | -------------------------------------------------------------------------------- /src/plugins/installed/index.js: -------------------------------------------------------------------------------- 1 | /** Provides a quick smoke test for proper installation. 2 | *@return `true` if this software has all its dependencies installed or not, the error that explains what fails otherwise. 3 | *@private 4 | */ 5 | function isInstalled() { 6 | try { 7 | require('../../'); 8 | return true; 9 | } catch (e) { 10 | return e; 11 | } 12 | } 13 | 14 | 15 | /** Presents the user with a message telling if this software passes a quick smoke test, and gives the main reason if not. 16 | * Exits the running process afterwards, with `0` if installed properly or nonzero otherwise. 17 | */ 18 | function tellUserInstallationStatus() { 19 | var evaluation = isInstalled(); 20 | 21 | if (evaluation === true) { 22 | console.log('Watai seems to be installed properly :) Now get testing!'); 23 | process.exit(0); 24 | } else { 25 | console.error('**Watai has not been installed properly, please make sure you followed all installation instructions.**'); 26 | console.error('Main reason: ' + evaluation.message + '.'); 27 | console.error('See http://github.com/MattiSG/Watai for details.'); 28 | process.exit(1); 29 | } 30 | } 31 | 32 | 33 | module.exports = tellUserInstallationStatus; // CommonJS export 34 | -------------------------------------------------------------------------------- /src/plugins/setup/index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('../config'); // we want to apply the exact same logic to the `setup` and `config` options, as they take similar input 2 | -------------------------------------------------------------------------------- /src/plugins/version/index.js: -------------------------------------------------------------------------------- 1 | /** Presents the user with the current installed version. 2 | * Exits the running process afterwards. 3 | */ 4 | function tellUserCurrentVersion() { 5 | var packageDescription = require('../../../package.json'); 6 | 7 | console.log(packageDescription.version); 8 | 9 | process.exit(0); 10 | } 11 | 12 | 13 | module.exports = tellUserCurrentVersion; // CommonJS export 14 | -------------------------------------------------------------------------------- /src/setup.json: -------------------------------------------------------------------------------- 1 | { 2 | "log": { 3 | "init": { 4 | "console": { 5 | "level" : "error", 6 | "colorize" : true 7 | } 8 | }, 9 | "load": { 10 | "console": { 11 | "level" : "info", 12 | "colorize" : true 13 | } 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/view/Matcher/Verbose.js: -------------------------------------------------------------------------------- 1 | /**@class Outputs a state descriptor status. 2 | */ 3 | var MatcherVerbose = new Class({ 4 | Extends: require('../PromiseView'), 5 | 6 | /** Presents details of a test success to the user. 7 | */ 8 | showSuccess: function showSuccess() { 9 | this.animator.log(' ┝ ✓', 'info', this.model); 10 | }, 11 | 12 | /** Presents details of a test failure to the user. 13 | * 14 | *@param {String} reason Details on the failure. 15 | */ 16 | showFailure: function showFailure(reason) { 17 | this.animator.log(' ┝ ✗', 'warn', reason); 18 | } 19 | }); 20 | 21 | 22 | module.exports = MatcherVerbose; // CommonJS export 23 | -------------------------------------------------------------------------------- /src/view/PromiseView.js: -------------------------------------------------------------------------------- 1 | var ERRORS_LIST = require('../errors'); 2 | 3 | 4 | var PromiseView = new Class(/** @lends PromiseView# */{ 5 | 6 | /** Shortcut for views that require animation. 7 | */ 8 | animator: require('../../src/lib/cli-animator'), 9 | 10 | /** The model represented by this view. 11 | */ 12 | model: null, 13 | 14 | submodel: { 15 | name: '', 16 | view: null 17 | }, 18 | 19 | /** A helper abstract view for models that offer a `promise` property and raises standard `start` and `` events. 20 | * 21 | *@constructs 22 | */ 23 | initialize: function init(model) { 24 | this.model = model; 25 | 26 | this.model.on('start', this.onStart.bind(this)); 27 | 28 | if (this.submodel.name) { 29 | this.model.on(this.submodel.name, function(submodel) { 30 | new this.submodel.view(submodel); 31 | }.bind(this)); 32 | } 33 | 34 | this.attach(); 35 | }, 36 | 37 | /** Attaches all events defined in the this class' `events` hash. 38 | */ 39 | attach: function attach() { 40 | for (var key in this.events) 41 | this.model.on(key, this.events[key].bind(this)); 42 | }, 43 | 44 | /** Tries to generate a human-readable version of errors propagated from external libraries. 45 | * 46 | *@param {Error|Object} error The original raised error. 47 | *@return {Hash} A hash with the following pairs: 48 | * title: a user-displayable explanation for the given error, or undefined if no detailed description could be found 49 | * help: a user-displayable list of possible actions to take to solve the problem 50 | * source: the original passed error 51 | */ 52 | getErrorDescription: function getErrorDescription(error) { 53 | var userDisplayable = {}; 54 | 55 | if (ERRORS_LIST[error && error.code]) // we have provided advanced help for such an error 56 | userDisplayable = ERRORS_LIST[error && error.code]; 57 | else if (error.data) { // unknown Selenium error, for example by SauceLabs. Do our best to format it. 58 | var lines = error.data.split('\n'); 59 | 60 | userDisplayable = { 61 | title: lines.shift(), 62 | help: lines.join('\n') 63 | }; 64 | } 65 | 66 | return { 67 | title: userDisplayable.title, // no default value: this is how a client can know if a detailed description was found; don't try putting `error.toString()`: some values do _not_ have a toString() method 68 | help: (userDisplayable.help ? userDisplayable.help + '\n' : '') 69 | + 'Get more help at ', 70 | source: error 71 | }; 72 | }, 73 | 74 | /** Presents details of a test start to the user. 75 | * Attaches to resolution handlers. 76 | */ 77 | onStart: function onStart() { 78 | this.model.promise.done( 79 | this.showSuccess.bind(this), 80 | this.showFailure.bind(this) 81 | ); 82 | this.model.promise.fin(this.showEnd.bind(this)); 83 | 84 | this.showStart(); 85 | }, 86 | 87 | /** Presents details of a model evaluation start to the user. 88 | */ 89 | showStart: function showStart() { 90 | // to be redefined by inheriting classes 91 | }, 92 | 93 | /** Presents details of a model success to the user. 94 | */ 95 | showSuccess: function showSuccess() { 96 | // to be redefined by inheriting classes 97 | }, 98 | 99 | /** Presents details of a model failure to the user. 100 | * 101 | *@param {String} reason The reason why the step failed. 102 | */ 103 | showFailure: function showFailure(reason) { 104 | // to be redefined by inheriting classes 105 | }, 106 | 107 | /** Presents details of the end of model evaluation to the user. 108 | */ 109 | showEnd: function showEnd() { 110 | // to be redefined by inheriting classes 111 | } 112 | }); 113 | 114 | module.exports = PromiseView; // CommonJS export 115 | -------------------------------------------------------------------------------- /src/view/Runner/CLI.js: -------------------------------------------------------------------------------- 1 | /**@class A command-line interface that outputs and formats a Runner’s events. 2 | */ 3 | var RunnerCLI = new Class({ 4 | Extends: require('../PromiseView'), 5 | 6 | submodel: { 7 | name: 'scenario', 8 | view: require('../Scenario/CLI') 9 | }, 10 | 11 | events: { 12 | /** Informs user that the emitting Runner is ready to start. 13 | */ 14 | ready: function onReady() { 15 | this.animator.log('⨁', 'info', this.model + ' '); 16 | } 17 | }, 18 | 19 | /** Informs user that the emitting Runner is waiting for the browser. 20 | */ 21 | showStart: function showStart() { 22 | this.animator.spin(this.model + ' (waiting for browser…)'); 23 | }, 24 | 25 | showFailure: function showFailure(reason) { 26 | var description = this.getErrorDescription(reason); 27 | if (description.title) { // if we can't give more info, simply don't show anything 28 | this.animator.log('✘ ', 'warn', description.title, 'warn', process.stderr); 29 | this.animator.log(' ', 'debug', description.help, 'debug', process.stderr); 30 | } 31 | }, 32 | 33 | /** Resets the shell prompt. 34 | */ 35 | showEnd: function showEnd() { 36 | if (this.model.config.ignore.length) { 37 | this.animator.log( 38 | '⨁ ', 39 | 'cyan', 40 | 'ignored scenario'.count(this.model.config.ignore.length) 41 | + ' (#' 42 | + this.model.config.ignore.join(', #') 43 | + ')', 44 | 'cyan' 45 | ); 46 | } 47 | 48 | this.animator.clear(); 49 | this.animator.showCursor(); 50 | } 51 | }); 52 | 53 | 54 | module.exports = RunnerCLI; // CommonJS export 55 | -------------------------------------------------------------------------------- /src/view/Runner/Dots.js: -------------------------------------------------------------------------------- 1 | var ScenarioDotsView = require('../Scenario/Dots'); 2 | 3 | 4 | /**@class An output in dots format for Runner events. 5 | */ 6 | var RunnerDots = new Class({ 7 | Extends: require('../PromiseView'), 8 | 9 | scenarios : [], 10 | startTime : null, 11 | readyTime : null, 12 | 13 | events: { 14 | /** Informs the user that the emitting Runner is ready to start. 15 | */ 16 | ready: function onReady() { 17 | this.readyTime = new Date(); 18 | process.stdout.write('Browser ready\n'); 19 | }, 20 | 21 | steps: function onScenario(scenario) { 22 | this.scenarios.push(new ScenarioDotsView(scenario)); 23 | } 24 | }, 25 | 26 | /** Informs the user that the emitting Runner is waiting for the browser. 27 | */ 28 | showStart: function showStart() { 29 | this.startTime = new Date(); 30 | process.stdout.write(this.model + '\n'); 31 | }, 32 | 33 | showFailure: function showFailure(reason) { 34 | var description = this.getErrorDescription(reason); 35 | if (description.title) { // if we can't give more info, simply don't show anything 36 | console.error(description.title); 37 | console.error(description.help); 38 | } 39 | }, 40 | 41 | /** Presents a summary of the test procedure to the user. 42 | */ 43 | showEnd: function showEnd() { 44 | var scenariosCount = this.scenarios.length, 45 | failuresCount = 0; 46 | 47 | process.stdout.write('\n'); 48 | 49 | this.scenarios.each(function(scenario) { 50 | if (scenario.model.promise.isRejected()) { 51 | failuresCount++; 52 | scenario.showFailureDetails(); 53 | } 54 | }); 55 | 56 | var successCount = (scenariosCount - failuresCount); 57 | 58 | process.stdout.write('\nFinished in ' 59 | + getDurationString(this.startTime, new Date()) 60 | + ': ' 61 | + 'scenario'.count(scenariosCount) 62 | + ' (' 63 | + this.model.config.ignore.length 64 | + ' ignored),' 65 | + 'success'.count(successCount, 'es') 66 | + ', ' 67 | + 'failure'.count(failuresCount) 68 | + '\n'); 69 | } 70 | }); 71 | 72 | 73 | /** Computes the duration between two dates. 74 | * The order of the two dates does not matter, a duration is always positive 75 | * 76 | *@param {Date} first The first date. 77 | *@param {Date} second The other date. 78 | *@returns {String} A human-readable duration string, of the form "h hours m minutes s seconds". 79 | *@private 80 | */ 81 | var getDurationString = function getDurationString(first, second) { 82 | var result = '', 83 | durationSeconds = Math.abs(second - first) / 1000, 84 | durations = { 85 | hour : Math.floor(durationSeconds / 3600), 86 | minute : Math.floor(durationSeconds / 60) % 60, 87 | second : Math.floor(durationSeconds) % 60 88 | }; // don't you remove this semicolon 89 | 90 | [ 'hour', 'minute', 'second' ].forEach(function(unit) { 91 | var value = durations[unit]; 92 | if (value > 0) 93 | result += unit.count(value) + ' '; 94 | }); 95 | 96 | return result.trim(); 97 | }; 98 | 99 | module.exports = RunnerDots; // CommonJS export 100 | -------------------------------------------------------------------------------- /src/view/Runner/Growl.js: -------------------------------------------------------------------------------- 1 | var growl; 2 | try { 3 | growl = require('growl'); 4 | } catch (e) { 5 | growl = function() {}; 6 | console.warn('Unable to load the "growl" module, will not define any Growl support.'); 7 | } 8 | 9 | /** Notifies the user of the result of a Runner evaluation. 10 | * 11 | *@class 12 | */ 13 | var RunnerGrowl = new Class({ 14 | Extends: require('../PromiseView'), 15 | 16 | showFailure: function showFailure(reason) { 17 | this.show((this.getErrorDescription(reason).title || 'failure'.count(Object.getLength(reason))), { 18 | title: this.model + ' failed', 19 | priority: 4 20 | }); 21 | }, 22 | 23 | showSuccess: function showSuccess() { 24 | this.show('scenario'.count(this.model.scenarios.length) + ' passed', { 25 | title: this.model + ' succeeded' 26 | }); 27 | }, 28 | 29 | /** Returns the name of the browser that was used by the runner this view is for, properly capitalized. 30 | * 31 | *@returns {String} 32 | */ 33 | getBrowserName: function getBrowserName() { 34 | return this.model.config.driverCapabilities.browserName.capitalize(); 35 | }, 36 | 37 | /** Displays a Growl notification, appending additional and default information 38 | * 39 | *@param {String} message The content to present to the user. 40 | *@param {Hash} options Options to pass to the `growl` method. 41 | *@see {@link https://github.com/visionmedia/node-growl|node-growl} 42 | */ 43 | show: function show(message, options) { 44 | var defaults = { 45 | name: 'Watai', 46 | image: this.getBrowserName(), 47 | priority: 3 48 | }; 49 | 50 | message += ' under ' + this.getBrowserName(); 51 | 52 | growl(message, Object.merge(defaults, options)); 53 | } 54 | }); 55 | 56 | 57 | module.exports = RunnerGrowl; // CommonJS export 58 | -------------------------------------------------------------------------------- /src/view/Runner/Instafail.js: -------------------------------------------------------------------------------- 1 | /**@class Logs scenario failures and errors as they come. 2 | */ 3 | var RunnerInstafail = new Class({ 4 | Extends: require('../PromiseView'), 5 | 6 | /** Attaches the Scenario/Instafail view to all scenarios started by the Runner listened to. 7 | */ 8 | submodel: { 9 | name: 'scenario', 10 | view: require('../Scenario/Instafail') 11 | } 12 | }); 13 | 14 | 15 | module.exports = RunnerInstafail; // CommonJS export 16 | -------------------------------------------------------------------------------- /src/view/Runner/PageDump.js: -------------------------------------------------------------------------------- 1 | /**@class Dumps page content on first step failure. 2 | */ 3 | var RunnerPageDump = new Class(/** @lends RunnerPageDump# */{ 4 | Extends: require('../PromiseView'), 5 | 6 | /** Whether the first scenario has already been found or not. 7 | * 8 | *@type {Boolean} 9 | *@private 10 | */ 11 | attached: false, 12 | 13 | /** Will be `join`ed and prepended to the DOM dump 14 | * 15 | *@private 16 | */ 17 | header: [ 18 | '------------', 19 | 'Page source:', 20 | '------------' 21 | ], 22 | 23 | /** The promise for a page source dump. 24 | * 25 | *@type {QPromise} 26 | *@private 27 | */ 28 | pageSourcePromise: null, 29 | 30 | /** Events that are listened to. 31 | */ 32 | events: { 33 | steps: function(scenario) { 34 | if (this.attached) 35 | return; 36 | 37 | var view = this, // callbacks reference traversal 38 | driver = this.model.getDriver(); 39 | 40 | scenario.on('step', function(step) { 41 | step.once('start', function() { 42 | step.promise.fail(function() { 43 | if (! view.pageSourcePromise) 44 | view.pageSourcePromise = driver.source(); 45 | }); 46 | }); 47 | }); 48 | 49 | this.attached = true; 50 | } 51 | }, 52 | 53 | showFailure: function() { 54 | if (this.pageSourcePromise) // we might fail even before the first scenario was started (unreachable server, bad syntax…). In this case, do not do anything. 55 | this.pageSourcePromise.then(this.dumpPage.bind(this)); 56 | }, 57 | 58 | /** Presents the given DOM dump to the user. 59 | * 60 | *@private 61 | */ 62 | dumpPage: function dumpPage(domDump) { 63 | var message = this.header; 64 | message.push(domDump); 65 | message.push(''); 66 | 67 | process.stdout.write(message.join('\n')); 68 | } 69 | 70 | }); 71 | 72 | 73 | module.exports = RunnerPageDump; // CommonJS export 74 | -------------------------------------------------------------------------------- /src/view/Runner/SauceLabs.js: -------------------------------------------------------------------------------- 1 | var SauceLabsTransmitter; 2 | try { 3 | SauceLabsTransmitter = require('saucelabs'); 4 | } catch (e) { 5 | console.warn('You requested the SauceLabs view but the "saucelabs" module could not be loaded.'); 6 | console.warn('You probably should `npm install saucelabs`.'); // TODO: require('npm').install('saucelabs'), see . Complicated because async, needs adding support for lazy loading of views. 7 | throw e; 8 | } 9 | 10 | /**@class A status transmitter to SauceLabs. 11 | */ 12 | var RunnerSauceLabs = new Class({ 13 | Extends: require('../PromiseView'), 14 | 15 | /** SauceLabs API wrapper object. 16 | * 17 | *@type {SauceLabsTransmitter} 18 | */ 19 | transmitter: null, 20 | 21 | /** Used to approximate the amount of minutes left in a SauceLabs account. 22 | * 23 | *@type {Date} 24 | */ 25 | startTime : null, 26 | 27 | /** Initiates the connection to SauceLabs. 28 | * Smoke tests the service. 29 | * Fetches some account data to log at the end of the test. 30 | */ 31 | showStart: function showStart() { 32 | this.startTime = new Date(); 33 | 34 | this.transmitter = new SauceLabsTransmitter(this.getAuth()); 35 | 36 | this.transmitter.getAccountDetails(function(err, accountDetails) { 37 | if (err) { 38 | this.animator.log('✘ ', 'warn', 'Could not get SauceLabs account details (' + err.error + ')', 'warn'); 39 | this.accountDetails = null; 40 | } 41 | 42 | this.accountDetails = accountDetails; 43 | }.bind(this)); 44 | 45 | this.transmitter.getServiceStatus(function(err, sauceStatus) { 46 | var serviceOperationalKey = 'service_operational', 47 | statusMessageKey = 'status_message'; // TODO: when jsrc implements ignoring lines, directly access the key in the conditional. See . 48 | 49 | if (! sauceStatus[serviceOperationalKey]) 50 | console.log('This job will probably fail, Sauce seems to be down: ' + sauceStatus[statusMessageKey]); 51 | }); 52 | 53 | if (this.model.config.quit != 'always') { 54 | this.animator.log('⚠︎ ', 'info', 'You are using SauceLabs but are not quitting browsers immediately, thus wasting 90 seconds per failed test', 'info'); 55 | this.animator.log(' ', 'debug', 'You should set the "quit" configuration element to "always", and set it to something else through the CLI when you want to take control of the browser', 'debug'); 56 | this.animator.log(' ', 'debug', 'See more at ', 'debug'); 57 | } 58 | }, 59 | 60 | /** Obtains SauceLabs authentication data, from configuration or, if not available, from environment variables. 61 | * 62 | *@throws {ReferenceError} If the SauceLabs authentication data can not be obtained. 63 | *@returns {Object} An object containing `username` and `password` keys, defining which SauceLabs username and access key should be used to access the account. 64 | */ 65 | getAuth: function getAuth() { 66 | var result = {}, 67 | authString = require('url').parse(this.model.config.seleniumServerURL).auth, 68 | authParts = []; 69 | 70 | if (authString && authString.contains(':')) // Node's url API stores authentication information as 'user:pass' 71 | authParts = authString.split(':'); 72 | 73 | result.username = authParts[0] || process.env.SAUCE_USERNAME; 74 | result.password = authParts[1] || process.env.SAUCE_ACCESS_KEY; 75 | 76 | if (! (result.username && result.password)) { 77 | this.animator.log('✘ ', 'warn', 'You requested the SauceLabs view, but the SauceLabs authentication information could not be found', 'warn'); 78 | this.animator.log(' ', 'debug', 'You should provide it through the `auth` part of the seleniumServerURL config key, or through the SAUCE_USERNAME and SAUCE_ACCESS_KEY environment variables', 'debug'); 79 | throw new ReferenceError('Could not get SauceLabs authentication information'); 80 | } 81 | 82 | return result; 83 | }, 84 | 85 | showSuccess: function showSuccess() { 86 | this.sendTestPassed(true); 87 | }, 88 | 89 | showFailure: function showFailure(reason) { 90 | this.sendTestPassed(false, reason); 91 | }, 92 | 93 | /** Notifies SauceLabs about the status of the current job. 94 | * 95 | *@param {Boolean} passed If `true`, flags the job as passed. If `false`, flags the job as failed. 96 | *@param {String} [reason] If passed, will be sent as a `custom-data` parameter to SauceLabs, under the `reason` key. 97 | */ 98 | sendTestPassed: function sendTestPassed(passed, reason) { 99 | this.transmitter.updateJob(this.model.driver.sessionID, { 100 | passed: passed, 101 | 'custom-data': { 102 | reason: reason 103 | } 104 | }, function(err) { 105 | if (err) 106 | console.error('Could not send status to SauceLabs', err); 107 | }); 108 | }, 109 | 110 | showEnd: function showEnd() { 111 | if (! this.accountDetails) return; // there was a connection error in the first place 112 | 113 | var spentMinutes = (new Date() - this.startTime) / 1000 / 60; 114 | 115 | console.log('See this job details on .'); 116 | console.log('You have about ' + Math.round(this.accountDetails.minutes - spentMinutes) + ' minutes left on the ' + this.accountDetails.id + ' SauceLabs account.'); // we don't get the exact amount left at the end to avoid slowing down the closure process 117 | } 118 | }); 119 | 120 | 121 | module.exports = RunnerSauceLabs; // CommonJS export 122 | -------------------------------------------------------------------------------- /src/view/Runner/Verbose.js: -------------------------------------------------------------------------------- 1 | /**@class A work-in-progress command-line interface that outputs and formats a Runner’s events in a tree-like format. 2 | * 3 | * WARNING: EXPERIMENTAL. This view is provided as a help for debugging cases, but it is not ready for actual delivery yet. 4 | */ 5 | var RunnerVerbose = new Class({ 6 | Extends: require('../PromiseView'), 7 | 8 | submodel: { 9 | name: 'scenario', 10 | view: require('../Scenario/Verbose') 11 | }, 12 | 13 | events: { 14 | /** Informs the user that this view's Runner is ready to start. 15 | */ 16 | ready: function onReady() { 17 | process.stdout.write('ready!\n'); 18 | } 19 | }, 20 | 21 | /** Informs user that this view's Runner is waiting for the browser. 22 | */ 23 | showStart: function showStart() { 24 | this.animator.log('➔ ', 'info', this.model); 25 | process.stdout.write(' Waiting for browser… '); 26 | }, 27 | 28 | showFailure: function showFailure(reason) { 29 | var description = this.getErrorDescription(reason); 30 | if (description.title) { // if we can't give more info, simply don't show anything 31 | this.animator.log('✘ ', 'warn', description.title, 'warn', process.stderr); 32 | this.animator.log('', 'debug', description.help, 'debug', process.stderr); 33 | } 34 | }, 35 | 36 | showEnd: function showEnd() { 37 | if (this.model.config.ignore.length) { 38 | this.animator.log( 39 | '⨁ ', 40 | 'cyan', 41 | 'ignored scenario'.count(this.model.config.ignore.length) 42 | + ' (#' 43 | + this.model.config.ignore.join(', #') 44 | + ')', 45 | 'cyan' 46 | ); 47 | } 48 | } 49 | }); 50 | 51 | module.exports = RunnerVerbose; // CommonJS export 52 | -------------------------------------------------------------------------------- /src/view/Scenario/CLI.js: -------------------------------------------------------------------------------- 1 | /** A command-line interface that outputs and formats a Scenario’s events. 2 | * 3 | *@class 4 | */ 5 | var ScenarioCLI = new Class(/** @lends ScenarioCLI# */{ 6 | Extends: require('../PromiseView'), 7 | 8 | submodel: { 9 | name: 'step', 10 | view: require('../Step/CLI') 11 | }, 12 | 13 | events: { 14 | step: function(step) { 15 | showStart = this.showStart.bind(this); 16 | step.on('start', function() { 17 | step.promise.fail(showStart); 18 | }); 19 | } 20 | }, 21 | 22 | showStart: function showStart() { 23 | this.animator.spin(this.model.description); 24 | }, 25 | 26 | /** Presents details of a test success to the user. 27 | */ 28 | showSuccess: function showSuccess() { 29 | this.animator.log('✔', 'info', this.model.description); 30 | }, 31 | 32 | /** Presents details of a test failure to the user. 33 | */ 34 | showFailure: function showFailure() { 35 | this.animator.log('✘', 'warn', this.model.description + ' (#' + this.model.id + ')', 'warn'); 36 | }, 37 | 38 | /** Clears the scenario spinner. 39 | */ 40 | showEnd: function showEnd() { 41 | this.animator.clear(); 42 | } 43 | }); 44 | 45 | module.exports = ScenarioCLI; // CommonJS export 46 | -------------------------------------------------------------------------------- /src/view/Scenario/Dots.js: -------------------------------------------------------------------------------- 1 | /** A command-line interface that outputs and formats a Scenario’s events. 2 | * 3 | *@class 4 | */ 5 | var ScenarioDots = new Class(/** @lends ScenarioDots# */{ 6 | Extends: require('../PromiseView'), 7 | 8 | /** Presents a brief summary of a test success to the user. 9 | */ 10 | showSuccess: function showSuccess() { 11 | process.stdout.write('.'); 12 | }, 13 | 14 | /** Presents a brief summary of a test failure to the user. 15 | */ 16 | showFailure: function showFailure() { 17 | process.stdout.write('F'); 18 | }, 19 | 20 | /** Presents details of a test failure to the user. 21 | */ 22 | showFailureDetails: function showFailureDetails() { 23 | var result = '[FAILED] ' 24 | + '#' + this.model.id + ': ' 25 | + this.model.description 26 | + ':\n\t- ' 27 | + this.model.promise.inspect().reason.join('\n\t- ') 28 | + '\n'; 29 | 30 | process.stdout.write(result); // more details at 31 | } 32 | }); 33 | 34 | module.exports = ScenarioDots; // CommonJS export 35 | -------------------------------------------------------------------------------- /src/view/Scenario/Instafail.js: -------------------------------------------------------------------------------- 1 | /** Logs scenario failures and errors as they come. 2 | * 3 | *@class 4 | */ 5 | var Instafail = new Class(/** @lends Instafail# */{ 6 | Extends: require('../PromiseView'), 7 | 8 | submodel: { 9 | name: 'step', 10 | view: require('../Step/Instafail') 11 | } 12 | }); 13 | 14 | module.exports = Instafail; // CommonJS export 15 | -------------------------------------------------------------------------------- /src/view/Scenario/Verbose.js: -------------------------------------------------------------------------------- 1 | /**@class A work-in-progress command-line interface that outputs and formats a Scenario’s events in a tree-like format. 2 | * 3 | * WARNING: EXPERIMENTAL. This view is provided as a help for debugging cases, but it is not ready for actual delivery yet. 4 | */ 5 | var ScenarioVerbose = new Class({ 6 | Extends: require('../PromiseView'), 7 | 8 | /** The amount of spaces expected for the numerical ID that will be prepended to Scenarios. 9 | * 10 | *@type {String} 11 | */ 12 | idPlaceholder: ' ', 13 | 14 | submodel: { 15 | name: 'step', 16 | view: require('../Step/Verbose') 17 | }, 18 | 19 | showStart: function showStart() { 20 | this.animator.log(' ' + this.getPaddedId() + ' ┍', 'gray', this.model.description, 'gray'); 21 | }, 22 | 23 | /** Presents details of a test success to the user. 24 | * 25 | *@param {Scenario} scenario The Scenario whose results are given. 26 | */ 27 | showSuccess: function showSuccess() { 28 | this.animator.log('✔ ' + this.getPaddedId() + ' ┕', 'info', this.model.description); 29 | }, 30 | 31 | /** Presents details of a test failure to the user. 32 | * 33 | *@param {String} reason Not used, as failures are described immediately in steps. 34 | */ 35 | showFailure: function showFailure(reason) { 36 | this.animator.log('✘ ' + this.getPaddedId() + ' ┕', 'warn', this.model.description, 'warn'); 37 | }, 38 | 39 | /** Visually separates two Scenarios. 40 | */ 41 | showEnd: function showEnd() { 42 | process.stdout.write('\n'); 43 | }, 44 | 45 | /** Returns the viewed Scenario's numerical ID, possibly padded with spaces so that they all have the same length. 46 | * The length is provided by the length of the `idPlaceholder` attribute. 47 | * 48 | *@private 49 | *@see idPlaceholder 50 | */ 51 | getPaddedId: function getPaddedId() { 52 | var result = this.model.id; 53 | 54 | while (result.length < this.idPlaceholder.length) 55 | result = ' ' + result; 56 | 57 | return result; 58 | } 59 | }); 60 | 61 | 62 | module.exports = ScenarioVerbose; // CommonJS export 63 | -------------------------------------------------------------------------------- /src/view/Step/CLI.js: -------------------------------------------------------------------------------- 1 | /** A command-line interface that outputs and formats a Step's failures. 2 | * 3 | *@class 4 | */ 5 | var StepCLI = new Class(/** @lends StepCLI# */{ 6 | Extends: require('../PromiseView'), 7 | 8 | /** Presents details of a step failure to the user. 9 | * 10 | *@param {String} message The reason why the step failed. 11 | */ 12 | showFailure: function showFailure(message) { 13 | // stepIndex++; // make the step index 1-based for the user // TODO: add stepIndex back 14 | this.animator.log(' ↳', 'cyan', message.replace(/\n/g, '\n '), 'cyan'); 15 | } 16 | }); 17 | 18 | module.exports = StepCLI; // CommonJS export 19 | -------------------------------------------------------------------------------- /src/view/Step/Instafail.js: -------------------------------------------------------------------------------- 1 | /** A command-line interface that outputs and formats a Step's failures. 2 | * 3 | *@class 4 | */ 5 | var StepInstafail = new Class(/** @lends StepInstafail# */{ 6 | Extends: require('../PromiseView'), 7 | 8 | /** Presents details of a step failure to the user. 9 | * 10 | *@param {String} message The reason why the step failed. 11 | */ 12 | showFailure: function showFailure(message) { 13 | // stepIndex++; // make the step index 1-based for the user // TODO: add stepIndex back 14 | process.stdout.write('- ' + message + '\n'); 15 | } 16 | }); 17 | 18 | module.exports = StepInstafail; // CommonJS export 19 | -------------------------------------------------------------------------------- /src/view/Step/Verbose.js: -------------------------------------------------------------------------------- 1 | /**@class A detailed step-by-step output. 2 | */ 3 | var StepVerbose = new Class({ 4 | Extends: require('../PromiseView'), 5 | 6 | submodel: { 7 | name: 'matcher', 8 | view: require('../Matcher/Verbose') 9 | }, 10 | 11 | /** Presents details of a test success to the user. 12 | */ 13 | showSuccess: function showSuccess() { 14 | if (this.model.type == 'state') 15 | return; 16 | 17 | this.animator.log(' ┝', 'info', this.model); 18 | }, 19 | 20 | /** Presents details of a test failure to the user. 21 | * 22 | *@param {String} reason Details on the failure. 23 | */ 24 | showFailure: function showFailure(reason) { 25 | if (this.model.type == 'state') 26 | return; 27 | 28 | this.animator.log(' ┝', 'cyan', reason, 'cyan'); 29 | } 30 | }); 31 | 32 | 33 | module.exports = StepVerbose; // CommonJS export 34 | -------------------------------------------------------------------------------- /test/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | seleniumServerURL: 'http://127.0.0.1:4444/wd/hub', 3 | baseURL: 'file://' + __dirname + '/resources/page.html', 4 | driverCapabilities: { 5 | browserName: 'chrome', 6 | javascriptEnabled: true 7 | }, 8 | quit: 'never', 9 | browserWarmupTimeout: 30 * 1000, // ms 10 | timeout: 500 // implicit wait for elements lookup in milliseconds, has to be lower than mocha's timeout to test for missing elements rejection 11 | } 12 | -------------------------------------------------------------------------------- /test/functional/ComponentScenarioTest.js: -------------------------------------------------------------------------------- 1 | var Watai = require('../unit/helpers/subject'), 2 | my = require('../unit/helpers/driver').getDriverHolder(), 3 | testComponent = require('../unit/helpers/testComponent'), 4 | config = require('../config'), 5 | should = require('should'); 6 | 7 | 8 | /** This test suite is written with [Mocha](http://visionmedia.github.com/mocha/) and [Should](https://github.com/visionmedia/should.js). 9 | */ 10 | describe('Component usage within Scenario', function() { 11 | var component; 12 | 13 | describe('actions', function() { 14 | var calledMarker = '', 15 | partOne = 'one', 16 | partTwo = 'two'; 17 | 18 | before(function() { 19 | component = new Watai.Component('Test component 2', Object.merge({ 20 | setMarker: function setMarker(one) { 21 | calledMarker = one; 22 | }, 23 | concatenateTwo: function concatenateTwo(first, second) { 24 | calledMarker = first + second; 25 | } 26 | }, testComponent.elements), my.driver); 27 | }); 28 | 29 | 30 | it('should bind one parameter', function(done) { 31 | var scenario = new Watai.Scenario('Test scenario', 32 | [ component.setMarker(partOne) ], 33 | { TestComponent: component }, 34 | config 35 | ); 36 | 37 | scenario.test().then(function() { 38 | try { 39 | should.equal(calledMarker, partOne); 40 | done(); 41 | } catch (err) { 42 | done(err); 43 | } 44 | }, function(report) { 45 | done(new Error(report)); 46 | }); 47 | }); 48 | 49 | it('should bind two parameters', function(done) { 50 | var scenario = new Watai.Scenario( 51 | 'Test scenario', 52 | [ component.concatenateTwo(partOne, partTwo) ], 53 | { TestComponent: component }, 54 | config 55 | ); 56 | 57 | scenario.test().then(function() { 58 | try { 59 | should.equal(calledMarker, partOne + partTwo); 60 | done(); 61 | } catch (err) { 62 | done(err); 63 | } 64 | }, function(report) { 65 | done(new Error(report)); 66 | }); 67 | }); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /test/integration/exitCodesTest.js: -------------------------------------------------------------------------------- 1 | var spawn = require('child_process').spawn; 2 | 3 | 4 | var BIN = './src/index.js'; 5 | 6 | 7 | describe('Exit code', function() { 8 | it('should be 2 when passed no arguments', function(done) { 9 | var subject = spawn(BIN); 10 | 11 | subject.on('exit', function(code) { 12 | code.should.equal(2); 13 | done(); 14 | }); 15 | }); 16 | 17 | it('should be 2 when passed too many arguments', function(done) { 18 | var subject = spawn(BIN, [ 'test/resources/FailingSuite', 'test/resources/FailingSuite' ]); 19 | 20 | subject.on('exit', function(code) { 21 | code.should.equal(2); 22 | done(); 23 | }); 24 | }); 25 | 26 | [ 'help', 'installed', 'version' ].forEach(function(option) { 27 | it('should be 0 when called with --' + option, function(done) { 28 | var subject = spawn(BIN, [ '--' + option ]); 29 | 30 | subject.on('exit', function(code) { 31 | code.should.equal(0); 32 | done(); 33 | }); 34 | }); 35 | }); 36 | 37 | describe('on an empty suite', function() { 38 | var message, 39 | code; 40 | 41 | before(function(done) { 42 | this.timeout(30 * 1000); 43 | 44 | var subject = spawn(BIN, [ 'test/resources/EmptySuite' ]); 45 | 46 | subject.stderr.on('data', function(data) { 47 | message = data.toString(); 48 | }); 49 | 50 | subject.on('exit', function(statusCode) { 51 | code = statusCode; 52 | done(); 53 | }); 54 | }); 55 | 56 | it('should be 16', function() { 57 | code.should.equal(16); 58 | }); 59 | 60 | it('should provide an explicit message', function() { 61 | message.should.match(/no scenario found/i); 62 | }); 63 | }); 64 | 65 | it('should be 1 on a failed test', function(done) { 66 | this.timeout(30 * 1000); 67 | 68 | var subject = spawn(BIN, [ 'test/resources/FailingSuite' ]); 69 | 70 | subject.on('exit', function(code) { 71 | code.should.equal(1); 72 | done(); 73 | }); 74 | }); 75 | 76 | it('should be 0 on a successful test', function(done) { 77 | this.timeout(30 * 1000); 78 | 79 | var subject = spawn(BIN, [ 'test/resources/SucceedingSuite' ]); 80 | 81 | subject.on('exit', function(code) { 82 | code.should.equal(0); 83 | done(); 84 | }); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /test/integration/ignoreScenariosTest.js: -------------------------------------------------------------------------------- 1 | var spawn = require('child_process').spawn; 2 | 3 | 4 | var BIN = './src/index.js'; 5 | 6 | 7 | describe('ignoring scenarios', function() { 8 | it('exit code should be 0 on a failing test ignoring its failing scenario', function(done) { 9 | this.timeout(30 * 1000); 10 | 11 | var config = { 12 | ignore: [ 1 ] 13 | }; 14 | 15 | var subject = spawn(BIN, [ '--config', JSON.stringify(config), 'test/resources/FailingSuite' ]); 16 | 17 | subject.on('exit', function(code) { 18 | code.should.equal(0); 19 | done(); 20 | }); 21 | }); 22 | 23 | it('should exit with 16 if all scenarios are ignored', function(done) { 24 | this.timeout(30 * 1000); 25 | 26 | var config = { 27 | ignore: [ 1 ] 28 | }; 29 | 30 | var subject = spawn(BIN, [ '--config', JSON.stringify(config), 'test/resources/SucceedingSuite' ]); 31 | 32 | subject.on('exit', function(code) { 33 | code.should.equal(16); 34 | done(); 35 | }); 36 | }); 37 | 38 | describe('ignoring a scenario that does not exist', function() { 39 | var code, 40 | message; 41 | 42 | before(function(done) { 43 | this.timeout(30 * 1000); 44 | 45 | var config = { 46 | ignore: [ 5555 ] 47 | }; 48 | 49 | var subject = spawn(BIN, [ '--config', JSON.stringify(config), 'test/resources/SucceedingSuite' ]); 50 | 51 | subject.stderr.on('data', function(data) { 52 | message = data.toString(); 53 | }); 54 | 55 | subject.on('exit', function(statusCode) { 56 | code = statusCode; 57 | done(); 58 | }); 59 | }); 60 | 61 | it('should exit with 2', function() { 62 | code.should.equal(2); 63 | }); 64 | 65 | it('should give details', function() { 66 | message.should.match(/could not be found/); 67 | }); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /test/integration/noServerTest.js: -------------------------------------------------------------------------------- 1 | var spawn = require('child_process').spawn; 2 | 3 | 4 | var BIN = './src/index.js'; 5 | 6 | 7 | describe('with no reachable server', function() { 8 | var subject, 9 | message; 10 | 11 | before(function() { 12 | subject = spawn(BIN, [ '--config', '{"seleniumServerURL":"http://0.0.0.0:3333","views":["CLI"]}', 'test/resources/SucceedingSuite' ]); // activate a view in order to test message clarity 13 | 14 | subject.stderr.on('data', function(data) { 15 | message = data.toString(); 16 | }); 17 | }); 18 | 19 | it('should fail', function(done) { 20 | this.timeout(10000); 21 | 22 | subject.on('exit', function(code) { 23 | code.should.equal(4); 24 | done(); 25 | }); 26 | }); 27 | 28 | it('should give details', function() { 29 | message.should.match(/server/); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /test/integration/optionConfigTest.js: -------------------------------------------------------------------------------- 1 | var spawn = require('child_process').spawn; 2 | 3 | 4 | var BIN = './src/index.js'; 5 | 6 | 7 | describe('--config option', function() { 8 | it('should override the config values', function(done) { 9 | this.timeout(30 * 1000); 10 | 11 | var config = { 12 | baseURL: 'file://' + __dirname + '/../resources/page_with_missing_element.html' // the strategy is to use a suite that fails with its default config, and works with the overriding options 13 | }; 14 | 15 | var subject = spawn(BIN, [ '--config', JSON.stringify(config), 'test/resources/FailingSuite' ]); 16 | 17 | subject.on('exit', function(code) { 18 | code.should.equal(0); 19 | done(); 20 | }); 21 | }); 22 | 23 | describe('overrides partial config values, such as', function() { // TODO: this would be better in SuiteLoader unit tests; to be done in a large SuiteLoader refactor 24 | it('baseURL#port', function(done) { 25 | this.timeout(30 * 1000); 26 | 27 | var config = { 28 | baseURL: { // the strategy is to use a suite that fails with its default config, and works with the overriding options 29 | pathname: __dirname + '/../resources/page_with_missing_element.html' // overriding the pathname only means at least the protocol has to be obtained from the actual config file 30 | } 31 | }; 32 | 33 | var subject = spawn(BIN, [ '--config', JSON.stringify(config), 'test/resources/FailingSuite' ]); 34 | 35 | subject.on('exit', function(code) { 36 | code.should.equal(0); 37 | done(); 38 | }); 39 | }); 40 | }); 41 | 42 | it('should fail if not passed any option', function(done) { 43 | var subject = spawn(BIN, [ '--config', 'test/resources/SucceedingSuite' ]); 44 | 45 | subject.on('exit', function(code) { 46 | code.should.not.equal(0); // Node 0.8 returns an error code 1 and Node 0.10 returns an error code 8, for retrocompatibility we test only if the code is not a success code 0 47 | done(); 48 | }); 49 | }); 50 | 51 | describe('passed badly-formatted options', function(done) { 52 | var subject, 53 | detailsGiven = false; 54 | 55 | before(function() { 56 | subject = spawn(BIN, [ '--config', '{', 'test/resources/FailingSuite' ]); 57 | 58 | subject.stderr.on('data', function(data) { 59 | if (data.toString().match(/--config/)) 60 | detailsGiven = true; 61 | }); 62 | }); 63 | 64 | 65 | it('should fail', function(done) { 66 | subject.on('exit', function(code) { 67 | code.should.not.equal(0); // Node 0.8 returns an error code 1 and Node 0.10 returns an error code 8, for retrocompatibility we test only if the code is not a success code 0 68 | done(); 69 | }); 70 | }); 71 | 72 | it('should give details', function() { 73 | detailsGiven.should.be.true; 74 | }); 75 | }); 76 | 77 | it('should not do anything if passed empty options', function(done) { 78 | this.timeout(30 * 1000); 79 | 80 | var subject = spawn(BIN, [ '--config', '{}', 'test/resources/SucceedingSuite' ]); 81 | 82 | subject.on('exit', function(code) { 83 | code.should.equal(0); 84 | done(); 85 | }); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /test/integration/optionSetupTest.js: -------------------------------------------------------------------------------- 1 | var spawn = require('child_process').spawn; 2 | 3 | 4 | var BIN = './src/index.js'; 5 | 6 | 7 | describe('--setup option', function() { 8 | it('should fail if not passed any option', function(done) { 9 | var subject = spawn(BIN, [ '--setup', 'test/resources/SucceedingSuite' ]); 10 | 11 | subject.on('exit', function(code) { 12 | code.should.not.equal(0); // Node 0.8 returns an error code 1 and Node 0.10 returns an error code 8, for retrocompatibility we test only if the code is not a success code 0 13 | done(); 14 | }); 15 | }); 16 | 17 | describe('passed badly-formatted options', function(done) { 18 | var subject, 19 | detailsGiven = false; 20 | 21 | before(function() { 22 | subject = spawn(BIN, [ '--setup', '{', 'test/resources/SucceedingSuite' ]); 23 | 24 | subject.stderr.on('data', function(data) { 25 | if (data.toString().match(/--setup/)) 26 | detailsGiven = true; 27 | }); 28 | }); 29 | 30 | 31 | it('should fail', function(done) { 32 | subject.on('exit', function(code) { 33 | code.should.not.equal(0); // Node 0.8 returns an error code 1 and Node 0.10 returns an error code 8, for retrocompatibility we test only if the code is not a success code 0 34 | done(); 35 | }); 36 | }); 37 | 38 | it('should give details', function() { 39 | detailsGiven.should.be.true; 40 | }); 41 | }); 42 | 43 | it('should not do anything if passed empty options', function(done) { 44 | this.timeout(30 * 1000); 45 | 46 | var subject = spawn(BIN, [ '--setup', '{}', 'test/resources/SucceedingSuite' ]); 47 | 48 | subject.on('exit', function(code) { 49 | code.should.equal(0); 50 | done(); 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --require should 2 | --reporter spec 3 | --slow 2000 4 | --timeout 4000 5 | --growl 6 | --recursive 7 | -------------------------------------------------------------------------------- /test/resources/BadConfigSuite/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | build: function(promise) { 3 | setTimeout(function() { 4 | promise.reject(new Error('boom')); 5 | }, 0); 6 | 7 | return promise.promise; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /test/resources/EmptySuite/.gitkeep: -------------------------------------------------------------------------------- 1 | This file is here to ensure its containing folder is versioned. 2 | -------------------------------------------------------------------------------- /test/resources/FailingSuite/1 - FailingScenario.js: -------------------------------------------------------------------------------- 1 | description: 'Trying to access a missing element should fail.', 2 | 3 | steps: [ 4 | { 'TestComponent.missing': true } 5 | ] 6 | -------------------------------------------------------------------------------- /test/resources/FailingSuite/2 - SucceedingScenario.js: -------------------------------------------------------------------------------- 1 | ../SucceedingSuite/1 - SucceedingScenario.js -------------------------------------------------------------------------------- /test/resources/FailingSuite/TestComponent.js: -------------------------------------------------------------------------------- 1 | missing: '#missing', 2 | present: 'body' 3 | -------------------------------------------------------------------------------- /test/resources/SucceedingSuite/1 - SucceedingScenario.js: -------------------------------------------------------------------------------- 1 | description: 'Trying to access a existing element should succeed.', 2 | 3 | steps: [ 4 | { 'TestComponent.present': true } 5 | ] 6 | -------------------------------------------------------------------------------- /test/resources/SucceedingSuite/TestComponent.js: -------------------------------------------------------------------------------- 1 | present: { css: 'body' } 2 | -------------------------------------------------------------------------------- /test/resources/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeout": 200, 3 | "quit": "always", 4 | "views": [] 5 | } 6 | -------------------------------------------------------------------------------- /test/resources/page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Watai test support page 5 | 6 | 7 | 25 | 26 | 27 |

Watai test support page

28 |
29 |

This paragraph has id toto

30 |

This paragraph has class tutu

31 |

This paragraph is the third of the selectors div

32 |

This paragraph is embedded in a link

33 |

Clicking this link will change contents beneath after 500ms

34 |

Clicking this link will change contents beneath after 500ms

35 |

36 |

37 |

38 |

I am hidden

39 | 40 |
41 | 42 |
43 |

I am under! Can't be touched!

44 | 45 |
46 | 47 |
48 | 49 | 50 |
51 | 52 |
    53 |
  1. #link has not been clicked yet
  2. 54 | 55 |
56 | 57 | 101 | 102 | 103 | -------------------------------------------------------------------------------- /test/resources/page_with_missing_element.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Watai test support page 2 5 | 6 | 7 | 25 | 26 | 27 |

Watai second test support page

28 |

This element is missing in the first test support page, and present only on this one.

29 | 30 | 31 | -------------------------------------------------------------------------------- /test/unit/controller/RunnerTest.js: -------------------------------------------------------------------------------- 1 | var should = require('should'), 2 | promises = require('q'), 3 | Watai = require('../helpers/subject'), 4 | config = require('../helpers/driver').config; 5 | 6 | 7 | var subject; 8 | 9 | describe('Runner', function() { 10 | describe('constructor', function() { 11 | it('should refuse to construct a runner with no config', function() { 12 | (function() { 13 | new Watai.Runner(); 14 | }).should.throw(); 15 | }); 16 | 17 | it('should refuse to construct a runner with no base URL', function() { 18 | (function() { 19 | new Watai.Runner({ 20 | seleniumServerURL: 'http://example.com' 21 | }); 22 | }).should.throw(); 23 | }); 24 | 25 | it('should refuse to construct a runner with no Selenium Server URL', function() { 26 | (function() { 27 | new Watai.Runner({ 28 | baseURL: 'http://example.com' 29 | }); 30 | }).should.throw(); 31 | }); 32 | 33 | it('should not throw when constructing with proper config', function() { 34 | (function() { 35 | subject = new Watai.Runner(config); 36 | }).should.not.throw(); 37 | }); 38 | 39 | it('should emit "ready" when ready', function(done) { 40 | this.timeout(config.browserWarmupTime); 41 | 42 | subject.isReady().should.be.false; 43 | 44 | subject.test(); 45 | subject.once('ready', function() { 46 | try { 47 | subject.isReady().should.be.true; 48 | done(); 49 | } catch (err) { 50 | done(err); 51 | } 52 | }); 53 | }); 54 | }); 55 | 56 | describe('driver', function() { 57 | it('should be defined after constructing a Runner', function() { 58 | should.exist(subject.getDriver()); 59 | }); 60 | }); 61 | 62 | 63 | var emitted = {}, // observer storage for event-emitted data 64 | passed = {}, // observer storage for data passed through promises, to compare with events 65 | scenarioEvaluationCount = 0; 66 | 67 | var scenario = new Watai.Scenario('RunnerTest scenario', [ 68 | function() { scenarioEvaluationCount++; } 69 | ], {}, require('../../config')); 70 | 71 | var failingScenario = new Watai.Scenario('RunnerTest failing scenario', [ 72 | function() { 73 | var result = promises.defer(); 74 | result.reject('This is reason enough for rejection.'); 75 | return result.promise; 76 | } 77 | ], {}, require('../../config')); 78 | 79 | 80 | describe('test()', function() { 81 | var subjectWithFailure; // a second subject that will have a failing scenario added 82 | 83 | 84 | before(function() { 85 | subjectWithFailure = new Watai.Runner(config); 86 | 87 | emitted.start = 0; 88 | 89 | subjectWithFailure.on('start', function() { 90 | emitted.start++; 91 | }); 92 | 93 | emitted.scenario = 0; 94 | 95 | subjectWithFailure.on('scenario', function() { 96 | emitted.scenario++; 97 | }); 98 | }); 99 | 100 | after(function(done) { 101 | subjectWithFailure.quitBrowser().done(done, done); 102 | }); 103 | 104 | 105 | it('should return a promise', function(done) { 106 | this.timeout(config.browserWarmupTime); 107 | 108 | subject.test().done(function() { done(); }); 109 | }); 110 | 111 | it('should evaluate scenarios once', function(done) { 112 | this.timeout(config.browserWarmupTime); 113 | 114 | subject.addScenario(scenario); 115 | 116 | subject.test().then(function() { 117 | scenarioEvaluationCount.should.equal(1); 118 | }).done(done); 119 | }); 120 | 121 | it('should evaluate scenarios once again if called again', function(done) { 122 | this.timeout(config.browserWarmupTime); 123 | 124 | subject.test().then(function() { 125 | scenarioEvaluationCount.should.equal(2); 126 | }).done(done); 127 | }); 128 | 129 | it('with failing scenarios should be rejected', function(done) { 130 | this.timeout(config.browserWarmupTime); 131 | 132 | subjectWithFailure.addScenario(failingScenario).test().then(function() { 133 | throw new Error('Resolved instead of rejected.'); 134 | }, function(report) { 135 | should.equal(typeof report, 'object'); 136 | if (! report[failingScenario]) 137 | throw new Error('Missing scenario.'); 138 | if (! report[failingScenario].length) 139 | throw new Error('Missing scenario failures details.'); 140 | passed.failures = report; 141 | }).done(done); 142 | }); 143 | 144 | describe('with bail option', function() { 145 | it('should not evaluate a scenario after one has failed', function(done) { 146 | var calledCount = scenarioEvaluationCount; 147 | 148 | subjectWithFailure.config.bail = true; 149 | subjectWithFailure.addScenario(scenario).test().then( 150 | function() { 151 | throw new Error('Resolved instead of being rejected!'); 152 | }, 153 | function() { 154 | if (scenarioEvaluationCount > calledCount) 155 | throw new Error('Bail option does not stop evaluation'); 156 | } 157 | ).done(done, done); 158 | }); 159 | }); 160 | 161 | describe('with an unreachable Selenium server', function() { 162 | var subject; 163 | 164 | before(function() { 165 | var unreachableConfig = Object.clone(config); 166 | 167 | unreachableConfig.seleniumServerURL = 'http://0.0.0.0:3333'; 168 | 169 | subject = new Watai.Runner(unreachableConfig); 170 | }); 171 | 172 | it('should be rejected', function(done) { 173 | subject.test().done(function() { 174 | done(new Error('Resolved instead of being rejected!')); 175 | }, function(err) { 176 | done(); 177 | }); 178 | }); 179 | }); 180 | }); 181 | 182 | describe('events', function() { 183 | it('should have emitted as many "scenario" events as loaded scenarios', function() { 184 | should.strictEqual(emitted.scenario, 2); 185 | }); 186 | 187 | it('should have emitted as many "start" events as was started', function() { 188 | should.strictEqual(emitted.start, 2); 189 | }); 190 | }); 191 | 192 | describe('quitting browser', function() { 193 | it('should be idempotent through repetition', function(done) { 194 | this.timeout(config.browserWarmupTime / 2); // this should be faster than warmup, but can still be longer than the default timeout 195 | 196 | subject.quitBrowser().then(function() { 197 | return subject.quitBrowser(); 198 | }).done(done); 199 | }); 200 | 201 | it('should not forbid a proper second run', function(done) { 202 | this.timeout(config.browserWarmupTime); 203 | 204 | subject.test().done(function() { done(); }, done); 205 | }); 206 | }); 207 | 208 | describe('automatic quitting', function() { 209 | it('should not quit if set to "never"', function(done) { 210 | this.timeout(config.browserWarmupTime); 211 | 212 | subject.config.quit = 'never'; 213 | subject.test().done(function() { 214 | should.exist(subject.initialized); 215 | done(); 216 | }); 217 | }); 218 | 219 | it('should quit on success if set to "on success"', function(done) { 220 | this.timeout(config.browserWarmupTime); 221 | 222 | subject.config.quit = 'on success'; 223 | subject.test().done(function() { 224 | should.not.exist(subject.initialized); 225 | done(); 226 | }); 227 | }); 228 | 229 | it('should quit if set to "always"', function(done) { 230 | this.timeout(config.browserWarmupTime); 231 | 232 | subject.config.quit = 'always'; 233 | subject.test().done(function() { 234 | should.not.exist(subject.initialized); 235 | done(); 236 | }); 237 | }); 238 | }); 239 | }); 240 | -------------------------------------------------------------------------------- /test/unit/helpers/StdoutSpy.js: -------------------------------------------------------------------------------- 1 | /** Buffer for data that would have been printed while muted. 2 | * 3 | *@type {String} 4 | */ 5 | var buffer = '', 6 | /** Set to `true` to silence calls to `process.stdout.write`. 7 | * 8 | *@type {Boolean} 9 | */ 10 | suspend = false; 11 | 12 | 13 | process.stdout.write = (function(write) { 14 | return function(buf, encoding, fd) { 15 | if (suspend) 16 | buffer += buf; 17 | else 18 | write.call(process.stdout, buf, encoding, fd); 19 | }; 20 | }(process.stdout.write)); 21 | 22 | 23 | /** Resets the printing buffer. 24 | * 25 | *@see #called 26 | */ 27 | exports.reset = function reset() { 28 | buffer = ''; 29 | }; 30 | 31 | /** Returns what would have been printed while muted. 32 | * 33 | *@returns {String} 34 | */ 35 | exports.printed = function printed() { 36 | return buffer; 37 | }; 38 | 39 | /** Tells whether `stdout.write` was called since the last call to `reset`. 40 | * 41 | *@returns {Boolean} 42 | */ 43 | exports.called = function called() { 44 | return Boolean(buffer); 45 | }; 46 | 47 | /** Stops `stdout` output. 48 | */ 49 | exports.mute = function mute() { 50 | suspend = true; 51 | }; 52 | 53 | /** Allows `stdout` output. 54 | */ 55 | exports.unmute = function unmute() { 56 | suspend = false; 57 | }; 58 | -------------------------------------------------------------------------------- /test/unit/helpers/driver.js: -------------------------------------------------------------------------------- 1 | /** This file mocks a Runner by providing two objects that would be provided by a Runner: a `config` and `driver` (through the async `getDriverHolder`). 2 | * The mocked Runner is more efficient for short-lived tests, as only one driver (hence browser) is actually created. 3 | */ 4 | 5 | var webdriver = require('wd'), 6 | ConfigLoader = require('mattisg.configloader'); 7 | 8 | /** Loaded configuration for the test runs. 9 | *@type {Object} 10 | */ 11 | config = new ConfigLoader({ 12 | from: __dirname, 13 | appName: 'watai' 14 | }).load('config'); 15 | 16 | 17 | /** The single driver instance to be used during the test run. 18 | *@type {WebDriver} 19 | *@private 20 | */ 21 | var driver, 22 | /** The number of clients that required access to the driver. 23 | * Allows us to avoid the overhead of starting a new driver for each test suite, while still making each suite runnable on its own. 24 | *@type {integer} 25 | *@private 26 | */ 27 | driverClientsCount = 0; 28 | 29 | 30 | /** Returns an object with a `driver` key, which will be set before the test suite in which this function is called, and will be quit after the suite if it is the last one of this test run. 31 | * 32 | *@return {Object} holder An Object whose `driver` key will be eventually set to a ready-to-use WebDriver instance. 33 | */ 34 | function getDriverHolder() { 35 | var result = Object.create(null); 36 | setDriverIn(result); 37 | return result; 38 | } 39 | 40 | /** Sets the `driver` key in the `holder` object before the test suite in which this function is called, and will quit the driver after it if it is the last suite of this test run. 41 | * 42 | *@param {Object} holder An Object whose `driver` key will be eventually set to a ready-to-use WebDriver instance. 43 | *@private 44 | */ 45 | function setDriverIn(holder) { 46 | driverClientsCount++; 47 | before(openDriverWithin(holder)); 48 | after(closeDriverWithin(holder)); 49 | } 50 | 51 | /** Provides a closure setting the `driver` key in the given object, that will eventually be set to a ready-to-use WebDriver instance, taking a callback for when it has finished. 52 | * 53 | *@param {object} destination An Object whose `driver` key will be eventually set to a ready-to-use WebDriver instance. 54 | *@return {function(function)} A setter taking a callback. 55 | *@private 56 | */ 57 | function openDriverWithin(destination) { 58 | return function openDriver(done) { 59 | this.timeout(config.browserWarmupTimeout); 60 | 61 | if (! driver) { 62 | driver = makeDriver(done); 63 | destination.driver = driver; 64 | } else { 65 | destination.driver = driver; 66 | done(); 67 | } 68 | }; 69 | } 70 | 71 | /** Creates a new WebDriver instance and loads the `baseURL` found in the loaded config, calling back when it is ready. 72 | * 73 | *@param {function} done Callback for completion. 74 | *@return {WebDriver} 75 | *@private 76 | */ 77 | function makeDriver(done) { 78 | var result = webdriver.promiseRemote(), 79 | seleniumServer = require('url').parse(config.seleniumServerURL); // TODO: get the URL already parsed from the config instead of serializing it at each step 80 | 81 | result.init(Object.merge(config.driverCapabilities, { 82 | host: seleniumServer.hostname, 83 | port: seleniumServer.port 84 | })).then(function() { 85 | return result.get(config.baseURL); 86 | }, function() { 87 | console.error([ 88 | '', 89 | '**The Selenium server could not be reached!**', 90 | '> Did you start it up?', 91 | ' See the troubleshooting guide if you need help ;)' 92 | ].join('\n')); 93 | 94 | process.exit(4); 95 | }).done(done); 96 | 97 | return result; 98 | } 99 | 100 | /** Provides a closure quitting the driver found in the `driver` key in the given object if it is the last call of this test run, taking a callback for when it has finished. 101 | * 102 | *@param {object} source An Object whose `driver` key will be eventually quit. 103 | *@return {function(function)} A possible quitter taking a callback. 104 | *@private 105 | */ 106 | function closeDriverWithin(source) { 107 | return function quitDriver(done) { 108 | this.timeout(config.browserWarmupTimeout); 109 | 110 | if (--driverClientsCount <= 0) 111 | source.driver.quit().done(done); 112 | else 113 | done(); 114 | }; 115 | } 116 | 117 | 118 | // CommonJS export 119 | exports.config = config; 120 | exports.getDriverHolder = getDriverHolder; 121 | -------------------------------------------------------------------------------- /test/unit/helpers/subject.js: -------------------------------------------------------------------------------- 1 | var loadPath = '../../../src'; 2 | 3 | /** The library to test, namespacing all public classes. 4 | */ 5 | var result = require(loadPath + '/Watai'); 6 | 7 | result.path = loadPath; // append this to `result` instead of using another key to avoid having to rewrite all `require`s in tests 8 | 9 | module.exports = result; 10 | -------------------------------------------------------------------------------- /test/unit/helpers/testComponent.js: -------------------------------------------------------------------------------- 1 | var Watai = require('./subject'); 2 | 3 | 4 | /** Component description of elements existing in the test support page resource. 5 | */ 6 | var elements = exports.elements = { 7 | id : '#toto', 8 | css : '.tutu', 9 | missing : '#missing', 10 | hidden : '#hidden', 11 | regexpTestField : 'input[name="regexpTestField"]', 12 | inputField : 'input[name="field"]', 13 | changeTextareaValueNowLink : { linkText: 'This paragraph is embedded in a link' }, 14 | changeTextareaValueLaterLink : '#delayLink', 15 | changeTextareaValueLaterAgainLink : '#delayLink2', 16 | pressButton : '#button', 17 | toggleCheckbox : '#box', 18 | selectRadio : '#radio', 19 | overlayedActionLink : '#under', 20 | hideOverlayLink : '#removeOver', 21 | output : '#output', 22 | outputField : { name: 'outputField' }, 23 | badSelector : { thisIsInvalid: 'sure' } 24 | }; 25 | 26 | /** Expected values for the texts of the elements described above, as defined in the test support page. 27 | * Exported for use in other tests. 28 | * 29 | *@see #elements 30 | */ 31 | exports.expectedContents = { 32 | id: 'This paragraph has id toto', 33 | outputField: 'This is a value' 34 | }; 35 | 36 | /** Expected values for the results of the elements described above, as defined in the test support page. 37 | * Exported for use in other tests. 38 | * 39 | *@see #elements 40 | */ 41 | exports.expectedOutputs = { 42 | changeTextareaValueNowLink: '#link has been clicked', 43 | changeTextareaValueLaterLink: '#delayLink has been clicked', 44 | changeTextareaValueLaterAgainLink: '#delayLink2 has been clicked', 45 | pressButton: '#button has been pressed', 46 | toggleCheckbox: '#box has been checked', 47 | selectRadio: '#radio has been selected', 48 | overlayedActionLink: '#under has been clicked' 49 | }; 50 | 51 | /** A full component describing the “main” part of the test support page. 52 | * Exported for use in other tests. 53 | * 54 | *@param {WebDriver} driver The driver in which the Component should be described. 55 | *@see #elements 56 | */ 57 | exports.getComponent = function(driver) { 58 | return new Watai.Component('Test component', Object.merge({ 59 | 60 | submit: function submit(value) { 61 | return this.setInputField(value)() 62 | .then(driver.submit.bind(driver)); 63 | }, 64 | 65 | beClever: function doSomethingVeryClever() { // used in report view test 66 | return true; 67 | } 68 | }, elements), driver); 69 | }; 70 | -------------------------------------------------------------------------------- /test/unit/lib/mootools-additions.js: -------------------------------------------------------------------------------- 1 | var should = require('should'); 2 | require('mootools'); 3 | require('../../../src/lib/mootools-additions'); 4 | 5 | 6 | describe('MooTools additions', function() { 7 | var newSubject = function newSubject() { 8 | var result = { 9 | touched: false, 10 | outer: { 11 | medium: { } 12 | } 13 | }; 14 | 15 | result.outer.medium.__defineGetter__('inner', function() { 16 | result.touched = true; 17 | return true; 18 | }); 19 | 20 | return result; 21 | }; 22 | 23 | var path = 'outer.medium.inner'; 24 | 25 | describe('Object', function() { 26 | describe('hasPropertyPath', function() { 27 | it('should find a path without actually accessing the last property', function() { 28 | var subject = newSubject(); 29 | Object.hasPropertyPath(subject, path).should.be.ok; 30 | subject.touched.should.not.be.ok; 31 | }); 32 | 33 | it('should not find a path that does not exist', function() { 34 | var subject = newSubject(); 35 | Object.hasPropertyPath(subject, 'toto').should.not.be.ok; 36 | Object.hasPropertyPath(subject, '.toto').should.not.be.ok; 37 | Object.hasPropertyPath(subject, path + '.toto').should.not.be.ok; 38 | }); 39 | }); 40 | 41 | describe('getFromPath', function() { 42 | it('should access a path', function() { 43 | var subject = newSubject(); 44 | Object.getFromPath(subject, path).should.be.ok; 45 | subject.touched.should.be.ok; 46 | }); 47 | 48 | it('should not find a path that does not exist', function() { 49 | var subject = newSubject(); 50 | should.not.exist(Object.getFromPath(subject, path + 'toto')); 51 | should.not.exist(Object.getFromPath(subject, path + '.toto')); 52 | }); 53 | }); 54 | }); 55 | 56 | describe('String', function() { 57 | describe('count', function() { 58 | var subject; 59 | 60 | describe('default pluralization', function() { 61 | beforeEach(function() { 62 | subject = 'scenario'; 63 | }); 64 | 65 | it('should keep the same form if 1', function() { 66 | subject.count(1).should.equal('1 ' + subject); 67 | }); 68 | 69 | it('should be pluralized if > 1', function() { 70 | subject.count(2).should.equal('2 ' + subject + 's'); 71 | }); 72 | 73 | it('should be pluralized if 0', function() { 74 | subject.count(0).should.equal('0 ' + subject + 's'); 75 | }); 76 | }); 77 | 78 | describe('specific pluralization', function() { 79 | beforeEach(function() { 80 | subject = 'success'; 81 | }); 82 | 83 | it('should keep the same form if 1', function() { 84 | subject.count(1, 'es').should.equal('1 ' + subject); 85 | }); 86 | 87 | it('should be pluralized if > 1', function() { 88 | subject.count(2, 'es').should.equal('2 ' + subject + 'es'); 89 | }); 90 | 91 | it('should be pluralized if 0', function() { 92 | subject.count(0, 'es').should.equal('0 ' + subject + 'es'); 93 | }); 94 | }); 95 | }); 96 | 97 | 98 | describe('humanize', function() { 99 | var EXPECTED = 'i like cookies'; 100 | 101 | it('should treat small camel-cased strings', function() { 102 | 'iLikeCookies'.humanize().should.equal(EXPECTED); 103 | }); 104 | 105 | it('should treat capitalized camel-cased strings', function() { 106 | 'ILikeCookies'.humanize().should.equal(EXPECTED); 107 | }); 108 | 109 | it('should treat hyphenated strings', function() { 110 | 'i-like-cookies'.humanize().should.equal(EXPECTED); 111 | }); 112 | 113 | it('should treat snake-cased strings', function() { 114 | 'i_like_cookies'.humanize().should.equal(EXPECTED); 115 | }); 116 | }); 117 | }); 118 | }); 119 | -------------------------------------------------------------------------------- /test/unit/model/ComponentTest.js: -------------------------------------------------------------------------------- 1 | var Watai = require('../helpers/subject'), 2 | my = require('../helpers/driver').getDriverHolder(), 3 | should = require('should'), 4 | subject, 5 | elements, 6 | expectedContents, 7 | expectedOutputs; 8 | 9 | 10 | /** Component description of elements existing in the “checking” part of the test support page resource. 11 | * These elements have their content updated according to actions made on the “main” elements described above. 12 | *@private 13 | */ 14 | var checkerElements = { 15 | output: '#output' 16 | }; 17 | 18 | 19 | /** This test suite is written with [Mocha](http://visionmedia.github.com/mocha/) and [Should](https://github.com/visionmedia/should.js). 20 | */ 21 | describe('Component', function() { 22 | var checker; 23 | 24 | before(function(done) { 25 | my.driver.refresh(done); 26 | }); 27 | 28 | before(function() { 29 | var testComponent = require('../helpers/testComponent'); 30 | elements = testComponent.elements; 31 | expectedContents = testComponent.expectedContents; 32 | expectedOutputs = testComponent.expectedOutputs; 33 | subject = testComponent.getComponent(my.driver); 34 | 35 | checker = new Watai.Component('Events results component', checkerElements, my.driver); 36 | }); 37 | 38 | describe('parsing', function() { 39 | it('should add all elements as properties', function() { 40 | for (var key in elements) 41 | if (elements.hasOwnProperty(key) 42 | && key != 'missing') { // Should.js' property checker accesses the property, which would therefore make the missing element throw because it is unreachable 43 | subject.should.have.property(key); 44 | should(typeof subject[key] == 'object', 'Key "' + key + '" is not an object'); // prototype of WebDriver internal objects is not augmented 45 | } 46 | }); 47 | 48 | it('should bind methods properly', function(done) { 49 | subject.submit('something')().then(function() { 50 | return subject.inputField; 51 | }).then(function(element) { 52 | return element.getValue(); 53 | }).then(function(value) { 54 | value.should.equal('Default'); // because the page has been reloaded 55 | }).done(done); 56 | }); 57 | }); 58 | 59 | 60 | describe('magic', function() { 61 | it('should do some magic on *Link names', function() { 62 | subject.should.have.property('changeTextareaValueNow'); 63 | subject.changeTextareaValueNow.should.be.a.Function(); // on 'link', this should be a shortcut to clicking the element, not a simple access 64 | }); 65 | 66 | 67 | Object.each({ 68 | changeTextareaValueNow : 'changeTextareaValueNowLink', 69 | press : 'pressButton', 70 | toggle : 'toggleCheckbox', 71 | select : 'selectRadio' 72 | }, function(elementName, action) { 73 | it('should bind magically created click actions on "' + elementName.replace(action, '') + '"-ending elements', function(done) { 74 | subject[action]()().then(function() { 75 | return checker.output; 76 | }).then(function(checkerOutput) { 77 | return checkerOutput.text(); 78 | }).then(function(checkerOutputText) { 79 | checkerOutputText.should.equal(expectedOutputs[elementName]); 80 | }).done(done, done); 81 | }); 82 | }); 83 | 84 | 85 | describe('setters', function() { 86 | var EXPECTED = 'set method test'; 87 | 88 | it('should be added for all field-type elements', function() { 89 | subject.setInputField.should.be.a.Function(); 90 | }); 91 | 92 | it('should be partial applicators for actually sending keys', function() { 93 | subject.setInputField(EXPECTED).should.be.a.Function(); 94 | }); 95 | 96 | it('should return a promise when calling partial applicator', function(done) { 97 | var typer = subject.setInputField(EXPECTED); 98 | 99 | typer().done(function() { done(); }); 100 | }); 101 | 102 | it('should actually send keys when calling partial applicator', function(done) { 103 | var typer = subject.setInputField(EXPECTED); 104 | 105 | typer().then(function(element) { 106 | return element.getValue(); 107 | }).then(function(value) { 108 | value.should.equal(EXPECTED); 109 | }).done(done); 110 | }); 111 | }); 112 | }); 113 | 114 | 115 | describe('element access', function() { 116 | function textShouldBe(elementPromise, expectedText, done) { 117 | return elementPromise.then(function(element) { 118 | return element.text(); 119 | }).then(function(text) { 120 | text.should.equal(expectedText); 121 | }).done(done); 122 | } 123 | 124 | 125 | it('should map elements to locators', function(done) { 126 | textShouldBe(subject.id, expectedContents.id, done); 127 | }); 128 | }); 129 | }); 130 | -------------------------------------------------------------------------------- /test/unit/model/LocatorTest.js: -------------------------------------------------------------------------------- 1 | var Watai = require('../helpers/subject'), 2 | my = require('../helpers/driver').getDriverHolder(), 3 | should = require('should'); 4 | 5 | 6 | /* Exported to be also used in ComponentTest. 7 | */ 8 | exports.checkLocator = checkLocator = function checkLocator(subject, locatorName, expectedContent) { 9 | it('should add a locator to the target object', function() { 10 | subject.should.have.property(locatorName); 11 | }); 12 | 13 | it('should return an object when accessed', function() { 14 | should(typeof subject[locatorName] == 'object'); // prototype of WebDriver internal objects is not augmented 15 | }); 16 | 17 | it('should have the correct text in the retrieved element', function(done) { 18 | subject[locatorName].then(function(element) { 19 | return element.text(); 20 | }).then(function(content) { 21 | content.should.equal(expectedContent); 22 | }).done(done); 23 | }); 24 | }; 25 | 26 | /** This test suite is redacted with [Mocha](http://visionmedia.github.com/mocha/) and [Should](https://github.com/visionmedia/should.js). 27 | */ 28 | describe('Locator', function() { 29 | var locatorsTarget = new (require('events').EventEmitter)(); 30 | 31 | before(function(done) { 32 | my.driver.refresh(done); 33 | }); 34 | 35 | describe('selector', function() { 36 | describe('default to css', function() { 37 | var locatorName = 'default'; 38 | 39 | before(function() { 40 | Watai.Locator.addLocator(locatorsTarget, locatorName, '#toto', my.driver); 41 | }); 42 | 43 | checkLocator(locatorsTarget, locatorName, 'This paragraph has id toto'); 44 | }); 45 | 46 | describe('with ID', function() { 47 | var locatorName = 'id'; 48 | 49 | before(function() { 50 | Watai.Locator.addLocator(locatorsTarget, locatorName, { id: 'toto' }, my.driver); 51 | }); 52 | 53 | checkLocator(locatorsTarget, locatorName, 'This paragraph has id toto'); 54 | }); 55 | 56 | describe('with css alias', function() { 57 | var locatorName = 'css'; 58 | 59 | before(function() { 60 | Watai.Locator.addLocator(locatorsTarget, locatorName, { css: '.tutu' }, my.driver); 61 | }); 62 | 63 | checkLocator(locatorsTarget, locatorName, 'This paragraph has class tutu'); 64 | }); 65 | 66 | describe('with css selector', function() { 67 | var locatorName = 'css selector'; 68 | 69 | before(function() { 70 | Watai.Locator.addLocator(locatorsTarget, locatorName, { 'css selector': '.tutu' }, my.driver); 71 | }); 72 | 73 | checkLocator(locatorsTarget, locatorName, 'This paragraph has class tutu'); 74 | }); 75 | 76 | describe('with Xpath', function() { 77 | var locatorName = 'xpath'; 78 | 79 | before(function() { 80 | Watai.Locator.addLocator(locatorsTarget, locatorName, { xpath: '//div[@id="selectors"]/p[3]' }, my.driver); 81 | }); 82 | 83 | checkLocator(locatorsTarget, locatorName, 'This paragraph is the third of the selectors div'); 84 | }); 85 | 86 | describe('with linkText alias', function() { 87 | var locatorName = 'linkText'; 88 | 89 | before(function() { 90 | Watai.Locator.addLocator(locatorsTarget, locatorName, { linkText: 'This paragraph is embedded in a link' }, my.driver); 91 | }); 92 | 93 | checkLocator(locatorsTarget, locatorName, 'This paragraph is embedded in a link'); 94 | }); 95 | 96 | describe('with link text', function() { 97 | var locatorName = 'link text'; 98 | 99 | before(function() { 100 | Watai.Locator.addLocator(locatorsTarget, locatorName, { 'link text': 'This paragraph is embedded in a link' }, my.driver); 101 | }); 102 | 103 | checkLocator(locatorsTarget, locatorName, 'This paragraph is embedded in a link'); 104 | }); 105 | 106 | it('should work on a field too', function(done) { 107 | var target = 'fieldGetter'; 108 | 109 | Watai.Locator.addLocator(locatorsTarget, target, { css: 'input[name="field"]' }, my.driver); 110 | 111 | locatorsTarget[target].then(function(element) { 112 | element.getAttribute('value').then(function(content) { 113 | content.should.equal('Default'); 114 | }).done(done); 115 | }); 116 | }); 117 | }); 118 | 119 | describe('setter', function() { 120 | it('should set the value upon attribution', function(done) { 121 | var target = 'fieldSetter', 122 | newContent = 'new content'; 123 | 124 | Watai.Locator.addLocator(locatorsTarget, target, { css: 'input[name="field"]' }, my.driver); 125 | 126 | locatorsTarget[target] = newContent; 127 | 128 | setTimeout(function() { // TODO: this has to be delayed because the setter above triggers a series of async actions, and we need the evaluation to be done *after* these actions. This should be modified along with a rethink of the way the setter works. 129 | locatorsTarget[target].then(function(element) { 130 | return element.getAttribute('value'); 131 | }).then(function(content) { 132 | content.should.equal(newContent); 133 | }).done(done); 134 | }, 200); 135 | }); 136 | 137 | it('should throw an exception if the setter already exists', function() { 138 | var target = 'fieldSetter', 139 | newContent = 'new content'; 140 | 141 | (function() { 142 | Watai.Locator.addLocator(locatorsTarget, target, { css: 'input[name="field"]' }, my.driver); 143 | }).should.throw(new RegExp('Cannot redefine.*' + target)); 144 | }); 145 | 146 | describe('metadata', function() { 147 | var target = 'field', 148 | newContent = 'new content', 149 | setterName = 'set' + target.capitalize(), 150 | subject; 151 | 152 | before(function() { 153 | Watai.Locator.addLocator(locatorsTarget, target, { css: 'input[name="field"]' }, my.driver); 154 | subject = locatorsTarget[setterName](newContent); 155 | }); 156 | 157 | it('should have title', function() { 158 | subject.should.have.property('title').with.equal('set field'); 159 | }); 160 | 161 | it('should have reference', function() { 162 | subject.should.have.property('reference').with.equal(setterName); 163 | }); 164 | 165 | it('should have component', function() { 166 | subject.should.have.property('component').with.equal(locatorsTarget); 167 | }); 168 | 169 | it('should have args', function() { 170 | subject.should.have.property('args').with.containEql(newContent); 171 | }); 172 | }); 173 | }); 174 | }); 175 | -------------------------------------------------------------------------------- /test/unit/model/ScenarioTest.js: -------------------------------------------------------------------------------- 1 | var promises = require('q'); 2 | 3 | var Watai = require('../helpers/subject'), 4 | my = require('../helpers/driver').getDriverHolder(), 5 | expectedOutputs = require('../helpers/testComponent').expectedOutputs, 6 | ComponentTest; 7 | 8 | 9 | /** This test suite is redacted with [Mocha](http://visionmedia.github.com/mocha/) and [Should](https://github.com/visionmedia/should.js). 10 | */ 11 | describe('Scenario', function() { 12 | var scenarioWithSteps; 13 | 14 | before(function(done) { 15 | my.driver.refresh(done); 16 | }); 17 | 18 | before(function() { 19 | ComponentTest = require('../helpers/testComponent').getComponent(my.driver); 20 | 21 | scenarioWithSteps = function scenarioWithSteps(scenario) { 22 | return new Watai.Scenario('Test scenario', scenario, { TestComponent: ComponentTest }, require('../../config')); 23 | }; 24 | }); 25 | 26 | 27 | describe('functional scenarios with', function() { 28 | var failureReason = 'It’s a trap!'; 29 | 30 | var failingScenarioTest = function() { 31 | return scenarioWithSteps([ 32 | function() { throw failureReason; } 33 | ]).test(); 34 | }; 35 | 36 | function makeFailingPromiseWithSuffix(suffix) { 37 | return function() { 38 | var deferred = promises.defer(); 39 | deferred.reject(failureReason + suffix); 40 | return deferred.promise; 41 | }; 42 | } 43 | 44 | var failingPromise = makeFailingPromiseWithSuffix(''); 45 | 46 | 47 | it('an empty scenario should be accepted', function(done) { 48 | scenarioWithSteps([]).test().done(function() { 49 | done(); 50 | }, function(err) { 51 | done(new Error(err)); 52 | }); 53 | }); 54 | 55 | it('a failing function should be rejected', function(done) { 56 | failingScenarioTest().done(function() { 57 | done(new Error('Resolved instead of rejected!')); 58 | }, function() { 59 | done(); // can't pass it directly, Mocha complains about param not being an error 60 | }); 61 | }); 62 | 63 | it('a failing promise should be rejected', function(done) { 64 | scenarioWithSteps([ 65 | failingPromise 66 | ]).test().done(function() { 67 | done(new Error('Resolved instead of rejected!')); 68 | }, function() { 69 | done(); 70 | }); 71 | }); 72 | 73 | it('multiple failing promises should be rejected', function(done) { 74 | scenarioWithSteps([ 75 | makeFailingPromiseWithSuffix(0), 76 | makeFailingPromiseWithSuffix(1), 77 | makeFailingPromiseWithSuffix(2) 78 | ]).test().done(function() { 79 | done(new Error('Resolved instead of rejected!')); 80 | }, function() { 81 | done(); 82 | }); 83 | }); 84 | 85 | it('a function should be called', function(done) { 86 | var called = false; 87 | 88 | scenarioWithSteps([ function() { 89 | called = true; 90 | } ]).test().done(function() { 91 | if (called) 92 | done(); 93 | else 94 | done(new Error('Promise resolved without actually calling the scenario function')); 95 | }, function() { 96 | done(new Error('Scenario evaluation failed, with' + (called ? '' : 'out') 97 | + ' actually calling the scenario function (but that’s still an error)')); 98 | }); 99 | }); 100 | }); 101 | 102 | 103 | describe('badly-formatted scenarios', function() { 104 | function scenarioShouldThrowWith(responsibleStep) { 105 | (function() { 106 | scenarioWithSteps([ 107 | responsibleStep 108 | ]); 109 | }).should.throw(/at step 1/); 110 | } 111 | 112 | it('with null should throw', function() { 113 | scenarioShouldThrowWith(null); 114 | }); 115 | 116 | it('with explicit undefined should throw', function() { 117 | scenarioShouldThrowWith(undefined); 118 | }); 119 | 120 | it('with undefined reference should throw', function() { 121 | var a; 122 | scenarioShouldThrowWith(a); 123 | }); 124 | 125 | it('with a free string should throw', function() { 126 | scenarioShouldThrowWith('string'); 127 | }); 128 | 129 | it('with a free number should throw', function() { 130 | scenarioShouldThrowWith(12); 131 | }); 132 | 133 | it('with a free 0 should throw', function() { 134 | scenarioShouldThrowWith(0); 135 | }); 136 | }); 137 | 138 | 139 | describe('unclickable elements', function() { 140 | it('should respect the global timeout', function(done) { 141 | var start = new Date(), 142 | configTimeout = require('../../config').timeout; 143 | 144 | this.timeout(configTimeout * 5); 145 | 146 | scenarioWithSteps([ 147 | ComponentTest.overlayedAction(), 148 | { 'TestComponent.output': expectedOutputs.overlayedActionLink } 149 | ]).test().then(function() { 150 | throw new Error('Passed while the overlayed element should not have been clickable!'); 151 | }, function() { 152 | (new Date().getTime()).should.be.above(start.getTime() + configTimeout); 153 | }).done(done, done); 154 | }); 155 | 156 | it('should give human-readable details', function(done) { 157 | scenarioWithSteps([ 158 | ComponentTest.overlayedAction() 159 | ]).test().done(function() { 160 | done(new Error('Passed while the overlayed element should not have been clickable!')); 161 | }, function(reasons) { 162 | var reason = reasons[0]; 163 | if (reason.match(/not clickable/)) 164 | done(); 165 | else 166 | done(new Error('"' + reason + '" is not a human-readable reason for failure')); 167 | }); 168 | }); 169 | 170 | it('should be fine if made clickable', function(done) { 171 | scenarioWithSteps([ 172 | ComponentTest.hideOverlay(), 173 | ComponentTest.overlayedAction(), 174 | { 175 | 'TestComponent.output': expectedOutputs.overlayedActionLink 176 | } 177 | ]).test().done(function() { done(); }, function(report) { 178 | done(new Error(report)); 179 | }); 180 | }); 181 | }); 182 | 183 | 184 | describe('events', function() { 185 | var subject; 186 | 187 | function expectFired(eventName, expectedParam) { 188 | var hasExpectedParam = arguments.length > 1; // allow for falsy values to be expected params 189 | 190 | return function(done) { 191 | subject.on(eventName, function(param) { 192 | if (hasExpectedParam) 193 | param.should.equal(expectedParam); 194 | done(); 195 | }); 196 | 197 | subject.test(); 198 | }; 199 | } 200 | 201 | function expectNotFired(eventName) { 202 | return function(done) { 203 | subject.on(eventName, function() { 204 | done(new Error('Fired while it should not have')); 205 | }); 206 | 207 | subject.test(); 208 | 209 | setTimeout(done, 40); 210 | }; 211 | } 212 | 213 | describe('of a scenario with a failing step', function() { 214 | beforeEach(function() { 215 | subject = scenarioWithSteps([ 216 | function() { throw 'Boom!'; } 217 | ]); 218 | }); 219 | 220 | [ 'start', 'step' ].forEach(function(type) { 221 | it('should fire a "' + type + '" event', expectFired(type)); 222 | }); 223 | }); 224 | 225 | describe('of a scenario with an empty scenario', function() { 226 | beforeEach(function() { 227 | subject = scenarioWithSteps([ ]); 228 | }); 229 | 230 | it('should fire a "start" event', expectFired('start')); 231 | it('should NOT fire any "step" event', expectNotFired('step')); 232 | }); 233 | }); 234 | }); 235 | -------------------------------------------------------------------------------- /test/unit/model/scenario/FunctionalStepTest.js: -------------------------------------------------------------------------------- 1 | var promises = require('q'); 2 | 3 | 4 | var Watai = require('../../helpers/subject'), 5 | my = require('../../helpers/driver').getDriverHolder(), 6 | FunctionalStep = Watai.steps.FunctionalStep; 7 | 8 | 9 | describe('FunctionalStep', function() { 10 | var TestComponent, 11 | subject; 12 | 13 | before(function(done) { 14 | my.driver.refresh(done); 15 | }); 16 | 17 | before(function() { 18 | TestComponent = require('../../helpers/testComponent').getComponent(my.driver); 19 | subject = new FunctionalStep(function() { /* do nothing */ }); 20 | }); 21 | 22 | describe('AstractStepTest (through FunctionalStep)', function() { 23 | it('should offer a `test` method', function(done) { 24 | subject.test.should.be.a.Function(); 25 | subject.test().done(function() { done(); }); 26 | }); 27 | 28 | it('should have a `startTime` date attribute', function() { 29 | subject.startTime.should.be.an.instanceof(Date); 30 | }); 31 | 32 | it('should have a `stopTime` date attribute', function() { 33 | subject.stopTime.should.be.an.instanceof(Date); 34 | }); 35 | 36 | it('should have a stopTime bigger than its startTime', function() { 37 | subject.stopTime.getTime().should.not.be.below(subject.startTime.getTime()); 38 | }); 39 | }); 40 | 41 | describe('user-visible errors', function() { 42 | function expectMessage(elementName, message, done) { 43 | new FunctionalStep(function() { 44 | return TestComponent[elementName].then(function(elm) { 45 | return elm.click(); 46 | }); 47 | }).test().then(function() { 48 | throw new Error('Accessing an element with an error did not trigger a failure.'); 49 | }, function(reason) { 50 | if (! reason.match(message)) 51 | throw new Error('"' + reason + '" is not clear enough.'); 52 | }).done(done); 53 | } 54 | 55 | it('should be clear for missing elements (code 7)', function(done) { 56 | expectMessage('missing', /could not be located/, done); 57 | }); 58 | 59 | it('should be clear for unclickable elements (code 13)', function(done) { 60 | expectMessage('overlayedActionLink', /not clickable/, done); 61 | }); 62 | 63 | it('should be clear for invalid selectors (code 32)', function(done) { 64 | expectMessage('badSelector', /locator/, done); 65 | }); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /test/unit/model/scenario/StateStepTest.js: -------------------------------------------------------------------------------- 1 | var promises = require('q'); 2 | 3 | 4 | var Watai = require('../../helpers/subject'), 5 | my = require('../../helpers/driver').getDriverHolder(), 6 | StateStep = Watai.steps.StateStep; 7 | 8 | /** Milliseconds the actions take to delay changing the output on the test page. 9 | * Set in the test page (`test/resources/page.html`). 10 | */ 11 | var DELAYED_ACTIONS_DELAY = 500; 12 | 13 | 14 | describe('StateStep', function() { 15 | var expectedContents = {}, 16 | wrongTexts = {}, 17 | TestComponent, 18 | firstKey; // the first key of expected texts. Yes, it is used in a test. 19 | 20 | before(function() { 21 | Object.each(require('../../helpers/testComponent').expectedContents, function(text, key) { // we need to namespace all attributes to TestComponent 22 | expectedContents['TestComponent.' + key] = text; 23 | wrongTexts['TestComponent.' + key] = text + ' **modified**'; 24 | 25 | if (! firstKey) 26 | firstKey = 'TestComponent.' + key; 27 | }); 28 | 29 | TestComponent = require('../../helpers/testComponent').getComponent(my.driver); 30 | }); 31 | 32 | 33 | it('should offer a `test` method', function() { 34 | var result = new StateStep(expectedContents, { TestComponent: TestComponent }); 35 | result.test.should.be.a.Function(); 36 | promises.isPromise(result.test()).should.be.ok; 37 | }); 38 | 39 | it('that are empty should pass', function(done) { 40 | new StateStep({}, { TestComponent: TestComponent }) 41 | .test().then(function() { 42 | done(); 43 | }, function(err) { 44 | done(new Error('Should have passed (reason: "' + err + ')')); 45 | } 46 | ).done(); 47 | }); 48 | 49 | 50 | describe('syntax checks', function() { 51 | it('with a non-existing property path should throw', function() { 52 | (function() { 53 | new StateStep({ toto: 'toto' }, { TestComponent: TestComponent }); // no component matches this property path. We have to protect users against misspelled paths. 54 | }).should.throw(/Could not find/); 55 | }); 56 | 57 | it('with a magically-added property path should throw', function() { 58 | (function() { 59 | new StateStep({ 'TestComponent.changeTextareaValueLater': 'toto' }, { TestComponent: TestComponent }); // The actual element is `changeTextareaValueLaterLink`. `changeTextareaValueLater` is an action shortcut, but may not be used as a property.]); 60 | }).should.throw(/not an element/); 61 | }); 62 | 63 | /* We cannot decide in advance whether a given identifier will match in another page or not. The only thing we can check is whether we're trying to describe an unknown component property. 64 | */ 65 | it('that are not accessible on the current page but properly written should not throw an error', function() { 66 | (function() { 67 | new StateStep({ 'TestComponent.missing': 'missing' }, { TestComponent: TestComponent }); 68 | }).should.not.throw(); 69 | }); 70 | }); 71 | 72 | 73 | describe('should be rejected on', function() { 74 | it('missing elements', function(done) { 75 | this.timeout(5000); 76 | 77 | new StateStep({ 'TestComponent.missing': 'toto' }, { TestComponent: TestComponent }) 78 | .test().then(function() { 79 | done(new Error('Resolved instead of rejected!')); 80 | }, function() { 81 | done(); 82 | } 83 | ).done(); 84 | }); 85 | 86 | it('non-matching textual content', function(done) { 87 | new StateStep(wrongTexts, { TestComponent: TestComponent }) 88 | .test().then(function() { 89 | done(new Error('Unmatched component state description should not be resolved.')); 90 | }, function(reason) { 91 | Object.each(require('../../helpers/testComponent').expectedContents, function(text, key) { 92 | if (! (reason 93 | && reason.contains(key) 94 | && reason.contains(wrongTexts['TestComponent.' + key]) 95 | && reason.contains(expectedContents['TestComponent.' + key]))) 96 | done(new Error('Unmatched component state description was properly rejected, but the reason for rejection was not clear enough (got "' + reason + '", expected values associated with "' + key + '").')); 97 | }); 98 | done(); 99 | } 100 | ).done(); 101 | }); 102 | }); 103 | 104 | 105 | describe('options', function() { 106 | describe('timeout', function() { 107 | var scenarioWithSteps, 108 | expectedOutputs; 109 | 110 | 111 | before(function() { 112 | scenarioWithSteps = function scenarioWithSteps(scenario) { 113 | return new Watai.Scenario('Test scenario', scenario, { TestComponent: TestComponent }, require('../../../config')); 114 | }; 115 | 116 | expectedOutputs = require('../../helpers/testComponent').expectedOutputs; 117 | }); 118 | 119 | 120 | it('should be allowed without any harm', function(done) { 121 | new StateStep({ timeout: 0 }, { StateStep: StateStep }) 122 | .test().then(function() { 123 | done(); 124 | }, done); 125 | }); 126 | 127 | it('should do immediate evaluation if set to 0', function(done) { 128 | scenarioWithSteps([ 129 | TestComponent.changeTextareaValueNow(), // make sure the content of the output is reset 130 | TestComponent.changeTextareaValueLater(), 131 | { 132 | timeout: 0, 133 | 'TestComponent.output': expectedOutputs.changeTextareaValueLaterLink 134 | } 135 | ]).test().then(function() { 136 | done(new Error('Matched while the expected result should have been set later than evaluation.')); 137 | }, function() { 138 | done(); 139 | }).done(); 140 | }); 141 | 142 | it('should do delayed evaluation if set to a proper positive value', function(done) { 143 | scenarioWithSteps([ 144 | TestComponent.changeTextareaValueNow(), // make sure the content of the output is reset 145 | TestComponent.changeTextareaValueLater(), 146 | { 147 | timeout: DELAYED_ACTIONS_DELAY * 2, 148 | 'TestComponent.output': expectedOutputs.changeTextareaValueLaterLink 149 | } 150 | ]).test().then(function() { 151 | done(); 152 | }, function(reason) { 153 | done(new Error(reason || 'No failure message passed.')); 154 | }).done(); 155 | }); 156 | 157 | it('should not be longer than needed if set to a positive value', function(done) { 158 | this.timeout(DELAYED_ACTIONS_DELAY * 3); 159 | 160 | scenarioWithSteps([ 161 | TestComponent.changeTextareaValueNow(), // make sure the content of the output is reset 162 | TestComponent.changeTextareaValueLaterAgain(), 163 | { 164 | timeout: DELAYED_ACTIONS_DELAY * 2, 165 | 'TestComponent.output': expectedOutputs.changeTextareaValueLaterAgainLink 166 | } 167 | ]).test().then(function() { 168 | done(); 169 | }, function(reason) { 170 | done(new Error(reason || 'No failure message passed.')); 171 | }).done(); 172 | }); 173 | 174 | it('should fail if expected state comes later than timeout', function(done) { 175 | this.timeout(DELAYED_ACTIONS_DELAY * 2); 176 | 177 | scenarioWithSteps([ 178 | TestComponent.changeTextareaValueNow(), // make sure the content of the output is reset 179 | TestComponent.changeTextareaValueLaterAgain(), 180 | { 181 | timeout: DELAYED_ACTIONS_DELAY / 10, 182 | 'TestComponent.output': expectedOutputs.changeTextareaValueLaterAgainLink 183 | } 184 | ]).test().then(function() { 185 | done(new Error('Matched while the expected result should have been set later than evaluation.')); 186 | }, function(err) { 187 | done(); 188 | }).done(); 189 | }); 190 | 191 | it('should fail if expected state comes later than timeout and timeout is set to 0', function(done) { 192 | this.timeout(DELAYED_ACTIONS_DELAY * 2); 193 | 194 | scenarioWithSteps([ 195 | TestComponent.changeTextareaValueNow(), // make sure the content of the output is reset 196 | TestComponent.changeTextareaValueLater(), 197 | { 198 | timeout: 0, 199 | 'TestComponent.output': expectedOutputs.changeTextareaValueLaterLink 200 | } 201 | ]).test().then(function() { 202 | done(new Error('Matched while the expected result should have been set later than evaluation.')); 203 | }, function() { 204 | done(); 205 | }); 206 | }); 207 | }); 208 | }); 209 | }); 210 | -------------------------------------------------------------------------------- /test/unit/model/scenario/state/ContentMatcherTest.js: -------------------------------------------------------------------------------- 1 | var Watai = require('../../../helpers/subject'), 2 | my = require('../../../helpers/driver').getDriverHolder(), 3 | ContentMatcher = Watai.matchers.ContentMatcher, 4 | TestComponent = require('../../../helpers/testComponent'); 5 | 6 | 7 | describe('ContentMatcher', function() { 8 | var component; 9 | 10 | function shouldPass(elementName, done) { 11 | new ContentMatcher(TestComponent.expectedContents[elementName], 'TestComponent.' + elementName, { TestComponent: component }) 12 | .test() 13 | .done(function() { done(); }); 14 | } 15 | 16 | function shouldFail(elementName, done) { 17 | new ContentMatcher(TestComponent.expectedContents[elementName] + 'cannot match that', 'TestComponent.' + elementName, { TestComponent: component }) 18 | .test() 19 | .done( 20 | function() { done(new Error('Resolved instead of rejected')); }, 21 | function() { done(); } 22 | ); 23 | } 24 | 25 | before(function() { 26 | component = TestComponent.getComponent(my.driver); 27 | }); 28 | 29 | describe('on existing elements', function() { 30 | describe('on textual content', function() { 31 | it('should pass on matching', function(done) { 32 | shouldPass('id', done); 33 | }); 34 | 35 | it('should fail on non-matching', function(done) { 36 | shouldFail('id', done); 37 | }); 38 | }); 39 | 40 | describe('on value', function() { 41 | it('should pass on matching', function(done) { 42 | shouldPass('outputField', done); 43 | }); 44 | 45 | it('should fail on non-matching', function(done) { 46 | shouldFail('outputField', done); 47 | }); 48 | }); 49 | }); 50 | 51 | describe('on missing elements', function() { 52 | it('should fail', function(done) { 53 | shouldFail('missing', done); 54 | }); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /test/unit/model/scenario/state/ContentRegExpMatcherTest.js: -------------------------------------------------------------------------------- 1 | var Watai = require('../../../helpers/subject'), 2 | my = require('../../../helpers/driver').getDriverHolder(), 3 | ContentRegExpMatcher = Watai.matchers.ContentRegExpMatcher, 4 | TestComponent = require('../../../helpers/testComponent'); 5 | 6 | 7 | describe('ContentRegExpMatcher', function() { 8 | var component; 9 | 10 | function shouldPass(elementName, done) { 11 | new ContentRegExpMatcher(/./, 'TestComponent.' + elementName, { TestComponent: component }) 12 | .test() 13 | .done(function() { done(); }); 14 | } 15 | 16 | function shouldFail(elementName, done) { 17 | new ContentRegExpMatcher(/herpaderp/, 'TestComponent.' + elementName, { TestComponent: component }) 18 | .test() 19 | .done( 20 | function() { done(new Error('Resolved instead of rejected')); }, 21 | function() { done(); } 22 | ); 23 | } 24 | 25 | before(function() { 26 | component = TestComponent.getComponent(my.driver); 27 | }); 28 | 29 | describe('on existing elements', function() { 30 | describe('on textual content', function() { 31 | it('should pass on matching', function(done) { 32 | shouldPass('id', done); 33 | }); 34 | 35 | it('should fail on non-matching', function(done) { 36 | shouldFail('id', done); 37 | }); 38 | }); 39 | 40 | describe('on value', function() { 41 | it('should pass on matching', function(done) { 42 | shouldPass('outputField', done); 43 | }); 44 | 45 | it('should fail on non-matching', function(done) { 46 | shouldFail('outputField', done); 47 | }); 48 | }); 49 | }); 50 | 51 | describe('on missing elements', function() { 52 | it('should fail', function(done) { 53 | shouldFail('missing', done); 54 | }); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /test/unit/model/scenario/state/FunctionMatcherTest.js: -------------------------------------------------------------------------------- 1 | var Watai = require('../../../helpers/subject'), 2 | my = require('../../../helpers/driver').getDriverHolder(), 3 | FunctionMatcher = Watai.matchers.FunctionMatcher, 4 | TestComponent; 5 | 6 | 7 | describe('FunctionMatcher', function() { 8 | var MESSAGE = 'This should make it fail'; 9 | 10 | 11 | before(function() { 12 | TestComponent = require('../../../helpers/testComponent').getComponent(my.driver); 13 | }); 14 | 15 | it('should pass on a function returning `true`', function(done) { 16 | new FunctionMatcher(function() { return true; }, 'TestComponent.toggleCheckbox', { TestComponent: TestComponent }) 17 | .test() 18 | .done(function() { done(); }, done); 19 | }); 20 | 21 | it('should pass on a function returning `false`', function(done) { 22 | new FunctionMatcher(function() { return false; }, 'TestComponent.toggleCheckbox', { TestComponent: TestComponent }) 23 | .test() 24 | .done(function() { done(); }, done); 25 | }); 26 | 27 | it('should fail on a throwing function', function(done) { 28 | new FunctionMatcher(function() { throw MESSAGE; }, 'TestComponent.toggleCheckbox', { TestComponent: TestComponent }) 29 | .test() 30 | .done(function() { 31 | done(new Error('Resolved instead of rejected!')); 32 | }, function(reason) { 33 | reason.should.match(new RegExp(MESSAGE)); 34 | done(); 35 | }); 36 | }); 37 | 38 | it('should pass on a function returning a resolved promise', function(done) { 39 | new FunctionMatcher( 40 | function(elm) { 41 | return elm.getAttribute('checked').then(function() { // this is just a way to obtain a promise 42 | // do nothing 43 | }); 44 | }, 45 | 'TestComponent.toggleCheckbox', 46 | { TestComponent: TestComponent } 47 | ).test() 48 | .done(function() { done(); }, done); 49 | }); 50 | 51 | it('should fail on a function returning a rejected promise', function(done) { 52 | new FunctionMatcher( 53 | function(elm) { 54 | return elm.getAttribute('checked').then(function() { // this is just a way to obtain a promise 55 | throw MESSAGE; 56 | }); 57 | }, 58 | 'TestComponent.toggleCheckbox', 59 | { TestComponent: TestComponent } 60 | ).test() 61 | .done(function() { 62 | done(new Error('Resolved instead of rejected!')); 63 | }, function(reason) { 64 | reason.should.match(new RegExp(MESSAGE)); 65 | done(); 66 | }); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /test/unit/model/scenario/state/VisibilityMatcherTest.js: -------------------------------------------------------------------------------- 1 | var Watai = require('../../../helpers/subject'), 2 | my = require('../../../helpers/driver').getDriverHolder(), 3 | VisibilityMatcher = Watai.matchers.VisibilityMatcher, 4 | TestComponent; 5 | 6 | 7 | describe('VisibilityMatcher', function() { 8 | before(function() { 9 | TestComponent = require('../../../helpers/testComponent').getComponent(my.driver); 10 | }); 11 | 12 | it('should pass on `true` in state descriptors on existing elements', function(done) { 13 | new VisibilityMatcher(true, 'TestComponent.output', { TestComponent: TestComponent }) 14 | .test().then(function() { 15 | done(); 16 | }, function(report) { 17 | var message = 'No failure report. See code'; 18 | 19 | if (report && report.failures && report.failures[0]) 20 | message = report.failures[0]; 21 | 22 | done(new Error(message)); 23 | } 24 | ).done(); 25 | }); 26 | 27 | it('should fail on `false` in state descriptors on existing elements', function(done) { 28 | new VisibilityMatcher(false, 'TestComponent.output', { TestComponent: TestComponent }) 29 | .test().then(function() { 30 | done(new Error('Resolved instead of rejected!')); 31 | }, function(reason) { 32 | reason.should.match(/was visible/); 33 | done(); 34 | } 35 | ).done(); 36 | }); 37 | 38 | it('should fail on `true` in state descriptors on missing elements', function(done) { 39 | new VisibilityMatcher(true, 'TestComponent.missing', { TestComponent: TestComponent }) 40 | .test().then(function() { 41 | done(new Error('Resolved instead of rejected!')); 42 | }, function(reason) { 43 | reason.should.match(/was not visible/); 44 | done(); 45 | } 46 | ).done(); 47 | }); 48 | 49 | it('should pass on `false` in state descriptors on missing elements', function(done) { 50 | new VisibilityMatcher(false, 'TestComponent.missing', { TestComponent: TestComponent }) 51 | .test().then(function() { 52 | done(); 53 | }, function(reason) { 54 | done(new Error(reason || 'No failure message passed.')); 55 | } 56 | ).done(); 57 | }); 58 | 59 | it('should fail on `true` in state descriptors on hidden elements', function(done) { 60 | new VisibilityMatcher(true, 'TestComponent.hidden', { TestComponent: TestComponent }) 61 | .test().then(function() { 62 | done(new Error('Resolved instead of rejected!')); 63 | }, function(reason) { 64 | reason.should.match(/was not visible/); 65 | done(); 66 | } 67 | ).done(); 68 | }); 69 | 70 | it('should pass on `false` in state descriptors on hidden elements', function(done) { 71 | new VisibilityMatcher(false, 'TestComponent.hidden', { TestComponent: TestComponent }) 72 | .test().then(function() { 73 | done(); 74 | }, function(reason) { 75 | done(new Error(reason || 'No failure message passed.')); 76 | } 77 | ).done(); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /test/unit/setup.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | log: { 3 | init: { 4 | console: { 5 | level : 'error', 6 | colorize : true 7 | } 8 | }, 9 | load: { 10 | console: { 11 | silent: true // we will test for errors and don't want these errors to be logged 12 | } 13 | } 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /test/unit/view/SauceLabsRunnerViewTest.js: -------------------------------------------------------------------------------- 1 | var urlUtils = require('url'); 2 | 3 | var Watai = require('../helpers/subject'), 4 | ConfigLoader = require('mattisg.configloader'), 5 | stdoutSpy = require('../helpers/StdoutSpy'), 6 | SauceLabsView = require(Watai.path + '/view/Runner/SauceLabs'); 7 | 8 | 9 | /** This test suite is written with [Mocha](http://visionmedia.github.com/mocha/) and [Should](https://github.com/visionmedia/should.js). 10 | */ 11 | describe('SauceLabs view', function() { 12 | var subject, 13 | runner, 14 | config; 15 | 16 | before(function() { 17 | config = new ConfigLoader({ 18 | from : __dirname, 19 | appName : 'watai', 20 | override : { quit: 'always' }, 21 | visitAlso : '../../src' 22 | }).load('config'); 23 | 24 | this.timeout(config.browserWarmupTime); 25 | 26 | runner = new Watai.Runner(config); 27 | 28 | subject = new SauceLabsView(runner); 29 | }); 30 | 31 | after(function(done) { 32 | this.timeout(config.browserWarmupTime); 33 | 34 | runner.quitBrowser().finally(done); 35 | }); 36 | 37 | 38 | describe('#getAuth', function() { 39 | describe('when there is no way to get the data', function() { 40 | before(stdoutSpy.reset); 41 | beforeEach(stdoutSpy.mute); 42 | afterEach(stdoutSpy.unmute); 43 | 44 | it('should throw', function() { 45 | (subject.getAuth.bind(subject)).should.throw(ReferenceError); 46 | }); 47 | 48 | it('should provide information to the user', function() { 49 | stdoutSpy.printed().should.containEql('SauceLabs authentication'); 50 | stdoutSpy.printed().should.containEql('config'); 51 | stdoutSpy.printed().should.containEql('SAUCE_USERNAME'); 52 | stdoutSpy.printed().should.containEql('SAUCE_ACCESS_KEY'); 53 | }); 54 | }); 55 | 56 | 57 | function checkMatch(username, accessKey) { 58 | var data = subject.getAuth(); 59 | data.username.should.equal(username); 60 | data.password.should.equal(accessKey); 61 | } 62 | 63 | describe('when auth is set through environment variables', function() { 64 | before(function() { 65 | process.env.SAUCE_USERNAME = 'user-env'; 66 | process.env.SAUCE_ACCESS_KEY = 'pass-env'; 67 | }); 68 | 69 | it('should obtain auth data from environment variables', function() { 70 | checkMatch('user-env', 'pass-env'); 71 | }); 72 | }); 73 | 74 | describe('when auth is set through config', function() { 75 | before(function() { 76 | var seleniumServerURL = urlUtils.parse(runner.config.seleniumServerURL); 77 | seleniumServerURL.auth = 'user-config:pass-config'; 78 | runner.config.seleniumServerURL = urlUtils.format(seleniumServerURL); 79 | }); 80 | 81 | it('should obtain auth data from configuration', function() { 82 | checkMatch('user-config', 'pass-config'); 83 | }); 84 | }); 85 | 86 | describe('when auth is set through both env and config', function() { 87 | before(function() { 88 | var seleniumServerURL = urlUtils.parse(runner.config.seleniumServerURL); 89 | seleniumServerURL.auth = 'user-config:'; 90 | runner.config.seleniumServerURL = urlUtils.format(seleniumServerURL); 91 | process.env.SAUCE_ACCESS_KEY = 'pass-env'; 92 | }); 93 | 94 | it('should obtain auth data half from configuration, half from environment variables', function() { 95 | checkMatch('user-config', 'pass-env'); 96 | }); 97 | }); 98 | }); 99 | 100 | describe('when not set to always quit', function() { 101 | before(function() { 102 | process.env.SAUCE_USERNAME = 'user-env'; 103 | process.env.SAUCE_ACCESS_KEY = 'pass-env'; 104 | 105 | runner.config.quit = 'never'; 106 | }); 107 | 108 | after(function() { 109 | runner.config.quit = 'always'; 110 | }); 111 | 112 | before(stdoutSpy.reset); 113 | beforeEach(stdoutSpy.mute); 114 | afterEach(stdoutSpy.unmute); 115 | 116 | it('should provide information to the user', function() { 117 | subject.showStart(); 118 | stdoutSpy.printed().should.containEql('quit'); 119 | stdoutSpy.printed().should.containEql('always'); 120 | stdoutSpy.printed().should.match(/wast.* 90 ?s/); // wasting, wastes, waste, 90 seconds, 90s… doesn't matter 121 | }); 122 | }); 123 | }); 124 | -------------------------------------------------------------------------------- /test/unit/view/StepVerboseViewTest.js: -------------------------------------------------------------------------------- 1 | var Watai = require('../helpers/subject'), 2 | stdoutSpy = require('../helpers/StdoutSpy'), 3 | StepVerboseView = require(Watai.path + '/view/Step/Verbose'), 4 | TestComponent = require('../helpers/testComponent'), 5 | my = require('../helpers/driver').getDriverHolder(); 6 | 7 | 8 | /** This test suite is written with [Mocha](http://visionmedia.github.com/mocha/) and [Should](https://github.com/visionmedia/should.js). 9 | */ 10 | describe('Step verbose view', function() { 11 | var step, 12 | subject, 13 | testComponent; 14 | 15 | before(function() { 16 | testComponent = TestComponent.getComponent(my.driver); 17 | }); 18 | 19 | beforeEach(function() { 20 | stdoutSpy.reset(); 21 | }); 22 | 23 | afterEach(function() { 24 | stdoutSpy.unmute(); // keep it in the `after` handler just in case something goes wrong in the test 25 | }); 26 | 27 | 28 | function testShouldContain(term, done) { 29 | subject = new StepVerboseView(step); 30 | 31 | var tester = function() { 32 | stdoutSpy.unmute(); 33 | stdoutSpy.printed().should.containEql(term); 34 | }; 35 | 36 | stdoutSpy.mute(); 37 | step.test().then(tester, tester).done(done, done); 38 | } 39 | 40 | function testShouldNotMatch(regexp, done) { 41 | subject = new StepVerboseView(step); 42 | 43 | var tester = function() { 44 | stdoutSpy.unmute(); 45 | stdoutSpy.printed().should.not.match(regexp); 46 | }; 47 | 48 | stdoutSpy.mute(); 49 | step.test().then(tester, tester).done(done, done); 50 | } 51 | 52 | 53 | describe('functional step', function() { 54 | describe('arbitrary action report', function() { 55 | var ACTION = 'testAction'; 56 | 57 | describe('successful action', function() { 58 | before(function() { 59 | step = new Watai.steps.FunctionalStep(function testAction() { 60 | /* do nothing */ 61 | }); 62 | }); 63 | 64 | it('should mention name of a successful action', function(done) { 65 | testShouldContain(ACTION, done); 66 | }); 67 | }); 68 | 69 | describe('failing action', function() { 70 | var REASON = 'Boom!'; 71 | 72 | before(function() { 73 | step = new Watai.steps.FunctionalStep(function testAction() { 74 | throw REASON; 75 | }); 76 | }); 77 | 78 | it('should mention action name', function(done) { 79 | testShouldContain(ACTION, done); 80 | }); 81 | 82 | it('should mention thrown value', function(done) { 83 | testShouldContain(REASON, done); 84 | }); 85 | }); 86 | }); 87 | 88 | describe('component action report', function() { 89 | var ACTION, 90 | PARAM; 91 | 92 | beforeEach(function() { 93 | if (typeof PARAM != 'undefined') 94 | step = new Watai.steps.FunctionalStep(testComponent[ACTION](PARAM)); 95 | else 96 | step = new Watai.steps.FunctionalStep(testComponent[ACTION]()); 97 | }); 98 | 99 | describe('with a simple action name', function() { 100 | before(function() { 101 | ACTION = 'submit'; 102 | PARAM = 'test'; 103 | }); 104 | 105 | it('should mention the name of the action', function(done) { 106 | testShouldContain(ACTION, done); 107 | }); 108 | }); 109 | 110 | describe('empty action parameters', function() { 111 | before(function() { 112 | ACTION = 'submit'; 113 | PARAM = ''; 114 | }); 115 | 116 | it('should be mentioned', function(done) { 117 | testShouldContain('""', done); 118 | }); 119 | }); 120 | 121 | describe('non-empty action parameters', function() { 122 | before(function() { 123 | ACTION = 'submit'; 124 | PARAM = 'test'; 125 | }); 126 | 127 | it('should be mentioned', function(done) { 128 | testShouldContain(PARAM, done); 129 | }); 130 | }); 131 | 132 | describe('with a multi-word action name', function() { 133 | before(function() { 134 | ACTION = 'beClever'; 135 | PARAM = undefined; 136 | }); 137 | 138 | var DESCRIPTION = 'do something very clever'; // this is the function name, humanized 139 | 140 | it('should mention the user-provided action name', function(done) { 141 | testShouldContain(DESCRIPTION, done); 142 | }); 143 | 144 | it('should mention the key at which the action is available', function(done) { 145 | testShouldContain(ACTION, done); 146 | }); 147 | }); 148 | 149 | describe('with a magic action', function() { 150 | before(function() { 151 | ACTION = 'changeTextareaValueNow'; 152 | PARAM = undefined; 153 | }); 154 | 155 | var DESCRIPTION = 'change textarea value now'; 156 | 157 | it('should mention the human-readable action', function(done) { 158 | testShouldContain(DESCRIPTION, done); 159 | }); 160 | 161 | it('should mention the generated action name', function(done) { 162 | subject = new StepVerboseView(step); 163 | 164 | stdoutSpy.mute(); 165 | step.test().then(function() { 166 | stdoutSpy.unmute(); 167 | stdoutSpy.printed().should.containEql(ACTION); 168 | stdoutSpy.printed().should.not.match(/link/i); // ensure the action name does not contain the original element name 169 | }).done(done, done); 170 | }); 171 | }); 172 | }); 173 | }); 174 | 175 | describe('text matching step', function() { 176 | var VALUE; 177 | 178 | beforeEach(function() { 179 | step = new Watai.steps.StateStep({ 180 | 'TestComponent.id': VALUE 181 | }, { 182 | TestComponent: testComponent 183 | }); 184 | }); 185 | 186 | describe('with an empty value', function() { 187 | before(function() { 188 | VALUE = ''; 189 | }); 190 | 191 | it('should mention the element name', function(done) { 192 | testShouldContain('id', done); 193 | }); 194 | 195 | it('should mention the expected value', function(done) { 196 | testShouldContain('empty string', done); 197 | }); 198 | }); 199 | 200 | describe('with a matching value', function() { 201 | before(function() { 202 | VALUE = TestComponent.expectedContents.id; 203 | }); 204 | 205 | it('should mention the matched value', function(done) { 206 | testShouldContain(VALUE, done); 207 | }); 208 | 209 | it('should not mention that the "value" matcher fails', function(done) { 210 | testShouldNotMatch(/Error|null/, done); 211 | }); 212 | }); 213 | 214 | describe('with a non-matching value', function() { 215 | before(function() { 216 | VALUE = 'herpaderp'; 217 | }); 218 | 219 | it('should mention the expected value', function(done) { 220 | testShouldContain(VALUE, done); 221 | }); 222 | 223 | it('should mention the actual value', function(done) { 224 | testShouldContain(TestComponent.expectedContents.id, done); 225 | }); 226 | 227 | it('should not mention that the "value" matcher fails', function(done) { 228 | testShouldNotMatch(/Error|null/, done); 229 | }); 230 | }); 231 | }); 232 | }); 233 | -------------------------------------------------------------------------------- /test/unit/view/ViewsTest.js: -------------------------------------------------------------------------------- 1 | var EventEmitter = require('events').EventEmitter; 2 | 3 | 4 | var Watai = require('../helpers/subject'), 5 | ConfigLoader = require('mattisg.configloader'), 6 | stdoutSpy = require('../helpers/StdoutSpy'); 7 | 8 | 9 | /** The views to test. 10 | */ 11 | var views = [ 12 | 'Runner/CLI', 13 | 'Runner/Dots', 14 | 'Runner/Verbose', 15 | 'Runner/Instafail' 16 | ], 17 | /** Event names mapped to the parameters expected by the event. 18 | */ 19 | events = [ 'start', 'ready' ]; 20 | 21 | 22 | 23 | /** This test suite is written with [Mocha](http://visionmedia.github.com/mocha/) and [Should](https://github.com/visionmedia/should.js). 24 | */ 25 | describe('Views', function() { 26 | var emitter; 27 | 28 | before(function(done) { 29 | var config = new ConfigLoader({ 30 | from : __dirname, 31 | appName : 'watai', 32 | override : { quit: 'always' }, 33 | visitAlso : '../../src' 34 | }).load('config'); 35 | 36 | this.timeout(config.browserWarmupTime); 37 | 38 | emitter = new Watai.Runner(config); 39 | emitter.test().done(function() { 40 | done(); 41 | }, function(err) { 42 | console.log('Additional error information:', err.data); 43 | done(err); 44 | }); 45 | }); 46 | 47 | views.forEach(function(view) { 48 | var subject; 49 | 50 | describe(view, function() { 51 | it('should exist', function() { 52 | (function() { 53 | var SubjectClass = require(Watai.path + '/view/' + view); 54 | subject = new SubjectClass(emitter); 55 | }).should.not.throw(); 56 | }); 57 | 58 | events.each(function(eventName) { 59 | describe(eventName + ' event', function() { 60 | before(function() { 61 | stdoutSpy.reset(); 62 | }); 63 | 64 | it('should output something', function() { 65 | stdoutSpy.mute(); 66 | emitter.emit(eventName); 67 | stdoutSpy.unmute(); 68 | stdoutSpy.called().should.be.true; 69 | }); 70 | 71 | after(function() { 72 | stdoutSpy.unmute(); // keep it in the `after` handler just in case something goes wrong in the test 73 | }); 74 | }); 75 | }); 76 | }); 77 | }); 78 | }); 79 | --------------------------------------------------------------------------------