├── .gitignore ├── .travis.yml ├── README.md ├── index.html ├── index.js ├── package.json └── tests ├── components.js ├── module.js ├── test-basics.js └── test-require.js /.gitignore: -------------------------------------------------------------------------------- 1 | .nyc_output 2 | coverage 3 | package-lock.json 4 | node_modules 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | cache: 3 | directories: 4 | - ~/.npm 5 | notifications: 6 | email: false 7 | node_js: 8 | - '8' 9 | after_success: 10 | - npm run travis-deploy-once "npm run semantic-release" 11 | branches: 12 | except: 13 | - /^v\d+\.\d+\.\d+$/ 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cappadonna 2 | 3 | Headless browser testing for tap with coverage reporting. 4 | 5 |

6 | 7 | 8 | 9 |

10 | 11 | Cappadonna merges several tools together for integrated testing. 12 | 13 | * [tap](http://www.node-tap.org/): as the base test framework. 14 | * [puppeteer](https://github.com/GoogleChrome/puppeteer): for headless browser access (Chrome). 15 | * [browserify](http://browserify.org/): for bundling. 16 | * [nyc/istanbul](https://istanbul.js.org/): for test coverage. 17 | 18 | Example: 19 | 20 | ```javascript 21 | const path = require('path') 22 | const cappadonna = require('cappadonna') 23 | const test = cappadonna(path.join(__dirname, 'bundle-entry-point.js')) 24 | 25 | test('basic test', async (page, t) => { 26 | /* we get a new webpage object with our bundle loaded for every test */ 27 | 28 | t.plan(1) 29 | let str = 'pass' 30 | 31 | /* append string to document.body and wait for the selector to succeed */ 32 | await page.appendAndWait(str, 'test-element') 33 | 34 | /* execute the given function in the browser */ 35 | await page.evaluate(() => { 36 | t.same('pass', document.querySelector('test-element').textContent) 37 | }) 38 | }) 39 | ``` 40 | ``` 41 | $ tap tests/test-*.js --coverage 42 | ``` 43 | 44 | When coverage is enabled all code, including what gets bundled and sent to the browser, will be instrumented and included in coverage. 45 | 46 | The `test` function and `t` variable are part of [tap](http://www.node-tap.org) and document [here](http://www.node-tap.org/asserts/). 47 | 48 | The `page` object is part of [puppeteer](https://github.com/GoogleChrome/puppeteer) and documented [here](https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#class-page). 49 | 50 | ## page.appendAndWait(htmlString, selector) 51 | 52 | Appends the htmlString to the page's body and waits for the selector to 53 | return true. 54 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /* globals cappadonna_proxy_t, cv_proxy_add */ 2 | const fs = require('fs') 3 | const browserify = require('browserify') 4 | const istanbul = require('browserify-istanbul') 5 | const puppeteer = require('puppeteer') 6 | const bl = require('bl') 7 | const path = require('path') 8 | const tap = require('tap') 9 | const {createHash} = require('crypto') 10 | const test = tap.test 11 | 12 | const COVERAGE_FOLDER = path.join(process.cwd(), '.nyc_output') 13 | 14 | /* istanbul ignore if */ 15 | if (global.__coverage__ && !fs.existsSync(COVERAGE_FOLDER)) { 16 | throw new Error('Coverage is enabled by {cwd}/.nyc_output does not exist') 17 | } 18 | 19 | const outputCoverage = (page) => { 20 | return new Promise(async (resolve, reject) => { 21 | const dumpCoverage = (payload) => { 22 | const cov = JSON.parse(payload) 23 | fs.writeFileSync( 24 | path.resolve(COVERAGE_FOLDER, `${Date.now()}.json`), 25 | JSON.stringify(cov, null, 2), 26 | 'utf8' 27 | ) 28 | return resolve() 29 | } 30 | await page.exposeFunction('dumpCoverage', (payload) => { 31 | dumpCoverage(payload) 32 | }) 33 | await page.evaluate(async () => { 34 | dumpCoverage(JSON.stringify(window.__coverage__)) 35 | }) 36 | }) 37 | } 38 | 39 | const index = path.join(__dirname, 'index.html') 40 | 41 | /* istanbul ignore next */ 42 | module.exports = (entryPoint, opts = {}) => { 43 | const browser = puppeteer.launch({args: ['--no-sandbox']}) 44 | 45 | const bundle = new Promise((resolve, reject) => { 46 | var b = browserify() 47 | /* istanbul ignore else */ 48 | if (global.__coverage__) { 49 | b.transform(istanbul, opts.istanbul) 50 | } 51 | 52 | if (opts.require) { 53 | b.require(entryPoint, opts.require) 54 | } else { 55 | b.add(entryPoint) 56 | } 57 | 58 | b.bundle().pipe(bl((err, buff) => { 59 | /* istanbul ignore next */ 60 | if (err) return reject(err) 61 | resolve(buff.toString()) 62 | })) 63 | }) 64 | 65 | let testCounter = 0 66 | 67 | let _test = (name, fn) => { 68 | testCounter++ 69 | return test(name, async t => { 70 | const _browser = await browser 71 | const page = await _browser.newPage() 72 | 73 | // we use a file url here so that the default page gets loaded in a secure context. 74 | // required to test apis like https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto 75 | // http://www.chromium.org/Home/chromium-security/security-faq#TOC-Which-origins-are-secure- 76 | 77 | await page.goto(opts.location || 'file://' + index) 78 | 79 | /* istanbul ignore next */ 80 | page.on('console', msg => console.log(msg.text())) 81 | /* istanbul ignore next */ 82 | page.on('error', err => { throw err }) 83 | /* istanbul ignore next */ 84 | page.on('pageerror', msg => { throw new Error(`Page Error: ${msg}`) }) 85 | 86 | await page.evaluate(() => { 87 | window.addEventListener('unhandledrejection', event => { 88 | /* istanbul ignore next */ 89 | let msg = event.reason.stack || event.reason.message 90 | /* istanbul ignore next */ 91 | console.error('[unhandledrejection]', msg) 92 | }) 93 | }) 94 | 95 | const code = await bundle 96 | await page.addScriptTag({content: code}) 97 | 98 | /* istanbul ignore else */ 99 | if (global.__coverage__) { 100 | if (!opts.require) { 101 | await page.evaluate(() => { 102 | /* istanbul ignore if */ 103 | if (!window.__coverage__) { 104 | let msg = 'Coverage is enabled but is missing from your bundle.' 105 | throw new Error(msg) 106 | } 107 | }) 108 | } else if (code.indexOf('__coverage__') === -1) { 109 | throw new Error('Coverage is enabled but is missing from your bundle.') 110 | } 111 | 112 | const cvobjects = {} 113 | Object.keys(global.__coverage__).forEach(filename => { 114 | const hash = createHash('sha1') 115 | hash.update(filename) 116 | const key = parseInt(hash.digest('hex').substr(0, 12), 16).toString(36) 117 | cvobjects[key] = global.__coverage__[filename] 118 | }) 119 | await page.exposeFunction('cv_proxy_add', async arr => { 120 | arr = JSON.parse(arr) 121 | let obj = cvobjects 122 | while (arr.length > 1) { 123 | obj = obj[arr.shift()] 124 | } 125 | obj[arr.shift()]++ 126 | }) 127 | await page.evaluate(keys => { 128 | const createProxy = parents => { 129 | return new Proxy({}, { 130 | get: (target, name) => 0, 131 | set: (obj, prop, value) => { 132 | const arr = parents.concat([prop]) 133 | cv_proxy_add(JSON.stringify(arr)) 134 | return true 135 | } 136 | }) 137 | } 138 | keys.forEach(key => { 139 | window[`cov_${key}`] = { 140 | f: createProxy([key, 'f']), 141 | s: createProxy([key, 's']), 142 | b: createProxy([key, 'b']) 143 | } 144 | }) 145 | }, Object.keys(cvobjects)) 146 | } 147 | 148 | await page.exposeFunction('cappadonna_proxy_t', async args => { 149 | args = JSON.parse(args) 150 | let key = args.shift() 151 | return t[key](...args) 152 | }) 153 | await page.evaluate(() => { 154 | window._t_promises = [] 155 | let handler = { 156 | get: (target, prop) => { 157 | return (...args) => { 158 | args = [prop].concat(args) 159 | let p = cappadonna_proxy_t(JSON.stringify(args)) 160 | window._t_promises.push(p) 161 | return p 162 | } 163 | } 164 | } 165 | window.t = new Proxy({}, handler) 166 | }) 167 | 168 | page.appendAndWait = async (inner, selector) => { 169 | await page.evaluate(inner => { 170 | document.body.innerHTML += inner 171 | }, inner) 172 | await page.waitFor(selector) 173 | return true 174 | } 175 | 176 | await fn(page, t, _browser) 177 | 178 | await page.evaluate(async () => { 179 | await Promise.all(window._t_promises) 180 | }) 181 | 182 | /* istanbul ignore else */ 183 | if (global.__coverage__) { 184 | await outputCoverage(page) 185 | } 186 | 187 | await page.close() 188 | testCounter-- 189 | if (testCounter === 0) { 190 | _browser.close() 191 | } 192 | }) 193 | } 194 | 195 | _test.tap = tap 196 | _test.bundle = bundle 197 | 198 | return _test 199 | } 200 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cappadonna", 3 | "version": "0.0.0-development", 4 | "description": "Headless browser testing for tap with coverage reporting.", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "tap tests/test-*.js --100", 8 | "commit": "git-cz", 9 | "posttest": "standard", 10 | "precommit": "npm test", 11 | "prepush": "npm test", 12 | "commitmsg": "validate-commit-msg", 13 | "travis-deploy-once": "travis-deploy-once", 14 | "semantic-release": "semantic-release" 15 | }, 16 | "keywords": [], 17 | "author": "Mikeal Rogers (http://www.mikealrogers.com)", 18 | "license": "Apache-2.0", 19 | "devDependencies": { 20 | "codecov": "^3.0.0", 21 | "cz-conventional-changelog": "^2.1.0", 22 | "husky": "^0.14.3", 23 | "semantic-release": "^12.4.1", 24 | "standard": "^10.0.3", 25 | "travis-deploy-once": "^4.3.4", 26 | "validate-commit-msg": "^2.14.0" 27 | }, 28 | "dependencies": { 29 | "bl": "^1.2.1", 30 | "browserify": "^15.0.0", 31 | "browserify-istanbul": "^3.0.1", 32 | "puppeteer": "^1.2.0", 33 | "tap": "^11.1.0" 34 | }, 35 | "repository": { 36 | "type": "git", 37 | "url": "https://github.com/mikeal/cappadonna.git" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/components.js: -------------------------------------------------------------------------------- 1 | window.capaTest = () => { 2 | return 'pass' 3 | } 4 | -------------------------------------------------------------------------------- /tests/module.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = 'pass' 3 | -------------------------------------------------------------------------------- /tests/test-basics.js: -------------------------------------------------------------------------------- 1 | /* globals capaTest */ 2 | const path = require('path') 3 | const cappadonna = require('../') 4 | 5 | const opts = { 6 | ignore: ['**/node_modules/**', '**/bower_components/**', '**/*.json'], 7 | include: [path.join(__dirname, '..', 'index.js'), '**/tests/**'], 8 | defaultIgnore: false 9 | } 10 | const components = path.join(__dirname, 'components.js') 11 | const test = cappadonna(components, {istanbul: opts}) 12 | 13 | test('basics', async (page, t) => { 14 | t.plan(4) 15 | t.ok(true) 16 | t.same('pass', 'pass') 17 | await page.evaluate(async () => { 18 | t.ok(true) 19 | t.same('pass', capaTest()) 20 | }) 21 | }) 22 | 23 | test('appendAndWait', async (page, t) => { 24 | t.plan(2) 25 | await page.appendAndWait('pass', 'test-me') 26 | await page.evaluate(async () => { 27 | t.ok(window.isSecureContext, 'load default page from file url so we have a secure context') 28 | t.same('pass', document.querySelector('test-me').textContent) 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /tests/test-require.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const cappadonna = require('../') 3 | 4 | const opts = { 5 | ignore: ['**/node_modules/**', '**/bower_components/**', '**/*.json'], 6 | include: [path.join(__dirname, '..', 'index.js'), '**/tests/**'], 7 | defaultIgnore: false 8 | } 9 | 10 | const aModule = path.join(__dirname, 'module.js') 11 | const test = cappadonna(aModule, {istanbul: opts, require: {expose: 'entry-module'}}) 12 | 13 | test('can require', async (page, t) => { 14 | t.plan(1) 15 | console.log('i planned') 16 | await page.evaluate(async () => { 17 | console.log('im in the page') 18 | t.equals(require('entry-module'), 'pass', 'should be able to require') 19 | console.log('iasserted and should be done') 20 | }) 21 | }) 22 | --------------------------------------------------------------------------------