├── .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 |
--------------------------------------------------------------------------------