websteps
Version:
End-to-end web testing on top of Chrome Debugging Protocol
218 lines (198 loc) • 6.84 kB
JavaScript
const childProcess = require('child_process');
const fs = require('fs');
const CDP = require('chrome-remote-interface');
function chromePath () {
switch (process.platform) {
case 'darwin':
return '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
case 'win32':
const localPath = `${process.env.LOCALAPPDATA}\\Google\\Chrome\\Application\\chrome.exe`;
if (fs.existsSync(localPath)) {
console.log({localPath})
return localPath;
}
// Note: https://bugs.chromium.org/p/chromium/issues/detail?id=380177
const globalPath = 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe';
return globalPath;
default:
return 'google-chrome';
}
}
function launching (options = {}) {
return new Promise((resolve, reject) => {
const browser = options.browser || 'chrome';
const port = options.port || 9222;
const userDataDir = options.userDataDir;
const verbose = options.verbose;
if (browser === 'chrome') {
const browserPath = chromePath();
const args = (options.args || []).concat([
`--remote-debugging-port=${port}`,
`--user-data-dir=${userDataDir}`,
'--no-first-run',
'--headless=new',
'--disable-gpu'
// 'about:blank' // Note: ensure a tab is opened as an "inspectable target"
].concat(verbose ? [`--enable-logging=stdout`] : []));
if (verbose) console.log(`Starting browser at ${browserPath}`);
const cp = childProcess.spawn(browserPath, args, {stdio: 'inherit'});
if (verbose) console.log(cp);
resolve(new Browser({browser, cp, port, verbose}));
} else {
reject(new Error(`Unsupported browser: ${browser}`));
}
});
}
class Browser {
constructor ({browser, cp, port, verbose}) {
this.browser = browser;
this.cp = cp;
this.port = port;
this.verbose = verbose;
}
close () {
if (this.verbose) console.log(`Closing ${this.browser}`);
if (process.platform === 'win32') {
// Note: Inspired by https://github.com/GoogleChrome/puppeteer/commit/ac109dba6ddaa62a32fe920e864238d41bf22251#diff-cd756d74c4f724a8d0ebe5f47fff175aR107
try {
childProcess.execSync(`taskkill /pid ${this.cp.pid} /T /F`);
} catch (err) {
console.log(`Ignoring: ${err.message}`);
}
} else {
this.cp.kill();
}
}
connecting (url) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (this.verbose) console.log('Connecting to browser via CDP');
CDP({port: this.port}, (client) => {
Promise.all([
client.Page.enable()
]).then((result) => {
resolve(new Window({client}));
}).catch((err) => {
console.error(`ERROR: ${err.message}`);
client.close();
reject(err);
});
}).on('error', (err) => {
console.error('Cannot connect to remote endpoint:', err);
reject(err);
});
}, 1000); // Note: waiting for browser to start, eventually retry with shorter interval to avoid the long initial wait if not necessary
});
}
}
class Window {
constructor ({client}) {
this.client = client;
}
attrOf (selector, attribute) {
return this.evaluate(`document.querySelector("${selector}").${attribute}`);
}
click (selector) {
return this.evaluate(`document.querySelector("${selector}").click()`);
}
close () {
this.client.close();
}
cssTextContains (str) {
return this.evaluate(`
[].slice.call(document.styleSheets).reduce((prev, styleSheet) => {
if (styleSheet.cssRules) {
return prev + [].slice.call(styleSheet.cssRules).reduce((prev, cssRule) => {
return prev + cssRule.cssText;
}, '');
} else {
return prev;
}
}, '').indexOf("${str}") >= 0;
`);
}
evaluate (expression) {
return new Promise((resolve, reject) => {
this.client.Runtime.evaluate({expression}, (err, result) => {
if (err) {
console.error(err);
reject(err);
} else {
// console.log('Runtime.evaluate', expression, result);
switch (result.result.type) {
case 'object':
switch (result.result.subtype) {
case 'null':
resolve(null);
break;
default:
resolve(result.result); // Note: we cannot access the object more directly than this
}
break;
default:
resolve(result.result.value);
}
}
});
});
}
htmlOf (selector) {
return this.evaluate(`document.querySelector("${selector}").innerHTML`);
}
navigating (url) {
// console.log('CDP navigate', url)
this.client.Page.navigate({url}); // Note: workaround for https://github.com/cyrus-and/chrome-remote-interface/issues/324
return this.client.Page.navigate({url});
}
reload () {
return this.client.Page.reload();
}
textOf (selector) {
return this.evaluate(`document.querySelector("${selector}").textContent`);
}
type (text, selector) {
// Note: see https://github.com/vitalyq/react-trigger-change
return this.evaluate(`
(() => {
const el = document.querySelector("${selector}");
const descriptor = Object.getOwnPropertyDescriptor(el, 'value');
const focusEvent = document.createEvent('UIEvents');
focusEvent.initEvent('focus', false, false);
el.dispatchEvent(focusEvent);
delete el.value;
el.value = "${text}";
const inputEvent = document.createEvent('HTMLEvents');
inputEvent.initEvent('input', true, false);
el.dispatchEvent(inputEvent);
Object.defineProperty(el, 'value', descriptor);
})()
`);
}
waitForElement (selector, options) {
options = options || {};
let timeout = options.timeout || 5000;
const pollTime = 100;
return new Promise((resolve, reject) => {
const poll = () => {
this.evaluate(`document.querySelector("${selector}")`).then((element) => {
if (element) {
resolve(element);
} else {
timeout -= pollTime;
if (timeout <= 0) {
reject(new Error(`Failing to find "${selector}"`));
} else {
setTimeout(poll, pollTime);
}
}
}).catch((err) => {
reject(err);
});
};
poll();
});
}
}
module.exports = {
launching: launching
};