hi all ,分享一个 puppeteer 的 debug, 更多可以持续关注 github | blog
有同学吐槽整个 CI/CD 下来时间太长了, 其中 e2e 测试节点就花了 10 分钟
现在我们采用的是 puppeteer 进行的一个自动化 e2e 测试, 该节点是在正式发布前, 预发发布后。
作为一个所有项目都必须要通过的一个节点, 它主要的功能是读取项目中的所有路由页面进行一个白屏测试与检查是否有 console.error 、网络错误等。
收到反馈后首先是进行排查, 发现该 spa 项目共 96 个 路由页面, 而只会开启 一个 puppeteer browser 实例去逐个对页面测试导致了耗时过长。
一开始也没有着急去改, 而是问第一版开发 e2e 的大佬, 为何没有开启多个 browser 实例去并行完成这些路由页面的任务, 得到的反馈是当时项目还比较小, 就没有做这方面的优化了。
看样子多个实例不是因为有坑才没做, 当时可能只是不想 Overdesign 。解决这个问题比较简单把收到的若干个任务进行分组, 然后去开启多个 browser 实例去并行完成这些任务即可。
如上图, 最后按照每组最多 20 个任务为一组优化后, 将该节点耗时减少到了 3 分 25 秒。
这里说明的一点分的组不是越多越好, 比如 96 个任务每组最大 20 个分为 5 组, 总时长并不会减少 5 倍。因为 browser 实例越多占用的系统资源也会越多。这有点像小学求最优解的题, 随着每组数量(x 轴)的增长, 总耗时(y 轴)会类似于一个抛物线。
其实 puppeteer 已经应用在我们很多的前端领域, 如上面所说的 e2e 测试, 其他诸如爬虫、页面定时巡检、页面性能监控都是使用的 puppeteer 。
本次就很快解决了这个问题, 出于好奇也粗略的去学习了一下 puppeteer 的实现原理。
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(); })();
// src/node/BrowserRunner.ts start(options: LaunchOptions): void { //... this.proc = childProcess.spawn( this._executablePath, this._processArguments, { // On non-windows platforms, `detached: true` makes child process a // leader of a new process group, making it possible to kill child // process tree with `.kill(-pid)` command. @see // https://nodejs.org/api/child_process.html#child_process_options_detached detached: process.platform !== 'win32', env, stdio, } ); // ... this._listeners = [ helper.addEventListener(process, 'exit', this.kill.bind(this)), ]; if (handleSIGINT) this._listeners.push( helper.addEventListener(process, 'SIGINT', () => { this.kill(); process.exit(130); }) ); if (handleSIGTERM) this._listeners.push( helper.addEventListener(process, 'SIGTERM', this.close.bind(this)) ); if (handleSIGHUP) this._listeners.push( helper.addEventListener(process, 'SIGHUP', this.close.bind(this)) ); }
// src/node/BrowserRunner.ts function waitForWSEndpoint( browserProcess: childProcess.ChildProcess, timeout: number, preferredRevision: string ): Promise<string> { return new Promise((resolve, reject) => { const rl = readline.createInterface({ input: browserProcess.stderr }); let stderr = ''; const listeners = [ helper.addEventListener(rl, 'line', onLine), helper.addEventListener(rl, 'close', () => onClose()), helper.addEventListener(browserProcess, 'exit', () => onClose()), helper.addEventListener(browserProcess, 'error', (error) => onClose(error) ), ]; const timeoutId = timeout ? setTimeout(onTimeout, timeout) : 0; /** * @param {!Error=} error */ function onClose(error?: Error): void { cleanup(); reject( new Error( [ 'Failed to launch the browser process!' + (error ? ' ' + error.message : ''), stderr, '', 'TROUBLESHOOTING: https://github.com/puppeteer/puppeteer/blob/main/docs/troubleshooting.md', '', ].join('\n') ) ); } function onTimeout(): void { cleanup(); reject( new TimeoutError( `Timed out after ${timeout} ms while trying to connect to the browser! Only Chrome at revision r${preferredRevision} is guaranteed to work.` ) ); } function onLine(line: string): void { stderr += line + '\n'; const match = line.match(/^DevTools listening on (ws:\/\/.*)$/); if (!match) return; cleanup(); resolve(match[1]); } function cleanup(): void { if (timeoutId) clearTimeout(timeoutId); helper.removeEventListeners(listeners); } });
上面 waitForWSEndpoint 函数获取到新打开的 chromium 进程的 WebSocket 监听的 url 后, 这里就通过 ws 这个 npm 包生成了一个 NodeWebSocket 。
到这里我们知道了提供若干个 api 的 puppeteer 原来是一个 WebSocket 客户端, 另一端是 chromium 进程进行真实的操作。
// src/node/NodeWebSocketTransport.ts import NodeWebSocket from 'ws'; export class NodeWebSocketTransport implements ConnectionTransport { static create(url: string): Promise<NodeWebSocketTransport> { // eslint-disable-next-line @typescript-eslint/no-var-requires const pkg = require('./../../../package.json'); return new Promise((resolve, reject) => { const ws = new NodeWebSocket(url, [], { followRedirects: true, perMessageDeflate: false, maxPayload: 256 * 1024 * 1024, // 256Mb headers: { 'User-Agent': `Puppeteer ${pkg.version}`, }, }); ws.addEventListener('open', () => resolve(new NodeWebSocketTransport(ws)) ); ws.addEventListener('error', reject); }); }
以浏览器新打开一个页面 newPage 函数的实现为例, 可知是通过 NodeWebSocket 发送了一个 'Target.createTarget' 事件, 可传参数见下面的 DevTools Protocol
//src/common/Browser.ts newPage(): Promise<Page> { return this._browser._createPageInContext(this._id); } async _createPageInContext(contextId?: string): Promise<Page> { const { targetId } = await this._connection.send('Target.createTarget', { url: 'about:blank', browserContextId: contextId || undefined, }); const target = this._targets.get(targetId); assert( await target._initializedPromise, 'Failed to create target for page' ); const page = await target.page(); return page; }
这里用来操控 chromium 的协议都可以在这里查阅 Chrome DevTools Protocol
发现问题后最好先追本溯源, 以免走前人踩过的坑。其次有多余的时间也不妨探究一下其实现原理, 技术其实都是相通的, 看的多了总是能举一反三 ~
![]() | 1 seki 2021-11-15 02:19:24 +08:00 可以考虑上一个 jest 这样的 test runner ,然后还有配合 jest-puppeteer 来写测试,这样可以免去一些对 puppeteer 的底层管理的需求 |
![]() | 2 chavyleung 2021-11-15 09:36:32 +08:00 ![]() |
![]() | 3 4ark 2021-11-15 09:58:05 +08:00 推荐一个之前项目用的 e2e 测试框架: https://www.cypress.io |
![]() | 4 ssshooter 2021-11-15 10:17:03 +08:00 模拟用户操作的原理是什么? |
![]() | 5 darrh00 2021-11-15 10:21:55 +08:00 比跨平台的 selenium 好在哪里? |
![]() | 7 aaronlam 2021-11-16 09:54:14 +08:00 遇到问题追根溯源的态度很赞,值得学习! |
![]() | 8 gavingeng 2021-11-16 10:25:48 +08:00 可以看看微软的 playwright ,也是原来的团队 2019 年跳槽过去搞的 |