Puppeteer与Electron结合实现自动化测试与桌面应用开发
1. Puppeteer与Electron的黄金组合自动化测试与桌面应用打包实战在Web自动化测试和桌面应用开发领域Puppeteer和Electron这对组合正在成为开发者手中的利器。Puppeteer作为Google官方维护的Node库提供了对Chrome/Chromium浏览器的高级控制能力而Electron则让开发者能够使用Web技术构建跨平台的桌面应用程序。当两者结合时我们可以在Electron应用中直接集成浏览器自动化功能实现诸如网页截图、PDF生成、表单自动填充等复杂场景。提示Puppeteer分为完整版(puppeteer)和核心版(puppeteer-core)后者更轻量但需要单独安装Chromium。在Electron项目中通常推荐使用puppeteer-core以避免二进制文件冲突。2. 环境准备与基础配置2.1 项目初始化与依赖安装首先创建一个新的Electron项目并安装必要依赖mkdir electron-puppeteer-demo cd electron-puppeteer-demo npm init -y npm install electron puppeteer-core --save-dev关键依赖说明electron: 桌面应用框架puppeteer-core: Puppeteer的精简版本(不包含内置Chromium)可选electron-builder或electron-packager(用于后续打包)2.2 基础代码结构典型的项目目录结构如下/electron-puppeteer-demo ├── main.js # Electron主进程 ├── preload.js # 预加载脚本 ├── renderer.js # 渲染进程 ├── index.html # 前端页面 └── package.jsonmain.js基础配置示例const { app, BrowserWindow } require(electron) const path require(path) let mainWindow function createWindow() { mainWindow new BrowserWindow({ width: 800, height: 600, webPreferences: { preload: path.join(__dirname, preload.js), nodeIntegration: false, contextIsolation: true } }) mainWindow.loadFile(index.html) } app.whenReady().then(createWindow)3. Puppeteer核心功能集成3.1 浏览器实例管理在Electron中集成Puppeteer需要特别注意执行环境。由于Electron本身基于Chromium我们需要确保Puppeteer使用正确的浏览器实例const puppeteer require(puppeteer-core) async function launchBrowser() { // 获取Electron自带的Chromium路径 const electronPath require(electron) const browser await puppeteer.launch({ executablePath: electronPath, headless: false, args: [--no-sandbox] // 在Electron中通常需要禁用沙箱 }) return browser }3.2 常见自动化场景实现网页截图功能async function takeScreenshot(url, path) { const browser await launchBrowser() const page await browser.newPage() await page.goto(url) await page.screenshot({ path }) await browser.close() }PDF生成功能async function generatePDF(htmlContent, outputPath) { const browser await launchBrowser() const page await browser.newPage() await page.setContent(htmlContent) await page.pdf({ path: outputPath, format: A4, printBackground: true }) await browser.close() }表单自动填写async function autoFillForm(formUrl, formData) { const browser await launchBrowser() const page await browser.newPage() await page.goto(formUrl) for (const [selector, value] of Object.entries(formData)) { await page.type(selector, value) } await page.click(#submit-button) await browser.close() }4. Electron应用打包实战4.1 打包工具选型与配置推荐使用electron-builder进行打包它支持多平台构建并提供丰富的配置选项npm install electron-builder --save-devpackage.json中添加打包配置{ build: { appId: com.example.puppeteerdemo, win: { target: nsis }, mac: { target: dmg }, linux: { target: AppImage }, extraResources: [ { from: node_modules/puppeteer-core/.local-chromium, to: chromium } ] } }4.2 解决常见打包问题问题1Chromium路径错误解决方案明确指定Chromium可执行文件路径const puppeteer require(puppeteer-core) const path require(path) const getChromiumPath () { if (process.env.NODE_ENV development) { return require(puppeteer-core).executablePath() } else { return path.join(process.resourcesPath, chromium, chrome.exe) } }问题2沙箱权限问题在Electron的主进程启动参数中添加app.commandLine.appendSwitch(no-sandbox) app.commandLine.appendSwitch(disable-setuid-sandbox)问题3资源文件丢失确保在electron-builder配置中正确包含所有必要资源extraResources: [ { from: assets/, to: assets }, { from: node_modules/some-module/, to: node_modules/some-module } ]5. 高级技巧与性能优化5.1 复用浏览器实例频繁创建和销毁浏览器实例会显著影响性能。推荐使用单例模式管理浏览器实例let browserInstance null async function getBrowserInstance() { if (!browserInstance) { browserInstance await puppeteer.launch({ executablePath: getChromiumPath(), headless: true, args: [--no-sandbox, --disable-setuid-sandbox] }) } return browserInstance } // 应用退出时关闭浏览器 app.on(will-quit, () { if (browserInstance) { browserInstance.close() } })5.2 内存管理与泄漏预防Puppeteer操作容易导致内存泄漏需要注意及时关闭不需要的页面const page await browser.newPage() try { // 执行操作... } finally { await page.close() }限制并发页面数量const MAX_CONCURRENT_PAGES 5 const activePages new Set() async function createPage() { while (activePages.size MAX_CONCURRENT_PAGES) { await new Promise(resolve setTimeout(resolve, 100)) } const page await browser.newPage() activePages.add(page) page.on(close, () activePages.delete(page)) return page }5.3 跨进程通信优化当Puppeteer操作需要从渲染进程触发时推荐使用IPC通信主进程const { ipcMain } require(electron) ipcMain.handle(take-screenshot, async (event, { url, path }) { return await takeScreenshot(url, path) })渲染进程const { ipcRenderer } require(electron) async function onScreenshotClick() { try { await ipcRenderer.invoke(take-screenshot, { url: https://example.com, path: screenshot.png }) alert(截图成功!) } catch (err) { console.error(截图失败:, err) } }6. 实战案例构建网页转PDF工具让我们通过一个完整案例巩固所学知识 - 开发一个将网页保存为PDF的桌面应用。6.1 功能设计输入URL或HTML内容自定义PDF选项页面大小、边距等保存到指定路径转换历史记录6.2 核心代码实现主进程 (main.js):const { app, BrowserWindow, ipcMain, dialog } require(electron) const puppeteer require(puppeteer-core) const path require(path) let mainWindow let browserInstance async function initPuppeteer() { browserInstance await puppeteer.launch({ executablePath: getChromiumPath(), headless: true, args: [--no-sandbox] }) } ipcMain.handle(convert-to-pdf, async (event, { source, options }) { const page await browserInstance.newPage() try { if (source.url) { await page.goto(source.url, { waitUntil: networkidle2 }) } else { await page.setContent(source.html) } const buffer await page.pdf(options) return buffer } finally { await page.close() } }) app.whenReady().then(async () { await initPuppeteer() createWindow() }) app.on(will-quit, () { if (browserInstance) { browserInstance.close() } })渲染进程 (renderer.js):document.getElementById(convert-btn).addEventListener(click, async () { const sourceType document.querySelector(input[namesource-type]:checked).value const source sourceType url ? { url: document.getElementById(url-input).value } : { html: document.getElementById(html-input).value } const options { format: document.getElementById(page-size).value, margin: { top: document.getElementById(margin-top).value mm, right: document.getElementById(margin-right).value mm, bottom: document.getElementById(margin-bottom).value mm, left: document.getElementById(margin-left).value mm }, printBackground: document.getElementById(print-background).checked } try { const pdfBuffer await window.electronAPI.convertToPdf({ source, options }) const { filePath } await window.electronAPI.showSaveDialog({ defaultPath: converted.pdf, filters: [{ name: PDF Files, extensions: [pdf] }] }) if (filePath) { await window.electronAPI.saveFile(filePath, pdfBuffer) alert(PDF转换并保存成功!) } } catch (err) { console.error(转换失败:, err) alert(转换失败: err.message) } })6.3 打包与分发最终的electron-builder配置{ build: { appId: com.example.web2pdf, productName: 网页转PDF工具, directories: { output: dist }, files: [ main.js, preload.js, renderer.js, index.html, assets/** ], extraResources: [ { from: node_modules/puppeteer-core/.local-chromium, to: chromium } ], win: { target: nsis, icon: assets/icon.ico }, mac: { target: dmg, icon: assets/icon.icns }, linux: { target: AppImage, icon: assets/icon.png } } }执行打包命令npx electron-builder --win --x64 npx electron-builder --mac --universal npx electron-builder --linux7. 调试技巧与问题排查7.1 常见错误解决方案错误Could not find Chrome解决方案明确指定Chromium路径const browser await puppeteer.launch({ executablePath: process.env.NODE_ENV development ? require(puppeteer-core).executablePath() : path.join(__dirname, chromium, chrome.exe) })错误Cache path is incorrectly configured解决方案设置明确的缓存路径const browser await puppeteer.launch({ userDataDir: path.join(app.getPath(userData), puppeteer-cache), // 其他配置... })错误Protocol error (Target.createTarget)解决方案确保使用兼容的Puppeteer和Chromium版本dependencies: { puppeteer-core: ^21.0.0, electron: ^28.0.0 }7.2 调试技巧启用详细日志const browser await puppeteer.launch({ dumpio: true, // 将Puppeteer日志输出到控制台 headless: false // 开发时使用可视模式 })使用DevTools调试渲染进程mainWindow.webContents.openDevTools()主进程调试 在package.json中添加scripts: { start: electron --inspect9229 . }然后通过Chrome DevTools连接到chrome://inspect7.3 性能监控实现简单的性能监控中间件async function withPerformanceLog(name, fn) { const start Date.now() try { const result await fn() const duration Date.now() - start console.log([Perf] ${name} completed in ${duration}ms) return result } catch (err) { const duration Date.now() - start console.error([Perf] ${name} failed after ${duration}ms, err) throw err } } // 使用示例 await withPerformanceLog(page-screenshot, async () { await page.screenshot({ path: example.png }) })8. 安全最佳实践8.1 内容安全策略(CSP)在index.html中添加meta http-equivContent-Security-Policy content default-src self; script-src self unsafe-inline; style-src self unsafe-inline; img-src self data:; 8.2 进程隔离始终启用contextIsolation和禁用nodeIntegrationnew BrowserWindow({ webPreferences: { preload: path.join(__dirname, preload.js), nodeIntegration: false, contextIsolation: true } })8.3 输入验证对所有用户输入进行严格验证function validateUrl(url) { try { const parsed new URL(url) return [http:, https:].includes(parsed.protocol) } catch { return false } } ipcMain.handle(navigate-to, async (event, url) { if (!validateUrl(url)) { throw new Error(Invalid URL) } // 安全操作... })8.4 权限控制限制Puppeteer的访问权限const browser await puppeteer.launch({ args: [ --disable-dev-shm-usage, --no-zygote, --no-sandbox, --disable-setuid-sandbox, --disable-gpu, --disable-accelerated-2d-canvas ] })9. 项目结构与代码组织9.1 推荐的项目结构/electron-puppeteer-app ├── src/ │ ├── main/ # 主进程代码 │ │ ├── index.js # 主入口 │ │ ├── puppeteer/ # Puppeteer相关服务 │ │ └── windows/ # 窗口管理 │ ├── renderer/ # 渲染进程代码 │ └── preload/ # 预加载脚本 ├── assets/ # 静态资源 ├── build/ # 构建配置 ├── dist/ # 打包输出 └── tests/ # 测试代码9.2 模块化设计示例puppeteer.service.js:const puppeteer require(puppeteer-core) const path require(path) class PuppeteerService { constructor() { this.browser null } async init() { if (this.browser) return this.browser await puppeteer.launch({ executablePath: this.getChromiumPath(), headless: true, args: [--no-sandbox] }) } getChromiumPath() { return process.env.NODE_ENV development ? require(puppeteer-core).executablePath() : path.join(process.resourcesPath, chromium, chrome.exe) } async createPage() { await this.init() return this.browser.newPage() } async close() { if (this.browser) { await this.browser.close() this.browser null } } } module.exports new PuppeteerService()9.3 使用TypeScript增强开发体验安装TypeScript相关依赖npm install typescript types/node types/electron types/puppeteer-core --save-devtsconfig.json配置示例{ compilerOptions: { target: ES2020, module: commonjs, strict: true, esModuleInterop: true, skipLibCheck: true, forceConsistentCasingInFileNames: true, outDir: ./dist, rootDir: ./src, baseUrl: ., paths: { main/*: [src/main/*], renderer/*: [src/renderer/*] } }, include: [src/**/*], exclude: [node_modules] }10. 测试与持续集成10.1 单元测试配置安装测试相关依赖npm install jest ts-jest types/jest electron-mocks --save-devjest.config.js示例module.exports { preset: ts-jest, testEnvironment: node, moduleNameMapper: { main/(.*): rootDir/src/main/$1, renderer/(.*): rootDir/src/renderer/$1 }, testPathIgnorePatterns: [ /node_modules/, /dist/ ] }10.2 Puppeteer测试示例import PuppeteerService from main/puppeteer/puppeteer.service describe(PuppeteerService, () { beforeEach(async () { await PuppeteerService.init() }) afterEach(async () { await PuppeteerService.close() }) it(should take screenshot, async () { const page await PuppeteerService.createPage() await page.goto(https://example.com) const screenshot await page.screenshot() expect(screenshot).toBeInstanceOf(Buffer) await page.close() }) })10.3 GitHub Actions CI配置.github/workflows/ci.yml示例name: CI on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkoutv2 - uses: actions/setup-nodev2 with: node-version: 18 - run: npm ci - run: npm test build: needs: test runs-on: ${{ matrix.os }} strategy: matrix: os: [macos-latest, ubuntu-latest, windows-latest] steps: - uses: actions/checkoutv2 - uses: actions/setup-nodev2 with: node-version: 18 - run: npm ci - run: npm run build:${{ matrix.os macos-latest mac || matrix.os windows-latest win || linux }} - uses: actions/upload-artifactv2 with: name: build-${{ matrix.os }} path: dist/