├── .github └── workflows │ └── build.yml ├── .gitignore ├── LICENSE ├── README.MD ├── features ├── google-search.feature ├── orca-scan-demo.feature ├── page-objects │ ├── google-search.js │ └── orca-scan.js ├── shared-objects │ └── test-data.js └── step-definitions │ ├── google-search-steps.js │ └── orca-scan-demo-steps.js ├── img └── cucumber-html-report.png ├── index.js ├── package-lock.json ├── package.json └── runtime ├── helpers.js ├── imageCompare.js ├── network-speed.js └── world.js /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | # Controls when the action will run. Triggers the workflow on push or pull request 4 | # events but only for the master branch 5 | on: 6 | push: 7 | branches: [ main ] 8 | pull_request: 9 | branches: [ main ] 10 | 11 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 12 | jobs: 13 | # This workflow contains a single job called "build" 14 | build: 15 | # The type of runner that the job will run on 16 | runs-on: ubuntu-latest 17 | 18 | # Steps represent a sequence of tasks that will be executed as part of the job 19 | steps: 20 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 21 | - uses: actions/checkout@v2 22 | - uses: actions/setup-node@v2-beta 23 | with: 24 | node-version: '16.20.1' 25 | - run: npm install 26 | # Disable AppArmor 27 | - run: echo 0 | sudo tee /proc/sys/kernel/apparmor_restrict_unprivileged_userns 28 | - run: npm test 29 | env: 30 | PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium-browser 31 | PUPPETEER_ARGS: --no-sandbox 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | node_modules 3 | .idea/ 4 | junit 5 | .DS_Store 6 | features/reports 7 | artifacts 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Orca Scan 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 | # [puppeteer-cucumber-js](https://github.com/orca-scan/puppeteer-cucumber-js) 2 | 3 | [![Build](https://github.com/orca-scan/puppeteer-cucumber-js/workflows/Build/badge.svg)](https://github.com/orca-scan/puppeteer-cucumber-js/actions?query=workflow%3ABuild) 4 | [![node-current](https://img.shields.io/node/v/puppeteer-cucumber-js)](https://nodejs.org/en/) 5 | [![License: MIT](https://img.shields.io/badge/License-ISC-informational.svg)](https://opensource.org/licenses/ISC) 6 | [![Puppeteer API](https://img.shields.io/badge/Puppeteer-docs-40b5a4)](https://pptr.dev/) 7 | 8 | Browser Automation framework using [puppeteer](https://github.com/puppeteer/puppeteer "view puppeteer documentation") and [cucumber-js](https://github.com/cucumber/cucumber-js "view cucumber js documentation"). 9 | 10 | Works with [Chrome](https://www.google.com/intl/en_uk/chrome/), [Firefox](https://www.mozilla.org/en-GB/firefox/new/), [Microsoft Edge](https://www.microsoft.com/en-us/edge) and [Brave](https://brave.com/download/). 11 | 12 | **Table of Contents** 13 | 14 | * [Installation](#installation) 15 | * [Usage](#usage) 16 | * [Options](#options) 17 | * [Browser teardown strategy](#browser-teardown-strategy) 18 | * [Directory structure](#directory-structure) 19 | * [Feature files](#feature-files) 20 | * [Step definitions](#step-definitions) 21 | * [Page objects](#page-objects) 22 | * [Shared objects](#shared-objects) 23 | * [Helpers](#helpers) 24 | * [Before/After hooks](#beforeafter-hooks) 25 | * [Visual Regression](#visual-regression) 26 | * [Reports](#reports) 27 | * [How to debug](#how-to-debug) 28 | * [Demo](#demo) 29 | * [Bugs](#bugs) 30 | * [Contributing](#contributing) 31 | * [License](#license) 32 | 33 | ## Installation 34 | 35 | ```bash 36 | npm install puppeteer-cucumber-js 37 | ``` 38 | 39 | ## Usage 40 | 41 | ```bash 42 | node ./node_modules/puppeteer-cucumber-js/index.js # path to the module within your project 43 | ``` 44 | 45 | ### Options 46 | 47 | ```bash 48 | --tags <@tagname> # cucumber @tag name to run 49 | --featureFiles # comma-separated list of feature files or path to directory 50 | --browser # browser to use (chrome, firefox, edge, brave). default chrome 51 | --browserPath # optional path to a browser executable 52 | --browser-teardown # browser cleanup after each scenario (always, clear, none). default always 53 | --headless # run browser in headless mode. defaults to false 54 | --devTools # open dev tools with each page. default false 55 | --noScreenshot # disable auto capturing of screenshots with errors 56 | --disableLaunchReport # disable auto opening the browser with test report 57 | --timeOut # steps definition timeout in milliseconds. defaults 10 seconds 58 | --worldParameters # JSON object to pass to cucumber-js world constructor 59 | --version # outputs puppeteer-cucumber-js version number 60 | --help # list puppeteer-cucumber-js options 61 | --failFast # abort the run on first failure 62 | --slowMo # specified amount of milliseconds to slow down Puppeteer operations by. defaults to 10 ms 63 | --networkSpeed # simulate network speed (gprs, 2g, 3g, 4g, dsl, wifi). default off 64 | ``` 65 | 66 | ### Browser teardown strategy 67 | 68 | The browser automatically closes after each scenario to ensure the next scenario uses a fresh browser environment. You can change this behavior using the `--browser-teardown` switch, options are: 69 | 70 | Value | Description 71 | ---------- | --------------- 72 | `always` | the browser automatically closes (default) 73 | `clear` | the browser automatically clears cookies, local and session storages 74 | `none` | the browser does nothing 75 | 76 | ### Directory structure 77 | 78 | Your files must live in a `features` folder within the root of your project: 79 | 80 | ```bash 81 | . 82 | └── features 83 |    ├── google-search.feature 84 |    └── step-definitions 85 |    │ └── google-search-steps.js 86 |    ├── page-objects 87 |    │   └── google-search.js 88 |    ├── shared-objects 89 |    │   └── test-data.js 90 |    └── reports # folder and content automatically created when tests run 91 |       ├── cucumber-report.html 92 |       ├── cucumber-report.json 93 |       └── junit-report.xml 94 | ``` 95 | 96 | ### Feature files 97 | 98 | A [Feature file](/features/google-search.feature) is a Business Readable file that lets you describe software behavior without detailing how that behavior is implemented. Feature files are written using the [Gherkin syntax](https://github.com/cucumber/cucumber/wiki/Gherkin). 99 | 100 | ```gherkin 101 | Feature: Searching for a barcode scanner app 102 | 103 | Scenario: Google search for barcode scanner app 104 | Given I am online at google.co.uk 105 | When I search Google for "barcode scanner app" 106 | Then I should see "Orca Scan" in the results 107 | 108 | Scenario: Google search for Orca Scan 109 | Given I am online at google.co.uk 110 | When I search Google for "Orca Scan" 111 | Then I should see "Orca Scan" in the results 112 | ``` 113 | 114 | ### Step definitions 115 | 116 | [Step definitions](features/step-definitions/google-search-steps.js) act as the glue between features files and the actual system under test. To avoid confusion **always** return a JavaScript promise from step definitions to let cucumber know when your task has completed. 117 | 118 | ```javascript 119 | this.Given(/^I am online at google.co.uk/, function() { 120 | 121 | // use the ./page-objects/google-search.js url property 122 | return helpers.loadPage(pageObjects.googleSearch.url); 123 | }); 124 | 125 | this.When(/^I search Google for "([^"]*)"$/, function (searchQuery) { 126 | 127 | // execute ./page-objects/google-search.js preformSearch method 128 | return pageObjects.googleSearch.preformSearch(searchQuery); 129 | }); 130 | 131 | this.Then(/^I should see "([^"]*)" in the results$/, function (keywords) { 132 | 133 | // resolves if an item on the page contains text 134 | return helpers.waitForLinkText(keywords, false, 30); 135 | }); 136 | ``` 137 | 138 | The following variables are available within the ```Given()```, ```When()``` and ```Then()``` functions: 139 | 140 | Variable | Description 141 | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ 142 | `helpers` | a collection of [helper methods](runtime/helpers.js) _things puppeteer does not provide but maybe should_ 143 | `puppeteer` | the raw [puppeteer](https://github.com/puppeteer/puppeteer) object 144 | `browser` | instance of the puppeteer [browser](https://pptr.dev/#?product=Puppeteer&version=v5.5.0&show=api-class-browser) object 145 | `page` | instance of the puppeteer [page](https://pptr.dev/#?product=Puppeteer&version=v5.5.0&show=api-class-page) object 146 | `pageObjects` | collection of page objects loaded from disk and keyed by filename 147 | `shared` | collection of **shared** objects loaded from disk and keyed by filename 148 | `trace` | handy trace method to log console output with increased visibility 149 | `assert` | instance of [chai assert](http://chaijs.com/api/assert/) to ```assert.isOk('everything', 'everything is ok')``` 150 | `expect` | instance of [chai expect](http://chaijs.com/api/bdd/) to ```expect('something').to.equal('something')``` 151 | 152 | ### Page objects 153 | 154 | [Page objects](/features/page-objects/google-search.js) allow you to define information about a specific page in one place such as selector, methods etc. 155 | These objects are accessible from within your step definition files and help to reduce code duplication. Should your page change, you can fix your tests by modifying the selectors in one location. 156 | 157 | You can access page object properties and methods via a global ```pageObject``` variable. Page objects are loaded from ```./features/page-objects``` folder and are exposed as a camel-cased version of their filename, for example ```./page-objects/google-search.js``` becomes ```pageObjects.googleSearch```. You can also use subdirectories, for example ```./page-objects/dir/google-search.js``` becomes ```pageObjects.dir.googleSearch```. 158 | 159 | Page objects also have access to the same runtime variables available to step definitions. 160 | 161 | An example page object: 162 | 163 | ```javascript 164 | let image; 165 | module.exports = { 166 | 167 | url: 'http://www.google.co.uk', 168 | 169 | selectors: { 170 | searchInput: '[name="q"]', 171 | searchResultLink: 'a > h3 > span', 172 | cookieIFrame: 'iframe[src*="consent.google.com"]', 173 | cookieAgreeButton: '#introAgreeButton > span > span' 174 | }, 175 | 176 | /** 177 | * enters a search term into Google's search box and presses enter 178 | * @param {string} searchQuery - phrase to search google with 179 | * @returns {Promise} a promise to enter the search values 180 | */ 181 | preformSearch: async function (searchQuery) { 182 | image = searchQuery; 183 | // get the selector above (pageObjects.googleSearch is this object) 184 | var selector = pageObjects.googleSearch.selectors.searchInput; 185 | await helpers.takeImage(`${image}_1-0.png`); 186 | 187 | // accept Googles `Before you continue` cookie dialog 188 | await helpers.clickElementWithinFrame(pageObjects.googleSearch.selectors.cookieIFrame, pageObjects.googleSearch.selectors.cookieAgreeButton); 189 | 190 | // set focus to the search box 191 | await page.focus(selector); 192 | 193 | // enter the search query 194 | await page.keyboard.type(searchQuery, { delay: 100 }); 195 | 196 | // press enter 197 | await helpers.compareImage(`${image}_1-0.png`); 198 | return page.keyboard.press('Enter'); 199 | } 200 | }; 201 | ``` 202 | 203 | ### Shared objects 204 | 205 | [Shared objects](/features/shared-objects/test-data.js) allow you to share anything from test data to helper methods throughout your project via a global ```sharedObjects``` object. Shared objects are automatically loaded from ```./features/shared-objects/``` and made available via a camel-cased version of their filename, for example ```./features/shared-objects/test-data.js``` becomes ```sharedObjects.testData```. You can also use subdirectories, for example ```./features/shared-objects/dir/test-data.js``` becomes ```sharedObjects.dir.testData```. 206 | 207 | Shared objects also have access to the same runtime variables available to step definitions. 208 | 209 | An example shared object: 210 | 211 | ```javascript 212 | module.exports = { 213 | username: "import-test-user", 214 | password: "import-test-pa**word" 215 | } 216 | ``` 217 | 218 | And its usage within a step definition: 219 | 220 | ```js 221 | module.exports = function () { 222 | 223 | this.Given(/^I am logged in"$/, function () { 224 | 225 | // set focus to username 226 | await page.focus('#username'); 227 | 228 | // type username 229 | await page.keyboard.type(sharedObjects.testData.username); 230 | 231 | // set focus to password 232 | await page.focus('#password'); 233 | 234 | // type password 235 | await page.keyboard.type(sharedObjects.testData.password); 236 | 237 | // press enter (submit form) 238 | return page.keyboard.press('Enter'); 239 | }); 240 | }; 241 | ``` 242 | 243 | ### Helpers 244 | 245 | [Helpers](/runtime/helpers.js) are globally defined helper methods that simplify working with puppeteer: 246 | 247 | ```js 248 | // Load a URL, returning only when all network activity has finished 249 | helpers.loadPage('http://www.google.com'); 250 | 251 | // Open a URL in a new tab or switch to the tab that already has it open and 252 | // set it's instance as the global page variable. 253 | helpers.openPage('http://www.yahoo.com'); 254 | 255 | // Removes an element from the dom 256 | helpers.removeElement('p > span'); 257 | 258 | // Waits for text to appear on the page 259 | helpers.waitForLinkText('Orca Scan', false, 30); 260 | 261 | // Waits for the browser to fire an event (including custom events) 262 | helpers.waitForEvent('app-ready'); 263 | 264 | // Gets an element within an iframe 265 | helpers.getElementWithinFrame('iframe[src*="consent.google.com"]', '#introAgreeButton > span > span'); 266 | 267 | // Clicks an element within an iframe 268 | helpers.clickElementWithinFrame('iframe[src*="consent.google.com"]', '#introAgreeButton > span > span'); 269 | 270 | // Removes all browser cookies 271 | helpers.clearCookies(); 272 | 273 | // Clears localStorage 274 | helpers.clearLocalStorage(); 275 | 276 | // Clears sessionStorage 277 | helpers.clearSessionStorage(); 278 | 279 | // Clears cookies and storage 280 | helpers.clearCookiesAndStorages(); 281 | 282 | // Stop the browser in debug mode (must have DevTools open) 283 | helpers.debug() 284 | 285 | // take image for comparisson 286 | helpers.takeImage('image_1-0.png', ['dynamic elements to hide']); 287 | 288 | // compare taken image with baseline image 289 | helpers.compareImage('image_1-0.png'); 290 | ``` 291 | 292 | ### Before/After hooks 293 | 294 | You can register before and after handlers for features and scenarios: 295 | 296 | | Event | Example 297 | | -------------- | ------------------------------------------------------------ 298 | | BeforeFeature | ```this.BeforeFeatures(function(feature, callback) {})``` 299 | | AfterFeature | ```this.AfterFeature(function(feature, callback) {});``` 300 | | BeforeScenario | ```this.BeforeScenario(function(scenario, callback) {});``` 301 | | AfterScenario | ```this.AfterScenario(function(scenario, callback) {});``` 302 | 303 | ```js 304 | module.exports = function () { 305 | 306 | // add a before feature hook 307 | this.BeforeFeature(function(feature, done) { 308 | console.log('BeforeFeature: ' + feature.getName()); 309 | done(); 310 | }); 311 | 312 | // add an after feature hook 313 | this.AfterFeature(function(feature, done) { 314 | console.log('AfterFeature: ' + feature.getName()); 315 | done(); 316 | }); 317 | 318 | // add before scenario hook 319 | this.BeforeScenario(function(scenario, done) { 320 | console.log('BeforeScenario: ' + scenario.getName()); 321 | done(); 322 | }); 323 | 324 | // add after scenario hook 325 | this.AfterScenario(function(scenario, done) { 326 | console.log('AfterScenario: ' + scenario.getName()); 327 | done(); 328 | }); 329 | }; 330 | ``` 331 | 332 | ### Visual Regression 333 | 334 | Visual regression testing, the ability to compare a whole page screenshots or of specific parts of the application / page under test. 335 | If there is dynamic content (i.e. a clock), hide this element by passing the selector (or an array of selectors, comma separated) to the takeImage function. 336 | ```js 337 | // usage within page-object file: 338 | await helpers.takeImage(fileName, [elementsToHide, elementsToHide]); 339 | await helpers.waitForTimeout(100); 340 | await helpers.compareImage(fileName); 341 | ``` 342 | 343 | ### Reports 344 | 345 | HTML, JSON and JUnit reports are auto generated with each test run and stored in `./features/reports/`: 346 | 347 | ![Cucumber HTML report](img/cucumber-html-report.png) 348 | 349 | ### How to debug 350 | 351 | To step into debug mode in the browser, enable dev tools `--devTools` and use `helpers.debug()` within your steps: 352 | 353 | ```js 354 | module.exports = function () { 355 | 356 | this.When(/^I search Google for "([^"]*)"$/, async function (searchQuery, done) { 357 | 358 | // Stop the browser in debug mode 359 | helpers.debug(); 360 | }); 361 | }; 362 | ``` 363 | 364 | ## Demo 365 | 366 | To demo the framework without installing in your project use the following commands: 367 | 368 | ```bash 369 | # download this example code 370 | git clone https://github.com/orca-scan/puppeteer-cucumber-js.git 371 | 372 | # go into the new directory 373 | cd puppeteer-cucumber-js 374 | 375 | # install dependencies 376 | npm install 377 | 378 | # run the google search feature 379 | node index 380 | ``` 381 | 382 | ## Bugs 383 | 384 | Please provide as much info as possible _(ideally a code snippet)_ when [raising a bug](https://github.com/orca-scan/puppeteer-cucumber-js/issues) 385 | 386 | ## Contributing 387 | 388 | PRs welcome 🤓 389 | 390 | ## License 391 | 392 | Licensed under ISC License © Orca Scan, the Barcode Scanner app for iOS and Android. 393 | -------------------------------------------------------------------------------- /features/google-search.feature: -------------------------------------------------------------------------------- 1 | Feature: Searching for a barcode scanner app 2 | Scenario: Google search for Orca Scan 3 | Given I am online at "https://www.google.co.uk/" 4 | When I search Google for "Orca Scan" 5 | Then I should see "Orca Scan" in the results 6 | Then I should go back one page 7 | When I am online at "https://www.google.com/" 8 | When I search Google for "Orca Scan." 9 | Then I should see "Orca Scan" in the results 10 | Then I should go back one page 11 | When I am online at "https://www.google.co.uk/" 12 | When I search Google for "Barcode Tracking, Simplified" 13 | Then I should see "Orca Scan" in the results 14 | Then I should go back one page 15 | When I am online at "https://www.google.com/" 16 | When I search Google for "Barcode Tracking, Simplified." 17 | Then I should see "Orca Scan" in the results 18 | -------------------------------------------------------------------------------- /features/orca-scan-demo.feature: -------------------------------------------------------------------------------- 1 | Feature: Book and Orca Scan demo 2 | A user should be able to book a product demo 3 | 4 | Scenario: Book an Orca Scan demo 5 | Given I am on the Orca Scan barcode tracking website 6 | When I click the Book a demo button 7 | Then I should be able to book a demo -------------------------------------------------------------------------------- /features/page-objects/google-search.js: -------------------------------------------------------------------------------- 1 | let image; 2 | module.exports = { 3 | 4 | url: 'http://www.google.co.uk/', 5 | 6 | selectors: { 7 | searchInput: '[name="q"]', 8 | searchResultLink: 'a > h3 > span', 9 | cookieIFrame: 'iframe[src*="consent.google.com"]', 10 | cookieAgreeButton: '#introAgreeButton > span > span' 11 | }, 12 | 13 | /** 14 | * enters a search term into Google's search box and presses enter 15 | * @param {string} searchQuery - phrase to search google with 16 | * @returns {Promise} a promise to enter the search values 17 | */ 18 | preformSearch: async function (searchQuery) { 19 | image = searchQuery; 20 | // get the selector above (pageObjects.googleSearch is this object) 21 | const selector = pageObjects.googleSearch.selectors.searchInput; 22 | await helpers.takeImage(`${image}_1-0.png`); 23 | 24 | // set focus to the search box 25 | await page.focus(selector); 26 | 27 | // enter the search query 28 | await page.keyboard.type(searchQuery, { delay: 100 }); 29 | 30 | // press enter 31 | await helpers.compareImage(`${image}_1-0.png`); 32 | return page.keyboard.press('Enter'); 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /features/page-objects/orca-scan.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | url: 'https://orcascan.com', 4 | 5 | selectors: { 6 | bookADemoButton: 'a[href^="/book-a-demo"]', 7 | calendlyIFrame: 'iframe[src*="calendly.com"]', 8 | calendlyIframeDemoButton: 'a[href*="orca-scan/demo"]' 9 | }, 10 | 11 | bookADemo: async function(eventName) { 12 | 13 | var element = await helpers.getElementWithinFrame(pageObjects.orcaScan.selectors.calendlyIFrame, pageObjects.orcaScan.selectors.calendlyIframeDemoButton); 14 | 15 | var text = await page.evaluate(function(el) { 16 | return el.textContent; 17 | }, element); 18 | 19 | return Promise.resolve(text.indexOf(eventName) > -1); 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /features/shared-objects/test-data.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | username: 'import-test-user', 3 | password: 'import-test-pa**word' 4 | }; 5 | -------------------------------------------------------------------------------- /features/step-definitions/google-search-steps.js: -------------------------------------------------------------------------------- 1 | module.exports = function () { 2 | 3 | this.Given(/^I am online at "([^"]*)"/, function (url) { 4 | return helpers.openPage(url); 5 | }); 6 | 7 | this.When(/^I search Google for "([^"]*)"$/, function (searchQuery) { 8 | 9 | // execute ./page-objects/google-search.js preformSearch method 10 | return pageObjects.googleSearch.preformSearch(searchQuery); 11 | }); 12 | 13 | this.Then(/^I should see "([^"]*)" in the results$/, function (keywords) { 14 | 15 | // resolves if an item on the page contains text 16 | return helpers.waitForLinkText(keywords, false, 30); 17 | }); 18 | 19 | this.Then(/^I should go back one page$/, function () { 20 | return page.goBack(); 21 | }); 22 | }; 23 | -------------------------------------------------------------------------------- /features/step-definitions/orca-scan-demo-steps.js: -------------------------------------------------------------------------------- 1 | module.exports = function () { 2 | 3 | this.Given(/^I am on the Orca Scan barcode tracking website/, function() { 4 | return helpers.loadPage(pageObjects.orcaScan.url); 5 | }); 6 | 7 | this.When(/^I click the Book a demo button$/, async function () { 8 | 9 | // click the book a demo button 10 | await page.click(pageObjects.orcaScan.selectors.bookADemoButton); 11 | 12 | // wait for calendly iframe to appear 13 | await page.waitForSelector(pageObjects.orcaScan.selectors.calendlyIFrame); 14 | }); 15 | 16 | this.Then(/^I should be able to book a demo$/, function () { 17 | 18 | // check the calendly booking frame appears 19 | return page.$(pageObjects.orcaScan.selectors.calendlyIFrame); 20 | }); 21 | }; 22 | -------------------------------------------------------------------------------- /img/cucumber-html-report.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orca-scan/puppeteer-cucumber-js/1e455ad181605980d14d27cbd8fe9132a703eb5f/img/cucumber-html-report.png -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var fs = require('fs-plus'); 4 | var path = require('path'); 5 | var program = require('commander'); 6 | var pjson = require('./package.json'); 7 | var cucumber = require('cucumber'); 8 | var chalk = require('chalk'); 9 | var helpers = require('./runtime/helpers.js'); 10 | var merge = require('merge'); 11 | var requireDir = require('require-dir'); 12 | var textFilesLoader = require('text-files-loader'); 13 | var networkSpeeds = require('./runtime/network-speed.js'); 14 | 15 | var config = { 16 | featureFiles: './features', 17 | steps: './features/step-definitions', 18 | pageObjects: './features/page-objects', 19 | sharedObjects: './features/shared-objects', 20 | reports: './features/reports', 21 | browser: 'chrome', 22 | browserTeardownStrategy: 'always', 23 | timeout: 15000, 24 | headless: false, 25 | devTools: false, 26 | slowMo: 10 27 | }; 28 | 29 | folderCheck(); 30 | 31 | // global defaults (before cli commands) 32 | global.browserName = 'chrome'; 33 | global.browserPath = ''; 34 | global.browserTeardownStrategy = config.browserTeardownStrategy; 35 | global.headless = config.headless; 36 | global.devTools = config.devTools; 37 | global.userAgent = ''; 38 | global.disableLaunchReport = false; 39 | global.noScreenshot = false; 40 | global.slowMo = config.slowMo; 41 | 42 | program 43 | .version(pjson.version) 44 | .description(pjson.description) 45 | .option('--tags <@tagname>', 'cucumber @tag name to run', collectPaths, []) 46 | .option('--featureFiles ', 'comma-separated list of feature files or path to directory. defaults to ' + config.featureFiles, config.featureFiles) 47 | .option('--browser ', 'name of browser to use (chrome, firefox, edge, brave). default ' + config.browser, config.browser) 48 | .option('--browserPath ', 'optional path to a browser executable') 49 | .option('--browser-teardown ', 'browser teardown after each scenario (always, clear, none). defaults ' + config.browserTeardownStrategy, config.browserTeardownStrategy) 50 | .option('--headless', 'whether to run browser in headless mode. defaults to true unless the devtools option is true', config.headless) 51 | .option('--devTools', 'auto-open a DevTools. if true headless mode is disabled.', config.devTools) 52 | .option('--noScreenshot', 'disable auto capturing of screenshots when an error is encountered') 53 | .option('--disableLaunchReport', 'Disables the auto opening the browser with test report') 54 | .option('--timeOut ', 'steps definition timeout in milliseconds. defaults to ' + config.timeout, coerceInt, config.timeout) 55 | .option('--worldParameters ', 'JSON object to pass to cucumber-js world constructor. defaults to empty', config.worldParameters) 56 | .option('--userAgent ', 'user agent string') 57 | .option('--failFast', 'abort the run on first failure') 58 | .option('--slowMo ', 'specified amount of milliseconds to slow down Puppeteer operations by. Defaults to ' + config.slowMo) 59 | .option('--networkSpeed ', 'simulate connection speeds, options are: gprs, 2g, 3g, 4g, dsl, wifi. Defaults is unset (full speed)') 60 | .option('--updateBaselineImage', 'automatically update the baseline image after a failed comparison') 61 | .parse(process.argv); 62 | 63 | program.on('--help', function () { 64 | console.log(' For more details please visit https://github.com/orca-scan/puppeteer-cucumber-js#readme\n'); 65 | }); 66 | 67 | // name of the browser to use for the test 68 | global.browserName = program.browser || global.browserName; 69 | 70 | // if user specified a browser path, check it exists first 71 | if (program.browserPath) { 72 | if (fs.existsSync(program.browserPath)) { 73 | global.browserPath = program.browserPath; 74 | } 75 | else { 76 | throw new Error('browserPath not found'); 77 | } 78 | } 79 | 80 | // if user specified brave, attempt to find the path 81 | if (program.browser === 'brave' && global.browserPath === '') { 82 | var bravePath = findBrave(); 83 | if (bravePath) { 84 | global.browserPath = bravePath; 85 | global.browserName = ''; 86 | } 87 | else { 88 | throw new Error('Brave Browser not found, check your installation or provide the path using --browserPath'); 89 | } 90 | } 91 | 92 | // how should the browser clean up 93 | global.browserTeardownStrategy = program.browserTeardown || global.browserTeardownStrategy; 94 | 95 | // should the browser be headless? 96 | global.headless = program.headless; 97 | 98 | // pass dev tools option 99 | global.devTools = program.devTools; 100 | 101 | // pass user agent if set (remove wrapped quotes) 102 | global.userAgent = String(program.userAgent || '').replace(/(^"|"$)/g, ''); 103 | 104 | // used within world.js to import page objects 105 | var pageObjectPath = path.resolve(config.pageObjects); 106 | 107 | // load page objects (after global vars have been created) 108 | if (fs.existsSync(pageObjectPath)) { 109 | 110 | // require all page objects using camel case as object names 111 | global.pageObjects = requireDir(pageObjectPath, { camelcase: true, recurse: true }); 112 | } 113 | 114 | // used within world.js to output reports 115 | global.reportsPath = path.resolve(config.reports); 116 | if (!fs.existsSync(config.reports)) { 117 | fs.makeTreeSync(config.reports); 118 | } 119 | 120 | // used within world.js to decide if reports should be generated 121 | global.disableLaunchReport = (program.disableLaunchReport); 122 | 123 | // used with world.js to determine if a screenshot should be captured on error 124 | global.noScreenshot = (program.noScreenshot); 125 | 126 | // set the default timeout if not passed via the command line 127 | global.DEFAULT_TIMEOUT = program.timeOut || config.timeout; 128 | 129 | // set the default slowMo if not passed via the command line 130 | global.DEFAULT_SLOW_MO = program.slowMo || config.slowMo; 131 | 132 | // set network speed if present 133 | if (program.networkSpeed) { 134 | 135 | // if network speed is defined, use it 136 | if (networkSpeeds[program.networkSpeed]) { 137 | global.networkSpeed = networkSpeeds[program.networkSpeed]; 138 | } 139 | // otherwise throw an error 140 | else { 141 | throw new Error('Invalid --networkSpeed, options are ' + Object.keys(networkSpeeds)); 142 | } 143 | } 144 | 145 | // used within world.js to import shared objects into the shared namespace 146 | var sharedObjectsPath = path.resolve(config.sharedObjects); 147 | 148 | // import shared objects 149 | if (fs.existsSync(sharedObjectsPath)) { 150 | 151 | var allDirs = {}; 152 | var dir = requireDir(sharedObjectsPath, { camelcase: true, recurse: true }); 153 | 154 | merge(allDirs, dir); 155 | 156 | // if we managed to import some directories, expose them 157 | if (Object.keys(allDirs).length > 0) { 158 | 159 | // expose globally 160 | global.sharedObjects = allDirs; 161 | } 162 | } 163 | 164 | // add helpers 165 | global.helpers = helpers; 166 | 167 | // rewrite command line switches for cucumber 168 | process.argv.splice(2, 100); 169 | 170 | // allow specific feature files to be executed 171 | if (program.featureFiles) { 172 | var splitFeatureFiles = program.featureFiles.split(','); 173 | 174 | splitFeatureFiles.forEach(function (feature) { 175 | process.argv.push(feature); 176 | }); 177 | } 178 | 179 | // add switch to tell cucumber to produce json report files 180 | process.argv.push('-f'); 181 | process.argv.push('pretty'); 182 | process.argv.push('-f'); 183 | process.argv.push('json:' + path.resolve(__dirname, global.reportsPath, 'cucumber-report.json')); 184 | 185 | // add cucumber world as first required script (this sets up the globals) 186 | process.argv.push('-r'); 187 | process.argv.push(path.resolve(__dirname, 'runtime/world.js')); 188 | 189 | // add path to import step definitions 190 | process.argv.push('-r'); 191 | process.argv.push(path.resolve(config.steps)); 192 | 193 | if (program.failFast) { 194 | process.argv.push('--fail-fast'); 195 | } 196 | 197 | // add tag(s) 198 | if (program.tags) { 199 | 200 | // get all tags from feature files 201 | var tagsFound = getFeaturesFileTags(); 202 | 203 | // go through each tag user passed in 204 | program.tags.forEach(function (tag) { 205 | 206 | // if tag does not start with `@` exit with error 207 | if (tag[0] !== '@') { 208 | logErrorToConsole('tags must start with a @'); 209 | process.exit(); 210 | } 211 | 212 | // if tag does not exist in feature files, error 213 | if (tagsFound.indexOf(tag) === -1) { 214 | logErrorToConsole(`tag ${tag} not found`); 215 | process.exit(); 216 | } 217 | }); 218 | 219 | program.tags.forEach(function (tag) { 220 | process.argv.push('-t'); 221 | process.argv.push(tag); 222 | }); 223 | } 224 | 225 | if (program.worldParameters) { 226 | process.argv.push('--world-parameters'); 227 | process.argv.push(program.worldParameters); 228 | } 229 | 230 | // add strict option (fail if there are any undefined or pending steps) 231 | process.argv.push('-S'); 232 | 233 | // // abort the run on first failure 234 | // process.argv.push('--fail-fast'); 235 | 236 | // 237 | // execute cucumber 238 | // 239 | var cucumberCli = new cucumber.Cli(process.argv); 240 | 241 | global.cucumber = cucumber; 242 | 243 | cucumberCli.run(function (succeeded) { 244 | 245 | var code = succeeded ? 0 : 1; 246 | 247 | function exitNow() { 248 | process.exit(code); 249 | } 250 | 251 | if (process.stdout.write('')) { 252 | exitNow(); 253 | } 254 | else { 255 | // write() returned false, kernel buffer is not empty yet... 256 | process.stdout.on('drain', exitNow); 257 | } 258 | }); 259 | 260 | /** 261 | * Check if required folders exist 262 | * @return {void} 263 | */ 264 | function folderCheck() { 265 | 266 | if (!fs.existsSync(config.featureFiles)) { 267 | logErrorToConsole(config.featureFiles + ' folder not found'); 268 | process.exit(); 269 | } 270 | 271 | if (!fs.existsSync(config.steps)) { 272 | logErrorToConsole(config.steps + ' folder not found'); 273 | process.exit(); 274 | } 275 | 276 | if (!fs.existsSync(config.pageObjects)) { 277 | logErrorToConsole(config.pageObjects + ' folder not found'); 278 | process.exit(); 279 | } 280 | } 281 | 282 | /** 283 | * Writes an error message to the console in red 284 | * @param {string} message - error message 285 | * @returns {void} 286 | */ 287 | function logErrorToConsole(message) { 288 | console.log(chalk.red('Error: ' + message || '')); 289 | } 290 | 291 | function findBrave() { 292 | 293 | var locations = [ 294 | '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser', 295 | 'C:\\Program Files (x86)\\BraveSoftware\\Brave-Browser\\Application\\brave.exe' 296 | ]; 297 | 298 | return locations.find(function(item) { 299 | return fs.existsSync(item); 300 | }); 301 | } 302 | 303 | function collectPaths(value, paths) { 304 | paths.push(value); 305 | return paths; 306 | } 307 | 308 | function coerceInt(value, defaultValue) { 309 | 310 | var int = parseInt(value, 10); 311 | 312 | if (typeof int === 'number') return int; 313 | 314 | return defaultValue; 315 | } 316 | 317 | /** 318 | * Get tags from feature files 319 | * @returns {Array} list of all tags found 320 | */ 321 | function getFeaturesFileTags() { 322 | 323 | // load all feature files into memory 324 | textFilesLoader.setup({ matchRegExp: /\.feature/ }); 325 | var featureFiles = textFilesLoader.loadSync(path.resolve(config.featureFiles)); 326 | 327 | var result = []; 328 | 329 | // go through each one and extract @tags 330 | Object.keys(featureFiles).forEach(function(key) { 331 | 332 | var content = String(featureFiles[key] || ''); 333 | 334 | result = result.concat(content.match(new RegExp('@[a-z0-9]+', 'g'))); 335 | }); 336 | 337 | return result; 338 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "puppeteer-cucumber-js", 3 | "version": "2.0.1", 4 | "description": "Browser Automation framework using puppeteer and cucumber-js", 5 | "main": "index.js", 6 | "bin": { 7 | "puppeteer-cucumber-js": "./index.js" 8 | }, 9 | "scripts": { 10 | "_test": "node index.js --headless --tags @ci --userAgent \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36\"", 11 | "test": "node index.js --headless --featureFiles ./features/orca-scan-demo.feature", 12 | "_postinstall": "PUPPETEER_PRODUCT=firefox node ./node_modules/puppeteer/install.js", 13 | "start": "node index.js", 14 | "dev": "node index.js --tags @search" 15 | }, 16 | "engines": { 17 | "node": "16.20.1", 18 | "npm": "8.19.4" 19 | }, 20 | "engineStrict": true, 21 | "author": "https://orcascan.com", 22 | "license": "ISC", 23 | "keywords": [ 24 | "puppeteer", 25 | "cucumber", 26 | "cucumber-js", 27 | "test", 28 | "bdd" 29 | ], 30 | "repository": { 31 | "type": "git", 32 | "url": "git://github.com/orca-scan/puppeteer-cucumber-js.git" 33 | }, 34 | "bugs": { 35 | "url": "https://github.com/orca-scan/puppeteer-cucumber-js/issues" 36 | }, 37 | "homepage": "https://github.com/orca-scan/puppeteer-cucumber-js#readme", 38 | "dependencies": { 39 | "chai": "3.5.0", 40 | "chalk": "1.1.3", 41 | "commander": "2.9.0", 42 | "cucumber": "1.3.3", 43 | "cucumber-html-reporter": "4.0.4", 44 | "cucumber-junit": "1.6.0", 45 | "edge-paths": "^2.1.0", 46 | "fs-extra": "^9.1.0", 47 | "fs-plus": "2.9.1", 48 | "merge": "^1.2.1", 49 | "node-resemble-js": "^0.2.0", 50 | "pixelmatch": "^5.2.1", 51 | "puppeteer": "^22.8.2", 52 | "require-dir": "0.3.2", 53 | "text-files-loader": "^1.0.5" 54 | }, 55 | "devDependencies": { 56 | "eslint": "^3.19.0", 57 | "eslint-config-airbnb-base": "^11.2.0", 58 | "eslint-plugin-import": "^2.2.0" 59 | }, 60 | "eslintConfig": { 61 | "extends": "airbnb-base", 62 | "env": { 63 | "es6": false, 64 | "browser": true 65 | }, 66 | "globals": { 67 | "browserPath": true, 68 | "userAgent": true, 69 | "devTools": true, 70 | "headless": true, 71 | "helpers": true, 72 | "browserTeardownStrategy": true, 73 | "page": true, 74 | "pageObjects": true, 75 | "browser": true, 76 | "expect": true, 77 | "Promise": true, 78 | "browserName": true, 79 | "DEFAULT_TIMEOUT": true 80 | }, 81 | "rules": { 82 | "brace-style": [ 83 | "error", 84 | "stroustrup" 85 | ], 86 | "comma-dangle": [ 87 | "error", 88 | "never" 89 | ], 90 | "func-names": 0, 91 | "indent": [ 92 | "error", 93 | 4, 94 | { 95 | "SwitchCase": 1 96 | } 97 | ], 98 | "max-len": [ 99 | 2, 100 | 180, 101 | 4, 102 | { 103 | "ignoreUrls": true, 104 | "ignoreComments": false 105 | } 106 | ], 107 | "new-cap": [ 108 | "error", 109 | { 110 | "capIsNewExceptions": [ 111 | "Router", 112 | "ObjectId", 113 | "DEBUG" 114 | ], 115 | "properties": false 116 | } 117 | ], 118 | "no-underscore-dangle": 0, 119 | "no-unused-vars": [ 120 | "warn" 121 | ], 122 | "no-use-before-define": [ 123 | "error", 124 | { 125 | "functions": false 126 | } 127 | ], 128 | "no-var": [ 129 | "off" 130 | ], 131 | "one-var": [ 132 | "off" 133 | ], 134 | "vars-on-top": [ 135 | "off" 136 | ], 137 | "no-param-reassign": [ 138 | "off" 139 | ], 140 | "no-lone-blocks": [ 141 | "off" 142 | ], 143 | "padded-blocks": 0, 144 | "prefer-template": [ 145 | "off" 146 | ], 147 | "prefer-arrow-callback": [ 148 | "off" 149 | ], 150 | "default-case": [ 151 | "off" 152 | ], 153 | "wrap-iife": [ 154 | 2, 155 | "inside" 156 | ], 157 | "no-plusplus": [ 158 | "off" 159 | ], 160 | "require-jsdoc": [ 161 | "warn", 162 | { 163 | "require": { 164 | "FunctionDeclaration": true, 165 | "MethodDefinition": true, 166 | "ClassDeclaration": true 167 | } 168 | } 169 | ], 170 | "object-shorthand": [ 171 | "error", 172 | "never" 173 | ], 174 | "space-before-function-paren": "off", 175 | "strict": "off", 176 | "valid-jsdoc": [ 177 | "error" 178 | ] 179 | } 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /runtime/helpers.js: -------------------------------------------------------------------------------- 1 | var urlParser = require('url'); 2 | const fs = require('fs-extra'); 3 | const PNG = require('pngjs').PNG; 4 | const pixelmatch = require('pixelmatch'); 5 | 6 | module.exports = { 7 | 8 | /** 9 | * returns a promise that is called when the url has loaded and the body element is present 10 | * @param {string} url - url to load 11 | * @param {integer} waitInSeconds - number of seconds to wait for page to load 12 | * @returns {Promise} resolved when url has loaded otherwise rejects 13 | * @example 14 | * await helpers.loadPage('http://www.google.com'); 15 | */ 16 | loadPage: function(url, waitInSeconds) { 17 | 18 | // use either passed in timeout or global default 19 | var timeout = (waitInSeconds) ? (waitInSeconds * 1000) : DEFAULT_TIMEOUT; 20 | 21 | // load the url and wait for all requests to end 22 | return page.goto(url, { timeout: timeout, waitUntil: 'load' }); 23 | }, 24 | 25 | /** 26 | * Opens URL in a new tab and set it as the current page, if the URL is 27 | * already open in another tab in the current browser then we will use 28 | * that instead of opening a new one. 29 | * @param {string} url - url to open (or find in existing tab) 30 | * @param {object} options - puppeteer page.goto options 31 | * @returns {Promise} resolves once new sheet open or focused 32 | */ 33 | openPage: async function (url, options) { 34 | 35 | // URLs returned from Chrome always have a trailing / 36 | // So convert incoming URL to match (converts urlParser.parse('https://api.com?23234234').format() to 'https://api.com/?23234234') 37 | url = urlParser.parse(url).format(); 38 | 39 | const pages = await browser.pages(); 40 | page = pages.find(page => page.url() === url); 41 | 42 | /** 43 | * We've not been able to find the page in current browser instance, 44 | * so lets open one and navigate to the url. 45 | */ 46 | if (page === undefined) { 47 | page = await browser.newPage(); 48 | await page.goto(url, { 49 | timeout: DEFAULT_TIMEOUT, 50 | waitUntil: 'load', 51 | ...options 52 | }); 53 | } 54 | 55 | /** 56 | * Set the global page and bring to front. 57 | */ 58 | page.bringToFront(); 59 | }, 60 | 61 | /** 62 | * Removes an element from the dom 63 | * @param {string} selector - query selector 64 | * @returns {Promise} resolves when complete 65 | * @example 66 | * await helpers.removeElement('p > span'); 67 | */ 68 | removeElement: function(selector) { 69 | 70 | return page.evaluate(function(elementSelector) { 71 | var el = document.querySelector(elementSelector); 72 | if (el) el.remove(); 73 | }, selector); 74 | }, 75 | 76 | /** 77 | * Waits for text to appear on the page 78 | * @param {string} text - text to find 79 | * @param {boolean} exact - exact match if true, otherwise partial 80 | * @param {integer} waitInSeconds - number of seconds to wait before giving up 81 | * @returns {Promise} resolves when complete 82 | * @example 83 | * await helpers.waitForLinkText('Orca Scan', false, 30); 84 | */ 85 | waitForLinkText: function(text, exact, waitInSeconds) { 86 | 87 | // use either passed in timeout or global default 88 | var timeout = (waitInSeconds) ? (waitInSeconds * 1000) : DEFAULT_TIMEOUT; 89 | 90 | return page.waitForFunction(function(textToFind, exactMatch) { 91 | 92 | return Array.prototype.slice.call(document.querySelectorAll('a')).some(function(link) { 93 | 94 | if (exactMatch) { 95 | return link.textContent === textToFind; 96 | } 97 | 98 | return link.textContent.indexOf(textToFind) > -1; 99 | }); 100 | 101 | }, { timeout: timeout }, text, exact); 102 | }, 103 | 104 | /** 105 | * Wait for the browser to fire an event (including custom events) 106 | * @param {string} eventName - Event name 107 | * @param {integer} waitInSeconds - number of seconds to wait 108 | * @returns {Promise} resolves when event fires or timeout is reached 109 | * @example 110 | * await helpers.waitForEvent('app-ready'); 111 | */ 112 | waitForEvent: async function(eventName, waitInSeconds) { 113 | 114 | var self = this; 115 | // use either passed in timeout or global default 116 | var timeout = (waitInSeconds) ? (waitInSeconds * 1000) : DEFAULT_TIMEOUT; 117 | 118 | // use race to implement a timeout 119 | return Promise.race([ 120 | 121 | // add event listener and wait for event to fire before returning 122 | page.evaluate(function(eventName) { 123 | return new Promise(function(resolve, reject) { 124 | document.addEventListener(eventName, function(e) { 125 | resolve(); // resolves when the event fires 126 | }); 127 | }); 128 | }, eventName), 129 | 130 | // if the event does not fire within n seconds, exit 131 | self.waitForTimeout(timeout) 132 | ]); 133 | }, 134 | 135 | /** 136 | * Gets an element within an iframe 137 | * @param {string} frameSelector - query selector for the iframe element 138 | * @param {string} childSelector - query selector for the child within the iframe 139 | * @returns {Promise} returns element if found otherwise rejects with an error 140 | * @example 141 | * await helpers.getElementWithinFrame('iframe[src*="consent.google.com"]', '#introAgreeButton > span > span'); 142 | */ 143 | getElementWithinFrame: async function(frameSelector, childSelector) { 144 | 145 | var elementHandle = await page.$(frameSelector); 146 | 147 | if (elementHandle) { 148 | var frame = await elementHandle.contentFrame(); 149 | 150 | if (frame) { 151 | // as this is an iframe, wait for it to load by waiting for the selector to appear 152 | await frame.waitForSelector(childSelector); 153 | 154 | return frame.$(childSelector); 155 | } 156 | } 157 | 158 | return Promise.reject('frame not found'); 159 | }, 160 | 161 | /** 162 | * Clicks an element within an iframe 163 | * @param {string} frameSelector - query selector for the iframe element 164 | * @param {string} childSelector - query selector for the child within the iframe 165 | * @returns {Promise} resolves when done otherwise throws an error 166 | * @example 167 | * await helpers.clickElementWithinFrame('iframe[src*="consent.google.com"]', '#introAgreeButton > span > span'); 168 | */ 169 | clickElementWithinFrame: async function(frameSelector, childSelector) { 170 | 171 | var el = await helpers.getElementWithinFrame(frameSelector, childSelector); 172 | 173 | if (el) { 174 | return el.click(); 175 | } 176 | 177 | return Promise.reject('element not found'); 178 | }, 179 | 180 | /** 181 | * Removes all browser cookies 182 | * @returns {Promise} resolves once done 183 | * @example 184 | * await helpers.clearCookies(); 185 | */ 186 | clearCookies: function() { 187 | 188 | return page.evaluate(function() { 189 | var cookies = document.cookie.split(';'); 190 | for (var i = 0; i < cookies.length; i++) { 191 | var cookie = cookies[i]; 192 | var eqPos = cookie.indexOf('='); 193 | var name = eqPos > -1 ? cookie.substr(0, eqPos) : cookie; 194 | document.cookie = name + '=;' + 195 | 'expires=Thu, 01-Jan-1970 00:00:01 GMT;' + 196 | 'path=/;' + 197 | 'domain=' + window.location.host + ';' + 198 | 'secure=;'; 199 | } 200 | }); 201 | }, 202 | 203 | /** 204 | * Clears localStorage 205 | * @returns {Promise} resolves once done 206 | * @example 207 | * await helpers.clearLocalStorage(); 208 | */ 209 | clearLocalStorage: function() { 210 | return page.evaluate(function() { 211 | window.localStorage.clear(); 212 | }); 213 | }, 214 | 215 | /** 216 | * Clears sessionStorage 217 | * @returns {Promise} resolves once done 218 | * @example 219 | * await helpers.clearSessionStorage(); 220 | */ 221 | clearSessionStorage: function() { 222 | return page.evaluate(function() { 223 | window.sessionStorage.clear(); 224 | }); 225 | }, 226 | 227 | /** 228 | * Clears cookies and storage 229 | * @returns {Promise} resolves once done 230 | * @example 231 | * await helpers.clearCookiesAndStorages(); 232 | */ 233 | clearCookiesAndStorages: async function() { 234 | await helpers.clearCookies(); 235 | await helpers.clearLocalStorage(); 236 | await helpers.clearSessionStorage(); 237 | }, 238 | 239 | /** 240 | * Stop the browser in debug mode 241 | * @returns {Promise} resolves once done 242 | * @example 243 | * await helpers.debug(); 244 | */ 245 | debug: function() { 246 | 247 | if (devTools === true) { 248 | return page.evaluate('debugger'); 249 | } 250 | return Promise.reject(new Error('DevTools must be enabled to use helpers.debug(). Enable DevTools using the -devTools switch')); 251 | }, 252 | 253 | /** 254 | * Visual comparison function 255 | * @param fileName 256 | * @returns {Promise} 257 | */ 258 | compareImage: async (fileName) => { 259 | const verify = require('./imageCompare'); 260 | await verify.assertion(fileName); 261 | await verify.value(); 262 | await verify.pass(); 263 | }, 264 | 265 | /** 266 | * @param fileName 267 | * @param elementsToHide 268 | * @returns {Promise} 269 | */ 270 | takeImage: async (fileName, elementsToHide) => { 271 | const verify = require('./imageCompare'); 272 | await verify.takeScreenshot(fileName, elementsToHide); 273 | }, 274 | 275 | /** 276 | * hideElemements hide elements 277 | * @param selectors 278 | */ 279 | hideElements: async (selectors) => { 280 | selectors = typeof selectors === 'string' ? [selectors] : selectors; 281 | for (let i = 0; i < selectors.length; i++) { 282 | const script = `document.querySelectorAll('${selectors[i]}').forEach(element => element.style.opacity = '0')`; 283 | await browser.execute(script); 284 | } 285 | }, 286 | 287 | /** 288 | * showElemements show elements 289 | * @param selectors 290 | */ 291 | showElements: async (selectors) => { 292 | selectors = typeof selectors === 'string' ? [selectors] : selectors; 293 | for (let i = 0; i < selectors.length; i++) { 294 | const script = `document.querySelectorAll('${selectors[i]}').forEach(element => element.style.opacity = '1')`; 295 | await browser.execute(script); 296 | } 297 | }, 298 | 299 | /** 300 | * Returns number of pixels that are different between two images 301 | * @param {string} fileName1 - name of the first file 302 | * @param {string} fileName2 - name of the second file 303 | * @returns 304 | */ 305 | numDiffPixels: async (fileName1, fileName2) => { 306 | const img1 = PNG.sync.read(fs.readFileSync('./artifacts/visual-regression/original/chrome/positive/' + fileName1 + '.png')); 307 | const img2 = PNG.sync.read(fs.readFileSync('./artifacts/visual-regression/original/chrome/positive/' + fileName2 + '.png')); 308 | const { width, height } = img1; 309 | const diff = new PNG({ width, height }); 310 | const numDiffPixels = pixelmatch(img1.data, img2.data, diff.data, width, height, { threshold: 0.1 }); 311 | return numDiffPixels; 312 | }, 313 | 314 | /** 315 | * Wait for a timeout 316 | * @param {integer} timeout - number of seconds to wait 317 | * @returns {Promise} resolves when done 318 | */ 319 | waitForTimeout: async function (timeout) { 320 | return new Promise(function (r) { 321 | setTimeout(r, timeout); 322 | }); 323 | } 324 | 325 | }; 326 | -------------------------------------------------------------------------------- /runtime/imageCompare.js: -------------------------------------------------------------------------------- 1 | const resemble = require('node-resemble-js'); 2 | const fs = require('fs-extra'); 3 | const program = require('commander'); 4 | 5 | const baselineDir = `./visual-regression-baseline/${browserName}/`; 6 | const resultDir = `./artifacts/visual-regression/original/${browserName}/`; 7 | const resultDirPositive = `${resultDir}positive/`; 8 | const resultDirNegative = `${resultDir}negative/`; 9 | const diffDir = `./artifacts/visual-regression/diffs/${browserName}/`; 10 | const diffDirPositive = `${diffDir}positive/`; 11 | const diffDirNegative = `${diffDir}negative/`; 12 | 13 | let fileName; 14 | let diffFile; 15 | 16 | module.exports = { 17 | /** 18 | * Take an image of the current page and saves it as the given filename. 19 | * @method saveScreenshot 20 | * @param {string} filename The complete path to the file name where the image should be saved. 21 | * @param elementsToHide 22 | * @param filename 23 | * @returns {Promise} 24 | */ 25 | takeScreenshot: async (filename, elementsToHide) => { 26 | if (elementsToHide) { 27 | await helpers.hideElements(elementsToHide); 28 | } 29 | fs.ensureDirSync(resultDirPositive); // Make sure destination folder exists, if not, create it 30 | const resultPathPositive = `${resultDirPositive}${filename}`; 31 | await page.screenshot({ 32 | path: resultPathPositive, 33 | type: 'png', 34 | fullPage: true, 35 | }); 36 | if (elementsToHide) { 37 | await helpers.showElements(elementsToHide); 38 | } 39 | console.log(`\t images saved to: ${resultPathPositive}`); 40 | }, 41 | 42 | /** 43 | * Runs assertions and comparison checks on the taken images 44 | * @param filename 45 | * @param expected 46 | * @param result 47 | * @param value 48 | * @returns {Promise} 49 | */ 50 | assertion: function(filename, expected, result, value) { 51 | fileName = filename; 52 | const baselinePath = `${baselineDir}${filename}`; 53 | const resultPathPositive = `${resultDirPositive}${filename}`; 54 | fs.ensureDirSync(baselineDir); // Make sure destination folder exists, if not, create it 55 | fs.ensureDirSync(diffDirPositive); // Make sure destination folder exists, if not, create it 56 | this.expected = expected || 0.1; // misMatchPercentage tolerance default 0.3% 57 | if (!fs.existsSync(baselinePath)) { 58 | // create new baseline image if none exists 59 | console.log('\t WARNING: Baseline image does NOT exist.'); 60 | console.log(`\t Creating Baseline image from Result: ${baselinePath}`); 61 | fs.writeFileSync(baselinePath, fs.readFileSync(resultPathPositive)); 62 | } 63 | resemble.outputSettings({ 64 | errorColor: { 65 | red: 225, 66 | green: 0, 67 | blue: 225 68 | }, 69 | errorType: 'movement', 70 | transparency: 0.1, 71 | largeImageThreshold: 1200 72 | }); 73 | resemble(baselinePath) 74 | .compareTo(resultPathPositive) 75 | .ignoreAntialiasing() 76 | .ignoreColors() 77 | .onComplete(async (res) => { 78 | result = await res; 79 | }); 80 | /** 81 | * @returns {Promise} 82 | */ 83 | this.value = async function () { 84 | filename = await fileName; 85 | const resultPathNegative = `${resultDirNegative}${filename}`; 86 | const resultPathPositive = `${resultDirPositive}${filename}`; 87 | while (typeof result === 'undefined') { 88 | await helpers.waitForTimeout(100); 89 | } 90 | const error = parseFloat(result.misMatchPercentage); // value this.pass is called with 91 | fs.ensureDirSync(diffDirNegative); // Make sure destination folder exists, if not, create it 92 | 93 | if (error > this.expected) { 94 | diffFile = `${diffDirNegative}${filename}`; 95 | 96 | const writeStream = fs.createWriteStream(diffFile); 97 | await result.getDiffImage().pack().pipe(writeStream); 98 | writeStream.on('error', (err) => { 99 | console.log('this is the writeStream error ', err); 100 | }); 101 | fs.ensureDirSync(resultDirNegative); // Make sure destination folder exists, if not, create it 102 | fs.removeSync(resultPathNegative); 103 | fs.moveSync(resultPathPositive, resultPathNegative); 104 | console.log(`\t Create diff image [negative]: ${diffFile}`); 105 | } 106 | else { 107 | diffFile = `${diffDirPositive}${filename}`; 108 | 109 | const writeStream = fs.createWriteStream(diffFile); 110 | result.getDiffImage().pack().pipe(writeStream); 111 | writeStream.on('error', (err) => { 112 | console.log('this is the writeStream error ', err); 113 | }); 114 | } 115 | }; 116 | /** 117 | * @returns {Promise} 118 | */ 119 | this.pass = async function () { 120 | value = parseFloat(result.misMatchPercentage); 121 | this.message = `image Match Failed for ${filename} with a tolerance difference of ${`${ 122 | value - this.expected 123 | } - expected: ${this.expected} but got: ${value}`}`; 124 | const baselinePath = `${baselineDir}${filename}`; 125 | const resultPathNegative = `${resultDirNegative}${filename}`; 126 | const pass = value <= this.expected; 127 | const err = value > this.expected; 128 | 129 | if (pass) { 130 | console.log(`image Match for ${filename} with ${value}% difference.`); 131 | await helpers.waitForTimeout(1000); 132 | } 133 | 134 | if (err === true && program.updateBaselineImages) { 135 | console.log( 136 | `${this.message} images at:\n` + 137 | ` Baseline: ${baselinePath}\n` + 138 | ` Result: ${resultPathNegative}\n` + 139 | ` cp ${resultPathNegative} ${baselinePath}` 140 | ); 141 | await fs.copy(resultPathNegative, baselinePath, (err) => { 142 | console.log(` All Baseline images have now been updated from: ${resultPathNegative}`); 143 | if (err) { 144 | log.error('The Baseline images were NOT updated: ', err.message); 145 | throw err; 146 | } 147 | }); 148 | } 149 | else if (err) { 150 | console.log( 151 | `${this.message} images at:\n` + 152 | ` Baseline: ${baselinePath}\n` + 153 | ` Result: ${resultPathNegative}\n` + 154 | ` Diff: ${diffFile}\n` + 155 | ` Open ${diffFile} to see how the image has changed.\n` + 156 | ' If the Resulting image is correct you can use it to update the Baseline image and re-run your test:\n' + 157 | ` cp ${resultPathNegative} ${baselinePath}` 158 | ); 159 | throw `${err} - ${this.message}`; 160 | } 161 | }; 162 | } 163 | }; 164 | -------------------------------------------------------------------------------- /runtime/network-speed.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | gprs: { 3 | offline: false, 4 | downloadThroughput: (50 * 1024) / 8, 5 | uploadThroughput: (20 * 1024) / 8, 6 | latency: 500 7 | }, 8 | '2g': { 9 | offline: false, 10 | downloadThroughput: (450 * 1024) / 8, 11 | uploadThroughput: (150 * 1024) / 8, 12 | latency: 150 13 | }, 14 | '3g': { 15 | offline: false, 16 | downloadThroughput: (1.5 * 1024 * 1024) / 8, 17 | uploadThroughput: (750 * 1024) / 8, 18 | latency: 40 19 | }, 20 | '4g': { 21 | offline: false, 22 | downloadThroughput: (4 * 1024 * 1024) / 8, 23 | uploadThroughput: (3 * 1024 * 1024) / 8, 24 | latency: 20 25 | }, 26 | dsl: { 27 | offline: false, 28 | downloadThroughput: (2 * 1024 * 1024) / 8, 29 | uploadThroughput: (1 * 1024 * 1024) / 8, 30 | latency: 5 31 | }, 32 | wifi: { 33 | offline: false, 34 | downloadThroughput: (30 * 1024 * 1024) / 8, 35 | uploadThroughput: (15 * 1024 * 1024) / 8, 36 | latency: 2 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /runtime/world.js: -------------------------------------------------------------------------------- 1 | /** 2 | * world.js is loaded by the cucumber framework before loading the step definitions and feature files 3 | * it is responsible for setting up and exposing the puppeteer/browser/page/assert etc required within each step definition 4 | */ 5 | 6 | var fs = require('fs-plus'); 7 | var path = require('path'); 8 | var chalk = require('chalk'); 9 | var puppeteer = require('puppeteer'); 10 | var expect = require('chai').expect; 11 | var assert = require('chai').assert; 12 | var reporter = require('cucumber-html-reporter'); 13 | var cucumberJunit = require('cucumber-junit'); 14 | var edgePaths = require('edge-paths'); 15 | var networkSpeeds = require('../runtime/network-speed.js'); 16 | 17 | var platform = process.platform; 18 | var edgePath = ''; 19 | 20 | try { 21 | edgePath = (platform === 'darwin' || platform === 'win32') ? edgePaths.getEdgePath() : ''; 22 | } 23 | catch (e) { 24 | console.log('Microsoft Edge not found'); 25 | } 26 | 27 | var browserWidth = 1024; 28 | var browserHeight = 768; 29 | 30 | /** 31 | * log output to the console in a readable/visible format 32 | * @returns {void} 33 | */ 34 | function trace() { 35 | var args = [].slice.call(arguments); 36 | var output = chalk.bgBlue.white('\n>>>>> \n' + args + '\n<<<<<\n'); 37 | 38 | console.log(output); 39 | } 40 | 41 | /** 42 | * Creates a list of variables to expose globally and therefore accessible within each step definition 43 | * @returns {void} 44 | */ 45 | function createWorld() { 46 | 47 | var runtime = { 48 | puppeteer: puppeteer, // the raw puppeteer object 49 | browser: null, // puppeteer browser object 50 | page: null, // puppeteer page object 51 | expect: expect, // expose chai expect to allow variable testing 52 | assert: assert, // expose chai assert to allow variable testing 53 | trace: trace // expose an info method to log output to the console in a readable/visible format 54 | }; 55 | 56 | // expose properties to step definition methods via global variables 57 | Object.keys(runtime).forEach(function (key) { 58 | if (key === 'driver' && browserTeardownStrategy !== 'always') { 59 | return; 60 | } 61 | 62 | // make property/method available as a global (no this. prefix required) 63 | global[key] = runtime[key]; 64 | }); 65 | } 66 | 67 | /** 68 | * Executes browser teardown strategy 69 | * @returns {Promise} resolves once teardown complete 70 | */ 71 | function teardownBrowser() { 72 | switch (browserTeardownStrategy) { 73 | case 'none': 74 | return Promise.resolve(); 75 | case 'clear': 76 | return helpers.clearCookiesAndStorages(); 77 | default: 78 | if (browser) { 79 | return browser.close(); 80 | } 81 | else { 82 | return Promise.resolve(); 83 | } 84 | } 85 | } 86 | 87 | // export the "World" required by cucumber to allow it to expose methods within step def's 88 | module.exports = async function () { 89 | 90 | createWorld(); 91 | 92 | // this.World must be set! 93 | this.World = createWorld; 94 | 95 | // set the default timeout for all tests 96 | this.setDefaultTimeout(global.DEFAULT_TIMEOUT); 97 | 98 | // create the browser before scenario if it's not instantiated 99 | this.registerHandler('BeforeScenario', async function () { 100 | 101 | if (!global.browser) { 102 | var browserOptions = { 103 | headless: headless === true, 104 | product: browserName || 'chrome', 105 | defaultViewport: null, 106 | devtools: devTools === true, 107 | slowMo: global.DEFAULT_SLOW_MO, // slow down by specified ms so we can view in headful mode 108 | args: [ 109 | `--window-size=${browserWidth},${browserHeight}` 110 | ] 111 | }; 112 | 113 | if (browserPath !== '') { 114 | delete browserOptions.product; 115 | browserOptions.executablePath = browserPath; 116 | } 117 | else if (browserName === 'edge') { 118 | delete browserOptions.product; 119 | browserOptions.executablePath = edgePath; 120 | } 121 | 122 | global.browser = await puppeteer.launch(browserOptions); 123 | } 124 | 125 | if (!global.page) { 126 | 127 | // chrome opens with exist tab 128 | var pages = await browser.pages(); 129 | 130 | // using first tab 131 | global.page = pages[0]; 132 | 133 | // throttle network if required 134 | if (global.networkSpeed) { 135 | 136 | // connect to dev tools 137 | var client = await page.target().createCDPSession(); 138 | 139 | // set throttling 140 | await client.send('Network.emulateNetworkConditions', global.networkSpeed); 141 | } 142 | 143 | // set user agent if present 144 | if (userAgent !== '') { 145 | await page.setUserAgent(userAgent); 146 | } 147 | } 148 | }); 149 | 150 | this.registerHandler('AfterFeatures', function (features, done) { 151 | 152 | var cucumberReportPath = path.resolve(global.reportsPath, 'cucumber-report.json'); 153 | 154 | if (global.reportsPath && fs.existsSync(global.reportsPath)) { 155 | 156 | // generate the HTML report 157 | var reportOptions = { 158 | theme: 'bootstrap', 159 | jsonFile: cucumberReportPath, 160 | output: path.resolve(global.reportsPath, 'cucumber-report.html'), 161 | reportSuiteAsScenarios: true, 162 | launchReport: (!global.disableLaunchReport), 163 | ignoreBadJsonFile: true 164 | }; 165 | 166 | reporter.generate(reportOptions); 167 | 168 | // grab the file data 169 | var reportRaw = fs.readFileSync(cucumberReportPath).toString().trim(); 170 | var xmlReport = cucumberJunit(reportRaw); 171 | var junitOutputPath = path.resolve(global.reportsPath, 'junit-report.xml'); 172 | 173 | fs.writeFileSync(junitOutputPath, xmlReport); 174 | } 175 | 176 | // teardownBrowser().then(done); 177 | teardownBrowser().then(done); 178 | }); 179 | 180 | // executed after each scenario (always closes the browser to ensure fresh tests) 181 | this.After(async function (scenario) { 182 | 183 | // if we have a page object and there is an error 184 | if (page && scenario.isFailed() && !global.noScreenshot) { 185 | 186 | // take a screenshot 187 | var screenshot = await page.screenshot({ encoding: 'base64', fullPage: true }); 188 | 189 | // add a screenshot to the error report 190 | scenario.attach(Buffer.from(screenshot, 'base64'), 'image/png'); 191 | } 192 | 193 | return teardownBrowser(); 194 | }); 195 | }; 196 | --------------------------------------------------------------------------------