Puppeteer介绍与实战
是什么
Puppeteer是 Google Chrome 团队官方的无界面(Headless)Chrome 工具,也被称为是无头浏览器,它是一个 Node 库,提供了一个高级的 API 来控制 DevTools 协议上的无头版 Chrome 。也可以配置为使用完整(非无头)的 Chrome。Chrome 在浏览器中的地位不必多说,因此,Chrome Headless 必将成为 web 应用自动化测试的行业标杆。使用 Puppeteer,相当于同时具有 Linux 和 Chrome 双端的操作能力,应用场景非常之多,本文最后会基于 puppeteer 写一个 jenkins 控制台部署命令来进行实战演练。
能做什么
你可以在浏览器中手动完成的大部分事情都可以使用 Puppeteer 完成!你可以从以下几个示例开始:
创建一个最新的自动化测试环境。使用最新的 JavaScript 和浏览器功能,直接在最新版本的 Chrome 中运行测试。
入门示例
想要在项目中使用 Puppeteer,只需要运行如下命令安装即可;不过要注意的是:Puppeteer 至少需要 Node v6.4.0,如要使用 async / await,只有 Node v7.6.0 或更高版本才支持;另外,安装 Puppeteer 时,它会下载最新版本的 Chromium(〜71Mb Mac,〜90Mb Linux,〜110Mb Win),保证与 API 协同工作。
yarn add puppeteer对于如何使用 Puppeteer,这非常之容易;如下简易的示例,即实现了:导航到 https://example.com 并将截屏保存为 example.png;
const puppeteer = require('puppeteer');(async () => { const browser = await puppeteer.launch() const page = await browser.newPage() await page.goto('https://example.com') await page.screenshot({ path: 'example.png' }) await browser.close()})()API 介绍
api 文档地址 https://pptr.dev/api
Puppeteer API 分层结构
Puppeteer 中的 API 分层结构基本和浏览器保持一致,下面对常使用到的几个类介绍一下:

-
Browser: 对应一个浏览器实例,一个 Browser 可以包含多个 BrowserContext
-
BrowserContext: 对应浏览器一个上下文会话,就像我们打开一个普通的 Chrome 之后又打开一个隐身模式的浏览器一样,BrowserContext 具有独立的 Session(cookie 和 cache 独立不共享),一个 BrowserContext 可以包含多个 Page
-
Page:表示一个 Tab 页面,通过 browserContext.newPage()/browser.newPage() 创建,browser.newPage() 创建页面时会使用默认的 BrowserContext,一个 Page 可以包含多个 Frame
-
Frame: 一个框架,每个页面有一个主框架(page.MainFrame()),也可以多个子框架,主要由 iframe 标签创建产生的
-
ExecutionContext: 是 javascript 的执行环境,每一个 Frame 都一个默认的 javascript 执行环境
-
ElementHandle: 对应 DOM 的一个元素节点,通过该该实例可以实现对元素的点击,填写表单等行为,我们可以通过选择器,xPath 等来获取对应的元素
-
JsHandle:对应 DOM 中的 javascript 对象,ElementHandle 继承于 JsHandle,由于我们无法直接操作 DOM 中对象,所以封装成 JsHandle 来实现相关功能
-
CDPSession:可以直接与原生的 CDP 进行通信,通过 session.send 函数直接发消息,通过 session.on 接收消息,可以实现 Puppeteer API 中没有涉及的功能
-
Coverage:获取 JavaScript 和 CSS 代码覆盖率
如何创建一个 Browser 实例
puppeteer 提供了两种方法用于创建一个 Browser 实例:
-
puppeteer.connect: 连接一个已经存在的 Chrome 实例
-
puppeteer.launch: 每次都启动一个 Chrome 实例
const puppeteer = require('puppeteer')let request = require('request-promise-native')//使用 puppeteer.launch 启动 Chrome;(async () => { const browser = await puppeteer.launch({ headless: false, //有浏览器界面启动 slowMo: 100, //放慢浏览器执行速度,方便测试观察 args: [ //启动 Chrome 的参数,详见上文中的介绍 '–no-sandbox', '--window-size=1280,960', ], }) const page = await browser.newPage() await page.goto('https://www.baidu.com') await page.close() await browser.close()})()//使用 puppeteer.connect 连接一个已经存在的 Chrome 实例;(async () => { //通过 9222 端口的 http 接口获取对应的 websocketUrl let version = await request({ uri: 'http://127.0.0.1:9222/json/version', json: true, }) //直接连接已经存在的 Chrome let browser = await puppeteer.connect({ browserWSEndpoint: version.webSocketDebuggerUrl, }) const page = await browser.newPage() await page.goto('https://www.baidu.com') await page.close() await browser.disconnect()})()这两种方式的对比:
-
puppeteer.launch 每次都要重新启动一个 Chrome 进程,启动平均耗时 100 到 150 ms,性能欠佳
-
puppeteer.connect 可以实现对于同一个 Chrome 实例的共用,减少启动关闭浏览器的时间消耗
-
puppeteer.launch 启动时参数可以动态修改
-
通过 puppeteer.connect 我们可以远程连接一个 Chrome 实例,部署在不同的机器上
-
puppeteer.connect 多个页面共用一个 chrome 实例,偶尔会出现 Page Crash 现象,需要进行并发控制,并定时重启 Chrome 实例
如何等待加载?
在实践中我们经常会遇到如何判断一个页面加载完成了,什么时机去截图,什么时机去点击某个按钮等问题,那我们到底如何去等待加载呢?
下面我们把等待加载的 API 分为三类进行介绍:
加载导航页面
- page.goto:打开新页面
- page.goBack :回退到上一个页面
- page.goForward :前进到下一个页面
- page.reload :重新加载页面
- page.waitForNavigation:等待页面跳转
Pupeeteer 中的基本上所有的操作都是异步的,以上几个 API 都涉及到关于打开一个页面,什么情况下才能判断这个函数执行完毕呢,这些函数都提供了两个参数 waitUtil 和 timeout,waitUtil 表示直到什么出现就算执行完毕,timeout 表示如果超过这个时间还没有结束就抛出异常。
await page.goto('https://www.baidu.com', { timeout: 30 * 1000, waitUntil: [ 'load', //等待 “load” 事件触发 'domcontentloaded', //等待 “domcontentloaded” 事件触发 'networkidle0', //在 500ms 内没有任何网络连接 'networkidle2' //在 500ms 内网络连接个数不超过 2 个 ]});以上 waitUtil 有四个事件,业务可以根据需求来设置其中一个或者多个触发才以为结束,networkidle0 和 networkidle2 中的 500ms 对时间性能要求高的用户来说,还是有点长的
等待元素、请求、响应
-
page.waitForXPath:等待 xPath 对应的元素出现,返回对应的 ElementHandle 实例
-
page.waitForSelector :等待选择器对应的元素出现,返回对应的 ElementHandle 实例
-
page.waitForResponse :等待某个响应结束,返回 Response 实例
-
page.waitForRequest:等待某个请求出现,返回 Request 实例
await page.waitForXPath('//img');await page.waitForSelector('#uniqueId');await page.waitForResponse('https://d.youdata.netease.com/api/dash/hello');await page.waitForRequest('https://d.youdata.netease.com/api/dash/hello');复制代码自定义等待
如果上面提供的等待方式都不能满足我们的需求,puppeteer 还提供我们提供两个函数:
-
page.waitForFunction:等待在页面中自定义函数的执行结果,返回 JsHandle 实例
-
page.waitFor:设置等待时间,实在没办法的做法
await page.goto(url, { timeout: 120000, waitUntil: 'networkidle2'});//我们可以在页面中定义自己认为加载完的事件,在合适的时间点我们将该事件设置为 true//以下是我们项目在触发截图时的判断逻辑,如果 renderdone 出现且为 true 那么就截图,如果是 Object,说明页面加载出错了,我们可以捕获该异常进行提示let renderdoneHandle = await page.waitForFunction('window.renderdone', { polling: 120});const renderdone = await renderdoneHandle.jsonValue();if (typeof renderdone === 'object') { console.log(`加载页面失败:报表${renderdone.componentId}出错 -- ${renderdone.message}`);}else{ console.log('页面加载成功');}基于 puppeteer 开发 jenkins 控制台部署命令
const chalk = require('chalk')const fs = require('fs')const path = require('path')const inquirer = require('inquirer')const { exec } = require('child_process')const oneLineLog = require('single-line-log').stdoutconst jenkinsPageUrl = 'https://jenkins.qmpoa.com/job/BetaFE_qmp_pc_ddm_new_bjqtable/build?delay=0sec'const configFilePath = path.join(__dirname, './jenkins_user_config')const configJoinChar = ' 'const envFilePath = path.join(__dirname, './jenkins_env')const branchChangedErrorMessage = 'remote branch changed'let browserconst autoBuild = async () => { try { console.log(chalk.yellow('启动中···')) browser = await require('puppeteer').launch({ // headless: false, // executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' }) const page = await browser.newPage() console.log(chalk.yellow('页面初始化中···')) await page.goto(jenkinsPageUrl) let userConfig if (fs.existsSync(configFilePath)) { userConfig = fs.readFileSync(configFilePath, 'utf-8').replace('\r', '').replace('\n', '') } if (!userConfig) { // 没有存配置文件,或配置文件没有内容,视为第一次使用 console.log(chalk.yellow('未检测到用户信息,请先登录')) userConfig = await getUserConfig() } await login(page, ...userConfig.split(configJoinChar)) // await page.waitForNavigation() console.log(chalk.yellow('数据获取中···')) await page.waitForResponse(res => { return res.url().includes('mbranch') }) await page.waitForTimeout(100) const localBranch = await getCurrentGitBranch() const remoteBranch = `origin/${localBranch}` const jenkinsPageBranches = await page.$$eval('#gitParameterSelect option', (nodes) => { return nodes.map(e => e.innerText) }) // console.log(remoteBranch, jenkinsPageBranches) if (!jenkinsPageBranches.includes(remoteBranch)) { if (jenkinsPageBranches.length) { await new Promise((resolve, reject) => { exec(`git push origin ${localBranch} -u`, () => { reject({ message: branchChangedErrorMessage }) }) }) } // console.log(chalk.yellowBright(`远程分支「${remoteBranch}」找不到,请检查!`)) // process.exit(1) } await page.select('#gitParameterSelect', remoteBranch) // 在页面设置分支 const buildInfo = { branch: remoteBranch } // 找对应的环境 const jenkinsEnv = await getJenkinsEnv() const envSelector = '[value="DEPLOYPATH"] + select' const jenkinsPageEnvOptions = await page.$$eval(envSelector + ' option', (nodes) => { return nodes.map(e => e.innerText) }) // console.log(jenkinsEnv, jenkinsPageEnvOptions) if (!jenkinsPageEnvOptions.includes(jenkinsEnv)) { console.log(chalk.yellowBright(`jenkins没有部署${jenkinsEnv}环境,请先部署!`)) console.log(chalk.blue(jenkinsPageUrl)) process.exit(1) } await page.select(envSelector, jenkinsEnv) // 在页面设置环境 buildInfo.env = jenkinsEnv if (process.argv.includes('--install')) { // 重新安装依赖 await page.click('.jenkins-checkbox') buildInfo.reinstall = true } const buildId = await getBuildSerialNumber(page) await page.click('button[name="Submit"]') console.log(chalk.yellow('已加入构建任务')) console.log(chalk.cyanBright( `构建参数: ${Object.entries(buildInfo).map(([k, v]) => `${k}:${chalk.greenBright(v)}`).join(' ')}` )) await page.waitForNavigation() let prevStatus const timer = setInterval(async () => { const status = await handleBuildStatus(page, buildId, prevStatus) if (status !== 'Pending' && status !== 'In progress') { clearInterval(timer) process.exit(0) } prevStatus = status }, 400) } catch (e) { console.log(chalk.red(e.message)) if (e.message.includes('Cannot find module \'puppeteer\'')) { console.log(chalk.red('请更新依赖')) process.exit(1) } else { if (e.message.includes(branchChangedErrorMessage)) { console.log(chalk.yellow('分支已更新,正在重新加载')) } else { console.log(chalk.yellow('未知错误,正在尝试重新启动')) } if (browser) { await browser.close() } autoBuild() } }}autoBuild()async function getCurrentGitBranch() { return new Promise((resolve, reject) => { // 高版本git使用命令 git branch --show-current exec('git rev-parse --abbrev-ref HEAD', (err, res) => { if (err) { reject(err) } else { resolve(res.replace('\r', '').replace('\n', '')) } }) })}async function chooseEnv() { const { isClientEnv, branches } = require('../../branch.config') const devBranches = branches .filter(item => !isClientEnv(item.name)) .map(item => { return { ...item, value: item.name } }) const branch = await inquirer.prompt([ { type: 'list', name: 'branch', message: '选择部署环境', choices: devBranches } ]) return branch.branch}async function getJenkinsEnv() { let env if (process.argv.includes('--set-env')) { env = await chooseEnv() } else { if (fs.existsSync(envFilePath)) { env = fs.readFileSync(envFilePath, 'utf-8') } if (!env) { env = await chooseEnv() } } env = env.toLowerCase().replace('ddm', '').replace('\r', '').replace('\n', '') return `qmp_pc_ddm${env ? '_' + env : ''}`}async function getUserConfig() { const uname = (await inquirer.prompt({ type: 'input', name: 'uname', message: '用户名:' })).uname const pwd = (await inquirer.prompt({ type: 'password', name: 'pwd', message: '密码:' })).pwd return `${uname}${configJoinChar}${pwd}`}async function fillLoginForm(page, uname = '', pwd = '') { await page.type('[name="j_username"]', uname) await page.type('[name="j_password"]', pwd) await page.click('[name="Submit"]') try { await page.waitForResponse(res => { // console.log(res.url()) return res.url().includes('loginError') }, { timeout: 1000 }) } catch (e) { // 没捕获到该请求,说明登录通过了 // 将用户名和密码存到配置文件里 fs.writeFile(configFilePath, `${uname}${configJoinChar}${pwd}`, () => {}) return true } console.log(chalk.red('登录用户名或密码错误,请重新输入!')) return false}async function login(page, uname = '', pwd = '') { let isPassed do { isPassed = await fillLoginForm(page, uname, pwd) if (!isPassed) { ([uname, pwd] = (await getUserConfig()).split(configJoinChar)) } } while (!isPassed)}async function getBuildSerialNumber(page) { const text = await page.$eval('.build-row', e => e.innerText) const idNum = parseInt(text.match(/#(\d+)/)[1]) return idNum + 1}let pendingTipDotCount = 0const maxPendingTipDotCount = 6async function handleBuildStatus(page, buildId, prevStatus) { // 几种情况 排队中 构建中 已取消 已失败 已成功 let status try { const attr = await page.$eval(`a.build-status-link[href*='${buildId}']`, e => e.getAttribute('tooltip')) if (attr) { status = ['Success', 'In progress', 'Failed', 'Aborted'].find(status => attr.startsWith(status)) if (status === 'Aborted') { oneLineLog.clear() oneLineLog('') console.log(chalk.gray('构建被取消!')) } else if (status === 'Failed') { oneLineLog.clear() oneLineLog('') console.log(chalk.red('构建失败!')) } else if (status === 'Success') { oneLineLog.clear() oneLineLog('') console.log(chalk.green('构建成功!')) } else if (status === 'In progress') { if (prevStatus !== status) { oneLineLog.clear() oneLineLog('') console.log(chalk.cyanBright('开始构建:')) } const width = await page.$eval(`.progress-bar[href*='${buildId}'] .progress-bar-done`, e => e.style.width) oneLineLog(chalk.blue(`任务${buildId}: 构建进度${width}`)) } } } catch (e) { // 没找到是pending status = 'Pending' const pendingList = await page.$$('.svg-icon.icon-nobuilt, .svg-icon[class*="-anime"]') const pendingLength = pendingList.length > 1 ? pendingList.length - 1 : 0 if (pendingLength) { if (pendingTipDotCount < maxPendingTipDotCount) { pendingTipDotCount += 1 } else { pendingTipDotCount = 0 } oneLineLog(chalk.blue(`排队中,前面还有${pendingLength}个任务${Array(pendingTipDotCount).fill('·').join('')}`)) } } return status}package.json添加命令
"jk": "node ./bin/jenkins/index.js","jki": "node ./bin/jenkins/index.js --install","jkn": "node ./bin/jenkins/index.js --set-env","jkin": "node ./bin/jenkins/index.js --install --set-env","jkni": "yarn jkin",控制台运行:


