├── test ├── utils │ ├── setup.js │ └── index.js ├── e2e │ ├── page-functions │ │ ├── settings.js │ │ ├── routeData.js │ │ ├── index.js │ │ ├── navigate.js │ │ ├── navigateByClick.js │ │ └── navigationHelper.js │ └── basic.test.js ├── fixtures │ ├── error-function.js │ ├── page-function.js │ └── basic │ │ └── index.html └── unit │ ├── __snapshots__ │ ├── webpage.selenium.test.js.snap │ └── utils.test.js.snap │ ├── webpage.puppeteer.test.js │ ├── webpage.test.js │ ├── command.browserstack-local.test.js │ ├── utils-package.test.js │ ├── browser-options.test.js │ ├── webpage.selenium.test.js │ ├── page-functions.test.js │ ├── command.static-server.test.js │ ├── browser.test.js │ ├── utils.test.js │ └── command.xvfb.test.js ├── renovate.json ├── .gitignore ├── .eslintignore ├── src ├── browsers │ ├── ie │ │ ├── index.js │ │ ├── browserstack │ │ │ ├── index.js │ │ │ └── local.js │ │ └── selenium.js │ ├── chrome │ │ ├── puppeteer.js │ │ ├── index.js │ │ ├── browserstack │ │ │ ├── index.js │ │ │ └── local.js │ │ └── selenium.js │ ├── edge │ │ ├── index.js │ │ ├── browserstack │ │ │ ├── index.js │ │ │ └── local.js │ │ └── selenium.js │ ├── firefox │ │ ├── index.js │ │ ├── browserstack │ │ │ ├── index.js │ │ │ └── local.js │ │ └── selenium.js │ ├── safari │ │ ├── index.js │ │ ├── browserstack │ │ │ ├── index.js │ │ │ └── local.js │ │ └── selenium.js │ ├── puppeteer │ │ ├── index.js │ │ ├── webpage.js │ │ └── core.js │ ├── jsdom │ │ ├── index.js │ │ └── webpage.js │ ├── selenium │ │ ├── index.js │ │ ├── logging.js │ │ └── webpage.js │ ├── browserstack │ │ ├── local.js │ │ └── index.js │ ├── index.js │ ├── saucelabs │ │ └── index.js │ ├── webpage.js │ └── browser.js ├── utils │ ├── hash.js │ ├── compiler.js │ ├── index.js │ ├── cache.js │ ├── errors.js │ ├── fs.js │ ├── misc.js │ ├── timers.js │ ├── constants.js │ ├── package.js │ ├── parser.js │ ├── browser.js │ ├── page-functions.js │ └── detectors │ │ └── chrome.js ├── commands │ ├── index.js │ ├── browserstack-local.js │ ├── static-server.js │ └── xvfb.js └── index.js ├── .editorconfig ├── .babelrc ├── scripts ├── create-dependencies.sh ├── builtins.js ├── rollup.config.js └── create-browsers.js ├── jest.config.js ├── .eslintrc.js ├── package.json ├── .circleci └── config.yml ├── CHANGELOG.md ├── README.md └── docs └── API.md /test/utils/setup.js: -------------------------------------------------------------------------------- 1 | jest.setTimeout(20000) 2 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@nuxtjs" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /test/e2e/page-functions/settings.js: -------------------------------------------------------------------------------- 1 | export const navTimeout = 10000 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | lib 4 | .env* 5 | *.log 6 | *.err 7 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | test/fixtures/** 4 | src/browsers/index.js 5 | -------------------------------------------------------------------------------- /test/fixtures/error-function.js: -------------------------------------------------------------------------------- 1 | const test = () => { 2 | return true 3 | 4 | 5 | export default test 6 | -------------------------------------------------------------------------------- /test/fixtures/page-function.js: -------------------------------------------------------------------------------- 1 | const test = () => { 2 | return true 3 | } 4 | 5 | export default test 6 | -------------------------------------------------------------------------------- /src/browsers/ie/index.js: -------------------------------------------------------------------------------- 1 | import IESeleniumBrowser from './selenium' 2 | 3 | export default IESeleniumBrowser 4 | -------------------------------------------------------------------------------- /src/browsers/chrome/puppeteer.js: -------------------------------------------------------------------------------- 1 | import PuppeteerBrowser from '../puppeteer' 2 | 3 | export default PuppeteerBrowser 4 | -------------------------------------------------------------------------------- /src/browsers/edge/index.js: -------------------------------------------------------------------------------- 1 | import EdgeSeleniumBrowser from './selenium' 2 | 3 | export default EdgeSeleniumBrowser 4 | -------------------------------------------------------------------------------- /src/browsers/firefox/index.js: -------------------------------------------------------------------------------- 1 | import FirefoxSeleniumBrowser from './selenium' 2 | 3 | export default FirefoxSeleniumBrowser 4 | -------------------------------------------------------------------------------- /src/browsers/safari/index.js: -------------------------------------------------------------------------------- 1 | import SafariSeleniumBrowser from './selenium' 2 | 3 | export default SafariSeleniumBrowser 4 | -------------------------------------------------------------------------------- /test/utils/index.js: -------------------------------------------------------------------------------- 1 | export function waitFor(ms) { 2 | return new Promise(resolve => setTimeout(resolve, ms || 100)) 3 | } 4 | -------------------------------------------------------------------------------- /src/browsers/chrome/index.js: -------------------------------------------------------------------------------- 1 | import PuppeteerCoreBrowser from '../puppeteer/core' 2 | 3 | export default PuppeteerCoreBrowser 4 | -------------------------------------------------------------------------------- /src/utils/hash.js: -------------------------------------------------------------------------------- 1 | import { createHash } from 'crypto' 2 | 3 | export const md5sum = str => createHash('md5').update(str).digest('hex') 4 | -------------------------------------------------------------------------------- /src/utils/compiler.js: -------------------------------------------------------------------------------- 1 | import { compile } from 'vue-template-compiler' 2 | 3 | export function getDefaultHtmlCompiler() { 4 | return html => compile(html).ast 5 | } 6 | -------------------------------------------------------------------------------- /test/e2e/page-functions/routeData.js: -------------------------------------------------------------------------------- 1 | const routeData = () => ({ 2 | path: window.$vueMeta.$route.path, 3 | query: window.$vueMeta.$route.query 4 | }) 5 | 6 | export default routeData 7 | -------------------------------------------------------------------------------- /src/commands/index.js: -------------------------------------------------------------------------------- 1 | import Xvfb from './xvfb' 2 | import BrowserStackLocal from './browserstack-local' 3 | import StaticServer from './static-server' 4 | 5 | export { 6 | Xvfb, 7 | BrowserStackLocal, 8 | StaticServer 9 | } 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /test/e2e/page-functions/index.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | 3 | const resolve = p => path.resolve(__dirname, p) 4 | 5 | export default [ 6 | resolve('./navigate.js'), 7 | resolve('./navigateByClick.js'), 8 | resolve('./routeData.js') 9 | ] 10 | -------------------------------------------------------------------------------- /src/browsers/ie/browserstack/index.js: -------------------------------------------------------------------------------- 1 | import BrowserStackBrowser from '../../browserstack' 2 | 3 | export default class IEBrowserStackBrowser extends BrowserStackBrowser { 4 | constructor(config) { 5 | super(config) 6 | 7 | this.setBrowser('ie') 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/browsers/edge/browserstack/index.js: -------------------------------------------------------------------------------- 1 | import BrowserStackBrowser from '../../browserstack' 2 | 3 | export default class EdgeBrowserStackBrowser extends BrowserStackBrowser { 4 | constructor(config) { 5 | super(config) 6 | 7 | this.setBrowser('edge') 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/browsers/chrome/browserstack/index.js: -------------------------------------------------------------------------------- 1 | import BrowserStackBrowser from '../../browserstack' 2 | 3 | export default class ChromeBrowserStackBrowser extends BrowserStackBrowser { 4 | constructor(config) { 5 | super(config) 6 | 7 | this.setBrowser('chrome') 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/browsers/safari/browserstack/index.js: -------------------------------------------------------------------------------- 1 | import BrowserStackBrowser from '../../browserstack' 2 | 3 | export default class SafariBrowserStackBrowser extends BrowserStackBrowser { 4 | constructor(config) { 5 | super(config) 6 | 7 | this.setBrowser('safari') 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/browsers/firefox/browserstack/index.js: -------------------------------------------------------------------------------- 1 | import BrowserStackBrowser from '../../browserstack' 2 | 3 | export default class FirefoxBrowserStackBrowser extends BrowserStackBrowser { 4 | constructor(config) { 5 | super(config) 6 | 7 | this.setBrowser('firefox') 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/browsers/ie/browserstack/local.js: -------------------------------------------------------------------------------- 1 | import BrowserstackLocalBrowser from '../../browserstack/local' 2 | 3 | export default class IEBrowserStackLocalBrowser extends BrowserstackLocalBrowser { 4 | constructor(config) { 5 | super(config) 6 | 7 | this.setBrowser('ie') 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/browsers/edge/browserstack/local.js: -------------------------------------------------------------------------------- 1 | import BrowserstackLocalBrowser from '../../browserstack/local' 2 | 3 | export default class EdgeBrowserStackLocalBrowser extends BrowserstackLocalBrowser { 4 | constructor(config) { 5 | super(config) 6 | 7 | this.setBrowser('edge') 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["@babel/plugin-syntax-dynamic-import"], 3 | "env": { 4 | "test": { 5 | "plugins": ["dynamic-import-node"], 6 | "presets": [ 7 | [ "@babel/env", { 8 | "targets": { "node": "current" } 9 | }] 10 | ] 11 | } 12 | }, 13 | } 14 | -------------------------------------------------------------------------------- /src/browsers/chrome/browserstack/local.js: -------------------------------------------------------------------------------- 1 | import BrowserstackLocalBrowser from '../../browserstack/local' 2 | 3 | export default class ChromeBrowserStackLocalBrowser extends BrowserstackLocalBrowser { 4 | constructor(config) { 5 | super(config) 6 | 7 | this.setBrowser('chrome') 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/browsers/safari/browserstack/local.js: -------------------------------------------------------------------------------- 1 | import BrowserstackLocalBrowser from '../../browserstack/local' 2 | 3 | export default class SafariBrowserStackLocalBrowser extends BrowserstackLocalBrowser { 4 | constructor(config) { 5 | super(config) 6 | 7 | this.setBrowser('safari') 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /test/e2e/page-functions/navigate.js: -------------------------------------------------------------------------------- 1 | import { navTimeout } from './settings' 2 | import { navigationHelper } from './navigationHelper' 3 | 4 | export default function navigate(path, timeout = navTimeout) { 5 | return navigationHelper(() => { 6 | window.$vueMeta.$router.push(path) 7 | }, timeout) 8 | } 9 | -------------------------------------------------------------------------------- /src/browsers/firefox/browserstack/local.js: -------------------------------------------------------------------------------- 1 | import BrowserstackLocalBrowser from '../../browserstack/local' 2 | 3 | export default class FirefoxBrowserStackLocalBrowser extends BrowserstackLocalBrowser { 4 | constructor(config) { 5 | super(config) 6 | 7 | this.setBrowser('firefox') 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /test/e2e/page-functions/navigateByClick.js: -------------------------------------------------------------------------------- 1 | import { navTimeout } from './settings' 2 | import { navigationHelper } from './navigationHelper' 3 | 4 | export default function navigateByClick(selector, timeout = navTimeout) { 5 | return navigationHelper(() => { 6 | document.querySelector(selector).click() 7 | }, timeout) 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | export * from './browser' 2 | export * from './compiler' 3 | export * from './cache' 4 | export * from './errors' 5 | export * from './fs' 6 | export * from './hash' 7 | export * from './misc' 8 | export * from './package' 9 | export * from './page-functions' 10 | export * from './parser' 11 | export * from './timers' 12 | -------------------------------------------------------------------------------- /src/browsers/puppeteer/index.js: -------------------------------------------------------------------------------- 1 | import PuppeteerCoreBrowser from './core' 2 | 3 | export default class PuppeteerBrowser extends PuppeteerCoreBrowser { 4 | async _loadDependencies() { 5 | if (!PuppeteerCoreBrowser.core) { 6 | PuppeteerCoreBrowser.core = await this.loadDependency('puppeteer') 7 | } 8 | 9 | // call super after setting core 10 | super._loadDependencies() 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/cache.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { md5sum } from './hash' 3 | import { findNodeModulesPath } from './package' 4 | 5 | export function createCacheKey(fn, opts) { 6 | if (typeof opts !== 'string') { 7 | opts = JSON.stringify(opts) 8 | } 9 | 10 | return md5sum(`${fn}-x-${opts}`) 11 | } 12 | 13 | export async function getCachePath(filePath = '') { 14 | const modulesPath = await findNodeModulesPath() 15 | return path.join(modulesPath, '.cache', 'tib', filePath) 16 | } 17 | -------------------------------------------------------------------------------- /scripts/create-dependencies.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | BASE=`dirname $0` 4 | JS_FILE="$BASE/../src/dependencies.js" 5 | 6 | CONTENT="/** 7 | * THIS FILE IS AUTOMATICALLY GENERATED 8 | * DONT CHANGE ANYTHING MANUALLY 9 | */ 10 | 11 | export const dependencies = {" 12 | 13 | for d in `grep -ir loadDependency\(\' src | cut -d\' -f2 | sort | uniq`; do 14 | CONTENT="$CONTENT 15 | '$d': () => import('$d')," >> $JS_FILE 16 | done 17 | 18 | CONTENT="${CONTENT:0:${#CONTENT}-1} 19 | }" 20 | 21 | echo "$CONTENT" > $JS_FILE 22 | -------------------------------------------------------------------------------- /scripts/builtins.js: -------------------------------------------------------------------------------- 1 | /* 2 | ** Core logic from https://github.com/sindresorhus/builtin-modules 3 | ** Many thanks to @sindresorhus 4 | */ 5 | import Module from 'module' 6 | 7 | const blacklist = [ 8 | 'sys' 9 | ] 10 | 11 | export const builtins = Module.builtinModules 12 | .filter(x => !/^_|^(internal|v8|node-inspect)\/|\//.test(x) && !blacklist.includes(x)) 13 | .sort() 14 | 15 | let builtinsObj = null 16 | 17 | const convertToObj = () => builtins.reduce((obj, builtin) => { 18 | obj[builtin] = true 19 | return obj 20 | }, (builtinsObj = {})) 21 | 22 | export const builtinsMap = () => builtinsObj || convertToObj() 23 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Browser from './browsers/browser' 2 | import * as commands from './commands' 3 | import { createPageFunctions } from './utils' 4 | 5 | export async function createBrowser(str, conf, autoStart = true) { 6 | const instance = Browser.get(str, conf) 7 | if (!autoStart) { 8 | return instance 9 | } 10 | 11 | return (await instance).start() 12 | } 13 | 14 | export function browser(...args) { 15 | /* istanbul ignore next */ 16 | console.warn('DeprecationWarning: \'browser\' has been renamed to \'createBrowser\'') // eslint-disable-line no-console 17 | /* istanbul ignore next */ 18 | return createBrowser(...args) 19 | } 20 | 21 | export { 22 | commands, 23 | createPageFunctions 24 | } 25 | -------------------------------------------------------------------------------- /src/utils/errors.js: -------------------------------------------------------------------------------- 1 | export class BrowserError extends Error { 2 | constructor(classInstance, message, ...params) { 3 | let className 4 | if (!message) { 5 | message = classInstance 6 | className = 'BrowserError' 7 | } else if (typeof classInstance === 'object') { 8 | className = classInstance.constructor.name 9 | } else if (typeof classInstance === 'string') { 10 | className = classInstance 11 | } 12 | 13 | // Pass remaining arguments (including vendor specific ones) to parent constructor 14 | super(`${className}: ${message}`, ...params) 15 | 16 | // Maintains proper stack trace for where our error was thrown (only available on V8) 17 | if (Error.captureStackTrace) { 18 | Error.captureStackTrace(this, BrowserError) 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | 4 | expand: true, 5 | 6 | forceExit: false, 7 | 8 | // https://github.com/facebook/jest/pull/6747 fix warning here 9 | // But its performance overhead is pretty bad (30+%). 10 | // detectOpenHandles: true 11 | 12 | setupFilesAfterEnv: ['./test/utils/setup'], 13 | 14 | coverageDirectory: './coverage', 15 | 16 | collectCoverageFrom: [ 17 | '**/src/**/*.js' 18 | ], 19 | 20 | coveragePathIgnorePatterns: [ 21 | 'node_modules', 22 | 'src/utils/detectors/chrome.js' 23 | ], 24 | 25 | testPathIgnorePatterns: [ 26 | 'node_modules' 27 | ], 28 | 29 | transformIgnorePatterns: [ 30 | 'node_modules' 31 | ], 32 | 33 | transform: { 34 | '^.+\\.js$': 'babel-jest' 35 | }, 36 | 37 | moduleFileExtensions: [ 38 | 'ts', 39 | 'js', 40 | 'json' 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /test/unit/__snapshots__/webpage.selenium.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`selenium/webpage should run async script in runAsyncScript 1`] = ` 4 | " 5 | var args = [].slice.call(arguments) 6 | var callback = args.pop() 7 | var retVal = (function() { return Promise.resolve(true); }).apply(null, args) 8 | if (retVal && retVal.then) { 9 | retVal.then(callback) 10 | } else { 11 | callback(retVal) 12 | } 13 | " 14 | `; 15 | 16 | exports[`selenium/webpage should run sync script in runAsyncScript and fix blockless bodies 1`] = ` 17 | " 18 | var args = [].slice.call(arguments) 19 | var callback = args.pop() 20 | var retVal = (function() { return true; }).apply(null, args) 21 | if (retVal && retVal.then) { 22 | retVal.then(callback) 23 | } else { 24 | callback(retVal) 25 | } 26 | " 27 | `; 28 | -------------------------------------------------------------------------------- /src/utils/fs.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import util from 'util' 3 | import Glob from 'glob' 4 | 5 | export const readDir = util.promisify(fs.readdir) 6 | export const readFile = util.promisify(fs.readFile) 7 | export const glob = util.promisify(Glob) 8 | 9 | export function exists(p) { 10 | return new Promise((resolve, reject) => { 11 | fs.access(p, fs.constants.F_OK, (err) => { 12 | if (err) { 13 | resolve(false) 14 | return 15 | } 16 | 17 | resolve(true) 18 | }) 19 | }) 20 | } 21 | 22 | export function stats(p) { 23 | return new Promise((resolve, reject) => { 24 | fs.stat(p, (err, stats) => { 25 | if (err) { 26 | resolve(false) 27 | return 28 | } 29 | 30 | resolve(stats) 31 | }) 32 | }) 33 | } 34 | 35 | export function requireResolve(path) { 36 | /* istanbul ignore next */ 37 | return require.resolve(path) 38 | } 39 | -------------------------------------------------------------------------------- /src/browsers/safari/selenium.js: -------------------------------------------------------------------------------- 1 | import SeleniumBrowser from '../selenium' 2 | 3 | export default class SafariSeleniumBrowser extends SeleniumBrowser { 4 | constructor(config) { 5 | super(config) 6 | 7 | this.setBrowser('safari') 8 | 9 | /* istanbul ignore next */ 10 | this.hook('selenium:build:before', async (builder) => { 11 | const options = new SafariSeleniumBrowser.Options() 12 | 13 | await this.callHook('selenium:build:options', options, builder) 14 | 15 | builder.setSafariOptions(options) 16 | }) 17 | } 18 | 19 | /* istanbul ignore next */ 20 | async _loadDependencies() { 21 | super._loadDependencies() 22 | 23 | // there is no separate safaridriver, it should already be installed 24 | 25 | if (!SafariSeleniumBrowser.Options) { 26 | const { Options } = await this.loadDependency('selenium-webdriver/safari') 27 | SafariSeleniumBrowser.Options = Options 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/utils/misc.js: -------------------------------------------------------------------------------- 1 | import { BrowserError } from './errors' 2 | 3 | export const camelCase = str => str.replace(/(^|[-_.\s]+)(.)/g, (m, $1, $2, offset) => !offset ? $2.toLowerCase() : $2.toUpperCase()) 4 | 5 | export function abstractGuard(className, { name } = {}) { 6 | if (className === name) { 7 | throw new BrowserError(`Do not use abstract class '${className}' directly`) 8 | } 9 | } 10 | 11 | export function isMockedFunction(fn, fnName) { 12 | return fn.name !== fnName 13 | } 14 | 15 | export async function loadDependency(dependency) { 16 | try { 17 | const module = await import(dependency).then(m => m.default || m) 18 | return module 19 | } catch (e) { 20 | throw new BrowserError(`Could not import the required dependency '${dependency}' 21 | (error: ${e.message}) 22 | 23 | Please install the dependency with: 24 | $ npm install ${dependency} 25 | or 26 | $ yarn add ${dependency} 27 | `) 28 | } 29 | } 30 | 31 | export const waitFor = time => new Promise(resolve => setTimeout(resolve, time || 1000)) 32 | -------------------------------------------------------------------------------- /src/browsers/jsdom/index.js: -------------------------------------------------------------------------------- 1 | import Browser from '../browser' 2 | import Webpage from './webpage' 3 | 4 | export default class JsdomBrowser extends Browser { 5 | constructor(config = {}) { 6 | config.xvfb = false 7 | 8 | super(config) 9 | 10 | this.config.jsdom = this.config.jsdom || {} 11 | 12 | this.logLevels = [] 13 | } 14 | 15 | async _loadDependencies() { 16 | if (!JsdomBrowser.jsdom) { 17 | JsdomBrowser.jsdom = await this.loadDependency('jsdom') 18 | } 19 | 20 | // call super after setting core 21 | super._loadDependencies() 22 | } 23 | 24 | setHeadless() { 25 | this.config.jsdom.pretendToBeVisual = false 26 | } 27 | 28 | setLogLevel(types) { 29 | this.config.jsdom.virtualConsole = true 30 | 31 | if (types && typeof types === 'string') { 32 | types = [types] 33 | } 34 | 35 | this.logLevels = types 36 | } 37 | 38 | _start() { 39 | this.driver = JsdomBrowser.jsdom 40 | } 41 | 42 | _page(url, readyCondition) { 43 | const page = new Webpage(this) 44 | return page.open(url, readyCondition) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/utils/timers.js: -------------------------------------------------------------------------------- 1 | import { BrowserError } from './errors' 2 | 3 | // TODO: add more test framework checks like sinon? 4 | export function disableTimers() { 5 | // find Jest fingerprint 6 | if (process.env.JEST_WORKER_ID) { 7 | try { 8 | jest.useFakeTimers() 9 | } catch (e) { 10 | /* istanbul ignore next */ 11 | throw new BrowserError(`Enabling fake timers failed: ${e.message}`) 12 | } 13 | } 14 | } 15 | 16 | export function enableTimers() { 17 | // find Jest fingerprint 18 | if (process.env.JEST_WORKER_ID) { 19 | try { 20 | // call useFakeTimers first as it seems there is no way to determine 21 | // if fake timers are used at all and otherwise runOnlyPendingTimers 22 | // will fail with a warning from within Jest 23 | // -> Disabled because we probably dont care about pending timers 24 | // jest.useFakeTimers() 25 | // jest.runOnlyPendingTimers() 26 | jest.useRealTimers() 27 | } catch (e) { 28 | /* istanbul ignore next */ 29 | throw new BrowserError(`Enabling real timers failed: ${e.message}`) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /test/fixtures/basic/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Home | Vue Meta Test 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |

Basic

Home

Go to About

Inspect Element to see the meta info

16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/browsers/ie/selenium.js: -------------------------------------------------------------------------------- 1 | import SeleniumBrowser from '../selenium' 2 | 3 | export default class IESeleniumBrowser extends SeleniumBrowser { 4 | constructor(config) { 5 | super(config) 6 | 7 | this.setBrowser('ie') 8 | 9 | /* istanbul ignore next */ 10 | this.hook('selenium:build:before', async (builder) => { 11 | // TODO 12 | const configArguments = [] 13 | 14 | const options = new IESeleniumBrowser.Options() 15 | options.addArguments(...configArguments) 16 | 17 | await this.callHook('selenium:build:options', options, builder) 18 | 19 | builder.setIEOptions(options) 20 | }) 21 | } 22 | 23 | /* istanbul ignore next */ 24 | async _loadDependencies() { 25 | super._loadDependencies() 26 | 27 | if (!IESeleniumBrowser.driverLoaded) { 28 | if (await this.loadDependency('iedriver')) { 29 | IESeleniumBrowser.driverLoaded = true 30 | } 31 | } 32 | 33 | if (!IESeleniumBrowser.Options) { 34 | const { Options } = await this.loadDependency('selenium-webdriver/ie') 35 | IESeleniumBrowser.Options = Options 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /test/e2e/page-functions/navigationHelper.js: -------------------------------------------------------------------------------- 1 | import { navTimeout } from './settings' 2 | 3 | export function navigationHelper(performNavigation, timeout = navTimeout) { 4 | const oldTitle = document.title 5 | 6 | // local firefox has sometimes not updated the title 7 | // even when the DOM is supposed to be fully updated 8 | function waitTitleChanged() { 9 | setTimeout(function () { 10 | if (oldTitle !== document.title) { 11 | window.$vueMeta.$emit('titleChanged') 12 | } else { 13 | waitTitleChanged() 14 | } 15 | }, 50) 16 | } 17 | 18 | // timeout after 10s 19 | const cbTimeout = setTimeout(() => { 20 | // eslint-disable-next-line no-console 21 | console.error(`browser: navigation timed out after ${Math.round(timeout / 1000)}s`) 22 | window.$vueMeta.$emit('titleChanged') 23 | }, timeout) 24 | 25 | return new Promise((resolve) => { 26 | window.$vueMeta.$once('routeChanged', waitTitleChanged) 27 | window.$vueMeta.$once('titleChanged', () => { 28 | clearTimeout(cbTimeout) 29 | resolve() 30 | }) 31 | 32 | performNavigation() 33 | }) 34 | } 35 | -------------------------------------------------------------------------------- /scripts/rollup.config.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import commonjs from 'rollup-plugin-commonjs' 3 | import nodeResolve from 'rollup-plugin-node-resolve' 4 | import json from 'rollup-plugin-json' 5 | import defaultsDeep from 'lodash.defaultsdeep' 6 | 7 | import { builtins } from './builtins' 8 | 9 | const pkg = require('../package.json') 10 | 11 | function rollupConfig({ 12 | external = [], 13 | plugins = [], 14 | ...config 15 | } = {}) { 16 | 17 | return defaultsDeep({}, config, { 18 | inlineDynamicImports: false, 19 | external: [ 20 | ...builtins, 21 | ...Object.keys(pkg.dependencies), 22 | ...external 23 | ], 24 | input: 'src/index.js', 25 | output: { 26 | name: 'tib', 27 | dir: path.dirname(pkg.main), 28 | entryFileNames: `${pkg.name}.js`, 29 | chunkFileNames: `${pkg.name}-[name].js`, 30 | format: 'cjs', 31 | sourcemap: false, 32 | preferConst: true 33 | }, 34 | plugins: [ 35 | json(), 36 | nodeResolve(), 37 | commonjs() 38 | ].concat(plugins), 39 | }) 40 | } 41 | 42 | export default [ 43 | rollupConfig() 44 | ] 45 | -------------------------------------------------------------------------------- /src/browsers/edge/selenium.js: -------------------------------------------------------------------------------- 1 | import SeleniumBrowser from '../selenium' 2 | 3 | export default class EdgeSeleniumBrowser extends SeleniumBrowser { 4 | constructor(config) { 5 | super(config) 6 | 7 | this.setBrowser('edge') 8 | 9 | /* istanbul ignore next */ 10 | this.hook('selenium:build:before', async (builder) => { 11 | // TODO 12 | const configArguments = [] 13 | 14 | const options = new EdgeSeleniumBrowser.Options() 15 | options.addArguments(...configArguments) 16 | 17 | await this.callHook('selenium:build:options', options, builder) 18 | 19 | builder.setEdgeOptions(options) 20 | }) 21 | } 22 | 23 | /* istanbul ignore next */ 24 | async _loadDependencies() { 25 | super._loadDependencies() 26 | 27 | if (!EdgeSeleniumBrowser.driverLoaded) { 28 | if (await this.loadDependency('edgedriver')) { 29 | EdgeSeleniumBrowser.driverLoaded = true 30 | } 31 | } 32 | 33 | if (!EdgeSeleniumBrowser.Options) { 34 | const { Options } = await this.loadDependency('selenium-webdriver/edge') 35 | EdgeSeleniumBrowser.Options = Options 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/browsers/selenium/index.js: -------------------------------------------------------------------------------- 1 | import Browser from '../browser' 2 | import Webpage from './webpage' 3 | 4 | export default class SeleniumBrowser extends Browser { 5 | async _loadDependencies() { 6 | super._loadDependencies() 7 | 8 | if (SeleniumBrowser.webdriver) { 9 | return 10 | } 11 | 12 | SeleniumBrowser.webdriver = await this.loadDependency('selenium-webdriver') 13 | } 14 | 15 | setHeadless() { 16 | super.setHeadless() 17 | this.config.browserArguments.push('headless') 18 | } 19 | 20 | flushLogs() {} 21 | 22 | async _start(capabilities = {}) { 23 | const builder = new SeleniumBrowser.webdriver.Builder() 24 | 25 | capabilities = this.getCapabilities(capabilities) 26 | builder.withCapabilities(capabilities) 27 | 28 | await this.callHook('selenium:build:before', builder) 29 | 30 | this.driver = await builder.build() 31 | } 32 | 33 | async _close() { 34 | if (!this.driver) { 35 | return 36 | } 37 | 38 | await this.driver.quit() 39 | } 40 | 41 | _page(url, readyCondition) { 42 | const page = new Webpage(this) 43 | return page.open(url, readyCondition) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /scripts/create-browsers.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const path = require('path') 4 | const fs = require('fs') 5 | const { promisify } = require('util') 6 | const Glob = require('glob') 7 | 8 | const glob = promisify(Glob) 9 | 10 | async function main() { 11 | const srcPath = path.resolve(__dirname, '../src/browsers') + '/' 12 | let files = await glob(`${srcPath}/!(utils)/**/*.js`) 13 | files = files 14 | .filter(f => !f.includes('webpage') && !f.includes('logging')) 15 | .map(f => f 16 | .replace(srcPath, '') 17 | .replace('.js', '') 18 | .replace('index', '') 19 | .replace(/\/+$/, '') 20 | ) 21 | .sort() 22 | 23 | const imports = files.reduce((acc, f) => { 24 | return `${acc} '${f}': () => _interopDefault(import('./${f}')), 25 | ` 26 | }, '') 27 | 28 | fs.writeFileSync(path.join(__dirname, '../src/browsers/index.js'), `/** 29 | * THIS FILE IS AUTOMATICALLY GENERATED 30 | * DONT CHANGE ANYTHING MANUALLY 31 | */ 32 | 33 | const _interopDefault = i => i.then(m => m.default || m) 34 | 35 | export const browsers = { 36 | ${imports.trim().slice(0, -1)} 37 | } 38 | `, { flag: 'w+' }) 39 | } 40 | 41 | main().then(() => console.log('Done')) 42 | -------------------------------------------------------------------------------- /src/utils/constants.js: -------------------------------------------------------------------------------- 1 | 2 | // https://www.browserstack.com/automate/node#configure-capabilities 3 | // https://wiki.saucelabs.com/display/DOCS/Platform+Configurator 4 | 5 | export const browsers = [ 6 | 'chrome', 7 | 'firefox', 8 | 'ie', 9 | 'jsdom', 10 | 'edge', 11 | 'safari' 12 | ] 13 | 14 | export const browserOptions = [ 15 | 'headless', 16 | 'xvfb', 17 | 'staticserver' 18 | ] 19 | 20 | export const drivers = [ 21 | 'puppeteer', 22 | 'puppeteer-core', 23 | 'selenium' 24 | ] 25 | 26 | export const providers = [ 27 | 'browserstack', 28 | 'saucelabs' 29 | ] 30 | 31 | export const browserVariants = { 32 | local: ['browserstack'], 33 | core: ['puppeteer'] 34 | } 35 | 36 | export const os = [ 37 | 'windows', 38 | 'linux', 39 | 'mac', 40 | 'macos', 41 | 'macosx', 42 | 'osx', 43 | 'android', 44 | 'ios' 45 | ] 46 | 47 | export const osVersions = { 48 | windows: [ 49 | 'xp', 50 | '7', 51 | '8', 52 | '8.1', 53 | '10' 54 | ], 55 | apple: [ 56 | '10.10', 57 | '10.11', 58 | '10.12', 59 | '10.13', 60 | '10.14', 61 | 'snowleopard', 62 | 'lion', 63 | 'mountainlion', 64 | 'mavericks', 65 | 'yosemite', 66 | 'elcapitan', 67 | 'sierra', 68 | 'highsierra', 69 | 'mojave' 70 | ] 71 | } 72 | -------------------------------------------------------------------------------- /src/browsers/firefox/selenium.js: -------------------------------------------------------------------------------- 1 | import SeleniumBrowser from '../selenium' 2 | import SeleniumLogging from '../selenium/logging' 3 | 4 | export default class FirefoxSeleniumBrowser extends SeleniumLogging(SeleniumBrowser) { 5 | constructor(config) { 6 | super(config) 7 | 8 | this.setBrowser('firefox') 9 | 10 | this.hook('selenium:build:before', async (builder) => { 11 | const configArguments = this.config.browserArguments 12 | 13 | if (!config.xvfb && !configArguments.some(a => a.includes('headless'))) { 14 | configArguments.push('headless') 15 | } 16 | 17 | const options = new FirefoxSeleniumBrowser.client.Options() 18 | options.addArguments(...configArguments) 19 | 20 | if (this.config.browserConfig.window) { 21 | options.windowSize(this.config.browserConfig.window.width, this.config.browserConfig.window.height) 22 | } 23 | 24 | await this.callHook('selenium:build:options', options, builder) 25 | 26 | builder.setFirefoxOptions(options) 27 | }) 28 | } 29 | 30 | async _loadDependencies() { 31 | super._loadDependencies() 32 | 33 | if (!FirefoxSeleniumBrowser.geckodriver) { 34 | FirefoxSeleniumBrowser.geckodriver = await this.loadDependency('geckodriver') 35 | } 36 | 37 | if (!FirefoxSeleniumBrowser.client) { 38 | FirefoxSeleniumBrowser.client = await this.loadDependency('selenium-webdriver/firefox') 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/browsers/browserstack/local.js: -------------------------------------------------------------------------------- 1 | import BrowserStackLocal from '../../commands/browserstack-local' 2 | import BrowserStackBrowser from './' 3 | 4 | export default class BrowserStackLocalBrowser extends BrowserStackBrowser { 5 | constructor(config) { 6 | super(config) 7 | 8 | // https://www.browserstack.com/local-testing#modifiers 9 | this.localConfig = { 10 | start: true, 11 | stop: true, 12 | key: this.getConfigProperty('key'), 13 | folder: this.config.folder, 14 | ...this.config.BrowserStackLocal 15 | } 16 | 17 | if (this.localConfig.start || typeof this.localConfig.start === 'undefined') { 18 | this.hook('start:before', () => BrowserStackLocal.start(this.localConfig)) 19 | } 20 | 21 | if (this.localConfig.stop || typeof this.localConfig.stop === 'undefined') { 22 | this.hook('close:after', () => BrowserStackLocal.stop()) 23 | } 24 | } 25 | 26 | async _loadDependencies() { 27 | super._loadDependencies() 28 | 29 | if (this.localConfig.start || typeof this.localConfig.start === 'undefined') { 30 | await BrowserStackLocal.loadDriver() 31 | } 32 | } 33 | 34 | async _start(capabilities = {}) { 35 | this.addCapability('browserstack.local', true) 36 | 37 | await super._start(capabilities) 38 | } 39 | 40 | getLocalFolderUrl(path = '/') { 41 | return `http://${this.getConfigProperty('user')}.browserstack.com${path}` 42 | } 43 | 44 | getUrl(path) { 45 | return this.getLocalFolderUrl(path) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /test/unit/webpage.puppeteer.test.js: -------------------------------------------------------------------------------- 1 | import Browser from '../../src/browsers/puppeteer' 2 | 3 | describe('puppeteer/webpage', () => { 4 | let browser 5 | let webpage 6 | let spy 7 | 8 | beforeAll(() => { 9 | browser = new Browser() 10 | 11 | spy = jest.fn() 12 | browser.driver = { 13 | newPage() { 14 | return Promise.resolve({ 15 | goto: (...args) => spy('goto', ...args), 16 | waitFor: (...args) => spy('waitFor', ...args), 17 | waitForSelector: (...args) => spy('waitForSelector', ...args), 18 | evaluate: (...args) => spy('evaluate', ...args), 19 | title: (...args) => spy('title', ...args) 20 | }) 21 | } 22 | } 23 | }) 24 | 25 | afterEach(() => jest.clearAllMocks()) 26 | 27 | test('should open page', async () => { 28 | const webPath = '/' 29 | webpage = await browser.page(webPath) 30 | expect(spy).toHaveBeenCalledWith('goto', webPath) 31 | expect(spy).toHaveBeenCalledWith('waitForSelector', 'body') 32 | expect(spy).not.toHaveBeenCalledWith('waitFor') 33 | }) 34 | 35 | test('should implement getHtml', () => { 36 | webpage.getHtml() 37 | expect(spy).toHaveBeenCalledWith('evaluate', expect.any(Function)) 38 | }) 39 | 40 | test('should implement getTitle', () => { 41 | webpage.getTitle() 42 | expect(spy).toHaveBeenCalledWith('title') 43 | }) 44 | 45 | test('should implement runScript', () => { 46 | const fn = () => {} 47 | webpage.runScript(fn, 'something') 48 | expect(spy).toHaveBeenCalledWith('evaluate', expect.any(Function), expect.any(Array), 'something') 49 | }) 50 | }) 51 | -------------------------------------------------------------------------------- /src/browsers/puppeteer/webpage.js: -------------------------------------------------------------------------------- 1 | import Webpage from '../webpage' 2 | import { parseFunction } from '../../utils' 3 | 4 | export default class PuppeteerWebpage extends Webpage { 5 | async open(url, readyCondition = 'body') { 6 | this.page = await this.driver.newPage() 7 | 8 | await this.browser.callHook('page:created', this.page) 9 | 10 | await this.page.goto(url) 11 | 12 | if (readyCondition) { 13 | let waitFn = 'waitForSelector' 14 | if (typeof readyCondition === 'number') { 15 | waitFn = 'waitForTimeout' 16 | } else if (typeof readyCondition === 'function') { 17 | waitFn = 'waitForFunction' 18 | } 19 | 20 | await this.page[waitFn](readyCondition) 21 | } 22 | 23 | return this.returnProxy() 24 | } 25 | 26 | runScript(pageFunction, ...args) { 27 | let parsedFn 28 | if (typeof pageFunction === 'function') { 29 | parsedFn = parseFunction(pageFunction, args, this.getBabelPresetOptions()) 30 | } else { 31 | parsedFn = pageFunction 32 | } 33 | 34 | // It would be bettter to return undefined when no el exists, 35 | // but selenium always returns null for undefined so better to keep 36 | // the return value consistent 37 | return this.page.evaluate( 38 | /* istanbul ignore next */ 39 | function (fn, ...args) { 40 | return (new (Function.bind.apply(Function, fn))()).apply(null, [].concat(args)) 41 | }, 42 | [null, ...parsedFn.args, parsedFn.body], 43 | ...args 44 | ) 45 | } 46 | 47 | getHtml() { 48 | /* istanbul ignore next */ 49 | const pageFn = () => window.document.documentElement.outerHTML 50 | return this.page.evaluate(pageFn) 51 | } 52 | 53 | getTitle() { 54 | return this.page.title() 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/utils/package.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { exists, requireResolve } from './fs' 3 | 4 | let packageName 5 | let nodeModulesPath 6 | 7 | export async function getPackageName(modulesPath) { 8 | if (!packageName) { 9 | const { name } = await import(path.resolve(modulesPath, '..', 'package.json')) 10 | packageName = name 11 | } 12 | 13 | return packageName 14 | } 15 | 16 | export async function checkNodeModulesPath(modulesPath) { 17 | const pathExists = await exists(modulesPath) 18 | 19 | if (pathExists) { 20 | nodeModulesPath = modulesPath 21 | return true 22 | } 23 | 24 | return false 25 | } 26 | 27 | export async function findNodeModulesPath(refresh) { 28 | if (nodeModulesPath && !refresh) { 29 | return nodeModulesPath 30 | } 31 | 32 | let modulesPath = path.resolve(__dirname, '../../..', 'node_modules') 33 | if (await checkNodeModulesPath(modulesPath)) { 34 | return nodeModulesPath 35 | } 36 | 37 | modulesPath = path.resolve('./node_modules') 38 | if (await checkNodeModulesPath(modulesPath)) { 39 | return nodeModulesPath 40 | } 41 | 42 | const hablePath = requireResolve('hable/package.json') 43 | modulesPath = path.dirname(path.dirname(hablePath)) 44 | if (await checkNodeModulesPath(modulesPath)) { 45 | return nodeModulesPath 46 | } 47 | 48 | modulesPath = __dirname 49 | while (modulesPath.length > 1) { 50 | const tryPath = path.join(modulesPath, 'node_modules') 51 | modulesPath = path.dirname(modulesPath) 52 | 53 | if (await checkNodeModulesPath(tryPath)) { 54 | modulesPath = tryPath 55 | break 56 | } 57 | } 58 | 59 | if (modulesPath.length <= 1) { 60 | modulesPath = '' 61 | } 62 | 63 | nodeModulesPath = modulesPath 64 | return modulesPath 65 | } 66 | -------------------------------------------------------------------------------- /src/browsers/chrome/selenium.js: -------------------------------------------------------------------------------- 1 | import ChromeDetector from '../../utils/detectors/chrome' 2 | import SeleniumBrowser from '../selenium' 3 | import SeleniumLogging from '../selenium/logging' 4 | import { BrowserError } from '../../utils' 5 | 6 | export default class ChromeSeleniumBrowser extends SeleniumLogging(SeleniumBrowser) { 7 | constructor(config) { 8 | super(config) 9 | 10 | this.setBrowser('chrome') 11 | 12 | this.hook('selenium:build:before', async (builder) => { 13 | let path = process.env.CHROME_EXECUTABLE_PATH 14 | 15 | if (!path) { 16 | path = new ChromeDetector().detect() 17 | } 18 | 19 | if (!path) { 20 | throw new BrowserError(this, 'Could not find Chrome executable path') 21 | } 22 | 23 | const configArguments = [ 24 | 'no-sandbox', 25 | 'disable-setuid-sandbox', 26 | ...this.config.browserArguments 27 | ] 28 | 29 | const options = new ChromeSeleniumBrowser.Options() 30 | options.setChromeBinaryPath(path) 31 | options.addArguments(...configArguments) 32 | 33 | await this.callHook('selenium:build:options', options, builder) 34 | 35 | builder.setChromeOptions(options) 36 | }) 37 | } 38 | 39 | setHeadless() { 40 | super.setHeadless() 41 | this.config.browserArguments.push('disable-gpu') 42 | } 43 | 44 | async _loadDependencies() { 45 | super._loadDependencies() 46 | 47 | if (!ChromeSeleniumBrowser.chromedriver) { 48 | ChromeSeleniumBrowser.chromedriver = await this.loadDependency('chromedriver') 49 | } 50 | 51 | if (!ChromeSeleniumBrowser.Options) { 52 | const { Options } = await this.loadDependency('selenium-webdriver/chrome') 53 | ChromeSeleniumBrowser.Options = Options 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/commands/browserstack-local.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import kill from 'tree-kill' 3 | import onExit from 'signal-exit' 4 | import { loadDependency } from '../utils' 5 | 6 | const consola = console // eslint-disable-line no-console 7 | 8 | let PID 9 | 10 | export default class BrowserStackLocal { 11 | static async loadDriver() { 12 | if (BrowserStackLocal.driver) { 13 | return 14 | } 15 | 16 | const browserstack = await loadDependency('browserstack-local') 17 | BrowserStackLocal.driver = new browserstack.Local() 18 | } 19 | 20 | static async start(config = {}) { 21 | // TODO: support webserver 22 | if (!config.folder && config.folder !== false) { 23 | config.folder = path.resolve(process.cwd()) 24 | } 25 | 26 | await BrowserStackLocal.loadDriver() 27 | 28 | // util.promisify doesnt work due to this binding and 29 | // .bind(local) didnt work either 30 | return new Promise((resolve, reject) => { 31 | BrowserStackLocal.driver.start(config, (error) => { 32 | if (error) { 33 | reject(error) 34 | } 35 | 36 | onExit(() => BrowserStackLocal.stop()) 37 | 38 | PID = BrowserStackLocal.driver.pid 39 | resolve(PID) 40 | }) 41 | }) 42 | } 43 | 44 | static stop(pid) { 45 | pid = pid || (BrowserStackLocal.driver && BrowserStackLocal.driver.pid) || PID 46 | 47 | if (!BrowserStackLocal.driver || !pid) { 48 | consola.warn('Stop called but browserstack-local was not started') 49 | return 50 | } 51 | 52 | return new Promise((resolve, reject) => { 53 | // local.stop is buggy, it doesnt kill anything and takes forever 54 | // after looking at the local.stop implementation tree-kill does 55 | // practically the same 56 | kill(pid, 'SIGTERM', (error) => { 57 | if (error) { 58 | /* istanbul ignore next */ 59 | reject(error) 60 | } 61 | 62 | resolve() 63 | }) 64 | }) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | browser: true, 6 | 'jest/globals': true 7 | }, 8 | parser: 'babel-eslint', 9 | parserOptions: { 10 | sourceType: 'module' 11 | }, 12 | extends: [ 13 | 'standard', 14 | 'plugin:import/errors', 15 | 'plugin:import/warnings', 16 | ], 17 | plugins: [ 18 | 'jest' 19 | ], 20 | rules: { 21 | // Enforce import order 22 | 'import/order': 2, 23 | 24 | // Imports should come first 25 | 'import/first': 2, 26 | 27 | // Other import rules 28 | 'import/no-mutable-exports': 2, 29 | 30 | // Allow unresolved imports 31 | 'import/no-unresolved': 0, 32 | 33 | // Allow paren-less arrow functions only when there's no braces 34 | 'arrow-parens': [2, 'as-needed', { requireForBlockBody: true }], 35 | 36 | // Allow async-await 37 | 'generator-star-spacing': 0, 38 | 39 | // Allow debugger during development 40 | 'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 1, 41 | 'no-console': process.env.NODE_ENV === 'production' ? 2 : 1, 42 | 43 | // Prefer const over let 44 | 'prefer-const': [2, { 45 | 'destructuring': 'any', 46 | 'ignoreReadBeforeAssign': false 47 | }], 48 | 49 | // No single if in an else block 50 | 'no-lonely-if': 2, 51 | 52 | // Force curly braces for control flow, 53 | // including if blocks with a single statement 54 | curly: [2, 'all'], 55 | 56 | // No async function without await 57 | 'require-await': 2, 58 | 59 | // Force dot notation when possible 60 | 'dot-notation': 2, 61 | 62 | 'no-var': 2, 63 | 64 | // Force object shorthand where possible 65 | 'object-shorthand': 2, 66 | 67 | // No useless destructuring/importing/exporting renames 68 | 'no-useless-rename': 2, 69 | 70 | 'space-before-function-paren': ['error', { 71 | anonymous: 'always', 72 | named: 'never', 73 | asyncArrow: 'always' 74 | }], 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /test/unit/webpage.test.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { promisify } from 'util' 3 | import Glob from 'glob' 4 | 5 | const glob = promisify(Glob) 6 | 7 | function capatilize(name) { 8 | return name 9 | .replace(/browserstack/i, 'BrowserStack') 10 | .replace(/^ie/i, 'IE') 11 | .replace(/(^|\/)([a-z])/gi, (m, $1, $2) => $2.toUpperCase()) 12 | } 13 | 14 | const browsers = { 15 | 'puppeteer/webpage': name => standardWebpageTest(name), 16 | 'selenium/webpage': name => standardWebpageTest(name), 17 | 'jsdom/webpage': name => standardWebpageTest(name) 18 | } 19 | 20 | async function standardWebpageTest(name, expectedConstructor) { 21 | if (!expectedConstructor) { 22 | expectedConstructor = capatilize(name) 23 | } 24 | 25 | const webpageImportPath = path.resolve(__dirname, '../../src/browsers/', name) 26 | const browserImportPath = path.dirname(webpageImportPath) 27 | 28 | const browser = await import(browserImportPath) 29 | .then(m => m.default || m) 30 | .then(Browser => new Browser()) 31 | 32 | const Webpage = await import(webpageImportPath).then(m => m.default || m) 33 | 34 | // throws error when started without browser arg 35 | expect(() => new Webpage()).toThrow(expectedConstructor) 36 | 37 | // throws error when browser not started 38 | expect(() => new Webpage(browser)).toThrow(expectedConstructor) 39 | 40 | return [browser, Webpage] 41 | } 42 | 43 | describe('webpage', () => { 44 | test('all files covered', async () => { 45 | const srcPath = path.resolve(__dirname, '../../src/browsers/') + '/' 46 | let files = await glob(`${srcPath}!(utils)/**/*.js`) 47 | files = files 48 | .filter(f => f.includes('webpage')) 49 | .map(f => f 50 | .replace(srcPath, '') 51 | .replace('.js', '') 52 | ) 53 | .sort() 54 | 55 | expect(Object.keys(browsers).sort()).toEqual(files) 56 | }) 57 | 58 | for (const name in browsers) { 59 | const tests = browsers[name] 60 | 61 | test(name, async () => { 62 | expect.hasAssertions() 63 | 64 | await tests(name) 65 | }) 66 | } 67 | }) 68 | -------------------------------------------------------------------------------- /test/unit/command.browserstack-local.test.js: -------------------------------------------------------------------------------- 1 | import browserstack from 'browserstack-local' 2 | import { loadDependency } from '../../src/utils' 3 | 4 | jest.mock('browserstack-local') 5 | jest.mock('../../src/utils') 6 | 7 | describe('xvfb', () => { 8 | let BrowserStackLocal 9 | 10 | beforeAll(() => { 11 | loadDependency.mockImplementation(() => browserstack) 12 | }) 13 | 14 | beforeEach(async () => { 15 | BrowserStackLocal = await import('../../src/commands/browserstack-local').then(m => m.default || m) 16 | }) 17 | 18 | afterEach(() => { 19 | jest.restoreAllMocks() 20 | jest.resetModules() 21 | }) 22 | 23 | test('should load browserstack driver once', async () => { 24 | expect(BrowserStackLocal.driver).toBeUndefined() 25 | await BrowserStackLocal.loadDriver() 26 | expect(BrowserStackLocal.driver).toBeDefined() 27 | expect(loadDependency).toHaveBeenCalledTimes(1) 28 | 29 | await BrowserStackLocal.loadDriver() 30 | expect(loadDependency).toHaveBeenCalledTimes(1) 31 | }) 32 | 33 | test('should start browserstack driver', async () => { 34 | BrowserStackLocal.driver = { 35 | start(config, fn) { 36 | fn() 37 | } 38 | } 39 | 40 | await expect(BrowserStackLocal.start()).resolves.toBeUndefined() 41 | }) 42 | 43 | test('should reject when browserstack fails to start', async () => { 44 | BrowserStackLocal.driver = { 45 | start(config, fn) { 46 | fn('test error') 47 | } 48 | } 49 | 50 | await expect(BrowserStackLocal.start()).rejects.toBe('test error') 51 | }) 52 | 53 | test('should warn when browserstack is stopped but not started', async () => { 54 | const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}) 55 | 56 | await BrowserStackLocal.stop() 57 | 58 | expect(spy).toHaveBeenCalledTimes(1) 59 | }) 60 | 61 | // TODO: how to mock tree-kill? 62 | test('should resolve when browserstack stopping succeeds', async () => { 63 | BrowserStackLocal.driver = { pid: -999 } 64 | await expect(BrowserStackLocal.stop()).resolves.toBeUndefined() 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /src/utils/parser.js: -------------------------------------------------------------------------------- 1 | import { types, transformFromAstSync } from '@babel/core' 2 | import { parseExpression } from '@babel/parser' 3 | import { BrowserError } from './errors' 4 | import { createCacheKey } from './cache' 5 | 6 | const fnCache = {} 7 | 8 | export function parseFunction(fn, args, presetOptions) { 9 | if (arguments.length === 2) { 10 | presetOptions = args || {} 11 | args = undefined 12 | } 13 | 14 | if (typeof fn !== 'function') { 15 | throw new BrowserError(`parseFunction expects the first argument to be a function, received '${typeof fn}' instead`) 16 | } 17 | 18 | const fnString = fn.toString() 19 | const cacheKey = createCacheKey(fnString, presetOptions) 20 | 21 | if (fnCache[cacheKey] && fnCache[cacheKey]) { 22 | return fnCache[cacheKey] 23 | } 24 | 25 | const parsed = {} 26 | let ast = parseExpression(fnString) 27 | parsed.args = ast.params.map(p => p.name) 28 | 29 | ast = ast.body 30 | 31 | /* transform can only transform a program and a program is an Array of 32 | * Statements. 33 | * The body of an arrow function like 'arg => arg' does not 34 | * contain a Statement, so we add a block & return statement in that case. 35 | * The return statement is needed later when we create a new Function and the 36 | * block statement helps so we can (almost) always call slice(1, -1) below to 37 | * retrieve the real function body 38 | */ 39 | if (!ast.type.includes('Statement')) { 40 | ast = types.blockStatement([types.returnStatement(ast)]) 41 | } 42 | 43 | const transpiled = transformFromAstSync( 44 | types.file(types.program([ast])), 45 | undefined, 46 | { 47 | sourceType: 'script', 48 | presets: [ 49 | ['@babel/preset-env', presetOptions] 50 | ] 51 | } 52 | ) 53 | 54 | if (transpiled.code[0] === '{') { 55 | // remove block statement needed to transform & trim block whitespace 56 | parsed.body = transpiled.code.slice(1, -1).trim() 57 | } else { 58 | /* istanbul ignore next */ 59 | parsed.body = transpiled.code 60 | } 61 | 62 | fnCache[cacheKey] = parsed 63 | return parsed 64 | } 65 | -------------------------------------------------------------------------------- /src/commands/static-server.js: -------------------------------------------------------------------------------- 1 | import http from 'http' 2 | import { loadDependency } from '../utils' 3 | 4 | export default class StaticServer { 5 | static async loadDependencies() { 6 | if (StaticServer.serveStatic) { 7 | return 8 | } 9 | 10 | StaticServer.serveStatic = await loadDependency('serve-static') 11 | StaticServer.finalhandler = await loadDependency('finalhandler') 12 | } 13 | 14 | static load(browser) { 15 | if (!browser.config.staticServer) { 16 | return 17 | } 18 | 19 | if (browser.config.staticServer === true) { 20 | browser.config.staticServer = {} 21 | } 22 | 23 | if (!browser.config.staticServer.folder) { 24 | const { folder } = browser.config 25 | if (!folder) { 26 | return 27 | } 28 | 29 | browser.config.staticServer.folder = folder 30 | } 31 | 32 | browser.hook('start:before', () => StaticServer.start(browser.config.staticServer, browser.config.quiet)) 33 | browser.hook('close:after', StaticServer.stop) 34 | } 35 | 36 | static async start(config, quiet) { 37 | await StaticServer.loadDependencies() 38 | 39 | const host = process.env.HOST || config.host || 'localhost' 40 | const port = process.env.PORT || config.port || 3000 41 | 42 | const serve = StaticServer.serveStatic(config.folder) 43 | 44 | const server = http.createServer((req, res) => { 45 | serve(req, res, StaticServer.finalhandler(req, res)) 46 | }) 47 | 48 | await new Promise((resolve, reject) => { 49 | server.on('error', reject) 50 | server.listen(port, host, () => { 51 | if (!quiet) { 52 | // eslint-disable-next-line no-console 53 | console.info(`tib: Static server started on http://${host}:${port}`) 54 | } 55 | 56 | config.host = host 57 | config.port = port 58 | 59 | StaticServer.server = server 60 | 61 | resolve(server) 62 | }) 63 | }) 64 | } 65 | 66 | static stop() { 67 | if (StaticServer.server) { 68 | return new Promise((resolve) => { 69 | StaticServer.server.close(() => { 70 | StaticServer.server = undefined 71 | resolve() 72 | }) 73 | }) 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/browsers/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * THIS FILE IS AUTOMATICALLY GENERATED 3 | * DONT CHANGE ANYTHING MANUALLY 4 | */ 5 | 6 | const _interopDefault = i => i.then(m => m.default || m) 7 | 8 | export const browsers = { 9 | 'browserstack': () => _interopDefault(import('./browserstack')), 10 | 'browserstack/local': () => _interopDefault(import('./browserstack/local')), 11 | 'chrome': () => _interopDefault(import('./chrome')), 12 | 'chrome/browserstack': () => _interopDefault(import('./chrome/browserstack')), 13 | 'chrome/browserstack/local': () => _interopDefault(import('./chrome/browserstack/local')), 14 | 'chrome/puppeteer': () => _interopDefault(import('./chrome/puppeteer')), 15 | 'chrome/selenium': () => _interopDefault(import('./chrome/selenium')), 16 | 'edge': () => _interopDefault(import('./edge')), 17 | 'edge/browserstack': () => _interopDefault(import('./edge/browserstack')), 18 | 'edge/browserstack/local': () => _interopDefault(import('./edge/browserstack/local')), 19 | 'edge/selenium': () => _interopDefault(import('./edge/selenium')), 20 | 'firefox': () => _interopDefault(import('./firefox')), 21 | 'firefox/browserstack': () => _interopDefault(import('./firefox/browserstack')), 22 | 'firefox/browserstack/local': () => _interopDefault(import('./firefox/browserstack/local')), 23 | 'firefox/selenium': () => _interopDefault(import('./firefox/selenium')), 24 | 'ie': () => _interopDefault(import('./ie')), 25 | 'ie/browserstack': () => _interopDefault(import('./ie/browserstack')), 26 | 'ie/browserstack/local': () => _interopDefault(import('./ie/browserstack/local')), 27 | 'ie/selenium': () => _interopDefault(import('./ie/selenium')), 28 | 'jsdom': () => _interopDefault(import('./jsdom')), 29 | 'puppeteer': () => _interopDefault(import('./puppeteer')), 30 | 'puppeteer/core': () => _interopDefault(import('./puppeteer/core')), 31 | 'safari': () => _interopDefault(import('./safari')), 32 | 'safari/browserstack': () => _interopDefault(import('./safari/browserstack')), 33 | 'safari/browserstack/local': () => _interopDefault(import('./safari/browserstack/local')), 34 | 'safari/selenium': () => _interopDefault(import('./safari/selenium')), 35 | 'saucelabs': () => _interopDefault(import('./saucelabs')), 36 | 'selenium': () => _interopDefault(import('./selenium')) 37 | } 38 | -------------------------------------------------------------------------------- /test/unit/utils-package.test.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import * as fs from '../../src/utils/fs' 3 | import * as pkgHelpers from '../../src/utils/package' 4 | 5 | describe('page functions', () => { 6 | beforeAll(async () => { 7 | }) 8 | 9 | afterEach(() => { 10 | jest.restoreAllMocks() 11 | }) 12 | 13 | test('modulesPath using relative path from dist', async () => { 14 | const modulePath = '/path/to/package/node_modules' 15 | jest.spyOn(path, 'resolve').mockReturnValue(modulePath) 16 | jest.spyOn(fs, 'exists').mockReturnValue(true) 17 | 18 | expect(await pkgHelpers.findNodeModulesPath(true)).toBe(modulePath) 19 | }) 20 | 21 | test('modulesPath using path:resolve', async () => { 22 | const modulePath = '/path/resolve/node_modules' 23 | jest.spyOn(fs, 'requireResolve').mockReturnValue('') 24 | jest.spyOn(path, 'resolve').mockReturnValue(modulePath) 25 | const spy = jest.spyOn(fs, 'exists').mockImplementation(() => { 26 | return spy.mock.calls.length === 2 27 | }) 28 | 29 | expect(await pkgHelpers.findNodeModulesPath(true)).toBe(modulePath) 30 | }) 31 | 32 | test('modulesPath using require:resolve', async () => { 33 | const modulePath = '/require/resolve/node_modules' 34 | jest.spyOn(fs, 'requireResolve').mockReturnValue(`${modulePath}/hable/package.json`) 35 | const spy = jest.spyOn(fs, 'exists').mockImplementation(() => { 36 | return spy.mock.calls.length === 3 37 | }) 38 | 39 | expect(await pkgHelpers.findNodeModulesPath(true)).toBe(modulePath) 40 | }) 41 | 42 | test('modulesPath using parent tree', async () => { 43 | const modulePath = path.join(__dirname, '../../src/utils/node_modules') 44 | jest.spyOn(fs, 'requireResolve').mockReturnValue('') 45 | jest.spyOn(path, 'resolve').mockReturnValue('') 46 | const spy = jest.spyOn(fs, 'exists').mockImplementation(() => { 47 | return spy.mock.calls.length === 4 48 | }) 49 | 50 | expect(await pkgHelpers.findNodeModulesPath(true)).toBe(modulePath) 51 | }) 52 | 53 | test('modulesPath empty', async () => { 54 | const modulePath = '' 55 | jest.spyOn(fs, 'requireResolve').mockReturnValue('') 56 | jest.spyOn(path, 'resolve').mockReturnValue('') 57 | jest.spyOn(fs, 'exists').mockReturnValue(false) 58 | 59 | expect(await pkgHelpers.findNodeModulesPath(true)).toBe(modulePath) 60 | }) 61 | }) 62 | -------------------------------------------------------------------------------- /src/browsers/puppeteer/core.js: -------------------------------------------------------------------------------- 1 | import ChromeDetector from '../../utils/detectors/chrome' 2 | import Browser from '../browser' 3 | import { BrowserError } from '../../utils' 4 | import Webpage from './webpage' 5 | 6 | export default class PuppeteerCoreBrowser extends Browser { 7 | async _loadDependencies() { 8 | super._loadDependencies() 9 | 10 | if (!PuppeteerCoreBrowser.core) { 11 | PuppeteerCoreBrowser.core = await this.loadDependency('puppeteer-core') 12 | } 13 | } 14 | 15 | setHeadless() { 16 | super.setHeadless() 17 | this.config.browserArguments.push('--headless') 18 | } 19 | 20 | setLogLevel(types) { 21 | const typeMap = { 22 | warning: 'warn' 23 | } 24 | 25 | if (types && typeof types === 'string') { 26 | types = [types] 27 | } 28 | 29 | this.hook('page:created', (page) => { 30 | /* eslint-disable no-console */ 31 | page.on('console', (msg) => { 32 | const msgType = msg.type() 33 | let type = typeMap[msgType] || msgType 34 | if (!types || types.includes(msgType) || types.includes(type)) { 35 | if (!console[type]) { 36 | console.warn(`Unknown console type ${type}`) 37 | type = 'log' 38 | } 39 | console[type](msg.text()) 40 | } 41 | }) 42 | /* eslint-enable no-console */ 43 | }) 44 | } 45 | 46 | async _start(capabilities, ...args) { 47 | // https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#puppeteerlaunchoptions 48 | const launchOptions = { 49 | args: [ 50 | '--no-sandbox', 51 | '--disable-setuid-sandbox', 52 | ...this.config.browserArguments, 53 | ...args 54 | ], 55 | ...capabilities 56 | } 57 | 58 | if (this.constructor.name === 'PuppeteerCoreBrowser') { 59 | let executablePath = process.env.PUPPETEER_EXECUTABLE_PATH 60 | if (!executablePath) { 61 | const detector = new ChromeDetector() 62 | executablePath = detector.detect() 63 | } 64 | 65 | if (!executablePath) { 66 | throw new BrowserError(this, 'Could not find a Chrome executable') 67 | } 68 | 69 | launchOptions.executablePath = executablePath 70 | } 71 | 72 | this.driver = await PuppeteerCoreBrowser.core.launch(launchOptions) 73 | } 74 | 75 | async _close() { 76 | if (!this.driver) { 77 | return 78 | } 79 | 80 | await this.driver.close() 81 | } 82 | 83 | _page(url, readyCondition) { 84 | const page = new Webpage(this) 85 | return page.open(url, readyCondition) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/browsers/saucelabs/index.js: -------------------------------------------------------------------------------- 1 | import SeleniumBrowser from '../selenium' 2 | import SeleniumLogging from '../selenium/logging' 3 | import { BrowserError } from '../../utils' 4 | 5 | export default class SauceLabsBrowser extends SeleniumLogging(SeleniumBrowser) { 6 | /* istanbul ignore next */ 7 | constructor(config) { 8 | config.xvfb = false 9 | 10 | super(config) 11 | 12 | this.hook('selenium:build:before', (builder) => { 13 | const user = this.getConfigProperty('user') 14 | const key = this.getConfigProperty('key') 15 | builder.usingServer(`http://${user}:${key}@ondemand.saucelabs.com:80/wd/hub`) 16 | }) 17 | 18 | throw new BrowserError(this, 'SauceLabs not yet implemented') 19 | } 20 | 21 | /* istanbul ignore next */ 22 | getConfigProperty(property, capabilities = {}, defaulValue) { 23 | const envKey = `BROWSERSTACK_${property.toUpperCase()}` 24 | if (process.env[envKey]) { 25 | return process.env[envKey] 26 | } 27 | 28 | const confKey = `browserstack.${property}` 29 | 30 | if (capabilities[confKey]) { 31 | return capabilities[confKey] 32 | } 33 | 34 | if (this.config.SauceLabsLocal && this.config.SauceLabsLocal[property]) { 35 | return this.config.SauceLabsLocal[property] 36 | } 37 | 38 | if (this.config.SauceLabs && this.config.SauceLabs[property]) { 39 | return this.config.SauceLabs[property] 40 | } 41 | 42 | if (typeof defaulValue !== 'undefined') { 43 | return defaulValue 44 | } 45 | 46 | throw new Error(`${this.constructor.name} could not resolve required config property '${property}'`) 47 | } 48 | 49 | /* istanbul ignore next */ 50 | async _start(capabilities = {}) { 51 | this.addCapabilities({ 52 | 'browserstack.user': this.getConfigProperty('user', capabilities), 53 | 'browserstack.key': this.getConfigProperty('key', capabilities), 54 | 'browserstack.debug': this.getConfigProperty('debug', capabilities, false) 55 | }) 56 | 57 | await super._start(capabilities) 58 | } 59 | 60 | /* istanbul ignore next */ 61 | setOS(name, version = '') { 62 | return this.addCapability('platform', `${name}${version ? ' ' : ''}${version || ''}`) 63 | } 64 | 65 | /* istanbul ignore next */ 66 | getBrowserVersion() { 67 | return this.getCapability('version') 68 | } 69 | 70 | /* istanbul ignore next */ 71 | setBrowserVersion(version) { 72 | return this.addCapability('version', version) 73 | } 74 | 75 | /* istanbul ignore next */ 76 | setDevice(deviceName) { 77 | return this.addCapability('device', deviceName) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/browsers/selenium/logging.js: -------------------------------------------------------------------------------- 1 | export default (SeleniumBrowser) => { 2 | return class extends SeleniumBrowser { 3 | setLogLevel(levels, types) { 4 | /* Seems only Chrome really supports browser logging 5 | * - https://github.com/mozilla/geckodriver/issues/284 6 | */ 7 | if (!this.constructor.name.includes('Chrome')) { 8 | console.warn(`Logging is not supported for ${this.constructor.name}`) // eslint-disable-line no-console 9 | return 10 | } 11 | 12 | if (this.driver) { 13 | console.warn('SeleniumBrowser: setLogLevel should be called before calling start()') // eslint-disable-line no-console 14 | } 15 | 16 | // flush the remote logs on every call to webpage 17 | this.hook('webpage:property', () => this.flushLogs()) 18 | 19 | this.hook('selenium:build:before', (builder) => { 20 | const logging = SeleniumBrowser.webdriver.logging 21 | 22 | const levelMap = { 23 | error: logging.Level.SEVERE, 24 | warning: logging.Level.WARNING, 25 | log: logging.Level.INFO, 26 | info: logging.Level.FINEST, 27 | debug: logging.Level.DEBUG 28 | } 29 | 30 | if (!Array.isArray(levels)) { 31 | levels = [levels] 32 | } 33 | 34 | let level 35 | for (const key in levelMap) { 36 | if (levels.includes(key)) { 37 | level = levelMap[key] 38 | break 39 | } 40 | } 41 | 42 | if (!types) { 43 | types = [logging.Type.BROWSER] 44 | } 45 | 46 | const prefs = new logging.Preferences() 47 | 48 | for (const key in logging.Type) { 49 | const type = logging.Type[key] 50 | if (types.includes(type)) { 51 | prefs.setLevel(type, level || 0) 52 | } 53 | } 54 | 55 | builder.setLoggingPrefs(prefs) 56 | this.logLevel = level || 0 57 | }) 58 | } 59 | 60 | flushLogs() { 61 | if (typeof this.logLevel === 'undefined') { 62 | return 63 | } 64 | 65 | const consoleLevelMap = { 66 | severe: 'error', 67 | warning: 'warn' 68 | } 69 | 70 | this.driver.manage().logs().get(SeleniumBrowser.webdriver.logging.Type.BROWSER).then((entries) => { 71 | entries.forEach((entry) => { 72 | const level = entry.level.name.toLowerCase() 73 | const consoleLevel = consoleLevelMap[level] 74 | const log = console[consoleLevel] || console[level] || console.log // eslint-disable-line no-console 75 | 76 | log(`(BROWSER) ${entry.level.name}: ${entry.message}`) 77 | }) 78 | }) 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/utils/browser.js: -------------------------------------------------------------------------------- 1 | import * as constants from './constants' 2 | 3 | export function getBrowserConfigFromString(browserString) { 4 | const browserConfig = {} 5 | 6 | browserString 7 | .split('/') 8 | .filter(s => !!s) 9 | .map(s => s.toLowerCase() 10 | // replace first occurence with = 11 | // -> Chrome 39 => chrome=39 12 | // -> Mac OS X=High Sierra => Mac OS X=High Sierra 13 | .replace(/([\s:])/, (m, $1, o, s) => (s.includes('=') ? $1 : '=')) 14 | ) 15 | .forEach((s) => { 16 | let [key, value] = s.split('=', 2) // eslint-disable-line prefer-const 17 | key = key.replace(/\s+/, '') 18 | 19 | if (constants.browsers.includes(key)) { 20 | browserConfig.browser = key 21 | 22 | if (value) { 23 | browserConfig.browserVersion = value.replace(/\s+/g, '') 24 | } 25 | return 26 | } 27 | 28 | if (constants.browserOptions.includes(key)) { 29 | browserConfig[key] = typeof value === 'undefined' ? true : value 30 | return 31 | } 32 | 33 | if (constants.os.includes(key)) { 34 | browserConfig.os = key 35 | 36 | if (value) { 37 | browserConfig.osVersion = value 38 | } 39 | return 40 | } 41 | 42 | if (constants.drivers.includes(key)) { 43 | browserConfig.driver = key 44 | return 45 | } 46 | 47 | if (constants.providers.includes(key)) { 48 | browserConfig.provider = key 49 | return 50 | } 51 | 52 | if (constants.browserVariants[key]) { 53 | browserConfig.browserVariant = key 54 | return 55 | } 56 | 57 | if (key === 'device') { 58 | browserConfig.device = value 59 | return 60 | } 61 | 62 | if (!value) { 63 | const screenResolution = key.split(/(\d+)x(\d+)/i) 64 | if (screenResolution && screenResolution.length > 1) { 65 | browserConfig.window = { 66 | width: parseInt(screenResolution[1]), 67 | height: parseInt(screenResolution[2]) 68 | } 69 | } 70 | } 71 | }) 72 | 73 | return browserConfig 74 | } 75 | 76 | export function getBrowserImportFromConfig(browserConfig) { 77 | const importPath = [] 78 | 79 | if (browserConfig.browser) { 80 | importPath.push(browserConfig.browser) 81 | } 82 | 83 | if (browserConfig.driver && !browserConfig.provider) { 84 | importPath.push(browserConfig.driver) 85 | } 86 | 87 | if (browserConfig.provider) { 88 | importPath.push(browserConfig.provider) 89 | } 90 | 91 | if (browserConfig.browserVariant) { 92 | importPath.push(browserConfig.browserVariant) 93 | } 94 | 95 | return importPath.join('/') 96 | } 97 | -------------------------------------------------------------------------------- /src/browsers/browserstack/index.js: -------------------------------------------------------------------------------- 1 | import SeleniumBrowser from '../selenium' 2 | import SeleniumLogging from '../selenium/logging' 3 | 4 | export default class BrowserStackBrowser extends SeleniumLogging(SeleniumBrowser) { 5 | constructor(config) { 6 | // always disable xvfb 7 | config.xvfb = false 8 | 9 | super(config) 10 | 11 | this.hook('selenium:build:before', (builder) => { 12 | builder.usingServer('https://hub-cloud.browserstack.com/wd/hub') 13 | }) 14 | 15 | // set default os if user hasnt set anything 16 | if (!config.browserConfig || !config.browserConfig.os) { 17 | this.setOS('windows', 10) 18 | } 19 | } 20 | 21 | getConfigProperty(property, capabilities = {}, defaulValue) { 22 | const envKey = `BROWSERSTACK_${property.toUpperCase()}` 23 | 24 | if (process.env[envKey]) { 25 | return process.env[envKey] 26 | } 27 | 28 | const confKey = `browserstack.${property}` 29 | 30 | if (capabilities[confKey]) { 31 | return capabilities[confKey] 32 | } 33 | 34 | if (this.config.BrowserStackLocal && this.config.BrowserStackLocal[property]) { 35 | return this.config.BrowserStackLocal[property] 36 | } 37 | 38 | if (this.config.BrowserStack && this.config.BrowserStack[property]) { 39 | return this.config.BrowserStack[property] 40 | } 41 | 42 | if (typeof defaulValue !== 'undefined') { 43 | return defaulValue 44 | } 45 | 46 | throw new Error(`${this.constructor.name} could not resolve required config property '${property}'`) 47 | } 48 | 49 | async _start(capabilities = {}) { 50 | this.addCapabilities({ 51 | 'browserstack.user': this.getConfigProperty('user', capabilities), 52 | 'browserstack.key': this.getConfigProperty('key', capabilities), 53 | 'browserstack.debug': this.getConfigProperty('debug', capabilities, false) 54 | }) 55 | 56 | await super._start(capabilities) 57 | } 58 | 59 | setHeadless() { return this } 60 | 61 | setWindow(width, height) { 62 | super.setWindow(width, height) 63 | this.addCapability('resolution', `${this.config.window.width}x${this.config.window.height}`) 64 | return this 65 | } 66 | 67 | getBrowserVersion() { 68 | return this.getCapability('browser_version') 69 | } 70 | 71 | setBrowserVersion(version) { 72 | return this.addCapability('browser_version', version) 73 | } 74 | 75 | setOS(name, version) { 76 | this.addCapability('os', name) 77 | 78 | if (version) { 79 | this.setOSVersion(version) 80 | } 81 | 82 | return this 83 | } 84 | 85 | setOSVersion(version) { 86 | return this.addCapability('os_version', version) 87 | } 88 | 89 | setDevice(device) { 90 | return this.addCapability('device', device) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /test/unit/__snapshots__/utils.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`utils browser strings 1`] = ` 4 | Object { 5 | "browser": "chrome", 6 | } 7 | `; 8 | 9 | exports[`utils browser strings 2`] = ` 10 | Object { 11 | "browser": "chrome", 12 | "os": "windows", 13 | } 14 | `; 15 | 16 | exports[`utils browser strings 3`] = ` 17 | Object { 18 | "browser": "chrome", 19 | "os": "windows", 20 | "osVersion": "8.1", 21 | } 22 | `; 23 | 24 | exports[`utils browser strings 4`] = ` 25 | Object { 26 | "browser": "chrome", 27 | "os": "windows", 28 | "osVersion": "8", 29 | } 30 | `; 31 | 32 | exports[`utils browser strings 5`] = ` 33 | Object { 34 | "browser": "chrome", 35 | "os": "windows", 36 | "osVersion": "7", 37 | } 38 | `; 39 | 40 | exports[`utils browser strings 6`] = ` 41 | Object { 42 | "browser": "chrome", 43 | "os": "macos", 44 | "osVersion": "high sierra", 45 | } 46 | `; 47 | 48 | exports[`utils browser strings 7`] = ` 49 | Object { 50 | "browser": "firefox", 51 | "os": "macos", 52 | "osVersion": "high sierra", 53 | } 54 | `; 55 | 56 | exports[`utils browser strings 8`] = ` 57 | Object { 58 | "browser": "firefox", 59 | "headless": true, 60 | } 61 | `; 62 | 63 | exports[`utils browser strings 9`] = ` 64 | Object { 65 | "driver": "puppeteer", 66 | "os": "linux", 67 | } 68 | `; 69 | 70 | exports[`utils browser strings 10`] = ` 71 | Object { 72 | "driver": "selenium", 73 | } 74 | `; 75 | 76 | exports[`utils browser strings 11`] = ` 77 | Object { 78 | "driver": "puppeteer", 79 | } 80 | `; 81 | 82 | exports[`utils browser strings 12`] = ` 83 | Object { 84 | "browser": "chrome", 85 | "browserVariant": "local", 86 | "browserVersion": "39", 87 | "os": "windows", 88 | "osVersion": "7", 89 | "provider": "browserstack", 90 | "window": Object { 91 | "height": 1024, 92 | "width": 1280, 93 | }, 94 | } 95 | `; 96 | 97 | exports[`utils browser strings 13`] = ` 98 | Object { 99 | "device": "android emulator", 100 | "os": "android", 101 | "osVersion": "8", 102 | "provider": "browserstack", 103 | } 104 | `; 105 | 106 | exports[`utils default html compiler should work 1`] = ` 107 | Object { 108 | "attrs": Array [ 109 | Object { 110 | "dynamic": undefined, 111 | "name": "id", 112 | "value": "\\"test\\"", 113 | }, 114 | ], 115 | "attrsList": Array [ 116 | Object { 117 | "name": "id", 118 | "value": "test", 119 | }, 120 | ], 121 | "attrsMap": Object { 122 | "id": "test", 123 | }, 124 | "children": Array [], 125 | "parent": undefined, 126 | "plain": false, 127 | "rawAttrsMap": Object {}, 128 | "static": true, 129 | "staticInFor": false, 130 | "staticRoot": false, 131 | "tag": "div", 132 | "type": 1, 133 | } 134 | `; 135 | -------------------------------------------------------------------------------- /src/browsers/selenium/webpage.js: -------------------------------------------------------------------------------- 1 | import Webpage from '../webpage' 2 | import { parseFunction } from '../../utils' 3 | 4 | export default class SeleniumWebpage extends Webpage { 5 | async open(url, readyCondition = 'body') { 6 | await this.driver.get(url) 7 | 8 | await this.browser.callHook('page:created', this.driver) 9 | 10 | if (!SeleniumWebpage.By) { 11 | SeleniumWebpage.By = this.browser.constructor.webdriver.By 12 | SeleniumWebpage.Condition = this.browser.constructor.webdriver.Condition 13 | SeleniumWebpage.until = this.browser.constructor.webdriver.until 14 | } 15 | 16 | if (readyCondition) { 17 | if (typeof readyCondition === 'function') { 18 | await this.driver.wait(this.runScript(readyCondition)) 19 | } else { 20 | const el = await SeleniumWebpage.until.elementLocated(SeleniumWebpage.By.css(readyCondition)) 21 | await SeleniumWebpage.until.elementIsVisible(el) 22 | } 23 | } 24 | 25 | return this.returnProxy() 26 | } 27 | 28 | runScript(fn, ...args) { 29 | let parsedFn 30 | if (typeof fn === 'function') { 31 | parsedFn = parseFunction(fn, this.getBabelPresetOptions()) 32 | } else { 33 | parsedFn = fn 34 | } 35 | 36 | const argStr = parsedFn.args.reduce((acc, v, i) => `${acc}var ${v} = arguments[${i}]; `, '') 37 | const script = `${argStr} 38 | ${parsedFn.body}` 39 | 40 | return this.driver.executeScript(script, ...args) 41 | } 42 | 43 | runAsyncScript(fn, ...args) { 44 | let parsedFn 45 | if (typeof fn === 'function') { 46 | parsedFn = parseFunction(fn, this.getBabelPresetOptions()) 47 | } else { 48 | parsedFn = fn 49 | } 50 | 51 | const argStr = parsedFn.args.reduce((acc, v, i) => `${acc}var ${v} = arguments[${i}]; `, '') 52 | const script = `${argStr} 53 | var args = [].slice.call(arguments) 54 | var callback = args.pop() 55 | var retVal = (function() { ${parsedFn.body} }).apply(null, args) 56 | if (retVal && retVal.then) { 57 | retVal.then(callback) 58 | } else { 59 | callback(retVal) 60 | } 61 | ` 62 | return this.driver.executeAsyncScript(script, ...args) 63 | } 64 | 65 | getHtml() { 66 | return this.driver.getPageSource() 67 | } 68 | 69 | async getWebElement(selector, fn = el => el) { 70 | return fn(await this.driver.findElement(SeleniumWebpage.By.css(selector))) 71 | } 72 | 73 | async getWebElements(selector, fn = els => els) { 74 | return Promise.all((await this.driver.findElements(SeleniumWebpage.By.css(selector))).map(fn)) 75 | } 76 | 77 | /* 78 | * WARNING: WebElements seem to return instead 79 | * of null when attribute doesnt exist 80 | */ 81 | getWebAttribute(selector, attribute) { 82 | return this.getWebElement(selector, el => el.getAttribute(attribute)) 83 | } 84 | 85 | getWebAttributes(selector, attribute) { 86 | return this.getWebElements(selector, el => el.getAttribute(attribute)) 87 | } 88 | 89 | async click(selector) { 90 | const el = await this.getWebElement(selector) 91 | await el.click() 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tib", 3 | "version": "0.7.5", 4 | "description": "Easy e2e browser testing in Node", 5 | "main": "lib/tib.js", 6 | "module": "src/index.js", 7 | "repository": "https://github.com/nuxt-contrib/tib", 8 | "author": "pimlie ", 9 | "license": "MIT", 10 | "scripts": { 11 | "build": "rimraf lib && rollup -c scripts/rollup.config.js", 12 | "coverage": "codecov", 13 | "lint": "eslint src test", 14 | "prerelease": "git checkout master && git pull -r", 15 | "release": "yarn lint && yarn test && yarn build && standard-version", 16 | "postrelease": "git push origin master --follow-tags && yarn publish", 17 | "test": "yarn test:unit && yarn test:e2e", 18 | "test:unit": "jest test/unit", 19 | "test:e2e": "jest test/e2e" 20 | }, 21 | "files": [ 22 | "lib", 23 | "src" 24 | ], 25 | "keywords": [ 26 | "selenium", 27 | "puppeteer", 28 | "browser", 29 | "testing", 30 | "end to end", 31 | "e2e", 32 | "browserstack", 33 | "saucelabs", 34 | "jest" 35 | ], 36 | "dependencies": { 37 | "@babel/core": "^7.12.10", 38 | "@babel/parser": "^7.12.10", 39 | "babel-loader": "^8.2.2", 40 | "glob": "^7.1.6", 41 | "hable": "^3.0.0", 42 | "signal-exit": "^3.0.3", 43 | "tree-kill": "^1.2.2", 44 | "vue-template-compiler": "^2.6.12", 45 | "webpack": "^5.10.1" 46 | }, 47 | "devDependencies": { 48 | "@babel/node": "^7.12.10", 49 | "@babel/plugin-syntax-dynamic-import": "^7.8.3", 50 | "@babel/preset-env": "^7.12.10", 51 | "add": "^2.0.6", 52 | "babel-core": "^6.26.3", 53 | "babel-eslint": "^10.1.0", 54 | "babel-jest": "^26.6.3", 55 | "babel-plugin-dynamic-import-node": "^2.3.3", 56 | "browserstack-local": "^1.4.8", 57 | "chromedriver": "^87.0.2", 58 | "codecov": "^3.8.1", 59 | "eslint": "^7.15.0", 60 | "eslint-config-standard": "^16.0.2", 61 | "eslint-plugin-es": "^4.1.0", 62 | "eslint-plugin-import": "^2.22.1", 63 | "eslint-plugin-jest": "^24.1.3", 64 | "eslint-plugin-node": "^11.1.0", 65 | "eslint-plugin-promise": "^4.2.1", 66 | "eslint-plugin-standard": "^5.0.0", 67 | "eslint-plugin-vue": "^7.2.0", 68 | "finalhandler": "^1.1.2", 69 | "geckodriver": "^1.21.1", 70 | "jest": "^26.6.3", 71 | "jsdom": "^16.4.0", 72 | "lodash.defaultsdeep": "^4.6.1", 73 | "node-env-file": "^0.1.8", 74 | "puppeteer": "^5.5.0", 75 | "puppeteer-core": "^5.5.0", 76 | "rimraf": "^3.0.2", 77 | "rollup": "^2.34.2", 78 | "rollup-plugin-commonjs": "^10.1.0", 79 | "rollup-plugin-json": "^4.0.0", 80 | "rollup-plugin-node-resolve": "^5.2.0", 81 | "selenium-webdriver": "^4.0.0-alpha.8", 82 | "serve-static": "^1.14.1", 83 | "standard-version": "^9.0.0", 84 | "yarn": "^1.22.10" 85 | }, 86 | "peerDependencies": { 87 | "browserstack-local": "^1.4.8", 88 | "chromedriver": "^87.0.2", 89 | "geckodriver": "^1.21.1", 90 | "jsdom": "^16.4.0", 91 | "puppeteer": "^5.5.0", 92 | "puppeteer-core": "^5.5.0", 93 | "selenium-webdriver": "^4.0.0-alpha.8", 94 | "serve-static": "^1.14.1" 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /test/unit/browser-options.test.js: -------------------------------------------------------------------------------- 1 | import Browser from '../../src/browsers/browser' 2 | import PuppeteerBrowser from '../../src/browsers/puppeteer' 3 | import PuppeteerCoreBrowser from '../../src/browsers/puppeteer/core' 4 | import ChromeSeleniumBrowser from '../../src/browsers/chrome/selenium' 5 | import FirefoxSeleniumBrowser from '../../src/browsers/firefox' 6 | import Xvfb from '../../src/commands/xvfb' 7 | 8 | process.env.PUPPETEER_EXECUTABLE_PATH = '/usr/bin/chromium-browser' 9 | process.env.CHROME_EXECUTABLE_PATH = '/usr/bin/chromium-browser' 10 | 11 | describe('browser options', () => { 12 | const xvfbSupportedPlatform = Xvfb.isSupported() 13 | 14 | test('chrome/puppeteer', async () => { 15 | let browser 16 | await expect(Browser.get('chrome/puppeteer').then(b => (browser = b))).resolves.not.toThrow() 17 | 18 | expect(browser).toBeInstanceOf(PuppeteerBrowser) 19 | expect(browser.config.browserConfig.browser).toBe('chrome') 20 | expect(browser.config.xvfb).toBe(xvfbSupportedPlatform) 21 | }) 22 | 23 | test('chrome/puppeteer/headless/xvfb', async () => { 24 | let browser 25 | await expect(Browser.get('chrome/puppeteer/headless/xvfb').then(b => (browser = b))).resolves.not.toThrow() 26 | 27 | expect(browser).toBeInstanceOf(PuppeteerBrowser) 28 | expect(browser.config.browserConfig.browser).toBe('chrome') 29 | expect(browser.config.xvfb).toBe(false) 30 | }) 31 | 32 | test('chrome/selenium', async () => { 33 | let browser 34 | await expect(Browser.get('chrome/selenium').then(b => (browser = b))).resolves.not.toThrow() 35 | 36 | expect(browser).toBeInstanceOf(ChromeSeleniumBrowser) 37 | expect(browser.config.browserConfig.browser).toBe('chrome') 38 | expect(browser.config.xvfb).toBe(xvfbSupportedPlatform) 39 | }) 40 | 41 | test('chrome/selenium/headless', async () => { 42 | let browser 43 | await expect(Browser.get('chrome/selenium/headless').then(b => (browser = b))).resolves.not.toThrow() 44 | 45 | expect(browser).toBeInstanceOf(ChromeSeleniumBrowser) 46 | expect(browser.config.browserConfig.browser).toBe('chrome') 47 | expect(browser.config.xvfb).toBe(false) 48 | }) 49 | 50 | test('firefox', async () => { 51 | let browser 52 | await expect(Browser.get('firefox').then(b => (browser = b))).resolves.not.toThrow() 53 | 54 | expect(browser).toBeInstanceOf(FirefoxSeleniumBrowser) 55 | expect(browser.config.browserConfig.browser).toBe('firefox') 56 | expect(browser.config.xvfb).toBe(xvfbSupportedPlatform) 57 | }) 58 | 59 | test('firefox/headless', async () => { 60 | let browser 61 | await expect(Browser.get('firefox/headless').then(b => (browser = b))).resolves.not.toThrow() 62 | 63 | expect(browser).toBeInstanceOf(FirefoxSeleniumBrowser) 64 | expect(browser.config.browserConfig.browser).toBe('firefox') 65 | expect(browser.config.xvfb).toBe(false) 66 | }) 67 | 68 | test('chrome', async () => { 69 | let browser 70 | await expect(Browser.get('chrome').then(b => (browser = b))).resolves.not.toThrow() 71 | 72 | expect(browser).toBeInstanceOf(PuppeteerCoreBrowser) 73 | expect(browser.config.browserConfig.browser).toBe('chrome') 74 | expect(browser.config.xvfb).toBe(xvfbSupportedPlatform) 75 | }) 76 | 77 | test('chrome/headless/xvfb', async () => { 78 | let browser 79 | await expect(Browser.get('chrome/headless/xvfb').then(b => (browser = b))).resolves.not.toThrow() 80 | 81 | expect(browser).toBeInstanceOf(PuppeteerCoreBrowser) 82 | expect(browser.config.browserConfig.browser).toBe('chrome') 83 | expect(browser.config.xvfb).toBe(false) 84 | }) 85 | 86 | test('chrome/staticServer', async () => { 87 | let browser 88 | await expect(Browser.get('chrome/staticserver').then(b => (browser = b))).resolves.not.toThrow() 89 | 90 | expect(browser).toBeInstanceOf(PuppeteerCoreBrowser) 91 | expect(browser.config.staticServer).toEqual({ folder: undefined }) 92 | }) 93 | }) 94 | -------------------------------------------------------------------------------- /src/utils/page-functions.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import webpack from 'webpack' 3 | import { readFile, exists, stats, glob } from './fs' 4 | import { getCachePath, createCacheKey } from './cache' 5 | import { camelCase } from './misc' 6 | import { findNodeModulesPath } from './package' 7 | 8 | export async function createPageFunctions(page, sourceFiles, babelPresets) { 9 | const pageFunctions = {} 10 | 11 | if (!sourceFiles) { 12 | return pageFunctions 13 | } 14 | 15 | if (typeof sourceFiles === 'string') { 16 | sourceFiles = await glob(sourceFiles) 17 | } 18 | 19 | for (let file of sourceFiles) { 20 | let fnName 21 | if (Array.isArray(file)) { 22 | [file, fnName] = file 23 | } else { 24 | fnName = camelCase(path.basename(file, '.js')) 25 | } 26 | 27 | if (pageFunctions[fnName]) { 28 | // eslint-disable-next-line no-console 29 | console.warn(`A page function with name '${fnName}' already exists, the previous one will be overwritten`) 30 | } 31 | 32 | pageFunctions[fnName] = async (...args) => { 33 | const body = await getPageFunctionBody(fnName, file, babelPresets) 34 | 35 | return page.runAsyncScript({ 36 | args: [], 37 | body: `${body} return (pageFn.${fnName} || pageFn.default).apply(null, arguments)` 38 | }, ...args) 39 | } 40 | } 41 | 42 | return pageFunctions 43 | } 44 | 45 | export async function getPageFunctionBody(fnName, filePath, babelPresets) { 46 | const cacheKey = createCacheKey(filePath, babelPresets) 47 | const cacheFile = `${fnName}-${cacheKey}.js` 48 | const cachePath = await getCachePath(cacheFile) 49 | let cacheValid = await exists(cachePath) 50 | 51 | if (cacheValid) { 52 | const { mtime: fileModified } = await stats(filePath) 53 | const { mtime: cacheModified } = await stats(cachePath) 54 | 55 | cacheValid = cacheModified > fileModified 56 | } 57 | 58 | if (!cacheValid) { 59 | // transpile page function 60 | await compilePageFunction({ 61 | name: 'pageFn', 62 | filePath, 63 | cachePath, 64 | babelPresets 65 | }) 66 | } 67 | 68 | const fnBody = await readFile(cachePath, { encoding: 'utf8' }) 69 | return fnBody 70 | } 71 | 72 | export async function compilePageFunction({ babelPresets, ...config }) { 73 | let babelOptions 74 | 75 | if (babelPresets) { 76 | babelOptions = { 77 | presets: [ 78 | ['@babel/preset-env', babelPresets] 79 | ] 80 | } 81 | } else { 82 | babelOptions = { 83 | babelrcRoots: [ 84 | '.', 85 | path.resolve(await findNodeModulesPath(), '..') 86 | ] 87 | } 88 | } 89 | 90 | config.babelOptions = babelOptions 91 | 92 | return new Promise((resolve, reject) => { 93 | const webpackConfig = createWebpackConfig(config) 94 | 95 | webpack(webpackConfig, (err, stats) => { 96 | /* istanbul ignore next */ 97 | if (err) { 98 | reject(err) 99 | return 100 | } 101 | 102 | if (stats.hasErrors()) { 103 | const [err] = stats.compilation.errors 104 | reject(err) 105 | return 106 | } 107 | 108 | resolve(stats) 109 | }) 110 | }) 111 | } 112 | export function createWebpackConfig({ 113 | name, 114 | filePath, 115 | cachePath, 116 | babelOptions = {} 117 | }) { 118 | return { 119 | mode: 'production', 120 | entry: filePath, 121 | output: { 122 | path: path.dirname(cachePath), 123 | filename: path.basename(cachePath), 124 | library: name, 125 | libraryTarget: 'var' 126 | }, 127 | plugins: [ 128 | new webpack.optimize.LimitChunkCountPlugin({ 129 | maxChunks: 1 130 | }) 131 | ], 132 | module: { 133 | rules: [ 134 | { 135 | test: /\.js$/, 136 | exclude: /node_modules/, 137 | use: { 138 | loader: 'babel-loader', 139 | options: babelOptions 140 | } 141 | } 142 | ] 143 | } 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/commands/xvfb.js: -------------------------------------------------------------------------------- 1 | import os from 'os' 2 | import { spawn } from 'child_process' 3 | import kill from 'tree-kill' 4 | import onExit from 'signal-exit' 5 | import { enableTimers, BrowserError } from '../utils' 6 | 7 | const consola = console // eslint-disable-line no-console 8 | 9 | const supportedPlatforms = [ 10 | 'linux', 11 | 'freebsd', 12 | 'openbsd' 13 | ] 14 | 15 | export default class Xvfb { 16 | static load(browser) { 17 | if (!browser.config.xvfb) { 18 | return 19 | } 20 | 21 | Xvfb.isSupported(true) 22 | 23 | const browserConfig = browser.config.browserConfig 24 | if (browserConfig && browserConfig.window) { 25 | if (typeof browser.config.xvfb !== 'object') { 26 | browser.config.xvfb = { args: [] } 27 | } 28 | 29 | browser.config.xvfb.args.push(`-screen 0 ${browserConfig.window.width}x${browserConfig.window.height}x24`) 30 | } 31 | 32 | const config = { 33 | quiet: browser.config.quiet, 34 | ...browser.config.xvfb 35 | } 36 | 37 | browser.hook('start:before', () => Xvfb.start(config)) 38 | browser.hook('close:after', Xvfb.stop) 39 | } 40 | 41 | static isSupported(failWhenNot) { 42 | const platform = os.platform() 43 | const supported = supportedPlatforms.includes(platform) 44 | 45 | if (!supported && failWhenNot) { 46 | throw new BrowserError(`Xvfb is not supported on ${platform} platforms`) 47 | } 48 | 49 | return supported 50 | } 51 | 52 | static isRunning() { 53 | return !!Xvfb.process && Xvfb.process.connected && !Xvfb.closed 54 | } 55 | 56 | static start({ displayNum = 99, args = [], quiet } = {}) { 57 | Xvfb.isSupported(true) 58 | Xvfb.closed = false 59 | 60 | if (Xvfb.isRunning()) { 61 | return 62 | } 63 | 64 | args = (Array.isArray(args) && args) || [] 65 | 66 | const display = `:${displayNum}` 67 | args.unshift(display) 68 | args.unshift('Xvfb') 69 | 70 | Xvfb.process = spawn('command', args, { 71 | shell: true, 72 | stdio: [ 73 | 'ignore', 74 | 'ignore', 75 | 'pipe' 76 | ] 77 | }) 78 | 79 | // set DISPLAY env 80 | process.env.DISPLAY = display 81 | 82 | let stderr = '' 83 | Xvfb.process.stderr.on('data', data => (stderr += `${data}`)) 84 | 85 | Xvfb.process.on('error', (err) => { 86 | Xvfb.closed = true 87 | 88 | if (err && err.code === 'ENOENT') { 89 | throw new BrowserError('Xvfb not found, please make sure Xvfb is installed') 90 | } 91 | }) 92 | 93 | Xvfb.process.on('close', (code, signal) => { 94 | Xvfb.closed = true 95 | 96 | if (code === 1) { 97 | const error = stderr.match(/\(EE\) (?!\(EE\))(.+?)$/m)[1] || stderr 98 | if (stderr.includes('already active for display')) { 99 | if (!quiet) { 100 | console.warn(`Xvfb: ${error}`, Xvfb.process.pid) // eslint-disable-line no-console 101 | } 102 | return 103 | } 104 | 105 | throw new BrowserError(`Failed to start Xvfb${error ? ', ' : ''}${error}`) 106 | } 107 | }) 108 | 109 | onExit(() => Xvfb.stop()) 110 | } 111 | 112 | static stop() { 113 | if (!Xvfb.process || Xvfb.closed) { 114 | return 115 | } 116 | 117 | kill(Xvfb.process.pid) 118 | 119 | // enable timers if they where faked by a test framework 120 | enableTimers() 121 | 122 | let closeTimeout 123 | const waitTimeout = new Promise((resolve) => { 124 | closeTimeout = setTimeout(() => { 125 | consola.warn('Timeout: Xvfb did not exit after 3s') 126 | resolve() 127 | }, 3000) 128 | closeTimeout.unref() 129 | }) 130 | 131 | const waitClosed = new Promise((resolve) => { 132 | const closeInterval = setInterval(() => { 133 | if (Xvfb.closed) { 134 | clearTimeout(closeTimeout) 135 | clearInterval(closeInterval) 136 | resolve() 137 | } 138 | }, 50) 139 | }) 140 | 141 | return Promise.race([waitClosed, waitTimeout]) 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | defaults: &defaults 4 | working_directory: ~/project 5 | docker: 6 | - image: circleci/node:latest 7 | environment: 8 | NODE_ENV: test 9 | 10 | jobs: 11 | setup: 12 | <<: *defaults 13 | steps: 14 | # Checkout repository 15 | - checkout 16 | 17 | # Restore cache 18 | - restore_cache: 19 | key: yarn-{{ checksum "yarn.lock" }} 20 | 21 | # Install dependencies 22 | - run: 23 | name: Install Dependencies 24 | command: NODE_ENV=dev yarn 25 | 26 | # Keep cache 27 | - save_cache: 28 | key: yarn-{{ checksum "yarn.lock" }} 29 | paths: 30 | - "node_modules" 31 | 32 | # Persist workspace 33 | - persist_to_workspace: 34 | root: ~/project 35 | paths: 36 | - node_modules 37 | 38 | lint: 39 | <<: *defaults 40 | steps: 41 | - checkout 42 | - attach_workspace: 43 | at: ~/project 44 | - run: 45 | name: Lint 46 | command: yarn lint 47 | 48 | audit: 49 | <<: *defaults 50 | steps: 51 | - checkout 52 | - attach_workspace: 53 | at: ~/project 54 | - run: 55 | name: Security Audit 56 | command: yarn audit --groups dependencies 57 | 58 | test-unit: 59 | <<: *defaults 60 | steps: 61 | - checkout 62 | - attach_workspace: 63 | at: ~/project 64 | - run: 65 | name: Unit Tests 66 | command: yarn test:unit --coverage && yarn coverage 67 | 68 | test-e2e-chrome: 69 | docker: 70 | - image: circleci/node:latest-browsers 71 | steps: 72 | - checkout 73 | - attach_workspace: 74 | at: ~/project 75 | - run: 76 | name: E2E Tests 77 | command: yarn test:e2e --coverage && yarn coverage 78 | environment: 79 | BROWSER_STRING: chrome 80 | 81 | test-e2e-firefox: 82 | docker: 83 | - image: circleci/node:latest-browsers 84 | steps: 85 | - checkout 86 | - attach_workspace: 87 | at: ~/project 88 | - run: 89 | name: E2E Tests 90 | command: yarn test:e2e --coverage && yarn coverage 91 | environment: 92 | BROWSER_STRING: firefox 93 | 94 | test-e2e-puppeteer: 95 | docker: 96 | - image: circleci/node:latest-browsers 97 | steps: 98 | - checkout 99 | - attach_workspace: 100 | at: ~/project 101 | - run: 102 | name: E2E Tests 103 | command: yarn test:e2e --coverage && yarn coverage 104 | environment: 105 | BROWSER_STRING: puppeteer 106 | 107 | test-e2e-browserstack: 108 | docker: 109 | - image: circleci/node:latest-browsers 110 | steps: 111 | - checkout 112 | - attach_workspace: 113 | at: ~/project 114 | - run: 115 | name: E2E Tests 116 | command: yarn test:e2e --coverage && yarn coverage 117 | environment: 118 | BROWSER_STRING: chrome/browserstack/local 119 | 120 | test-e2e-jsdom: 121 | docker: 122 | - image: circleci/node 123 | steps: 124 | - checkout 125 | - attach_workspace: 126 | at: ~/project 127 | - run: 128 | name: E2E Tests 129 | command: yarn test:e2e --coverage && yarn coverage 130 | environment: 131 | BROWSER_STRING: jsdom 132 | 133 | workflows: 134 | version: 2 135 | 136 | commit: 137 | jobs: 138 | - setup 139 | - lint: { requires: [setup] } 140 | - audit: { requires: [setup] } 141 | - test-unit: { requires: [lint] } 142 | - test-e2e-browserstack: { requires: [lint], filters: { branches: { ignore: /pull\/.+/ } } } 143 | - test-e2e-chrome: { requires: [lint] } 144 | - test-e2e-firefox: { requires: [lint] } 145 | - test-e2e-jsdom: { requires: [lint] } 146 | - test-e2e-puppeteer: { requires: [lint] } 147 | -------------------------------------------------------------------------------- /test/unit/webpage.selenium.test.js: -------------------------------------------------------------------------------- 1 | import Browser from '../../src/browsers/selenium' 2 | 3 | describe('selenium/webpage', () => { 4 | let browser 5 | let webpage 6 | let spy 7 | 8 | beforeAll(() => { 9 | browser = new Browser() 10 | 11 | spy = jest.fn() 12 | browser.constructor.webdriver = { 13 | By: { 14 | css: (...args) => spy('By.css', ...args) 15 | }, 16 | until: { 17 | elementIsVisible: (...args) => spy('until.elementIsVisible', ...args), 18 | elementLocated: (...args) => spy('until.elementLocated', ...args) 19 | } 20 | } 21 | 22 | const getAttribute = jest.fn() 23 | const findElement = jest.fn().mockReturnValue({ getAttribute }) 24 | const findElements = jest.fn().mockReturnValue([{ getAttribute }, { getAttribute }]) 25 | browser.driver = { 26 | get: (...args) => spy('get', ...args), 27 | wait: (...args) => spy('wait', ...args), 28 | getPageSource: (...args) => spy('getPageSource', ...args), 29 | getAttribute, 30 | findElement, 31 | findElements, 32 | executeScript: (...args) => spy('executeScript', ...args), 33 | executeAsyncScript: (...args) => spy('executeAsyncScript', ...args) 34 | } 35 | }) 36 | 37 | afterEach(() => jest.clearAllMocks()) 38 | 39 | test('should open page', async () => { 40 | const webPath = '/' 41 | webpage = await browser.page(webPath) 42 | expect(spy).toHaveBeenCalledTimes(4) 43 | expect(spy).toHaveBeenCalledWith('get', webPath) 44 | expect(spy).toHaveBeenCalledWith('until.elementLocated', undefined) 45 | expect(spy).toHaveBeenCalledWith('By.css', 'body') 46 | expect(spy).toHaveBeenCalledWith('until.elementIsVisible', undefined) 47 | }) 48 | 49 | test('should implement getHtml', () => { 50 | webpage.getHtml() 51 | expect(spy).toHaveBeenCalledWith('getPageSource') 52 | }) 53 | 54 | test('should implement runScript', () => { 55 | const fn = () => true 56 | webpage.runScript(fn) 57 | expect(spy).toHaveBeenCalledWith('executeScript', expect.stringMatching('return true;')) 58 | }) 59 | 60 | test('should run sync script in runAsyncScript and fix blockless bodies', () => { 61 | // runAsync fixes blockless bodies & sync scripts in async 62 | const callback = jest.fn() 63 | const fn = () => true 64 | 65 | webpage.runAsyncScript(fn, true) 66 | 67 | expect(spy).toHaveBeenCalledWith('executeAsyncScript', expect.any(String), true) 68 | expect(spy.mock.calls[0][1]).toContain('then(callback)') 69 | expect(spy.mock.calls[0][1]).toMatchSnapshot(); 70 | 71 | (function runEval() { 72 | expect(() => eval(spy.mock.calls[0][1])).not.toThrow() // eslint-disable-line no-eval 73 | })(callback) 74 | expect(callback).toHaveBeenCalledTimes(1) 75 | }) 76 | 77 | test('should run async script in runAsyncScript', async () => { 78 | const callback = jest.fn() 79 | const fn = () => { return Promise.resolve(true) } 80 | 81 | webpage.runAsyncScript(fn, true) 82 | 83 | expect(spy).toHaveBeenCalledWith('executeAsyncScript', expect.any(String), true) 84 | expect(spy.mock.calls[0][1]).toContain('then(callback)') 85 | expect(spy.mock.calls[0][1]).toMatchSnapshot() 86 | 87 | await new Promise((resolve) => { 88 | (function runEval() { 89 | expect(() => eval(spy.mock.calls[0][1])).not.toThrow() // eslint-disable-line no-eval 90 | })(() => { callback(); resolve() }) 91 | }) 92 | expect(callback).toHaveBeenCalledTimes(1) 93 | }) 94 | 95 | test('should implement getWebElement', async () => { 96 | await webpage.getWebElement() 97 | expect(browser.driver.findElement).toHaveBeenCalledTimes(1) 98 | }) 99 | 100 | test('should implement getWebElements', async () => { 101 | await webpage.getWebElements() 102 | expect(browser.driver.findElements).toHaveBeenCalledTimes(1) 103 | }) 104 | 105 | test('should implement getWebAttribute', async () => { 106 | await webpage.getWebAttribute() 107 | expect(browser.driver.findElement).toHaveBeenCalledTimes(1) 108 | expect(browser.driver.getAttribute).toHaveBeenCalledTimes(1) 109 | }) 110 | 111 | test('should implement getWebAttributes', async () => { 112 | await webpage.getWebAttributes() 113 | expect(browser.driver.findElements).toHaveBeenCalledTimes(1) 114 | expect(browser.driver.getAttribute).toHaveBeenCalledTimes(2) 115 | }) 116 | }) 117 | -------------------------------------------------------------------------------- /test/unit/page-functions.test.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import * as fs from '../../src/utils/fs' 3 | import * as pagefns from '../../src/utils/page-functions' 4 | 5 | describe('page functions', () => { 6 | beforeAll(async () => { 7 | }) 8 | 9 | afterEach(() => { 10 | jest.restoreAllMocks() 11 | }) 12 | 13 | test('returns empty object when no page fns specified', async () => { 14 | expect(await pagefns.createPageFunctions()).toEqual({}) 15 | }) 16 | 17 | test('uses page fns from glob path', async () => { 18 | const page = { 19 | runAsyncScript: jest.fn() 20 | } 21 | 22 | const transpiledFunctions = await pagefns.createPageFunctions(page, path.resolve(__dirname, '../fixtures/*.js')) 23 | 24 | expect(transpiledFunctions).toMatchObject({ 25 | errorFunction: expect.any(Function), 26 | pageFunction: expect.any(Function) 27 | }) 28 | }) 29 | 30 | test('prints warning on functions with same name ', async () => { 31 | const spy = jest.spyOn(console, 'warn') 32 | const page = { 33 | runAsyncScript: jest.fn() 34 | } 35 | 36 | const pageFunctions = [ 37 | path.resolve(__dirname, '../fixtures/page-function.js'), 38 | [path.resolve(__dirname, '../fixtures/error-function.js'), 'pageFunction'] 39 | ] 40 | 41 | const transpiledFunctions = await pagefns.createPageFunctions(page, pageFunctions) 42 | 43 | expect(transpiledFunctions).toMatchObject({ 44 | pageFunction: expect.any(Function) 45 | }) 46 | expect(spy).toHaveBeenCalledWith('A page function with name \'pageFunction\' already exists, the previous one will be overwritten') 47 | }) 48 | 49 | test('create page functions for ie 9', async () => { 50 | jest.spyOn(fs, 'stats').mockReturnValue({ mtime: 1 }) 51 | 52 | const page = { 53 | runAsyncScript: jest.fn() 54 | } 55 | 56 | const babelPresets = { 57 | targets: { ie: 9 } 58 | } 59 | 60 | const pageFunctions = [ 61 | [path.resolve(__dirname, '../fixtures/page-function.js'), 'TestPageFunction'] 62 | ] 63 | const transpiledFunctions = await pagefns.createPageFunctions(page, pageFunctions, babelPresets) 64 | expect(transpiledFunctions).toEqual(expect.any(Object)) 65 | expect(transpiledFunctions.TestPageFunction).toEqual(expect.any(Function)) 66 | 67 | await transpiledFunctions.TestPageFunction() 68 | 69 | expect(page.runAsyncScript).toHaveBeenCalledWith(expect.any(Object)) 70 | 71 | const transpiledFn = page.runAsyncScript.mock.calls[0][0] 72 | expect(transpiledFn.body).toEqual(expect.stringContaining('function(){return!0}')) 73 | }) 74 | 75 | test('create page functions for node (and test cache validation)', async () => { 76 | jest.spyOn(fs, 'stats').mockImplementation(path => ({ mtime: path.includes('/.cache/tib/') ? 0 : 1 })) 77 | jest.spyOn(fs, 'exists').mockReturnValue(true) 78 | 79 | const page = { 80 | runAsyncScript: jest.fn() 81 | } 82 | 83 | const babelPresets = { 84 | targets: { node: 'current' } 85 | } 86 | 87 | const pageFunctions = [ 88 | path.resolve(__dirname, '../fixtures/page-function.js') 89 | ] 90 | const transpiledFunctions = await pagefns.createPageFunctions(page, pageFunctions, babelPresets) 91 | expect(transpiledFunctions).toEqual(expect.any(Object)) 92 | expect(transpiledFunctions.pageFunction).toEqual(expect.any(Function)) 93 | 94 | await transpiledFunctions.pageFunction() 95 | 96 | expect(page.runAsyncScript).toHaveBeenCalledWith(expect.any(Object)) 97 | 98 | const transpiledFn = page.runAsyncScript.mock.calls[0][0] 99 | expect(transpiledFn.body).toEqual(expect.stringContaining('()=>!0')) 100 | }) 101 | 102 | test('create page functions with a webpack error', async () => { 103 | const page = { 104 | getBabelPresetOptions: () => ({ 105 | targets: { node: 'current' } 106 | }), 107 | runAsyncScript: jest.fn() 108 | } 109 | 110 | const pageFunctions = [ 111 | [path.resolve(__dirname, '../fixtures/error-function.js'), 'TestPageFunction'] 112 | ] 113 | const transpiledFunctions = await pagefns.createPageFunctions(page, pageFunctions) 114 | expect(transpiledFunctions).toEqual(expect.any(Object)) 115 | expect(transpiledFunctions.TestPageFunction).toEqual(expect.any(Function)) 116 | 117 | await expect(transpiledFunctions.TestPageFunction()).rejects.toThrow('Module build failed') 118 | }) 119 | }) 120 | -------------------------------------------------------------------------------- /test/unit/command.static-server.test.js: -------------------------------------------------------------------------------- 1 | import http from 'http' 2 | import finalhandler from 'finalhandler' 3 | import serveStatic from 'serve-static' 4 | import { loadDependency } from '../../src/utils' 5 | 6 | jest.mock('finalhandler') 7 | jest.mock('serve-static') 8 | jest.mock('../../src/utils') 9 | 10 | describe('StaticServer', () => { 11 | let StaticServer 12 | 13 | beforeAll(() => { 14 | loadDependency.mockImplementation(moduleName => moduleName === 'finalhandler' ? finalhandler : serveStatic) 15 | }) 16 | 17 | beforeEach(async () => { 18 | StaticServer = await import('../../src/commands/static-server').then(m => m.default || m) 19 | }) 20 | 21 | afterEach(() => { 22 | jest.restoreAllMocks() 23 | jest.resetModules() 24 | }) 25 | 26 | test('should load dependencies once', async () => { 27 | expect(StaticServer.express).toBeUndefined() 28 | await StaticServer.loadDependencies() 29 | expect(StaticServer.serveStatic).toBeDefined() 30 | 31 | expect(loadDependency).toHaveBeenCalledTimes(2) 32 | 33 | await StaticServer.loadDependencies() 34 | expect(loadDependency).toHaveBeenCalledTimes(2) 35 | }) 36 | 37 | test('should not load without browser config', () => { 38 | const hook = jest.fn() 39 | 40 | StaticServer.load({ config: {}, hook }) 41 | expect(hook).not.toHaveBeenCalled() 42 | }) 43 | 44 | test('should not load without folder in config', () => { 45 | const hook = jest.fn() 46 | 47 | StaticServer.load({ config: { staticServer: {} }, hook }) 48 | expect(hook).not.toHaveBeenCalled() 49 | }) 50 | 51 | test('should load with boolean config', () => { 52 | const hook = jest.fn() 53 | const browser = { 54 | config: { 55 | folder: 'test', 56 | staticServer: true 57 | }, 58 | hook 59 | } 60 | StaticServer.load(browser) 61 | 62 | expect(hook).toHaveBeenCalledTimes(2) 63 | expect(browser.config.staticServer).toBeInstanceOf(Object) 64 | expect(browser.config.staticServer.folder).toBe('test') 65 | }) 66 | 67 | test('should load with browser config', () => { 68 | const hook = jest.fn() 69 | StaticServer.load({ config: { staticServer: { folder: 'test' } }, hook }) 70 | 71 | expect(hook).toHaveBeenCalled() 72 | }) 73 | 74 | test('should start static server', async () => { 75 | const on = jest.fn() 76 | const listen = jest.fn((port, host, cb) => cb()) 77 | jest.spyOn(console, 'info').mockImplementation(_ => _) 78 | jest.spyOn(http, 'createServer').mockImplementation((fn) => { 79 | fn() 80 | return { on, listen } 81 | }) 82 | 83 | StaticServer.serveStatic = jest.fn(() => () => {}) 84 | StaticServer.finalhandler = jest.fn() 85 | 86 | const staticServerConfig = { 87 | folder: 'test-folder', 88 | host: 'test-host', 89 | port: 667 90 | } 91 | 92 | await expect(StaticServer.start(staticServerConfig)).resolves.toBeUndefined() 93 | 94 | expect(StaticServer.finalhandler).toHaveBeenCalled() 95 | expect(StaticServer.serveStatic).toHaveBeenCalledWith(staticServerConfig.folder) 96 | 97 | expect(listen).toHaveBeenCalledWith(staticServerConfig.port, staticServerConfig.host, expect.any(Function)) 98 | // eslint-disable-next-line no-console 99 | expect(console.info).toHaveBeenCalled() 100 | }) 101 | 102 | test('should start static server but not warn when quiet', async () => { 103 | const on = jest.fn() 104 | const listen = jest.fn((port, host, cb) => cb()) 105 | jest.spyOn(console, 'info').mockImplementation(_ => _) 106 | jest.spyOn(http, 'createServer').mockImplementation((fn) => { 107 | fn() 108 | return { on, listen } 109 | }) 110 | 111 | StaticServer.serveStatic = jest.fn(() => () => {}) 112 | StaticServer.finalhandler = jest.fn() 113 | 114 | const staticServerConfig = { 115 | folder: 'test-folder', 116 | host: 'test-host', 117 | port: 667 118 | } 119 | 120 | await expect(StaticServer.start(staticServerConfig, true)).resolves.toBeUndefined() 121 | 122 | expect(StaticServer.finalhandler).toHaveBeenCalled() 123 | expect(StaticServer.serveStatic).toHaveBeenCalledWith(staticServerConfig.folder) 124 | 125 | expect(listen).toHaveBeenCalledWith(staticServerConfig.port, staticServerConfig.host, expect.any(Function)) 126 | // eslint-disable-next-line no-console 127 | expect(console.info).not.toHaveBeenCalled() 128 | }) 129 | 130 | test('should stop static server', async () => { 131 | const close = jest.fn(cb => cb()) 132 | StaticServer.server = { close } 133 | 134 | await expect(StaticServer.stop()).resolves.toBeUndefined() 135 | 136 | expect(close).toHaveBeenCalled() 137 | }) 138 | }) 139 | -------------------------------------------------------------------------------- /src/browsers/jsdom/webpage.js: -------------------------------------------------------------------------------- 1 | import { BrowserError } from '../../utils' 2 | import Webpage from '../webpage' 3 | 4 | export default class JsdomWebpage extends Webpage { 5 | async open(url, readyCondition = 'body') { 6 | const jsdomOpts = this.browser.config.jsdom || {} 7 | 8 | const options = { 9 | resources: 'usable', 10 | runScripts: 'dangerously', 11 | virtualConsole: false, 12 | pretendToBeVisual: true, 13 | beforeParse(window) { 14 | // Mock window.scrollTo 15 | window.scrollTo = () => {} 16 | 17 | if (typeof jsdomOpts.beforeParse === 'function') { 18 | jsdomOpts.beforeParse(window) 19 | } 20 | }, 21 | ...jsdomOpts 22 | } 23 | 24 | const onJsdomError = (err) => { 25 | throw new BrowserError(this, err) 26 | } 27 | 28 | if (options.virtualConsole === true) { 29 | const logLevels = this.browser.logLevels 30 | 31 | const pageConsole = new Proxy({}, { 32 | get(target, type) { 33 | if (logLevels.includes(type)) { 34 | return console[type] // eslint-disable-line no-console 35 | } 36 | 37 | return _ => _ 38 | } 39 | }) 40 | 41 | const virtualConsole = new this.driver.VirtualConsole() 42 | virtualConsole.on('jsdomError', onJsdomError) 43 | virtualConsole.sendTo(pageConsole) 44 | 45 | options.virtualConsole = virtualConsole 46 | } else { 47 | delete options.virtualConsole 48 | } 49 | 50 | if (url.startsWith('file://')) { 51 | this.page = await this.driver.JSDOM.fromFile(url.substr(7), options) 52 | } else { 53 | this.page = await this.driver.JSDOM.fromURL(url, options) 54 | } 55 | 56 | await this.browser.callHook('page:created', this.page) 57 | 58 | this.browser.hook('close:before', () => { 59 | if (options.virtualConsole) { 60 | options.virtualConsole.removeListener('jsdomError', onJsdomError) 61 | } 62 | 63 | this.page.window.close() 64 | }) 65 | 66 | this.window = this.page.window 67 | this.document = this.page.window.document 68 | 69 | if (readyCondition) { 70 | const t = this 71 | await new Promise((resolve, reject) => { 72 | let iter = 1 73 | 74 | async function waitForElement() { 75 | let isReady 76 | 77 | if (typeof readyCondition === 'function') { 78 | isReady = await t.wrapWithGlobals(readyCondition) 79 | } else { 80 | isReady = !!t.document.querySelector(readyCondition) 81 | } 82 | 83 | if (isReady) { 84 | resolve() 85 | return 86 | } 87 | 88 | if (iter > 100) { 89 | reject(new BrowserError(t, `Timeout reached on waiting for readyCondition: ${readyCondition}`)) 90 | return 91 | } 92 | 93 | setTimeout(waitForElement, 100) 94 | iter++ 95 | } 96 | 97 | waitForElement() 98 | }) 99 | } 100 | 101 | return this.returnProxy() 102 | } 103 | 104 | async wrapWithGlobals(fn) { 105 | global.window = this.window 106 | global.document = this.document 107 | 108 | const ret = await fn() 109 | 110 | delete global.window 111 | delete global.document 112 | 113 | return ret 114 | } 115 | 116 | getBabelPresetOptions(...args) { 117 | const presetOptions = super.getBabelPresetOptions(...args) 118 | 119 | presetOptions.targets = { 120 | node: 'current' 121 | } 122 | 123 | return presetOptions 124 | } 125 | 126 | runScript(fn, ...args) { 127 | if (typeof fn === 'object') { 128 | // eslint-disable-next-line no-new-func 129 | fn = new Function(...fn.args, fn.body) 130 | } 131 | 132 | return this.wrapWithGlobals(() => fn(...args)) 133 | } 134 | 135 | getHtml() { 136 | return this.document.documentElement.outerHTML 137 | } 138 | 139 | getTitle() { 140 | return this.document.title 141 | } 142 | 143 | getElementFromPage(pageFunction, selector, ...args) { 144 | const el = this.document.querySelector(selector) 145 | if (!el) { 146 | return Promise.resolve(null) 147 | } 148 | 149 | return this.wrapWithGlobals(() => pageFunction(el, ...args)) 150 | } 151 | 152 | getElementsFromPage(pageFunction, selector, ...args) { 153 | const els = Array.from(this.document.querySelectorAll(selector)) 154 | return this.wrapWithGlobals(() => pageFunction(els, ...args)) 155 | } 156 | 157 | clickElement(selector) { 158 | /* istanbul ignore next */ 159 | const pageFn = (el) => { 160 | const event = new window.Event('click') 161 | el.dispatchEvent(event) 162 | 163 | return new Promise(resolve => setTimeout(resolve, 500)) 164 | } 165 | return this.getElementFromPage(pageFn, selector) 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /test/e2e/basic.test.js: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path' 2 | import fs from 'fs' 3 | import env from 'node-env-file' 4 | import { createBrowser, createPageFunctions } from '../../src' 5 | import { waitFor } from '../utils' 6 | import pageFunctions from './page-functions' 7 | 8 | const browserString = process.env.BROWSER_STRING || 'puppeteer/core' 9 | 10 | describe(browserString, () => { 11 | let browser 12 | let page 13 | const folder = resolve(__dirname, '..', 'fixtures/basic') 14 | 15 | beforeAll(async () => { 16 | if (browserString.includes('browserstack') && browserString.includes('local')) { 17 | const envFile = resolve(__dirname, '..', '..', '.env-browserstack') 18 | if (fs.existsSync(envFile)) { 19 | env(envFile) 20 | } 21 | } 22 | 23 | try { 24 | browser = await createBrowser(browserString, { 25 | folder, 26 | async extendPage(page) { 27 | return { 28 | ...await createPageFunctions(page, pageFunctions), 29 | routeData() { 30 | return page.runScript(() => ({ 31 | path: window.$vueMeta.$route.path, 32 | query: window.$vueMeta.$route.query 33 | })) 34 | } 35 | } 36 | } 37 | }, false) 38 | 39 | browser.setLogLevel(['warn', 'error', 'info', 'log']) 40 | 41 | await browser.start() 42 | } catch (e) { 43 | console.error(e) // eslint-disable-line no-console 44 | } 45 | 46 | /* TODO: check why Jest doesnt bail when browser fails to start 47 | * - https://github.com/facebook/jest/issues/6695 48 | */ 49 | expect(browser).toBeDefined() 50 | expect(browser.isReady()).toBe(true) 51 | }) 52 | 53 | afterAll(async () => { 54 | if (browser) { 55 | await browser.close() 56 | } 57 | }) 58 | 59 | test('load page', async function () { 60 | // this should prevent the test to succeed if the browser failed to start 61 | expect.hasAssertions() 62 | 63 | if (!browser) { 64 | return 65 | } 66 | 67 | const webPath = '/index.html' 68 | 69 | const url = browser.getUrl(webPath) 70 | 71 | page = await browser.page(url, () => !!window.$vueMeta) 72 | 73 | const html = await page.getHtml() 74 | 75 | expect(html).toBeDefined() 76 | expect(html).toContain('') 78 | }) 79 | 80 | test('getElement', async () => { 81 | const div = await page.getElement('div') 82 | expect(div.attrsMap).toEqual({ id: 'app' }) 83 | expect(div.children.length).toBe(5) 84 | }) 85 | 86 | test('getElements', async () => { 87 | const divs = await page.getElements('div') 88 | expect(divs[0].attrsMap).toEqual({ id: 'app' }) 89 | expect(divs[1].attrsMap).toEqual({}) 90 | expect(divs[1].children.length).toBe(3) 91 | }) 92 | 93 | test('getAttribute', async () => { 94 | expect(await page.getAttribute('div', 'id')).toBe('app') 95 | }) 96 | 97 | test('getAttributes', async () => { 98 | expect(await page.getAttributes('div', 'id')).toEqual(['app', null]) 99 | }) 100 | 101 | test('getText', async () => { 102 | expect(await page.getText('h1')).toBe('Basic') 103 | }) 104 | 105 | test('getTexts', async () => { 106 | expect(await page.getTexts('h1, h2')).toEqual(['Basic', 'Home']) 107 | }) 108 | 109 | test('getElementCount', async () => { 110 | expect(await page.getElementCount('meta')).toBe(2) 111 | }) 112 | 113 | test('getTitle', async () => { 114 | expect(await page.getTitle()).toBe('Home | Vue Meta Test') 115 | }) 116 | 117 | test('run(Async)Script', async () => { 118 | try { 119 | await page.navigate('/about') 120 | } catch (e) {} 121 | 122 | expect(await page.routeData()).toMatchObject({ 123 | path: '/about' 124 | }) 125 | 126 | expect(await page.getTitle()).toBe('About') 127 | }) 128 | 129 | test('clickElement', async () => { 130 | await page.clickElement('a') 131 | 132 | await waitFor(1000) 133 | 134 | expect(await page.routeData()).toMatchObject({ 135 | path: '/' 136 | }) 137 | 138 | expect(await page.getTitle()).toBe('Home | Vue Meta Test') 139 | }) 140 | 141 | test('click', async () => { 142 | try { 143 | await page.navigateByClick('a') 144 | } catch (e) {} 145 | 146 | expect(await page.routeData()).toMatchObject({ 147 | path: '/about' 148 | }) 149 | 150 | expect(await page.getTitle()).toBe('About') 151 | }) 152 | 153 | test('Doesnt fail on non-existing elements', async () => { 154 | await expect(page.getText('.non-existing-element-class')).resolves.toBeNull() 155 | await expect(page.getTexts('.non-existing-element-class')).resolves.toEqual([]) 156 | }) 157 | 158 | test('getText with trim', async () => { 159 | expect(await page.getText('body', true)).toEqual('Basic About Go to Home Inspect Element to see the meta info') 160 | }) 161 | 162 | test('getTexts with trim', async () => { 163 | expect(await page.getTexts('div', true)).toEqual(['Basic About Go to Home Inspect Element to see the meta info', 'About Go to Home']) 164 | }) 165 | }) 166 | -------------------------------------------------------------------------------- /test/unit/browser.test.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { promisify } from 'util' 3 | import Glob from 'glob' 4 | import { createBrowser } from '../../src' 5 | 6 | const glob = promisify(Glob) 7 | 8 | function capatilize(name) { 9 | return name 10 | .replace(/browserstack/i, 'BrowserStack') 11 | .replace(/^ie/i, 'IE') 12 | .replace(/(^|\/)([a-z])/gi, (m, $1, $2) => $2.toUpperCase()) 13 | } 14 | 15 | /* eslint-disable no-unused-vars, quote-props */ 16 | const browsers = { 17 | 'browserstack': async (name) => { 18 | const browser = await standardBrowserTest(name) 19 | }, 20 | 'browserstack/local': async (name) => { 21 | const browser = await standardBrowserTest(name) 22 | }, 23 | 'ie': async (name) => { 24 | await expect(createBrowser(name)).rejects.toThrow() 25 | }, 26 | 'ie/selenium': async (name) => { 27 | await expect(createBrowser(name)).rejects.toThrow() 28 | }, 29 | 'ie/browserstack': async (name) => { 30 | const browser = await standardBrowserTest(name) 31 | }, 32 | 'ie/browserstack/local': async (name) => { 33 | const browser = await standardBrowserTest(name) 34 | }, 35 | 'jsdom': async (name) => { 36 | const browser = await standardBrowserTest(name) 37 | }, 38 | 'edge': async (name) => { 39 | await expect(createBrowser(name)).rejects.toThrow() 40 | }, 41 | 'edge/selenium': async (name) => { 42 | await expect(createBrowser(name)).rejects.toThrow() 43 | }, 44 | 'edge/browserstack': async (name) => { 45 | const browser = await standardBrowserTest(name) 46 | }, 47 | 'edge/browserstack/local': async (name) => { 48 | const browser = await standardBrowserTest(name) 49 | }, 50 | 'chrome': async (name) => { 51 | const browser = await standardBrowserTest(name, 'PuppeteerCoreBrowser') 52 | }, 53 | 'chrome/puppeteer': async (name) => { 54 | const browser = await standardBrowserTest(name, 'PuppeteerBrowser') 55 | }, 56 | 'chrome/selenium': async (name) => { 57 | const browser = await standardBrowserTest(name) 58 | }, 59 | 'chrome/browserstack': async (name) => { 60 | const browser = await standardBrowserTest(name) 61 | }, 62 | 'chrome/browserstack/local': async (name) => { 63 | const browser = await standardBrowserTest(name) 64 | }, 65 | 'firefox': async (name) => { 66 | const browser = await standardBrowserTest(name, 'FirefoxSeleniumBrowser') 67 | }, 68 | 'firefox/selenium': async (name) => { 69 | const browser = await standardBrowserTest(name) 70 | }, 71 | 'firefox/browserstack': async (name) => { 72 | const browser = await standardBrowserTest(name) 73 | }, 74 | 'firefox/browserstack/local': async (name) => { 75 | const browser = await standardBrowserTest(name) 76 | }, 77 | 'safari': async (name) => { 78 | await expect(createBrowser(name)).rejects.toThrow() 79 | }, 80 | 'safari/selenium': async (name) => { 81 | await expect(createBrowser(name)).rejects.toThrow() 82 | }, 83 | 'safari/browserstack': async (name) => { 84 | const browser = await standardBrowserTest(name) 85 | }, 86 | 'safari/browserstack/local': async (name) => { 87 | const browser = await standardBrowserTest(name) 88 | }, 89 | 'selenium': async (name) => { 90 | const browser = await standardBrowserTest(name) 91 | }, 92 | 'puppeteer': async (name) => { 93 | const browser = await standardBrowserTest(name) 94 | }, 95 | 'puppeteer/core': async (name) => { 96 | const browser = await standardBrowserTest(name) 97 | }, 98 | 'saucelabs': async (name) => { 99 | await expect(createBrowser(name)).rejects.toThrow() 100 | } 101 | } 102 | 103 | async function standardBrowserTest(name, expectedConstructor) { 104 | if (!expectedConstructor) { 105 | expectedConstructor = `${capatilize(name)}Browser` 106 | } 107 | await expect(createBrowser(name, undefined, false)).resolves.not.toThrow() 108 | 109 | const browser = await createBrowser(name, undefined, false) 110 | expect(browser.constructor.name).toBe(expectedConstructor) 111 | 112 | // test setting options in build:before hook for Selenium browsers 113 | const spy = jest.fn() 114 | const requestedProperties = [] 115 | const builder = new Proxy({}, { 116 | get(target, key) { 117 | requestedProperties.push(key) 118 | return spy 119 | } 120 | }) 121 | 122 | await browser.callHook('selenium:build:before', builder) 123 | if (expectedConstructor.includes('Selenium') && expectedConstructor !== 'SeleniumBrowser') { 124 | const browserName = name.split('/').shift() 125 | 126 | expect(requestedProperties).toContain(`set${capatilize(browserName)}Options`) 127 | expect(spy).toHaveBeenCalledTimes(1) 128 | expect(spy).toHaveBeenCalledWith(expect.any(Object)) 129 | } 130 | 131 | return browser 132 | } 133 | 134 | describe('browser', () => { 135 | test('all files covered', async () => { 136 | const srcPath = path.resolve(__dirname, '../../src/browsers/') + '/' 137 | let files = await glob(`${srcPath}/!(utils)/**/*.js`) 138 | files = files 139 | .filter(f => !f.includes('webpage') && !f.includes('logging')) 140 | .map(f => f 141 | .replace(srcPath, '') 142 | .replace('.js', '') 143 | .replace('index', '') 144 | .replace(/\/+$/, '') 145 | ) 146 | .sort() 147 | 148 | expect(Object.keys(browsers).sort()).toEqual(files) 149 | }) 150 | 151 | process.env.PUPPETEER_EXECUTABLE_PATH = '/usr/bin/chromium-browser' 152 | process.env.CHROME_EXECUTABLE_PATH = '/usr/bin/chromium-browser' 153 | process.env.BROWSERSTACK_USER = 'user' 154 | process.env.BROWSERSTACK_KEY = 'key' 155 | 156 | for (const name in browsers) { 157 | const tests = browsers[name] 158 | 159 | test(name, async () => { 160 | expect.hasAssertions() 161 | 162 | await tests(name) 163 | }) 164 | } 165 | }) 166 | -------------------------------------------------------------------------------- /src/browsers/webpage.js: -------------------------------------------------------------------------------- 1 | import { BrowserError, abstractGuard, parseFunction, getDefaultHtmlCompiler } from '../utils' 2 | 3 | export default class Webpage { 4 | constructor(browser) { 5 | abstractGuard('Webpage', new.target) 6 | 7 | if (!browser || !browser.driver) { 8 | throw new BrowserError(this, 'Browser driver is required, has the browser been started succesfully?') 9 | } 10 | 11 | this.browser = browser 12 | this.driver = this.browser.driver 13 | this.userExtended = {} 14 | } 15 | 16 | returnProxy() { 17 | return new Proxy(this, { 18 | get(target, property) { 19 | target.browser.callHook('webpage:property', property) 20 | 21 | if (target.userExtended && target.userExtended[property]) { 22 | return target.userExtended[property] 23 | } 24 | 25 | if (target[property]) { 26 | return target[property] 27 | } 28 | 29 | if (target.page && target.page[property]) { 30 | return target.page[property] 31 | } 32 | 33 | return target.driver[property] 34 | } 35 | }) 36 | } 37 | 38 | extend(extendWith = {}) { 39 | this.userExtended = extendWith 40 | } 41 | 42 | getHtmlCompiler() { 43 | if (this._htmlCompiler) { 44 | return this._htmlCompiler 45 | } 46 | 47 | let htmlCompiler 48 | if (typeof this.driver.htmlCompiler === 'function') { 49 | htmlCompiler = this.driver.htmlCompiler 50 | } 51 | 52 | if (!htmlCompiler || typeof htmlCompiler !== 'function') { 53 | htmlCompiler = getDefaultHtmlCompiler() 54 | } 55 | 56 | this._htmlCompiler = htmlCompiler 57 | return this._htmlCompiler 58 | } 59 | 60 | getBabelPresetOptions({ polyfills = false } = {}) { 61 | const presetOptions = {} 62 | 63 | const browser = this.browser.getBrowser() 64 | const version = this.browser.getBrowserVersion() 65 | 66 | if (browser && version) { 67 | presetOptions.targets = { 68 | [browser]: version 69 | } 70 | } 71 | 72 | if (polyfills) { 73 | presetOptions.useBuiltIns = polyfills === true ? 'usage' : polyfills 74 | } 75 | 76 | return presetOptions 77 | } 78 | 79 | getHtml() {} 80 | 81 | runScript(...args) {} 82 | 83 | runAsyncScript(...args) { 84 | return this.runScript(...args) 85 | } 86 | 87 | async getElement(selector) { 88 | const html = await this.getElementHtml(selector) 89 | return this.getHtmlCompiler()(html) 90 | } 91 | 92 | async getElements(selector) { 93 | const htmls = await this.getElementsHtml(selector) 94 | const htmlCompiler = this.getHtmlCompiler() 95 | return htmls.map(html => htmlCompiler(html)) 96 | } 97 | 98 | getElementFromPage(pageFunction, selector, ...args) { 99 | const parsedFn = parseFunction(pageFunction, this.getBabelPresetOptions()) 100 | 101 | // It would be bettter to return undefined when no el exists, 102 | // but selenium always returns null for undefined so better to keep 103 | // the return value consistent 104 | 105 | /* eslint-disable no-var */ 106 | return this.runScript( 107 | /* istanbul ignore next */ 108 | function (selector, fn, args) { 109 | var el = document.querySelector(selector) 110 | if (!el) { 111 | return null 112 | } 113 | 114 | return (new (Function.bind.apply(Function, fn))()).apply(null, [el].concat(args)) 115 | }, 116 | selector, 117 | [null, ...parsedFn.args, parsedFn.body], 118 | args 119 | ) 120 | /* eslint-enable no-var */ 121 | } 122 | 123 | getElementsFromPage(pageFunction, selector, ...args) { 124 | const parsedFn = parseFunction(pageFunction, this.getBabelPresetOptions()) 125 | 126 | /* eslint-disable no-var */ 127 | return this.runScript( 128 | /* istanbul ignore next */ 129 | function (selector, fn, args) { 130 | var els = document.querySelectorAll(selector) 131 | return (new (Function.bind.apply(Function, fn))()).apply(null, [Array.prototype.slice.call(els)].concat(args)) 132 | }, 133 | selector, 134 | [null, ...parsedFn.args, parsedFn.body], 135 | args 136 | ) 137 | /* eslint-enable no-var */ 138 | } 139 | 140 | getElementCount(selector) { 141 | /* istanbul ignore next */ 142 | const pageFn = els => els.length 143 | return this.getElementsFromPage(pageFn, selector) 144 | } 145 | 146 | getElementHtml(selector) { 147 | /* istanbul ignore next */ 148 | const pageFn = el => el.outerHTML 149 | return this.getElementFromPage(pageFn, selector) 150 | } 151 | 152 | getElementsHtml(selector) { 153 | /* istanbul ignore next */ 154 | const pageFn = els => els.map(el => el.outerHTML) 155 | return this.getElementsFromPage(pageFn, selector) 156 | } 157 | 158 | getAttribute(selector, attribute) { 159 | /* istanbul ignore next */ 160 | const pageFn = (el, attribute) => el.getAttribute(attribute) 161 | return this.getElementFromPage(pageFn, selector, attribute) 162 | } 163 | 164 | getAttributes(selector, attribute) { 165 | /* istanbul ignore next */ 166 | const pageFn = (els, attribute) => els.map(el => el.getAttribute(attribute)) 167 | return this.getElementsFromPage(pageFn, selector, attribute) 168 | } 169 | 170 | getText(selector, trim) { 171 | /* istanbul ignore next */ 172 | const pageFn = el => el.textContent 173 | return this.getElementFromPage(pageFn, selector).then(text => (trim ? text.trim() : text)) 174 | } 175 | 176 | getTexts(selector, trim) { 177 | /* istanbul ignore next */ 178 | const pageFn = els => els.map(el => el.textContent) 179 | return this.getElementsFromPage(pageFn, selector).then(texts => (trim ? texts.map(t => t.trim()) : texts)) 180 | } 181 | 182 | clickElement(selector) { 183 | /* istanbul ignore next */ 184 | const pageFn = el => el.click() 185 | return this.getElementFromPage(pageFn, selector) 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /test/unit/utils.test.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import * as utils from '../../src/utils' 3 | import Browser from '../../src/browsers/browser' 4 | 5 | describe('utils', () => { 6 | test('browser strings', async () => { 7 | const browserStrings = [ 8 | 'chrome', 9 | 'chrome/windows', 10 | 'chrome/windows 8.1', 11 | 'chrome/windows=8', 12 | 'chrome/windows:7', 13 | 'macos high sierra/chrome', 14 | 'mac os=high sierra/firefox', 15 | 'firefox/headless', 16 | 'puppeteer/linux', 17 | 'selenium', 18 | 'puppeteer', 19 | 'windows 7/chrome 39/browserstack/local/1280x1024', 20 | 'browserstack/android 8/device=Android Emulator' 21 | ] 22 | 23 | await Promise.all(browserStrings.map(async (s) => { 24 | /* 25 | * !!! check the snapshot carefully before updating !!! 26 | */ 27 | // console.log(utils.getBrowserConfigFromString(s)) 28 | expect(utils.getBrowserConfigFromString(s)).toMatchSnapshot() 29 | 30 | // quick test just to make sure no combo throws error 31 | await expect(Browser.get(s, { BrowserStackLocal: { key: 'key' } })).resolves.not.toThrow() 32 | })) 33 | }) 34 | 35 | test('abstractGuard: prevents instantiating abstract class', () => { 36 | expect(() => new Browser()).toThrow('Do not use abstract class') 37 | }) 38 | 39 | test('loadDependency: throws error on non-existing dependency', async () => { 40 | await expect(utils.loadDependency('does-not-exists')).rejects.toThrow('BrowserError: Could not import the required dependency \'does-not-exists\'') 41 | }) 42 | 43 | test('enableTimers: reinstates timers in Jest environment', () => { 44 | const spy = jest.spyOn(jest, 'useRealTimers').mockImplementation(() => {}) 45 | utils.enableTimers() 46 | 47 | // TODO: spying on jest fns doesnt work 48 | expect(spy).toHaveBeenCalledTimes(0) 49 | jest.restoreAllMocks() 50 | }) 51 | 52 | test('parseFunction: should throw error when argumentis not a fn', () => { 53 | expect(() => utils.parseFunction('test')).toThrow('BrowserError') 54 | }) 55 | 56 | test('parseFunction: returns parsed function', () => { 57 | const fn = arg => !!arg 58 | 59 | const parsedFn = utils.parseFunction(fn) 60 | expect(parsedFn.args).toEqual(['arg']) 61 | expect(parsedFn.body).toEqual('return !!arg;') 62 | }) 63 | 64 | test('parseFunction: doesnt add return statement if not required', () => { 65 | const fn = (arg) => { return !!arg } 66 | 67 | const parsedFn = utils.parseFunction(fn) 68 | expect(parsedFn.args).toEqual(['arg']) 69 | expect(parsedFn.body.trim()).toEqual('return !!arg;') 70 | }) 71 | 72 | test('parseFunction: transpiles bodyblock-less arrow function to target', () => { 73 | const fn = arg => !!arg 74 | 75 | const parsedFn = utils.parseFunction(fn, { targets: { safari: '5.1' } }) 76 | expect(parsedFn.args).toEqual(['arg']) 77 | expect(parsedFn.body.trim()).toEqual('return !!arg;') 78 | }) 79 | test('parseFunction: transpiles bodyblock-less arrow function to target', () => { 80 | const fn = arg => !!arg 81 | 82 | const parsedFn = utils.parseFunction(fn, { targets: { safari: '5.1' } }) 83 | expect(parsedFn.args).toEqual(['arg']) 84 | expect(parsedFn.body.trim()).toEqual('return !!arg;') 85 | }) 86 | 87 | test('parseFunction: transpiles arrow function to target', () => { 88 | const fn = (arg, ret) => { 89 | if (ret) { 90 | return !!arg 91 | } 92 | 93 | return !arg 94 | } 95 | 96 | const parsedFn = utils.parseFunction(fn, { targets: { safari: '5.1' } }) 97 | expect(parsedFn.args).toEqual(['arg', 'ret']) 98 | expect(parsedFn.body.trim()).toEqual(`if (ret) { 99 | return !!arg; 100 | } 101 | 102 | return !arg;`) 103 | }) 104 | 105 | test('parseFunction: transpiles function with inner arrow fn to target', () => { 106 | const fn = function (arg) { 107 | return () => !!arg 108 | } 109 | 110 | const parsedFn = utils.parseFunction(fn, { targets: { safari: '5.1' } }) 111 | expect(parsedFn.args).toEqual(['arg']) 112 | expect(parsedFn.body.trim()).toEqual(`return function () { 113 | return !!arg; 114 | };`) 115 | }) 116 | 117 | test('parseFunction: caches transpiled functions per preset option', () => { 118 | const fn = function (arg) { 119 | return () => !!arg 120 | } 121 | 122 | const parsedFn = utils.parseFunction(fn, { targets: { chrome: 71 } }) 123 | expect(parsedFn.args).toEqual(['arg']) 124 | expect(parsedFn.body.trim()).toEqual('return () => !!arg;') 125 | }) 126 | 127 | test('default html compiler should work', () => { 128 | const compiler = utils.getDefaultHtmlCompiler() 129 | const ast = compiler('
') 130 | 131 | expect(ast).toEqual(expect.any(Object)) 132 | expect(ast).toMatchSnapshot() 133 | }) 134 | 135 | test('should accept error with only message', () => { 136 | const error = new utils.BrowserError('my test error') 137 | expect(error.message).toBe('BrowserError: my test error') 138 | }) 139 | 140 | test('should accept error with class instance', () => { 141 | class MyTestClass {} 142 | const instance = new MyTestClass() 143 | 144 | const error = new utils.BrowserError(instance, 'my test error') 145 | expect(error.message).toBe('MyTestClass: my test error') 146 | }) 147 | 148 | test('should accept error with identifier string', () => { 149 | const error = new utils.BrowserError('TestIdentifier', 'my test error') 150 | expect(error.message).toBe('TestIdentifier: my test error') 151 | }) 152 | 153 | test('timers', async () => { 154 | utils.disableTimers() 155 | 156 | const start = process.hrtime.bigint() 157 | const waitPromise = utils.waitFor(250) 158 | jest.runAllTimers() 159 | await waitPromise 160 | 161 | const duration = parseInt(process.hrtime.bigint() - start) / 1e6 162 | 163 | expect(duration).toBeLessThan(250) 164 | 165 | utils.enableTimers() 166 | }) 167 | 168 | test('fs: exists', async () => { 169 | expect(await utils.exists(__dirname)).toBe(true) 170 | expect(await utils.exists(path.join(__dirname, '/doesnt-exists'))).toBe(false) 171 | }) 172 | 173 | test('fs: stats', async () => { 174 | expect(await utils.stats(__dirname)).toBeTruthy() 175 | expect(await utils.stats(path.join(__dirname, '/doesnt-exists'))).toBe(false) 176 | }) 177 | }) 178 | -------------------------------------------------------------------------------- /test/unit/command.xvfb.test.js: -------------------------------------------------------------------------------- 1 | import os from 'os' 2 | import cp from 'child_process' 3 | import kill from 'tree-kill' 4 | 5 | jest.mock('tree-kill') 6 | jest.mock('../../src/utils/timers') 7 | 8 | describe('xvfb', () => { 9 | let Xvfb 10 | beforeEach(async () => { 11 | Xvfb = await import('../../src/commands/xvfb').then(m => m.default || m) 12 | }) 13 | 14 | afterEach(() => { 15 | jest.restoreAllMocks() 16 | jest.resetModules() 17 | }) 18 | 19 | test('should not throw error on unsupported platforms', () => { 20 | jest.spyOn(os, 'platform').mockReturnValue('not supported') 21 | 22 | expect(() => Xvfb.isSupported()).not.toThrow() 23 | }) 24 | 25 | test('should throw error on unsupported platforms when set', () => { 26 | jest.spyOn(os, 'platform').mockReturnValue('not supported') 27 | 28 | expect(() => Xvfb.isSupported(true)).toThrow('not supported') 29 | }) 30 | 31 | test('should set browser config when load called', () => { 32 | jest.spyOn(os, 'platform').mockReturnValue('linux') 33 | 34 | const browser = { 35 | hook: () => {}, 36 | config: {} 37 | } 38 | 39 | expect(browser.config.xvfb).toBeUndefined() 40 | Xvfb.load(browser) 41 | expect(browser.config.xvfb).toBeUndefined() 42 | 43 | browser.config.xvfb = true 44 | 45 | Xvfb.load(browser) 46 | expect(browser.config.xvfb).toBe(true) 47 | }) 48 | 49 | test('should add window args from browser config', () => { 50 | jest.spyOn(os, 'platform').mockReturnValue('linux') 51 | 52 | const width = 111 53 | const height = 222 54 | 55 | const browser = { 56 | hook: () => {}, 57 | config: { 58 | xvfb: true, 59 | browserConfig: { 60 | window: { width, height } 61 | } 62 | } 63 | } 64 | 65 | Xvfb.load(browser) 66 | expect(browser.config.xvfb).toEqual(expect.any(Object)) 67 | expect(browser.config.xvfb.args).toEqual(expect.any(Array)) 68 | expect(browser.config.xvfb.args.length).toBe(1) 69 | expect(browser.config.xvfb.args[0]).toEqual(`-screen 0 ${width}x${height}x24`) 70 | }) 71 | 72 | test('should not start twice', () => { 73 | jest.spyOn(os, 'platform').mockReturnValue('linux') 74 | const spawn = jest.spyOn(cp, 'spawn').mockImplementation(() => { 75 | return { 76 | connected: true, 77 | on() {}, 78 | stderr: { 79 | on() {} 80 | } 81 | } 82 | }) 83 | 84 | expect(Xvfb.isRunning()).toBe(false) 85 | 86 | Xvfb.start() 87 | expect(spawn).toHaveBeenCalledTimes(1) 88 | expect(Xvfb.isRunning()).toBe(true) 89 | 90 | Xvfb.start() 91 | expect(spawn).toHaveBeenCalledTimes(1) 92 | }) 93 | 94 | test('should throw error when Xvfb not found', () => { 95 | jest.spyOn(os, 'platform').mockReturnValue('linux') 96 | jest.spyOn(cp, 'spawn').mockImplementation(() => { 97 | return { 98 | connected: true, 99 | on(type, fn) { 100 | if (type === 'error') { 101 | fn({ code: 'ENOENT' }) 102 | } 103 | }, 104 | stderr: { 105 | on() {} 106 | } 107 | } 108 | }) 109 | 110 | expect(() => Xvfb.start()).toThrow('Xvfb not found') 111 | expect(Xvfb.isRunning()).toBe(false) 112 | }) 113 | 114 | test('should warn when Xvfb already running and quiet false', () => { 115 | jest.spyOn(os, 'platform').mockReturnValue('linux') 116 | const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}) 117 | jest.spyOn(cp, 'spawn').mockImplementation(() => { 118 | return { 119 | connected: true, 120 | on(type, fn) { 121 | if (type === 'close') { 122 | fn(1, 0) 123 | } 124 | }, 125 | stderr: { 126 | on(type, fn) { 127 | if (type === 'data') { 128 | fn(`(EE) 129 | Fatal server error: 130 | (EE) Server is already active for display 99 131 | If this server is no longer running, remove /tmp/.X99-lock 132 | and start again. 133 | (EE)`) 134 | } 135 | } 136 | } 137 | } 138 | }) 139 | 140 | Xvfb.start() 141 | expect(spy).toHaveBeenCalledTimes(1) 142 | expect(Xvfb.isRunning()).toBe(false) 143 | }) 144 | 145 | test('should warn when Xvfb already running unless quiet', () => { 146 | jest.spyOn(os, 'platform').mockReturnValue('linux') 147 | const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}) 148 | jest.spyOn(cp, 'spawn').mockImplementation(() => { 149 | return { 150 | connected: true, 151 | on(type, fn) { 152 | if (type === 'close') { 153 | fn(1, 0) 154 | } 155 | }, 156 | stderr: { 157 | on(type, fn) { 158 | if (type === 'data') { 159 | fn(`(EE) 160 | Fatal server error: 161 | (EE) Server is already active for display 99 162 | If this server is no longer running, remove /tmp/.X99-lock 163 | and start again. 164 | (EE)`) 165 | } 166 | } 167 | } 168 | } 169 | }) 170 | 171 | Xvfb.start({ quiet: true }) 172 | expect(spy).not.toHaveBeenCalled() 173 | expect(Xvfb.isRunning()).toBe(false) 174 | }) 175 | 176 | test('should warn when Xvfb failed to start', () => { 177 | jest.spyOn(os, 'platform').mockReturnValue('linux') 178 | jest.spyOn(cp, 'spawn').mockImplementation(() => { 179 | return { 180 | connected: true, 181 | on(type, fn) { 182 | if (type === 'close') { 183 | fn(1, 0) 184 | } 185 | }, 186 | stderr: { 187 | on(type, fn) { 188 | if (type === 'data') { 189 | fn(`(EE) 190 | Fatal server error: 191 | (EE) Unrecognized option: 0 192 | (EE) 193 | `) 194 | } 195 | } 196 | } 197 | } 198 | }) 199 | 200 | expect(() => Xvfb.start()).toThrow('BrowserError: Failed to start Xvfb, Unrecognized option: 0') 201 | expect(Xvfb.isRunning()).toBe(false) 202 | }) 203 | 204 | test('should do nothing on stop when not started', () => { 205 | Xvfb.stop() 206 | expect(kill).not.toHaveBeenCalled() 207 | }) 208 | 209 | test('should wait on stop for closed to be true', async () => { 210 | const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}) 211 | jest.useFakeTimers() 212 | 213 | Xvfb.process = true 214 | Xvfb.closed = false 215 | 216 | const stopPromise = Xvfb.stop() 217 | Xvfb.closed = true 218 | jest.advanceTimersByTime(100) 219 | 220 | await expect(stopPromise).resolves.toBeUndefined() 221 | 222 | jest.advanceTimersByTime(3100) 223 | expect(spy).not.toHaveBeenCalled() 224 | }) 225 | 226 | test('should timeout on stop', async () => { 227 | const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}) 228 | jest.useFakeTimers() 229 | 230 | Xvfb.process = true 231 | Xvfb.closed = false 232 | 233 | const stopPromise = Xvfb.stop() 234 | 235 | jest.advanceTimersByTime(3100) 236 | await expect(stopPromise).resolves.toBeUndefined() 237 | expect(spy).toHaveBeenCalledTimes(1) 238 | }) 239 | }) 240 | -------------------------------------------------------------------------------- /src/browsers/browser.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import Hookable from 'hable' 3 | import onExit from 'signal-exit' 4 | import { Xvfb, StaticServer } from '../commands' 5 | import { 6 | abstractGuard, 7 | loadDependency, 8 | isMockedFunction, 9 | disableTimers, 10 | enableTimers, 11 | getBrowserConfigFromString, 12 | getBrowserImportFromConfig, 13 | BrowserError 14 | } from '../utils' 15 | import { browsers } from '.' 16 | 17 | export default class Browser extends Hookable { 18 | constructor(config = {}) { 19 | super() 20 | 21 | abstractGuard('Browser', new.target) 22 | 23 | this.config = { 24 | quiet: false, 25 | ...config 26 | } 27 | 28 | this.ready = false 29 | 30 | if (config.extendPage && typeof config.extendPage === 'function') { 31 | this.hook('page:after', async (page) => { 32 | const extendWith = await config.extendPage(page) 33 | if (extendWith && typeof extendWith === 'object') { 34 | page.extend(extendWith) 35 | } 36 | }) 37 | } 38 | 39 | this.capabilities = {} 40 | 41 | // before browserConfig 42 | this.config.browserArguments = this.config.browserArguments || [] 43 | 44 | if (this.config.browserConfig) { 45 | for (const key in this.config.browserConfig) { 46 | if (key.startsWith('driver') || key.startsWith('provider') || key === 'browserVariant') { 47 | continue 48 | } 49 | 50 | if (key === 'staticserver') { 51 | if (!this.config.staticServer) { 52 | this.config.staticServer = true 53 | } 54 | continue 55 | } 56 | 57 | if (key === 'xvfb') { 58 | if (this.config.browserConfig[key] === 'false' || this.config.browserConfig.headless) { 59 | continue 60 | } 61 | 62 | if (!this.config.xvfb) { 63 | this.config.xvfb = Xvfb.isSupported() 64 | } 65 | 66 | continue 67 | } 68 | 69 | const fn = `set${key.charAt(0).toUpperCase()}${key.slice(1)}` 70 | if (this[fn]) { 71 | this[fn](this.config.browserConfig[key]) 72 | } else { 73 | console.warn(`browserConfig '${key}' could not be set`) // eslint-disable-line no-console 74 | } 75 | } 76 | } 77 | 78 | if (!this.config.xvfb && this.config.xvfb !== false) { 79 | this.config.xvfb = Xvfb.isSupported() 80 | } 81 | 82 | Xvfb.load(this) 83 | StaticServer.load(this) 84 | 85 | if (isMockedFunction(setTimeout, 'setTimeout')) { 86 | // eslint-disable-next-line no-console 87 | console.warn(`Mocked timers detected 88 | 89 | The browser probably won't ever start with globally mocked timers. Will try to automatically use real timers on start and set to use fake timers after start. If the browser still hangs and doesn't start, make sure to only mock the global timers after the browser has started `) 90 | 91 | this.hook('start:before', () => enableTimers()) 92 | this.hook('start:after', () => disableTimers()) 93 | } 94 | } 95 | 96 | static async get(browserString = '', config = {}) { 97 | const browserConfig = getBrowserConfigFromString(browserString) 98 | const browserImport = getBrowserImportFromConfig(browserConfig) 99 | 100 | if (!browsers[browserImport]) { 101 | throw new BrowserError(`Unknown browser, no import exists for '${browserImport}'`) 102 | } 103 | 104 | try { 105 | // add browserConfig to config 106 | config.browserConfig = browserConfig 107 | 108 | const Browser = await browsers[browserImport]() 109 | 110 | const browserInstance = new Browser(config) 111 | await browserInstance.loadDependencies() 112 | return browserInstance 113 | } catch (e) { 114 | if (e instanceof BrowserError) { 115 | throw e 116 | } else { 117 | throw new BrowserError(`Error occured while loading '${browserConfig.browser || browserString}' browser`, e) 118 | } 119 | } 120 | } 121 | 122 | setLogLevel(level) {} 123 | 124 | async loadDependency(dependency) { 125 | try { 126 | return await loadDependency(dependency) 127 | } catch (e) { 128 | throw new BrowserError(this, e.message) 129 | } 130 | } 131 | 132 | _loadDependencies() {} 133 | 134 | async loadDependencies(...args) { 135 | await this.callHook('dependencies:load') 136 | 137 | await this._loadDependencies(...args) 138 | 139 | await this.callHook('dependencies:loaded') 140 | } 141 | 142 | getUrl(urlPath) { 143 | if (this.config.staticServer) { 144 | const { host, port } = this.config.staticServer 145 | return `http://${host}:${port}${urlPath}` 146 | } 147 | 148 | return `file://${path.join(this.config.folder, urlPath)}` 149 | } 150 | 151 | getCapabilities(capabilities) { 152 | if (!capabilities) { 153 | return this.capabilities 154 | } 155 | 156 | return { 157 | ...this.capabilities, 158 | ...capabilities 159 | } 160 | } 161 | 162 | getCapability(capability) { 163 | return this.capabilities[capability] 164 | } 165 | 166 | addCapability(key, value) { 167 | this.capabilities[key] = value 168 | return this 169 | } 170 | 171 | addCapabilities(capabilities) { 172 | this.capabilities = { 173 | ...this.capabilities, 174 | ...capabilities 175 | } 176 | return this 177 | } 178 | 179 | setWindow(width, height) { 180 | if (!height && typeof width === 'object') { 181 | this.config.window = width 182 | return this 183 | } 184 | 185 | this.config.window = { width, height } 186 | return this 187 | } 188 | 189 | getBrowser(name) { 190 | return this.getCapability('browserName') 191 | } 192 | 193 | setBrowser(name, version = '') { 194 | this.addCapability('browserName', name) 195 | 196 | if (version) { 197 | this.setBrowserVersion(version) 198 | } 199 | 200 | return this 201 | } 202 | 203 | setHeadless() { 204 | this.config.xvfb = false 205 | return this 206 | } 207 | 208 | getBrowserVersion() { return undefined } 209 | 210 | setBrowserVersion() { return this } 211 | 212 | setOs(...args) { return this.setOS(...args) } 213 | 214 | setOsVersion(...args) { return this.setOSVersion(...args) } 215 | 216 | setOS() { return this } 217 | 218 | setOSVersion() { return this } 219 | 220 | setDevice() { return this } 221 | 222 | isReady() { 223 | return this.ready 224 | } 225 | 226 | _start() {} 227 | 228 | _close() {} 229 | 230 | _page() {} 231 | 232 | async start(capabilities, ...args) { 233 | await this.callHook('start:before') 234 | 235 | try { 236 | await this._start(capabilities, ...args) 237 | 238 | await this.callHook('start:after', this.driver) 239 | 240 | this.ready = true 241 | 242 | onExit(() => this.close()) 243 | 244 | return this 245 | /* istanbul ignore next */ 246 | } catch (e) { 247 | await this.close() 248 | 249 | throw new BrowserError(e) 250 | } 251 | } 252 | 253 | async close(...args) { 254 | await this.callHook('close:before') 255 | 256 | await this._close(...args) 257 | 258 | await this.callHook('close:after') 259 | } 260 | 261 | async page(...args) { 262 | await this.callHook('page:before') 263 | 264 | const page = await this._page(...args) 265 | 266 | await this.callHook('page:after', page) 267 | 268 | return page 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [0.7.5](https://github.com/nuxt-contrib/tib/compare/v0.7.4...v0.7.5) (2020-12-12) 6 | 7 | 8 | ### Bug Fixes 9 | 10 | * replace deprecated waitFor (resolves: [#63](https://github.com/nuxt-contrib/tib/issues/63)) ([542024f](https://github.com/nuxt-contrib/tib/commit/542024f5b37def13e6c7a0846fec481c6486cfa8)) 11 | 12 | ### [0.7.4](https://github.com/nuxt-contrib/tib/compare/v0.7.3...v0.7.4) (2019-11-27) 13 | 14 | 15 | ### Bug Fixes 16 | 17 | * **detection:** Invalid Chrome detection command ([#37](https://github.com/nuxt-contrib/tib/issues/37)) ([ded2478](https://github.com/nuxt-contrib/tib/commit/ded2478b0394a34231f17540755c1408cd74901f)) 18 | 19 | ### [0.7.3](https://github.com/nuxt-contrib/tib/compare/v0.7.2...v0.7.3) (2019-11-05) 20 | 21 | 22 | ### Bug Fixes 23 | 24 | * **detection:** Chrome detection fails on MacOs Catalina ([#30](https://github.com/nuxt-contrib/tib/issues/30)) ([c2527c6](https://github.com/nuxt-contrib/tib/commit/c2527c6)), closes [#29](https://github.com/nuxt-contrib/tib/issues/29) 25 | 26 | ### [0.7.2](https://github.com/nuxt-contrib/tib/compare/v0.7.1...v0.7.2) (2019-09-09) 27 | 28 | 29 | ### Bug Fixes 30 | 31 | * **selenium:** add mapping for browser log levels to selenium ([d78fd28](https://github.com/nuxt-contrib/tib/commit/d78fd28)) 32 | * **static-server:** always set folder when unset ([e4b5cf4](https://github.com/nuxt-contrib/tib/commit/e4b5cf4)) 33 | * correct regexp for detecting chrome ([#27](https://github.com/nuxt-contrib/tib/issues/27)) ([28ca90b](https://github.com/nuxt-contrib/tib/commit/28ca90b)), closes [#23](https://github.com/nuxt-contrib/tib/issues/23) 34 | 35 | ### [0.7.1](https://github.com/nuxt-contrib/tib/compare/v0.7.0...v0.7.1) (2019-08-15) 36 | 37 | 38 | ### Bug Fixes 39 | 40 | * remove circular dependencies in utils ([062b66e](https://github.com/nuxt-contrib/tib/commit/062b66e)) 41 | 42 | ## [0.7.0](https://github.com/nuxt-contrib/tib/compare/v0.6.5...v0.7.0) (2019-08-15) 43 | 44 | 45 | ### Bug Fixes 46 | 47 | * only slice transpiled code if wrapped in block ([086b395](https://github.com/nuxt-contrib/tib/commit/086b395)) 48 | 49 | 50 | ### Features 51 | 52 | * add jsdom browser ([c2288ba](https://github.com/nuxt-contrib/tib/commit/c2288ba)) 53 | * support loading page functions from files using webpack ([#19](https://github.com/nuxt-contrib/tib/issues/19)) ([5f3322b](https://github.com/nuxt-contrib/tib/commit/5f3322b)) 54 | 55 | ### [0.6.5](https://github.com/nuxt-contrib/tib/compare/v0.6.4...v0.6.5) (2019-07-28) 56 | 57 | 58 | ### Bug Fixes 59 | 60 | * change repository url ([7609958](https://github.com/nuxt-contrib/tib/commit/7609958)) 61 | 62 | 63 | 64 | ### [0.6.4](https://github.com/nuxt-contrib/tib/compare/v0.6.3...v0.6.4) (2019-07-28) 65 | 66 | 67 | ### Bug Fixes 68 | 69 | * use 'folder: false' to use bs-local with an internal server ([8bd4abc](https://github.com/nuxt-contrib/tib/commit/8bd4abc)) 70 | 71 | 72 | 73 | ### [0.6.3](https://github.com/nuxt-contrib/tib/compare/v0.6.2...v0.6.3) (2019-07-14) 74 | 75 | 76 | ### Bug Fixes 77 | 78 | * **staticserver:** keep ref to config ([e6fde22](https://github.com/nuxt-contrib/tib/commit/e6fde22)) 79 | 80 | 81 | 82 | ### [0.6.2](https://github.com/nuxt-contrib/tib/compare/v0.6.1...v0.6.2) (2019-07-14) 83 | 84 | 85 | ### Bug Fixes 86 | 87 | * dont return early in getElementsFromPage ([4324211](https://github.com/nuxt-contrib/tib/commit/4324211)) 88 | 89 | 90 | ### Features 91 | 92 | * add quiet option to disable logs ([0025dbb](https://github.com/nuxt-contrib/tib/commit/0025dbb)) 93 | 94 | 95 | 96 | ### [0.6.1](https://github.com/nuxt-contrib/tib/compare/v0.6.0...v0.6.1) (2019-07-13) 97 | 98 | 99 | ### Bug Fixes 100 | 101 | * dont overwrite staticServer config ([be5675c](https://github.com/nuxt-contrib/tib/commit/be5675c)) 102 | 103 | 104 | 105 | ## [0.6.0](https://github.com/nuxt-contrib/tib/compare/v0.5.2...v0.6.0) (2019-07-13) 106 | 107 | 108 | ### Bug Fixes 109 | 110 | * ignore grep errors for chrome detector ([262ae34](https://github.com/nuxt-contrib/tib/commit/262ae34)) 111 | 112 | 113 | ### Features 114 | 115 | * add static webserver command ([9b075d4](https://github.com/nuxt-contrib/tib/commit/9b075d4)) 116 | * add trim option to getText(s) ([1caadd0](https://github.com/nuxt-contrib/tib/commit/1caadd0)) 117 | 118 | 119 | ### Tests 120 | 121 | * fix for selenium ([7533ed4](https://github.com/nuxt-contrib/tib/commit/7533ed4)) 122 | 123 | 124 | 125 | ## [0.5.2](https://github.com/nuxt-contrib/tib/compare/v0.5.1...v0.5.2) (2019-05-05) 126 | 127 | 128 | 129 | ## [0.5.1](https://github.com/nuxt-contrib/tib/compare/v0.5.0...v0.5.1) (2019-04-03) 130 | 131 | 132 | ### Bug Fixes 133 | 134 | * use correct es5 entry point ([cc68e67](https://github.com/nuxt-contrib/tib/commit/cc68e67)) 135 | 136 | 137 | 138 | # [0.5.0](https://github.com/nuxt-contrib/tib/compare/v0.4.0...v0.5.0) (2019-03-26) 139 | 140 | 141 | ### Bug Fixes 142 | 143 | * detect chrome correctly on darwin (use egrep) ([c6068a2](https://github.com/nuxt-contrib/tib/commit/c6068a2)) 144 | * implement safari correctly ([15c4d8b](https://github.com/nuxt-contrib/tib/commit/15c4d8b)) 145 | * only enable xfvb by default on supported platforms ([d6df88c](https://github.com/nuxt-contrib/tib/commit/d6df88c)) 146 | * only load xfvb by default on supported platforms ([52fac06](https://github.com/nuxt-contrib/tib/commit/52fac06)) 147 | 148 | 149 | ### Features 150 | 151 | * rename browser export to createBrowser ([d62e935](https://github.com/nuxt-contrib/tib/commit/d62e935)) 152 | 153 | 154 | 155 | # [0.4.0](https://github.com/nuxt-contrib/tib/compare/v0.3.0...v0.4.0) (2019-03-21) 156 | 157 | 158 | ### Features 159 | 160 | * add mocked timer detection and try to workaround them ([ea9409c](https://github.com/nuxt-contrib/tib/commit/ea9409c)) 161 | 162 | 163 | 164 | # [0.3.0](https://github.com/nuxt-contrib/tib/compare/v0.2.2...v0.3.0) (2019-03-21) 165 | 166 | 167 | ### Bug Fixes 168 | 169 | * add exit listeners to exit child procs on interruption ([744dcff](https://github.com/nuxt-contrib/tib/commit/744dcff)) 170 | 171 | 172 | ### Features 173 | 174 | * auto transpile when browser version is specified ([f610f25](https://github.com/nuxt-contrib/tib/commit/f610f25)) 175 | 176 | 177 | 178 | ## [0.2.2](https://github.com/nuxt-contrib/tib/compare/v0.2.1...v0.2.2) (2019-03-20) 179 | 180 | 181 | ### Bug Fixes 182 | 183 | * **browserstack:** only set default os when not set ([3fa0322](https://github.com/nuxt-contrib/tib/commit/3fa0322)) 184 | 185 | 186 | 187 | ## [0.2.1](https://github.com/nuxt-contrib/tib/compare/v0.2.0...v0.2.1) (2019-03-20) 188 | 189 | 190 | 191 | # [0.2.0](https://github.com/nuxt-contrib/tib/compare/v0.1.0...v0.2.0) (2019-03-20) 192 | 193 | 194 | ### Bug Fixes 195 | 196 | * **selenium:** make sync scripts work in run async ([596f2ad](https://github.com/nuxt-contrib/tib/commit/596f2ad)) 197 | 198 | 199 | ### Features 200 | 201 | * add cjs build ([aa6cdd4](https://github.com/nuxt-contrib/tib/commit/aa6cdd4)) 202 | * improvments ([d3db982](https://github.com/nuxt-contrib/tib/commit/d3db982)) 203 | * split puppeteer/puppeteer-core ([341356b](https://github.com/nuxt-contrib/tib/commit/341356b)) 204 | 205 | 206 | 207 | # 0.1.0 (2019-03-15) 208 | 209 | 210 | ### Bug Fixes 211 | 212 | * dont throw error when Xvfb is already running (which is fine) ([35eaccb](https://github.com/nuxt-contrib/tib/commit/35eaccb)) 213 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # test in browser (tib) 2 | Build Status 3 | Coverage Status 4 | [![npm](https://img.shields.io/npm/dt/tib.svg)](https://www.npmjs.com/package/tib) 5 | [![npm (scoped with tag)](https://img.shields.io/npm/v/tib/latest.svg)](https://www.npmjs.com/package/tib) 6 | 7 | Helper classes for e2e browser testing in Node with a uniform interface. 8 | 9 | ## Introduction 10 | 11 | `tib` aims to provide a uniform interface for testing with both jsdom, Puppeteer and Selenium while using either local browsers or a 3rd party provider. This way you can write a single e2e test and simply switch the browser environment by changing the [`BrowserString`](#browser-strings) 12 | 13 | The term `helper classes` stems from that this package wont enforce test functionality on you (which would require another learning curve). `tib` allows you to use the test suite you are already familair with. Use `tib` to retrieve and assert whether the html you expect to be loaded is really loaded, both on page load as after interacting with it through javascript. 14 | 15 | ## Supported browsers/drivers/providers: 16 | 17 | - Puppeteer 18 | - \-core 19 | - Selenium 20 | - Firefox 21 | - Chrome 22 | - Safari 23 | - IE (_untested_) 24 | - Edge (_untested_) 25 | - jsdom 26 | - BrowserStack 27 | 28 | All browser/provider specific dependencies are peer dependencies and are dynamically loaded. You only need to install the peer-dependencies you plan to use 29 | 30 | ## Features 31 | 32 | - Retrieve html as ASTElements (using [`vue-template-compiler`](https://www.npmjs.com/package/vue-template-compiler)) 33 | - Very easy to write page function to run in the browser 34 | - just remember to only use language features the loaded page already has polyfills for 35 | - syntax is automatically transpiled when browser version is specified 36 | - e.g. arrow functions will be transpiled to normal functions when you specify 'safari 5.1' 37 | - Supports BrowserStack-Local to easily tests local html files 38 | - Serve your local html files with a simple webserver 39 | - Automatically starts Xvfb for non-headless support (on supported platforms) 40 | - set `xvfb: false` if you want to specify DISPLAY manually 41 | 42 | ## Documentation 43 | 44 | ### Install 45 | 46 | ```bash 47 | $ yarn add -D tib 48 | ``` 49 | #### Extra steps on Mac OS with Safari 50 | 51 | Make sure to Enable WebDriver Support, see [here](https://developer.apple.com/documentation/webkit/testing_with_webdriver_in_safari) for more information 52 | 53 | ### Usage 54 | 55 | ```js 56 | import { createBrowser } from 'tib' 57 | 58 | const browserString = 'firefox/headless' 59 | const autoStart = false // default true 60 | const config = { 61 | extendPage(page) { 62 | return { 63 | myPageFn() { 64 | // do something 65 | } 66 | } 67 | } 68 | } 69 | 70 | const browser = await createBrowser(browserString, config, autoStart) 71 | if (!autoStart) { 72 | await browser.start() 73 | } 74 | ``` 75 | 76 | ### Browser Strings 77 | 78 | Browser strings are broken up into capability pairs (e.g. `chrome 71` is a capability pair consisting of `browser name` and `browser version`). Those pairs are then matched against a list of known properties (see [constants.js](./src/utils/constants.js) for the full list). Browser and provider properties are used to determine the required import (see [browsers.js](./src/browsers.js)). The remaining properties should be capabilities and are depending on whether the value was recognised applied to the browser instance by calling the corresponding `set` methods. 79 | 80 | ### API 81 | 82 | Read the [API reference](./docs/API.md) 83 | 84 | ## Example 85 | 86 | also check [our e2e tests](./test/e2e/basic.test.js) for more information 87 | 88 | ```js 89 | import { createBrowser, commands: { Xvfb, BrowserStackLocal } } from 'tib' 90 | 91 | const browserString = 'windows 10/chrome 71/browserstack/local/1920x1080' 92 | // const browserString = 'puppeteer/core/staticserver' 93 | 94 | describe('my e2e test', () => { 95 | let myBrowser 96 | 97 | beforeAll(async () => { 98 | myBrowser = await createBrowser(browserString, { 99 | // if true or undefined then Xvfb is automatically started before 100 | // the browser and the displayNum=99 added to the process.env 101 | xvfb: false, 102 | quiet: false, 103 | folder: process.cwd(), 104 | staticServer: { 105 | host: 'localhost', // or set process.env.HOST 106 | port: 3000 // or set process.env.PORT 107 | }, 108 | // only used for BrowserStackLocal browsers 109 | BrowserStackLocal: { 110 | start: true, // default, if false then call 'const pid = await BrowserStackLocal.start()' 111 | stop: true, // default, if false then call 'await BrowserStackLocal.stop(pid)' 112 | user: process.env.BROWSERSTACK_USER, 113 | key: process.env.BROWSERSTACK_KEY 114 | }, 115 | extendPage(page) { 116 | return { 117 | getRouteData() { 118 | return page.runScript(() => { 119 | // this function is executed within the page context 120 | // if you use features like Promises and are testing on 121 | // older browsers make sure you have a polyfill already 122 | // loaded 123 | return myRouter.currentRoute 124 | }) 125 | }, 126 | async navigate(path) { 127 | await page.runAsyncScript((path) => { 128 | return new Promise(resolve => { 129 | myRouter.on('navigationFinished', resolve) 130 | window.myRouter.navigate(path) 131 | }) 132 | }, path) 133 | } 134 | } 135 | } 136 | }) 137 | }) 138 | 139 | afterAll(() => { 140 | if (myBrowser) { 141 | await myBrowser.close() 142 | } 143 | }) 144 | 145 | test('router', async () => { 146 | const url = myBrowser.getUrl('/') 147 | 148 | const page = await myBrowser.page(url) 149 | 150 | // you should probably expect and not log this 151 | console.log(await page.getHtml()) 152 | console.log(await page.getElement('div')) 153 | console.log(await page.getElements('div')) 154 | console.log(await page.getElementCount('div')) 155 | console.log(await page.getAttribute('div', 'id')) 156 | console.log(await page.getAttributes('div', 'id')) 157 | console.log(await page.getText('h1')) 158 | console.log(await page.getTexts('h1, h2')) 159 | console.log(await page.getTitle()) 160 | 161 | await page.navigate('/about') 162 | console.log(await page.getRouteData()) 163 | 164 | console.log(await page.getTitle()) 165 | }) 166 | }) 167 | ``` 168 | 169 | ## FAQ 170 | 171 | #### I receive a `WebDriverError: invalid argument: can't kill an exited process` error 172 | 173 | Its a Selenium error and means the browser couldnt be started or exited immeditately after start. Try to run with `xvfb: true` 174 | 175 | ## Known issues / caveats 176 | 177 | - If Node force exits then local running commands might keep running (eg geckodriver, chromedriver, Xvfb, browserstack-local) 178 | - _workaround_: none unfortunately 179 | - On CircleCI puppeteer sometimes triggers `Protocol error (Runtime.callFunctionOn): Target closed` error on page.evaluate. This could be related to a version mismatch between the browser and puppeteer. 180 | - _workaround_: use `chrome/selenium` 181 | - with Firefox you cannot run two page functions at the same time, also not when they are async 182 | - _workaround_: combine the functionality you need in a single page function 183 | - with Safari you can get ScriptTimeoutError on asynchronous page function execution. Often the timeout seems false as it is in ms and the scripts are still executed 184 | - _workaround_: wrap runAsyncScript calls in `try/catch` to just ignore the timeout :) 185 | 186 | ## Todo's 187 | - local ie/edge 188 | - more platforms 189 | - SauceLabs (key required) 190 | - others? 191 | - increase coverage 192 | -------------------------------------------------------------------------------- /src/utils/detectors/chrome.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license Copyright 2016 Google Inc. All Rights Reserved. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 4 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. 5 | */ 6 | import fs from 'fs' 7 | import path from 'path' 8 | import { execSync, execFileSync } from 'child_process' 9 | import isWsl from 'is-wsl' 10 | import uniq from 'lodash/uniq' 11 | 12 | const newLineRegex = /\r?\n/ 13 | 14 | /** 15 | * This class is based on node-get-chrome 16 | * https://github.com/mrlee23/node-get-chrome 17 | * https://github.com/gwuhaolin/chrome-finder 18 | */ 19 | export default class ChromeDetector { 20 | constructor() { 21 | this.platform = isWsl ? 'wsl' : process.platform 22 | } 23 | 24 | detect(platform = this.platform) { 25 | const handler = this[platform] 26 | if (typeof handler !== 'function') { 27 | throw new Error(`${platform} is not supported.`) 28 | } 29 | return this[platform]()[0] 30 | } 31 | 32 | darwin() { 33 | const suffixes = [ 34 | '/Contents/MacOS/Chromium', 35 | '/Contents/MacOS/Google Chrome Canary', 36 | '/Contents/MacOS/Google Chrome' 37 | ] 38 | const LSREGISTER = 39 | '/System/Library/Frameworks/CoreServices.framework' + 40 | '/Versions/A/Frameworks/LaunchServices.framework' + 41 | '/Versions/A/Support/lsregister' 42 | const installations = [] 43 | const customChromePath = this.resolveChromePath() 44 | if (customChromePath) { 45 | installations.push(customChromePath) 46 | } 47 | execSync( 48 | `${LSREGISTER} -dump` + 49 | " | grep -E -i -o '/.+(google chrome( canary)?|chromium)\\.app(\\s|$)'" + 50 | " | grep -E -v 'Caches|TimeMachine|Temporary|/Volumes|\\.Trash'" 51 | ) 52 | .toString() 53 | .split(newLineRegex) 54 | .forEach((inst) => { 55 | suffixes.forEach((suffix) => { 56 | const execPath = path.join(inst.trim(), suffix) 57 | if (this.canAccess(execPath)) { 58 | installations.push(execPath) 59 | } 60 | }) 61 | }) 62 | // Retains one per line to maintain readability. 63 | // clang-format off 64 | const priorities = [ 65 | { regex: new RegExp(`^${process.env.HOME}/Applications/.*Chrome.app`), weight: 50 }, 66 | { regex: new RegExp(`^${process.env.HOME}/Applications/.*Chrome Canary.app`), weight: 51 }, 67 | { regex: new RegExp(`^${process.env.HOME}/Applications/.*Chromium.app`), weight: 52 }, 68 | { regex: /^\/Applications\/.*Chrome.app/, weight: 100 }, 69 | { regex: /^\/Applications\/.*Chrome Canary.app/, weight: 101 }, 70 | { regex: /^\/Applications\/.*Chromium.app/, weight: 102 }, 71 | { regex: /^\/Volumes\/.*Chrome.app/, weight: -3 }, 72 | { regex: /^\/Volumes\/.*Chrome Canary.app/, weight: -2 }, 73 | { regex: /^\/Volumes\/.*Chromium.app/, weight: -1 } 74 | ] 75 | if (process.env.LIGHTHOUSE_CHROMIUM_PATH) { 76 | priorities.push({ regex: new RegExp(process.env.LIGHTHOUSE_CHROMIUM_PATH), weight: 150 }) 77 | } 78 | if (process.env.CHROME_PATH) { 79 | priorities.push({ regex: new RegExp(process.env.CHROME_PATH), weight: 151 }) 80 | } 81 | // clang-format on 82 | return this.sort(installations, priorities) 83 | } 84 | 85 | /** 86 | * Look for linux executables in 3 ways 87 | * 1. Look into CHROME_PATH env variable 88 | * 2. Look into the directories where .desktop are saved on gnome based distro's 89 | * 3. Look for google-chrome-stable & google-chrome executables by using the which command 90 | */ 91 | linux() { 92 | let installations = [] 93 | // 1. Look into CHROME_PATH env variable 94 | const customChromePath = this.resolveChromePath() 95 | if (customChromePath) { 96 | installations.push(customChromePath) 97 | } 98 | // 2. Look into the directories where .desktop are saved on gnome based distro's 99 | const desktopInstallationFolders = [ 100 | path.join(require('os').homedir(), '.local/share/applications/'), 101 | '/usr/share/applications/' 102 | ] 103 | desktopInstallationFolders.forEach((folder) => { 104 | installations = installations.concat(this.findChromeExecutables(folder)) 105 | }) 106 | // Look for chromium(-browser) & google-chrome(-stable) executables by using the which command 107 | const executables = [ 108 | 'chromium-browser', 109 | 'chromium', 110 | 'google-chrome-stable', 111 | 'google-chrome' 112 | ] 113 | executables.forEach((executable) => { 114 | try { 115 | const chromePath = execFileSync('which', [executable]) 116 | .toString() 117 | .split(newLineRegex)[0] 118 | if (this.canAccess(chromePath)) { 119 | installations.push(chromePath) 120 | } 121 | } catch (e) { 122 | // Not installed. 123 | } 124 | }) 125 | if (!installations.length) { 126 | throw new Error( 127 | 'The environment variable CHROME_PATH must be set to ' + 128 | 'executable of a build of Chromium version 54.0 or later.' 129 | ) 130 | } 131 | const priorities = [ 132 | { regex: /chromium-browser$/, weight: 51 }, 133 | { regex: /chromium$/, weight: 50 }, 134 | { regex: /chrome-wrapper$/, weight: 49 }, 135 | { regex: /google-chrome-stable$/, weight: 48 }, 136 | { regex: /google-chrome$/, weight: 47 } 137 | ] 138 | if (process.env.LIGHTHOUSE_CHROMIUM_PATH) { 139 | priorities.push({ 140 | regex: new RegExp(process.env.LIGHTHOUSE_CHROMIUM_PATH), 141 | weight: 100 142 | }) 143 | } 144 | if (process.env.CHROME_PATH) { 145 | priorities.push({ regex: new RegExp(process.env.CHROME_PATH), weight: 101 }) 146 | } 147 | return this.sort(uniq(installations.filter(Boolean)), priorities) 148 | } 149 | 150 | wsl() { 151 | // Manually populate the environment variables assuming it's the default config 152 | process.env.LOCALAPPDATA = this.getLocalAppDataPath(process.env.PATH) 153 | process.env.PROGRAMFILES = '/mnt/c/Program Files' 154 | process.env['PROGRAMFILES(X86)'] = '/mnt/c/Program Files (x86)' 155 | return this.win32() 156 | } 157 | 158 | win32() { 159 | const installations = [] 160 | const sep = path.sep 161 | const suffixes = [ 162 | `${sep}Chromium${sep}Application${sep}chrome.exe`, 163 | `${sep}Google${sep}Chrome SxS${sep}Application${sep}chrome.exe`, 164 | `${sep}Google${sep}Chrome${sep}Application${sep}chrome.exe`, 165 | `${sep}chrome-win32${sep}chrome.exe`, 166 | `${sep}Google${sep}Chrome Beta${sep}Application${sep}chrome.exe` 167 | ] 168 | const prefixes = [ 169 | process.env.LOCALAPPDATA, 170 | process.env.PROGRAMFILES, 171 | process.env['PROGRAMFILES(X86)'] 172 | ].filter(Boolean) 173 | const customChromePath = this.resolveChromePath() 174 | if (customChromePath) { 175 | installations.push(customChromePath) 176 | } 177 | prefixes.forEach(prefix => 178 | suffixes.forEach((suffix) => { 179 | const chromePath = path.join(prefix, suffix) 180 | if (this.canAccess(chromePath)) { 181 | installations.push(chromePath) 182 | } 183 | }) 184 | ) 185 | return installations 186 | } 187 | 188 | resolveChromePath() { 189 | if (this.canAccess(process.env.CHROME_PATH)) { 190 | return process.env.CHROME_PATH 191 | } 192 | if (this.canAccess(process.env.LIGHTHOUSE_CHROMIUM_PATH)) { 193 | console.warn( // eslint-disable-line no-console 194 | 'ChromeLauncher', 195 | 'LIGHTHOUSE_CHROMIUM_PATH is deprecated, use CHROME_PATH env variable instead.' 196 | ) 197 | return process.env.LIGHTHOUSE_CHROMIUM_PATH 198 | } 199 | } 200 | 201 | getLocalAppDataPath(path) { 202 | const userRegExp = /\/mnt\/([a-z])\/Users\/([^/:]+)\/AppData\// 203 | const results = userRegExp.exec(path) || [] 204 | return `/mnt/${results[1]}/Users/${results[2]}/AppData/Local` 205 | } 206 | 207 | sort(installations, priorities) { 208 | const defaultPriority = 10 209 | return installations 210 | .map((inst) => { 211 | for (const pair of priorities) { 212 | if (pair.regex.test(inst)) { 213 | return { path: inst, weight: pair.weight } 214 | } 215 | } 216 | return { path: inst, weight: defaultPriority } 217 | }) 218 | .sort((a, b) => b.weight - a.weight) 219 | .map(pair => pair.path) 220 | } 221 | 222 | canAccess(file) { 223 | if (!file) { 224 | return false 225 | } 226 | try { 227 | fs.accessSync(file) 228 | return true 229 | } catch (e) { 230 | return false 231 | } 232 | } 233 | 234 | findChromeExecutables(folder) { 235 | const argumentsRegex = /(^[^ ]+).*/ // Take everything up to the first space 236 | const chromeExecRegex = '^Exec=/.*/(google-chrome|chrome|chromium)-.*' 237 | const installations = [] 238 | if (this.canAccess(folder)) { 239 | // Output of the grep & print looks like: 240 | // /opt/google/chrome/google-chrome --profile-directory 241 | // /home/user/Downloads/chrome-linux/chrome-wrapper %U 242 | let execPaths 243 | const execOptions = { 244 | stdio: [0, 'pipe', 'ignore'] 245 | } 246 | // Some systems do not support grep -R so fallback to -r. 247 | // See https://github.com/GoogleChrome/chrome-launcher/issues/46 for more context. 248 | try { 249 | execPaths = execSync( 250 | `grep -ER "${chromeExecRegex}" ${folder} | awk -F '=' '{print $2}'`, 251 | execOptions 252 | ) 253 | } catch (e) { 254 | execPaths = execSync( 255 | `grep -Er "${chromeExecRegex}" ${folder} | awk -F '=' '{print $2}'`, 256 | execOptions 257 | ) 258 | } 259 | execPaths = execPaths 260 | .toString() 261 | .split(newLineRegex) 262 | .map(execPath => execPath.replace(argumentsRegex, '$1')) 263 | execPaths.forEach( 264 | execPath => this.canAccess(execPath) && installations.push(execPath) 265 | ) 266 | } 267 | return installations 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /docs/API.md: -------------------------------------------------------------------------------- 1 | # API Reference 2 | 3 | > Return types clarification 4 | > 5 | > `resolves`: the function is asynchronous and returns a Promise which resolves to the value
6 | > `returns`: the function is synchronous and returns the value 7 | 8 | ## Configuration 9 | 10 | ### `folder` 11 | 12 | The local folder with static html files you wish to test. This folder is used both by browserstack-local and the static webserver. 13 | If you dont use either of those, the files will be loaded through the `file://` protocol. 14 | 15 | ### `quiet` 16 | _boolean_ (default: `false`) 17 | 18 | If tue then information logging eg by static server and xvfb wont show 19 | 20 | ### `xvfb` 21 | _boolean_ (default: `true`) 22 | 23 | Whether a Xvfb server should be started. 24 | 25 | Is automatically set to `false` for headless browsers or when using a provider 26 | 27 | ### `window` 28 | _object_ (default: `undefined`) 29 | 30 | An object in the form `{ width, height }` where width & height define the window size. Can be overridden by [`setWindow`](#setwindow) 31 | 32 | ### `extendPage` 33 | _function_ (default: `undefined`) 34 | 35 | This function is called after a page is opened and receives the `webpage` instance as argument. 36 | 37 | It should return or resolve to an object which is used in the [page proxy](#pageproxy) 38 | 39 | ### `staticServer` 40 | _boolean_ / _object_ 41 | 42 | If an object the configuration for the included static webserver. If `true` then the default configuration will be used. 43 | 44 | - `host` (_string_, default `localhost`)
45 | The hostname / ip to run the static server on. Optionally you can also set the `HOST` env variable. 46 | - `port` (_integer_, default `3000`)
47 | The port the static server should run on. Optionally you can also set the `PORT` env variable. 48 | - `folder` (_string_, optional)
49 | The path used as the webroot for the webserver. If not provided the default `folder` browser config option is used 50 | 51 | ### `BrowserStackLocal` 52 | _object_ 53 | 54 | This configuration is only used with browserstack/local browsers 55 | 56 | The object can list the following properties: 57 | 58 | - `start` (_boolean_, default `true`)
59 | Whether to start the browserstack-local daemon on `browser.start` 60 | - `stop` (_boolean_, default `true`)
61 | Whether to stop the browserstack-local daemon on `browser.close` 62 | - `user` (_string_)
63 | Your browserstack user name
64 | > for security reasons it is recommended to use the env var `BROWSERSTACK_USER` instead 65 | - `key` (_string_)
66 | Your browserstack key
67 | > for security reasons it is recommended to use the env var `BROWSERSTACK_KEY` instead 68 | - `folder` (_string_, _boolean_ optional)
69 | The path to the folder which should be accessible through the browserstack-local tunnel. Explicitly set this to `false` if you wish to test an internal server. If any other falsy value is provided the default `folder` browser config option is used. 70 | 71 | ## Browser methods 72 | 73 | ### _`constructor`_ 74 | _arguments_ 75 | - config (type: `object`) 76 | 77 | ### `start` 78 | _arguments_ 79 | - capabilities (type: `string`) 80 | - ...arguments (type: `any`) 81 | 82 | _resolves_ `this` 83 | 84 | _rejects on error_ 85 | 86 | Starts the browser and all extra commands 87 | 88 | For Puppeteer capabilities are added as launch options and arguments to the `args` key of launch options
89 | For Selenium arguments are ignored 90 | 91 | ### `isReady` 92 | _returns_ `boolean` 93 | 94 | Returns true when the browser was started succesfully 95 | 96 | ### `close` 97 | _resolves_ `void` 98 | 99 | Closes the browser and all commands that were started 100 | 101 | ### `getUrl` 102 | _arguments_ 103 | - path (type: `string`) 104 | _returns_ `url` 105 | 106 | Returns the full url for a path. Depending your browser config it returns the url to/for browserstack-local, static server or just local files. 107 | 108 | ### `page` 109 | _arguments_ 110 | - url (type: `string`) 111 | - readyCondition? (type: `string` | `Function`) 112 | 113 | _resolves_ `page proxy` (see below) 114 | 115 | _rejects on error_ 116 | 117 | Opens the url on a new webpage in the browser and waits for the readyCondition to become true. 118 | 119 | ##### Page Proxy 120 | 121 | It resolves to a Proxy which returns properties in the following order: 122 | - a `userExtended` property (see configuration options) 123 | - a Webpage property (from tib) 124 | - a Page property (from the BrowserDriver, _Puppeteer only_) 125 | - a BrowserDriver property 126 | 127 | If the requested property doesnt exist on any of the internal objects it returns `undefined` 128 | 129 | Also see the [`webpage:property`](#webpageproperty) hook which is called every time you access a property through the Proxy 130 | 131 | ### `setLogLevel` 132 | 133 | ### `getCapabilities` 134 | _arguments_ 135 | - capabilities? (type `object`) 136 | 137 | _returns_ `object` 138 | 139 | Returns the current capabilities. If the `capabilities` argument is specified these capabilities are also returned but are not added to the current capabilities. 140 | 141 | ### `addCapabilities` 142 | _arguments_ 143 | - capabilities (type `object`) 144 | 145 | _returns_ `this` 146 | 147 | Adds to or sets the current capabilities with the new `capabilities` 148 | 149 | ### `getCapability` 150 | _arguments_ 151 | - capability (type `string`) 152 | 153 | _returns_ `string` 154 | 155 | Returns the value for the requested `capability` 156 | 157 | ### `addCapability` 158 | _arguments_ 159 | - key (type `string`) 160 | - value (type `string`) 161 | 162 | _returns_ `this` 163 | 164 | Adds or sets the value of the capability with name `key` 165 | 166 | ### `setWindow` 167 | _arguments_ 168 | - width (type `number`) 169 | - height (type `number`) 170 | 171 | Sets the window size. 172 | 173 | Also used for the Xvfb comamand 174 | 175 | ### `getBrowser` 176 | _returns_ `string` 177 | 178 | Returns the browser name 179 | 180 | ### `setBrowser` 181 | _arguments_ 182 | - browserName (type `string`) 183 | 184 | > Dont call this unless you have to, if you use a browserstring the browser name should already be set correctly 185 | 186 | Sets the browser name 187 | 188 | ### `setHeadless` 189 | _returns_ `this` 190 | 191 | If called the browser will be started headless (if supported). Disables the Xvfb command 192 | 193 | ### `getBrowserVersion` 194 | _returns_ `string` 195 | 196 | > this capability is probably only usefull when using a provider or selenium server 197 | 198 | Returns the browser version which was set 199 | 200 | ### `setBrowserVersion` 201 | _arguments_ 202 | - version (type `string`) 203 | 204 | _returns_ `this` 205 | 206 | > this capability is probably only usefull when using a provider or selenium server 207 | 208 | Sets the browser version 209 | 210 | ### `setOs` / `setOS` 211 | _arguments_ 212 | - name (type `string`) 213 | - version (type `string`) 214 | 215 | _returns_ `this` 216 | 217 | > this capability is probably only usefull when using a provider or selenium server 218 | 219 | Sets the os name and os version 220 | 221 | ```js 222 | browser.setOs('windows', 7) 223 | ``` 224 | 225 | ### `setOsVersion` / `setOSVersion` 226 | _arguments_ 227 | - version (type `string`) 228 | 229 | _returns_ `this` 230 | 231 | > this capability is probably only usefull when using a provider or selenium server 232 | 233 | Sets the os version 234 | 235 | ```js 236 | browser.setOs('windows') // default 237 | browser.setOsVersion(7) 238 | ``` 239 | 240 | ### `setDevice` 241 | _arguments_ 242 | - name (type `string`) 243 | 244 | _returns_ `this` 245 | 246 | > this capability is probably only usefull when using a provider or selenium server 247 | 248 | Sets the name of the device (eg for mobile testing) 249 | 250 | ### `getLocalFolderUrl` 251 | _arguments_ 252 | - path (type `string`) 253 | 254 | > This method is only available in BrowserStackLocal browsers 255 | 256 | Returns the full url for the relative `path` so browserstack can access your code through the browserstack-local tunnel 257 | 258 | ## Webpage methods 259 | 260 | ### getHtml 261 | _resolves_ `string` 262 | 263 | The full html of the loaded Webpage 264 | 265 | ### getTitle 266 | _resolves_ `string` 267 | 268 | Resolves the document title of the loaded Webpage 269 | 270 | ### getElement 271 | _arguments_ 272 | - selector (type: `string`) 273 | 274 | _resolves_ `ASTElement` 275 | 276 | Retrieves the html from the matched element and parses the returned outerHTML with `htmlCompiler` to return the corresponding `ASTElement` 277 | 278 | ### getElements 279 | _arguments_ 280 | - selector (type: `string`) 281 | 282 | _resolves_ `Array` 283 | 284 | Retrieves the html from matched elements and parses the returned outerHTML with `htmlCompiler` to return an array of the corresponding `ASTElement`s 285 | 286 | ### getElementCount 287 | _arguments_ 288 | - selector (type: `string`) 289 | 290 | _resolves_ `number` 291 | 292 | Retrieves the number of elements found 293 | 294 | ### getElementHtml 295 | _arguments_ 296 | - selector (type: `string`) 297 | 298 | _resolves_ `string` 299 | 300 | Retrieves the outerHTML for the matched element 301 | 302 | ### getElementsHtml 303 | _arguments_ 304 | - selector (type: `string`) 305 | 306 | _resolves_ `Array` 307 | 308 | Retrieves an array with the outerHTML of all matched elements 309 | 310 | ### getAttribute 311 | _arguments_ 312 | - selector (type: `string`) 313 | - attribute (type: `string`) 314 | 315 | _resolves_ `string` 316 | 317 | Retrieves the value of the attribute for the matched element 318 | 319 | ### getAttributes 320 | _arguments_ 321 | - selector (type: `string`) 322 | - attribute (type: `string`) 323 | 324 | _resolves_ `Array` 325 | 326 | Retrieves an array of the attribute values for the matched elements 327 | 328 | ### getText 329 | _arguments_ 330 | - selector (type: `string`) 331 | - trim (type: `boolean`) 332 | 333 | _resolves_ `string` 334 | 335 | Retrieves the `textContent` for the matched element. If trim is true the textContent will be trimmed 336 | 337 | ### getTexts 338 | _arguments_ 339 | - selector (type: `string`) 340 | - trim (type: `boolean`) 341 | 342 | _resolves_ `Array` 343 | 344 | Retrieves an array with the `textContent` of all matched elements. If trim is true the textContent's will be trimmed 345 | 346 | ### clickElement 347 | _arguments_ 348 | - selector (type: `string`) 349 | 350 | _resolves_ `void` 351 | 352 | Calls click on the matched element 353 | 354 | ### runScript 355 | _arguments_ 356 | - pageFunction (type: `Function`) 357 | - ...arguments (type: `any`) 358 | 359 | _returns `any`_ 360 | 361 | Executes the synchronous pageFunction in the loaded webpage context. The pageFunction is parsed to a string using `@babel/parser`, then transpiled with `@babel/core/transform` for the specified browser version*. The function is reconstructed on the webpage using `new Function`. 362 | If you need to pass arguments from your test scope to the pageFunction, pass them as additional arguments to runScript. Make sure to only pass serializable variables (e.g. a Function is not). 363 | 364 | It returns whatever `pageFunction` returns 365 | 366 | *Please note that the syntax is transpiled but polyfills are not added automatically. Polyfills need to be already loaded on the webpage. In other words, dont use features in your test pageFunctions which you dont also use in production 367 | 368 | ### runAsyncScript 369 | _arguments_ 370 | - pageFunction (type: `Function`) 371 | - ...arguments (type: `any`) 372 | 373 | _returns `any`_ 374 | 375 | Does the same as `runScript` but pageFunction can be asynchronous 376 | 377 | 378 | ## Hooks 379 | 380 | ### `dependencies:load` 381 | 382 | Called before any browser dependency is loaded 383 | 384 | ### `dependencies:loaded` 385 | 386 | Called immediately after all browser dependencies are loaded 387 | 388 | ### `start:before` 389 | 390 | Called before the browser is started 391 | 392 | ### `start:after` 393 | _passed arguments_ 394 | - driver (type: `object`, the browser driver instance) 395 | 396 | > When starting the browser failed this hook will not be called 397 | 398 | Called immediately after the browser has started 399 | 400 | ### `close:before` 401 | 402 | Called before the browser is closed 403 | 404 | ### `close:after` 405 | 406 | Called immediately after the browser was closed 407 | 408 | ### `page:before` 409 | 410 | Called before a new browser page is opened 411 | 412 | ### `page:created` 413 | _passed arguments_ 414 | - page (type: `object`, the page instance) 415 | 416 | Called immediately after a new browser page is instantiated, but before navigation occures* and before the wait condition is triggered. 417 | 418 | *On puppeteer, on selenium instantiating and navigating arent separate actions 419 | 420 | ### `page:after` 421 | _passed arguments_ 422 | - page (type: `object`, the page instance) 423 | 424 | Called immediately after a new browser page was opened 425 | 426 | ### `webpage.property` 427 | _passed arguments_ 428 | - property (type: `string`) 429 | 430 | Called whenever a property from the page proxy is requested. 431 | 432 | ### `selenium:build:before` _Selenium browsers only_ 433 | _passed arguments_ 434 | - builder (type: `object`, the Selenium builder instance) 435 | 436 | Called just before the build is called on the Selenium builder to start the browser 437 | 438 | ### `selenium:build:options` _Selenium browsers only_ 439 | _passed arguments_ 440 | - options (type: `array`) 441 | - builder (type: `object`, the Selenium builder instance) 442 | 443 | Called just before browser specific options are applied 444 | --------------------------------------------------------------------------------