Appium + WebDriverIO + Jest 自动化测试环境搭建与实践指南 🧪

本文档旨在整理 Appium 自动化测试环境的搭建过程,包括所需软件安装、常见问题排查、基础脚本编写、元素定位方法以及使用 Jest 进行测试和进阶应用。

1. 环境安装与配置 🛠️

1.1 必备软件

  • Java JDK (推荐 18 或更高版本)

    • 安装命令 (Debian/Ubuntu):
      sudo apt-get update
      sudo apt-get install openjdk-18-jdk
    • 验证安装: java -version
  • Android SDK

    • 安装 Android Studio 或独立的 SDK Command-line Tools。
    • 重要: 配置环境变量 ANDROID_HOME 指向 SDK 的安装目录,并将 platform-tools 和 tools (或 cmdline-tools/latest/bin) 添加到系统 PATH
    • 参考官方教程或搜索 “安装 Android SDK 教程”。
  • ADB (Android Debug Bridge)

    • 通常随 Android SDK 的 platform-tools 一起安装。
    • 验证安装: adb version
  • Node.js (推荐 LTS 版本)

    • 从 Node.js 官网 下载安装包或使用包管理器 (如 nvmaptbrew) 安装。
    • 验证安装: node -v 和 npm -v
  • WebDriverIO

    • 将在后续步骤中通过 npm 安装。

1.2 安装 Appium

  • 使用 npm 全局安装 Appium 命令行工具:

    npm i --location=global appium
    • 注意: --location=global 是较新 npm 版本推荐的替代 -g 的方式。
  • 测试是否安装成功:

    appium --version
    # 或者直接启动服务
    appium

1.3 安装 Appium 驱动 (以 UiAutomator2 为例)

  • Appium 需要特定的驱动程序来与不同平台的 UI 框架交互。对于 Android,常用的是 UiAutomator2
    appium driver install uiautomator2

2. 安装过程中的常见问题与解决 🐛

2.1 PowerShell 执行策略问题 (Windows) PowerShell Script Execution Policy Issue (Windows) 윈도우 PowerShell 스크립트 실행 정책 문제 (Windows)

  • 问题描述: 在 Windows PowerShell 中运行 appium 命令时报错:
    appium : 无法加载文件 C:\Users\yys53\AppData\Roaming\npm\appium.ps1,因为在此系统上禁止运行脚本。有关详细信息,请参阅 https:/go.microsoft.com/fwlink/?LinkID=135170 中的 about_Execution_Policies。
  • 原因: Windows PowerShell 的执行策略 (Execution Policy) 默认设置为 Restricted,禁止运行任何脚本。
  • 解决方案 (推荐): 更改当前用户的执行策略。
    1. 以管理员身份 运行 PowerShell。
    2. 执行以下命令:
      Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
      • RemoteSigned: 允许本地脚本运行,远程脚本需签名。相对安全。
      • CurrentUser: 策略仅应用于当前用户。
    3. 当提示确认时,输入 A 并按 Enter。
  • 总结: 修改执行策略后,即可正常运行 appium.ps1 脚本启动 Appium 服务。

2.2 Chromedriver 下载超时问题 Chromedriver Download Timeout Issue

  • 问题描述: 安装 uiautomator2 驱动时报错,提示下载 Chromedriver 超时。
    Error: ✖ Encountered an error when installing package: npm command 'install ... appium-uiautomator2-driver --json' failed with code 1.
    STDOUT:
    {
      "error": {
        "code": 1,
        "summary": "command failed",
        "detail": "... Error installing Chromedriver: timeout of 15000ms exceeded ..."
      }
    }
    STDERR:
    npm ERR! ... AxiosError: timeout of 15000ms exceeded
    npm ERR! ... Downloading Chromedriver can be skipped by setting the'APPIUM_SKIP_CHROMEDRIVER_INSTALL' environment variable.
  • 原因: 网络问题或默认的 15 秒超时时间不足以下载 Chromedriver。
  • 解决方案:
    1. 增加超时时间: 设置环境变量 APPIUM_CHROMEDRIVER_INSTALL_TIMEOUT (单位:毫秒),然后重试安装。
      • Linux/macOS:
        export APPIUM_CHROMEDRIVER_INSTALL_TIMEOUT=60000 # 设置为 60 秒
        appium driver install uiautomator2
      • Windows (cmd):
        set APPIUM_CHROMEDRIVER_INSTALL_TIMEOUT=60000
        appium driver install uiautomator2
      • Windows (PowerShell):
        $env:APPIUM_CHROMEDRIVER_INSTALL_TIMEOUT="60000"
        appium driver install uiautomator2
    2. 跳过下载 (如果暂时不需要 Webview 测试): 设置环境变量 APPIUM_SKIP_CHROMEDRIVER_INSTALL 为 true,然后重试安装。
      • Linux/macOS:
        export APPIUM_SKIP_CHROMEDRIVER_INSTALL=true
        appium driver install uiautomator2
      • Windows (cmd):
        set APPIUM_SKIP_CHROMEDRIVER_INSTALL=true
        appium driver install uiautomator2
      • Windows (PowerShell):
        $env:APPIUM_SKIP_CHROMEDRIVER_INSTALL="true"
        appium driver install uiautomator2

2.3 npm 证书过期问题 (CERT_HAS_EXPIRED) npm Certificate Expired Issue

  • 问题描述: 使用 npm 安装包 (如 appium) 时报错,提示证书过期。
    npm ERR! code CERT_HAS_EXPIRED
    npm ERR! errno CERT_HAS_EXPIRED
    npm ERR! request to https://registry.npm.taobao.org/appium failed, reason: certificate has expired
    (即使切换到淘宝镜像也可能出现)
  • 原因:
    • 系统时间不正确。
    • npm 配置的 registry (源) 使用的 SSL 证书确实已过期。
    • 网络环境中有中间人干扰 SSL 验证。
  • 解决方案:
    1. 检查并同步系统时间: 确保你的计算机时间是准确的。
    2. 切换回官方 npm 源 (或尝试其他可靠镜像):
      npm config set registry https://registry.npmjs.org/
    3. (不推荐,有安全风险) 临时禁用 SSL 验证:
      npm set strict-ssl false
      重要: 安装完成后,建议通过 npm set strict-ssl true 恢复 SSL 验证。
    4. 更新 Node.js 和 npm: 旧版本可能包含过时的证书信息。

2.4 npm 缓存权限问题 (EACCES) npm Cache Permission Issue

  • 问题描述: 运行 appium driver install 或其他 npm 命令时报错,提示缓存文件夹包含 root 用户拥有的文件。
    Error: npm command 'info appium-uiautomator2-driver ... --json' failed with code 243.
    STDOUT:
    {
      "error": {
        "code": "EACCES",
        "summary": "\nYour cache folder contains root-owned files...",
        "detail": "To permanently fix this problem, please run:\n  sudo chown -R 501:20 \"/Users/yangyongsheng/.npm\""
      }
    }
    STDERR:
    npm ERR! code EACCES
    npm ERR! syscall open
    npm ERR! path /Users/yangyongsheng/.npm/_cacache/...
    npm ERR! errno -13
    npm ERR! Your cache folder contains root-owned files...
    npm ERR! To permanently fix this problem, please run:
    npm ERR!   sudo chown -R 501:20 "/Users/yangyongsheng/.npm"
  • 原因: 之前可能使用了 sudo 运行 npm 命令,导致缓存目录 ~/.npm 的部分文件或文件夹所有者变成了 root 用户,而当前用户没有权限写入。错误信息中 501:20 代表当前用户的 UID 和 GID。
  • 解决方案: 按照错误提示,更改 ~/.npm 目录及其所有内容的所有者为当前用户。
    • 注意: 命令中的 501:20 需要替换为你自己系统的用户 ID 和组 ID。通常可以直接使用当前用户名。可以在终端运行 id -u 获取用户 ID,id -g 获取组 ID。或者更简单地:
      sudo chown -R $(whoami) ~/.npm
    • 或者使用错误提示中给出的具体 ID (如果确定无误):
      sudo chown -R 501:20 "/Users/yangyongsheng/.npm"

3. 编写第一个 WebDriverIO 脚本 📝

3.1 示例代码 (控制微信) Example Code (Controlling WeChat)

// 引入 WebDriverIO 库中的 remote 函数
const { remote } = require('webdriverio');

// 微信应用的启动配置 (Desired Capabilities)
const capabilities = {
  platformName: 'Android',                      // 指定平台为 Android
  'appium:automationName': 'UiAutomator2',      // 使用 UiAutomator2 自动化引擎
  'appium:deviceName': 'Android',               // 设备名 (可自定义)
  'appium:appPackage': 'com.tencent.mm',        // 微信应用的包名
  'appium:appActivity': '.ui.LauncherUI',       // 微信启动页的 Activity
  'appium:noReset': true,                      // 保留应用数据和登录状态,不重置应用
  'appium:unicodeKeyboard': false,              // 建议设为 true,使用 Appium 输入法避免中文输入问题
  'appium:resetKeyboard': false,               // 建议设为 true, 配合 unicodeKeyboard 使用
  'appium:udid': '192.168.31.41:5555',          // 目标设备的唯一标识符 (通过 adb devices 获取)
};

// Appium 服务器配置
const wdOpts = {
  hostname: process.env.APPIUM_HOST || 'localhost',  // Appium 服务器的主机名 (通常是本机)
  port: parseInt(process.env.APPIUM_PORT, 10) || 4723, // Appium 服务器的端口号
  logLevel: 'info',                                  // WebDriverIO 日志级别 (可选 'trace', 'debug', 'info', 'warn', 'error', 'silent')
  capabilities,                                      // 使用上面定义的应用配置
};

// 主函数 (入口)
async function main() {
  // 创建 WebDriver 实例,连接到 Appium 服务器
  const driver = await remote(wdOpts);
  console.log('🚀 WebDriver session established!');

  try {
    // === 元素定位与操作 ===

    // 等待 "发现" 按钮出现并点击 (使用 XPath)
    console.log('🔍 Locating "发现" button...');
    const findBtn = await driver.$('//android.widget.TextView[@text="发现"]');
    await findBtn.waitForExist({ timeout: 15000 }); // 等待元素存在
    await findBtn.waitForDisplayed({ timeout: 10000 }); // 等待元素可见
    console.log('✅ "发现" button found!');
    await findBtn.click();
    console.log('🖱️ Clicked "发现" button');

    // 示例:通过 resource-id 定位朋友圈入口并点击
    // (注意: resource-id 可能随版本变化,请使用 Appium Inspector 确认)
    console.log('🔍 Locating "朋友圈" entry...');
    const momentsEntry = await driver.$('//android.widget.TextView[@text="朋友圈"]'); // 假设文本是 "朋友圈"
    // 或者 const momentsEntry = await driver.$('android=new UiSelector().resourceId("com.tencent.mm:id/...")'); // 使用 resource-id
    await momentsEntry.waitForExist({ timeout: 10000 });
    await momentsEntry.waitForDisplayed({ timeout: 10000 });
    console.log('✅ "朋友圈" entry found!');
    // await momentsEntry.click(); // 示例点击
    // console.log('🖱️ Clicked "朋友圈" entry');

    // 示例:获取元素属性 (假设获取某个时间元素的文本)
    // console.log('🔍 Locating time element...');
    // const timeSel = await driver.$('//android.widget.ImageView[@resource-id="com.tencent.mm:id/huj"]'); // 需要用 Inspector 确认
    // await timeSel.waitForExist({ timeout: 5000 });
    // const timeText = await timeSel.getAttribute('text'); // 或 content-desc 等属性
    // console.log(`🕒 Time element attribute: ${timeText}`);

    // 示例:滑动操作 (从屏幕中心向上滑动半个屏幕)
    // console.log('🖐️ Performing swipe action...');
    // const { width, height } = await driver.getWindowSize();
    // await driver.touchPerform([
    //   { action: 'press', options: { x: width / 2, y: height * 0.75 } },
    //   { action: 'wait', options: { ms: 500 } },
    //   { action: 'moveTo', options: { x: width / 2, y: height * 0.25 } },
    //   { action: 'release' }
    // ]);
    // console.log('🖐️ Swipe completed');

    // 暂停 1 秒,观察效果
    await driver.pause(1000);

    // 示例:保存某个元素的截图 (如头像)
    // console.log('📸 Taking screenshot of an element...');
    // const item = await driver.$('...'); // 定位到包含头像的父元素或直接定位头像
    // const avatarSel = await item.$('//android.widget.ImageView[@resource-id="com.tencent.mm:id/a27"]'); // 需要用 Inspector 确认
    // await avatarSel.waitForExist({ timeout: 5000 });
    // await avatarSel.saveScreenshot('./avatar.png');
    // console.log('🖼️ Avatar screenshot saved to avatar.png');

  } catch (err) {
      console.error("❌ An error occurred during the test:", err);
      // 可以在这里添加截图或记录更多错误信息的操作
      // await driver.saveScreenshot('./error_screenshot.png');
  } finally {
    // 测试结束,等待 1 秒后关闭会话
    console.log('⏳ Pausing before closing session...');
    await driver.pause(1000);
    if (driver) {
        await driver.deleteSession();
        console.log('✅ WebDriver session closed.');
    }
  }
}

// 运行主函数并捕获任何未处理的 Promise 拒绝
main().catch(console.error);

3.2 代码解释 Code Explanation

  • require('webdriverio'): 引入 WebDriverIO 库。
  • capabilities: 定义测试目标设备和应用的信息,这是 Appium 服务器识别和启动应用的关键。
    • platformName: 'Android' 或 'iOS'。
    • automationName: 使用的自动化引擎,Android 通常是 'UiAutomator2',iOS 是 'XCUITest'。
    • deviceName: 任意设备名,或 adb devices 显示的设备 ID。
    • appPackage & appActivity: 安卓应用包名和启动 Activity,用于启动特定应用。可通过 adb shell dumpsys window | findstr mCurrentFocus (Windows) 或 adb shell dumpsys window | grep mCurrentFocus (Linux/Mac) 等命令获取当前前台应用的包名和 Activity。
    • noResettrue 表示不清空应用数据,false 表示每次启动都像新安装一样。
    • unicodeKeyboard & resetKeyboard: 推荐都设为 true,以支持 Appium 输入中文和特殊字符,并在测试结束后恢复原始输入法。
    • udid: 必填,指定要操作的设备。
  • wdOpts: 配置 WebDriverIO 客户端如何连接 Appium 服务器。
    • hostname: Appium 服务地址。
    • port: Appium 服务端口。
    • logLevel: 控制 WebDriverIO 输出的日志详细程度。
  • remote(wdOpts): 异步函数,根据配置建立与 Appium 服务器的连接,返回一个 driver 对象。
  • driver.$('XPATH or other selector'): 查找单个元素。返回一个 Element 对象。支持多种选择器策略,如 XPath, Accessibility ID, UIAutomator (Android), Predicate String (iOS) 等。
  • await element.waitForExist() / await element.waitForDisplayed(): 等待元素在 DOM 中出现或在屏幕上可见。
  • await element.click(): 点击元素。
  • await element.getAttribute('attributeName'): 获取元素的指定属性值 (如 textcontent-descresource-idcheckedselected 等)。
  • await driver.pause(milliseconds): 暂停执行指定时间。
  • await element.saveScreenshot(filePath): 将元素的截图保存到指定路径。
  • await driver.touchPerform([...]): 执行复杂的触摸手势,如滑动、长按、多点触控。
  • await driver.deleteSession(): 断开与 Appium 服务器的连接,结束测试会话。
  • try...catch...finally: 标准的错误处理结构,确保即使测试中出错,deleteSession 也能被执行,释放资源。

4. 元素定位 🔍

4.1 使用 Appium Inspector

  • Appium Inspector 是一个独立的桌面应用,用于连接到 Appium 会话,查看应用的 UI 结构,并获取元素的定位符。
  • 下载地址: Releases · appium/appium-inspector
  • 使用方法:
    1. 启动 Appium 服务器 (appium 命令)。
    2. 打开 Appium Inspector。
    3. 配置 "Desired Capabilities" (与脚本中的 capabilities 类似)。
    4. 点击 "Start Session"。
    5. Inspector 会在设备上启动应用,并显示应用界面的截图和 UI 元素层级树。
    6. 点击界面上的元素,右侧会显示该元素的属性 (如 resource-idtextcontent-descxpath 等),方便你选择合适的定位策略。

4.2 JSON Representation 配置 (用于 Appium Inspector)

这是在 Appium Inspector 中配置 "Desired Capabilities" 的 JSON 格式示例:

{
  "platformName": "Android",
  "appium:automationName": "UiAutomator2",
  "appium:appPackage": "com.eggflower.read", // 替换为你的 App 包名
  "appium:appActivity": "com.dragon.read.pages.splash.SplashActivity", // 替换为你的 App 启动 Activity
  "appium:noReset": true,
  "appium:unicodeKeyboard": false, // 建议设为 true
  "appium:resetKeyboard": false,  // 建议设为 true
  "appium:udid": "192.168.31.182:5555" // 替换为你的设备 UDID
}

4.3 (提及) 使用 lxml 解析 XML

  • Appium/UiAutomator2 可以获取当前页面的 UI 结构 XML (driver.getPageSource())。
  • 如果需要复杂的离线分析或处理 UI 结构,可以使用 Python 的 lxml 库或其他 XML 解析库来解析这个 XML 字符串,并使用 XPath 进行查询。但在 WebDriverIO 脚本中,通常直接使用 driver.$ 或 driver.$$ 进行在线定位。

5. 使用 Jest 运行 Appium 测试 🚀

Jest 是一个流行的 JavaScript 测试框架。将其与 WebDriverIO 结合可以提供强大的测试运行、断言和报告功能。

5.1 遇到的 ESM 支持问题 ESM Support Issues Encountered

  • 问题描述 1: 运行 Jest 时报错,提示需要支持 ES Modules 的 Node.js 版本。
    You need to run with a version of node that supports ES Modules in the VM API. See https://jestjs.io/docs/ecmascript-modules
  • 问题描述 2: (Node.js 20.x 版本偶现,18.x 较常见) Jest 进程直接崩溃退出,状态码 3221225477 (通常表示访问冲突或依赖问题)。
    RUNS  ./ld.test.js
    node:child_process:960
        throw err;
        ^
    
    Error: Command failed: npx jest ld.test.js 5 --config D:/node/jest.config.js
        at checkExecSyncError (node:child_process:885:11)
        ...
      status: 3221225477,
      signal: null,
      ...
    }
  • 原因:
    • Jest 对 ES Modules (ESM) 的原生支持需要 Node.js 12.16.0+ 或 13.2.0+,并且需要显式开启实验性 VM 模块标志。
    • Appium/WebDriverIO 或其依赖项可能混合使用了 CommonJS (CJS) 和 ESM,或者测试代码本身使用了 ESM 语法 (import/export),导致 Jest 在默认配置下无法正确处理。
    • 状态码 3221225477 (0xC0000005) 是 Windows 上的 "Access Violation" 错误,可能由多种原因引起,包括 Node.js 版本与依赖库 (特别是 C++ 插件) 的不兼容、内存问题、或者 ESM 加载过程中的内部错误。

5.2 解决方案:配置 Jest 以支持 ESM Solution: Configuring Jest for ESM Support

  1. 安装 cross-env: 用于跨平台设置环境变量。

    npm install --save-dev cross-env
  2. 修改 package.json 中的 scripts:

    • 在运行 Jest 的命令前添加 cross-env NODE_OPTIONS='--experimental-vm-modules'
    • --runInBand 建议在调试 Appium 测试时使用,确保测试按顺序串行执行,避免资源冲突。
    {
      "scripts": {
        "test": "cross-env NODE_OPTIONS='--experimental-vm-modules' jest --runInBand",
        "test:coverage": "cross-env NODE_OPTIONS='--experimental-vm-modules' jest --runInBand --coverage"
      }
      // ... 其他配置
    }
  3. Windows 环境下的 NODE_OPTIONS:

    • 如果你不使用 cross-env,在不同终端设置环境变量的方式不同:
      • cmd: set NODE_OPTIONS=--experimental-vm-modules
      • PowerShell: $env:NODE_OPTIONS="--experimental-vm-modules"
    • 注意: 直接在终端设置只对当前会话有效。写入 package.json 更方便。
  4. (可选) 使用 Babel 转译: 如果配置 NODE_OPTIONS 后仍有问题,或想确保更好的兼容性,可以使用 Babel 将 ESM 代码转译为 Jest 能理解的 CommonJS。

    • 安装依赖: npm install --save-dev @babel/core @babel/preset-env babel-jest
    • 创建 babel.config.js 文件:
      module.exports = {
        presets: [['@babel/preset-env', {targets: {node: 'current'}}]],
      };
    • Jest 会自动检测并使用 Babel 进行转译。

5.3 Node 版本问题与潜在错误 Node Version Issues & Potential Errors

  • 关于 status: 3221225477 错误:
    • 此错误较为棘手,网上没有统一的完美解决方案。
    • 尝试的策略:
      • 更换 Node.js 版本: 尝试不同的 Node.js 18.x 或 20.x 的子版本,有时特定版本组合可能存在问题。
      • 清理和重装依赖: 删除 node_modules 和 package-lock.json,然后运行 npm install
      • 更新 Jest 和相关依赖: npm update jest @babel/core @babel/preset-env babel-jest webdriverio --save-dev (或 save)。
      • 检查系统环境: 确保没有其他软件冲突,系统资源充足。
    • 临时方案: 如果 Jest 运行持续不稳定,可以像笔记中提到的,将 WebDriverIO 脚本单独运行 (不通过 Jest),或者尝试其他测试框架 (如 Mocha)。

6. 进阶:封装与多设备测试 ✨ Advanced: Abstraction & Multi-Device Testing

为了提高代码复用性和可维护性,可以将 Appium/WebDriverIO 的常用操作封装成工具类,并设计测试脚本以支持多设备并行或串行测试。

6.1 封装 MobileApp 工具类 (mobileAppUtils.js)

下面的代码展示了一个封装了 Appium 连接、启动、元素查找、点击、截图、会话管理、ADB 连接、ATX Agent 卸载以及 Allure 报告生成的工具类。

// mobileAppUtils.js
const { remote } = require('webdriverio');
const { exec } = require('child_process');
const net = require('net');
const fs = require('fs');
const path = require('path');

// 全局配置 (可通过环境变量覆盖)
const config = {
    // Appium 相关路径 (请根据你的实际安装位置修改)
    appiumCmdPath: process.env.APPIUM_CMD_PATH || 'C:/Users/yys53/AppData/Roaming/npm/appium.cmd', // Windows .cmd 路径
    appiumJsPath: process.env.APPIUM_JS_PATH || 'C:/Users/yys53/AppData/Roaming/npm/node_modules/appium/bin/appium.js', // Appium 主 js 文件路径
    // ADB 路径 (请根据你的实际安装位置修改)
    adbPath: process.env.ADB_PATH || 'D:/sdk/android-studio-2024.2.2.13-windows/android-studio/platform-tools/adb.exe',
    // Appium 服务器地址和端口
    appiumHost: process.env.APPIUM_HOST || 'localhost',
    appiumPort: parseInt(process.env.APPIUM_PORT, 10) || 4723,
    // ATX Agent 包名 (用于卸载示例)
    atxPackageName: 'com.github.uiautomator',
    // 重试次数
    maxRetryAttempts: 3,
    // 应用启动超时时间 (毫秒)
    appLaunchTimeout: 30000,
};

// 清理文件夹函数
async function clearFolder(folderPath) {
    // ... (代码同原文) ...
    try {
        // 检查文件夹是否存在
        if (!fs.existsSync(folderPath)) {
            console.log(`文件夹不存在,无需清理: ${folderPath}`);
            return;
        }
        const files = await fs.promises.readdir(folderPath);
        await Promise.all(files.map(async (file) => {
            const filePath = path.join(folderPath, file);
            const stat = await fs.promises.stat(filePath);
            if (stat.isDirectory()) {
                // 递归删除子文件夹
                await clearFolder(filePath);
                await fs.promises.rmdir(filePath);
            } else {
                // 删除文件
                await fs.promises.unlink(filePath);
            }
        }));
        console.log(`已成功清理文件夹: ${folderPath}`);
    } catch (error) {
        // 忽略文件不存在的错误,但记录其他错误
        if (error.code !== 'ENOENT') {
            console.error(`清理文件夹失败: ${folderPath}`, error);
        }
    }
}


// 生成 Allure 报告函数
async function generateAllureReport(udid, appPackage) {
    // ... (代码同原文, 注意路径处理和错误处理) ...
    const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));

    // 构建包含 UDID 和 AppPackage 的文件夹名称,替换非法字符
    const folderSuffix = `${udid.replace(/:|\./g, '_')}_${appPackage.replace(/\./g, '_')}`;
    const allureResultsPath = path.resolve(__dirname, 'allure-results'); // 使用绝对路径
    const allureReportPath = path.resolve(__dirname, `allure-report_${folderSuffix}`); // 使用绝对路径

    try {
        console.log(`📊 开始为设备 ${udid} (${appPackage}) 生成 Allure 报告...`);

        // 确保 allure-results 文件夹存在
        if (!fs.existsSync(allureResultsPath)) {
            console.warn(`⚠️ Allure 结果文件夹不存在: ${allureResultsPath}. 可能没有测试结果.`);
            // fs.mkdirSync(allureResultsPath, { recursive: true }); // 如果需要,可以创建
            return; // 没有结果,直接返回
        }

        // 确保 allure-report 文件夹存在,如果已存在则先清理
        if (fs.existsSync(allureReportPath)) {
            console.log(`🧹 清理旧的 Allure 报告文件夹: ${allureReportPath}`);
            await clearFolder(allureReportPath); // 清理报告目录
            await fs.promises.rmdir(allureReportPath); // 删除空目录本身
        }
        // 不再需要创建,generate 命令会自动创建

        // 使用 allure generate 命令生成报告
        // 确保 allure 命令在 PATH 中,或者提供完整路径
        // 双引号处理路径中的空格
        const allureCmd = `allure generate "${allureResultsPath}" --clean -o "${allureReportPath}"`;
        console.log('执行命令:', allureCmd);

        // 短暂延时,确保文件系统操作完成
        // console.log('⏳ 等待 2 秒...');
        // await delay(2000);
        // console.log('延时结束,开始生成报告');

        await new Promise((resolve, reject) => {
            // 使用 { shell: true } 以便能正确解析带引号的路径和命令
            exec(allureCmd, { shell: true }, (error, stdout, stderr) => {
                if (error) {
                    console.error(`❌ Allure 报告生成失败 (${udid}, ${appPackage}):`, error);
                    console.error('stderr:', stderr);
                    // 打印 stdout 也可能有助于调试
                    console.log('stdout:', stdout);
                    reject(new Error(`Allure generate command failed: ${stderr || error.message}`));
                } else {
                    console.log(`✅ Allure 报告已生成: ${allureReportPath}`);
                    // 打印 Allure 输出,通常会提示报告生成位置
                    console.log('Allure generate stdout:', stdout);
                    resolve(stdout);
                }
            });
        });

        // **重要:** 生成报告后才清理 results 文件夹
        console.log(`🧹 开始清理 Allure 结果文件夹: ${allureResultsPath}...`);
        await clearFolder(allureResultsPath); // 清理 results 目录
         if (fs.existsSync(allureResultsPath)) {
             await fs.promises.rmdir(allureResultsPath).catch(err => {
                 // 如果目录非空(可能并发写入导致),记录但不中断
                 if (err.code !== 'ENOTEMPTY') console.error(`删除 allure-results 目录时出错: ${err}`);
             });
         }

    } catch (error) {
        console.error(`❌ Allure 报告生成或清理过程中发生错误 (${udid}, ${appPackage}):`, error);
        console.error('错误堆栈:', error.stack);
        // 这里可以考虑抛出错误,让调用者知道生成失败
        // throw error;
    }
}


// MobileApp 类
class MobileApp {
    constructor(capabilities, wdOpts) {
        this.capabilities = capabilities;
        this.wdOpts = { ...wdOpts, capabilities }; // 合并 capabilities 到 wdOpts
        this.driver = null;
        this.udid = this.capabilities['appium:udid']; // 存储 udid
        this.appPackage = this.capabilities['appium:appPackage']; // 存储 appPackage
        console.log(`📱 初始化 MobileApp 实例: UDID=${this.udid}, App=${this.appPackage}`);
    }

    // 检查 Appium 服务是否在指定端口运行
    async isAppiumRunning(host, port) {
        // ... (代码同原文) ...
         return new Promise((resolve) => {
            const socket = new net.Socket();
            socket.setTimeout(1000); // 设置 1 秒超时
            socket.on('connect', () => {
                socket.destroy();
                resolve(true);
            });
            socket.on('error', (err) => {
                // console.warn(`检查 Appium 端口 ${host}:${port} 出错: ${err.message}`);
                socket.destroy();
                resolve(false);
            });
            socket.on('timeout', () => {
                // console.warn(`检查 Appium 端口 ${host}:${port} 超时`);
                socket.destroy();
                resolve(false);
            });
            socket.connect(port, host);
        });
    }

    // 启动 Appium 服务 (注意:这种方式不够健壮,建议外部独立管理 Appium 服务)
    async startAppiumServer() {
        // ... (代码同原文, 添加更多日志和错误处理) ...
         return new Promise((resolve, reject) => {
            let appiumCmd = config.appiumCmdPath;
            // 简单处理路径,更健壮的方式是使用 path.normalize 或 cross-spawn
            if (process.platform === 'win32' && appiumCmd.includes('appium.cmd')) {
                 // Windows .cmd 文件通常可以直接执行
                 console.log(`尝试使用 .cmd 启动 Appium: ${appiumCmd}`);
            } else if (config.appiumJsPath && fs.existsSync(config.appiumJsPath)) {
                 // 尝试使用 node 直接运行 appium.js
                 appiumCmd = `node "${config.appiumJsPath}"`;
                 console.log(`尝试使用 node 启动 Appium: ${appiumCmd}`);
            } else {
                 // 尝试直接使用全局 appium 命令
                 appiumCmd = 'appium';
                 console.log(`尝试使用全局命令启动 Appium: ${appiumCmd}`);
            }


            console.log(`⏳ 正在执行命令启动 Appium: ${appiumCmd}`);
            const appiumProcess = exec(appiumCmd, { shell: true }, (error, stdout, stderr) => {
                // 这个回调在进程结束后执行,不适合判断服务是否已就绪
                if (error) {
                    // 记录错误但不立即 reject,因为可能服务仍在后台启动中
                    console.error(`Appium 进程执行出错 (可能已在后台启动): ${stderr || error.message}`);
                    // reject(new Error(`启动 Appium 服务失败: ${stderr || error.message}`));
                }
            });

            let resolved = false;
            const resolveOnce = () => {
                if (!resolved) {
                    resolved = true;
                    console.log('✅ Appium 服务似乎已启动 (检测到监听日志)');
                    resolve();
                }
            };
             const rejectOnce = (err) => {
                if (!resolved) {
                    resolved = true;
                    console.error('❌ 启动 Appium 服务失败');
                    reject(err);
                }
             }

            // 监听标准输出查找启动成功的标志
            appiumProcess.stdout.on('data', (data) => {
                const output = data.toString();
                console.log('Appium stdout:', output); // 打印日志方便调试
                // 查找 Appium 启动成功的日志行
                if (output.includes('Appium REST http interface listener started')) {
                     resolveOnce();
                }
            });

            // 监听错误输出
            appiumProcess.stderr.on('data', (data) => {
                const errorOutput = data.toString();
                console.error('Appium stderr:', errorOutput);
                // 如果 stderr 包含明确的错误信息,可以提前 reject
                 if (errorOutput.includes('EADDRINUSE') || errorOutput.includes('Could not start REST http interface')) {
                     rejectOnce(new Error(`启动 Appium 服务失败: ${errorOutput}`));
                 }
            });

             // 进程退出时的处理
             appiumProcess.on('close', (code) => {
                 if (code !== 0 && !resolved) {
                    rejectOnce(new Error(`Appium 进程意外退出,退出码: ${code}`));
                 }
             });

             // 添加超时机制
            const startTimeout = setTimeout(() => {
                 rejectOnce(new Error('启动 Appium 服务超时 (30 秒)'));
             }, 30000); // 30 秒超时

            // 清理超时定时器
            appiumProcess.on('exit', () => clearTimeout(startTimeout));
            appiumProcess.on('error', (err) => { // 监听进程本身的错误,例如命令找不到
                clearTimeout(startTimeout);
                rejectOnce(new Error(`执行 Appium 命令失败: ${err.message}`));
            });
        });
    }

    // 连接 ADB 设备 (带重试)
    async connectAdb(udid, attempts = 0) {
        // ... (代码同原文) ...
        if (attempts >= config.maxRetryAttempts) {
            throw new Error(`❌ 连接 ADB 失败 (${udid}),超出最大重试次数 ${config.maxRetryAttempts}`);
        }
        return new Promise((resolve, reject) => {
            const connectCmd = `"${config.adbPath}" connect ${udid}`; // 给 adb 路径加上引号
            console.log(`[ADB Connect Attempt ${attempts + 1}/${config.maxRetryAttempts}] 执行: ${connectCmd}`);
            exec(connectCmd, (error, stdout, stderr) => {
                if (error) {
                    console.warn(`⚠️ ADB 连接失败 (${udid}): ${stderr || error.message}. 重试中...`);
                    setTimeout(() => this.connectAdb(udid, attempts + 1).then(resolve).catch(reject), 2000); // 增加重试间隔
                    return;
                }
                const output = stdout.toString();
                // 检查多种可能的成功连接信息
                if (output.includes('connected to') || output.includes('already connected to')) {
                    console.log(`✅ ADB 已连接到设备: ${udid}`);
                    resolve();
                } else {
                    console.warn(`⚠️ ADB 连接响应未明确表示成功 (${udid}): ${output || '无输出'}. 重试中...`);
                    setTimeout(() => this.connectAdb(udid, attempts + 1).then(resolve).catch(reject), 2000);
                }
            });
        });
    }


    // 初始化 Appium 会话 (带重试)
    async init(attempts = 0) {
        console.log(`[Init Attempt ${attempts + 1}/${config.maxRetryAttempts}] 开始初始化 Appium 会话 for ${this.udid}...`);
        if (attempts >= config.maxRetryAttempts) {
            throw new Error(`❌ 初始化 Appium 失败 (${this.udid}),超出最大重试次数`);
        }
        try {
            // 1. 确保 ADB 已连接
            await this.connectAdb(this.udid);

            // 2. 检查 Appium 服务是否运行 (如果需要,尝试启动 - 但建议外部管理)
            if (!await this.isAppiumRunning(config.appiumHost, config.appiumPort)) {
                console.log(`⏳ Appium 服务在 ${config.appiumHost}:${config.appiumPort} 未运行,等待或尝试启动...`);
                 // await this.startAppiumServer(); // 取消注释以启用自动启动,但风险较高
                 // 等待一段时间,看服务是否由外部启动
                 await new Promise(resolve => setTimeout(resolve, 5000));
                 if (!await this.isAppiumRunning(config.appiumHost, config.appiumPort)) {
                     throw new Error(`Appium 服务在 ${config.appiumHost}:${config.appiumPort} 未启动`);
                 }
                console.log('✅ Appium 服务已运行.');
            } else {
                 console.log(`✅ Appium 服务已在 ${config.appiumHost}:${config.appiumPort} 运行.`);
             }

            // 3. 创建 WebDriver 会话
            console.log(`⏳ 正在创建 WebDriver 会话 for ${this.udid}...`);
            // 增加连接超时时间
            this.wdOpts.connectionRetryTimeout = 120000; // 120 秒
            this.wdOpts.connectionRetryCount = 3;
            this.driver = await remote(this.wdOpts);
            console.log(`✅ WebDriver 会话成功建立 for ${this.udid}`);
            return this.driver; // 返回 driver 实例

        } catch (error) {
            console.warn(`⚠️ 初始化失败 (${this.udid}): ${error.message}. 重试中...`);
            // 在重试前,尝试清理可能残留的会话或状态
            if (this.driver) {
                try {
                    await this.driver.deleteSession();
                } catch (deleteError) {
                    console.warn(`清理旧会话失败: ${deleteError.message}`);
                }
                this.driver = null;
            }
            await new Promise(resolve => setTimeout(resolve, 3000 * (attempts + 1))); // 增加重试等待时间
            // 递归调用 init 进行重试
             return this.init(attempts + 1);
            // throw error; // 如果不使用递归重试,则抛出错误
        }
    }


    // 检查包是否安装
    async isPackageInstalled(udid, packageName) {
        // ... (代码同原文) ...
         const listCmd = `"${config.adbPath}" -s ${udid} shell pm list packages | grep "${packageName}$"`; // 精确匹配包名结尾
        return new Promise(resolve => {
            exec(listCmd, (error, stdout, stderr) => {
                if (error) {
                    // 如果 grep 没找到会返回错误码 1,这是正常的
                    if (error.code === 1) {
                        resolve(false);
                    } else {
                        console.error(`检查包安装状态出错 (${udid}, ${packageName}): ${stderr || error.message}`);
                        resolve(false); // 出错时保守地认为未安装或状态未知
                    }
                    return;
                }
                // 如果 stdout 有内容,说明包已安装
                resolve(stdout?.trim() !== '');
            });
        });
    }

    // 卸载 ATX Agent (如果已安装)
    async uninstallAtx(udid) {
        // ... (代码同原文, 增加日志) ...
        const isInstalled = await this.isPackageInstalled(udid, config.atxPackageName);
        if (isInstalled) {
            console.log(`⏳ 正在卸载 ATX Agent (${config.atxPackageName}) on ${udid}...`);
            const uninstallCmd = `"${config.adbPath}" -s ${udid} uninstall ${config.atxPackageName}`;
            return new Promise((resolve, reject) => {
                exec(uninstallCmd, (error, stdout, stderr) => {
                    if (error) {
                        console.error(`❌ 卸载 ATX agent 失败 (${udid}): ${stderr || error.message}`);
                        reject(new Error(`卸载 ATX agent 失败: ${stderr || error.message}`));
                    } else {
                        console.log(`✅ ATX Agent 已卸载 (${udid}). Output: ${stdout}`);
                        resolve(stdout);
                    }
                });
            });
        } else {
            console.log(`ℹ️ ATX Agent (${config.atxPackageName}) 未安装在 ${udid},无需卸载.`);
            return Promise.resolve('Not installed'); // 返回一个表示未安装的状态
        }
    }

    // 查找元素 (带重试和等待)
    async findElementByXPath(xpath, attempts = 0) {
        // ... (代码同原文, 增加等待时间) ...
        if (!this.driver) throw new Error("Driver not initialized");
        const timeout = 15000; // 增加等待超时时间
        console.log(`[Find Element Attempt ${attempts + 1}/${config.maxRetryAttempts}] 查找 XPath: ${xpath}`);
        if (attempts >= config.maxRetryAttempts) {
            throw new Error(`❌ 查找元素失败 (${this.udid}),超出最大重试次数: ${xpath}`);
        }
        try {
            const element = await this.driver.$(xpath);
            // 等待元素存在于 DOM 中
            await element.waitForExist({ timeout, interval: 500 });
            // 等待元素在屏幕上可见
            await element.waitForDisplayed({ timeout, interval: 500 });
            console.log(`✅ 找到元素: ${xpath}`);
            return element;
        } catch (error) {
            console.warn(`⚠️ 查找元素失败: ${xpath} (${this.udid}),重试中... Error: ${error.message}`);
            await new Promise(resolve => setTimeout(resolve, 1500)); // 增加重试间隔
            return this.findElementByXPath(xpath, attempts + 1);
        }
    }

    // 保存元素截图
    async saveElementScreenShot(element, filePath) {
        if (!this.driver) throw new Error("Driver not initialized");
        try {
             console.log(`📸 正在为元素截图并保存到: ${filePath}`);
             await element.saveScreenshot(filePath);
             console.log(`✅ 元素截图已保存: ${filePath}`);
             return filePath;
        } catch (error) {
             console.error(`❌ 保存元素截图失败 (${this.udid}): ${error.message}`);
             throw error;
        }
    }

    // 点击元素 (带前置等待)
    async clickElement(elementOrXpath) {
        if (!this.driver) throw new Error("Driver not initialized");
        let element = elementOrXpath;
         try {
             // 如果传入的是 xpath 字符串,先查找元素
             if (typeof elementOrXpath === 'string') {
                 console.log(`🖱️ 准备点击 XPath: ${elementOrXpath}`);
                 element = await this.findElementByXPath(elementOrXpath);
             } else {
                 console.log(`🖱️ 准备点击提供的元素`);
                 // 确保元素仍然可用和可见
                 await element.waitForExist({ timeout: 5000 });
                 await element.waitForDisplayed({ timeout: 5000 });
             }

             await element.click();
             console.log(`✅ 元素已点击`);
             // 点击后可以加一个短暂的暂停,等待 UI 响应
             await this.driver.pause(500);
             return true;
         } catch (error) {
             console.error(`❌ 点击元素失败 (${this.udid}): ${error.message}`);
             // 可以在失败时尝试截图
             // await this.driver.saveScreenshot(`./click_error_${Date.now()}.png`);
             throw error;
         }
    }

    // 启动应用 (使用 activateApp)
    async launchApp() {
        if (!this.driver) throw new Error("Driver not initialized");
        try {
            console.log(`🚀 正在尝试激活应用: ${this.appPackage}`);
            await this.driver.activateApp(this.appPackage);
            console.log(`✅ 成功发送激活应用命令: ${this.appPackage}`);
            // 等待应用启动完成 (可以等待某个首页元素出现)
            await this.waitForAppLaunch();
        } catch (error) {
            console.error(`❌ 激活/启动应用 ${this.appPackage} 失败 (${this.udid}):`, error.message);
            throw error;
        }
    }

    // 等待应用启动完成 (通过检查关键元素)
    async waitForAppLaunch() {
        if (!this.driver) throw new Error("Driver not initialized");
        try {
            // **重要:** 替换为你应用启动后必然出现的元素的 XPath 或其他定位符
            const firstElementXPath = '//android.widget.FrameLayout[@resource-id="android:id/content"]//android.widget.TextView[1]'; // 示例:等待第一个 TextView 出现
            // 或者更具体的元素,如首页的 Tab
            // const firstElementXPath = '//android.widget.TextView[@text="首页"]';

            console.log(`⏳ 等待应用 ${this.appPackage} 启动完成 (检查元素: ${firstElementXPath})...`);
            await this.driver.waitUntil(async () => {
                try {
                    const element = await this.driver.$(firstElementXPath);
                    const isDisplayed = await element.isDisplayed();
                    // console.log(`检查元素 ${firstElementXPath} 是否可见: ${isDisplayed}`);
                    return isDisplayed;
                } catch (error) {
                     // 如果查找元素时出错 (例如元素还不存在),继续等待
                     // console.warn(`等待应用启动时查找元素出错: ${error.message}`);
                    return false;
                }
            }, {
                timeout: config.appLaunchTimeout,
                timeoutMsg: `❌ 应用 ${this.appPackage} 启动超时 (${config.appLaunchTimeout}ms) 未找到元素 ${firstElementXPath}`,
                interval: 1000 // 每隔 1 秒检查一次
            });

            console.log(`✅ 应用 ${this.appPackage} 已成功启动并准备就绪.`);
        } catch (error) {
            console.error(`❌ 应用 ${this.appPackage} 启动失败或超时 (${this.udid}): `, error.message);
            // 可以在超时时截图
            // await this.driver.saveScreenshot(`./launch_error_${this.udid}_${Date.now()}.png`);
            throw error;
        }
    }


    // 关闭 WebDriver 会话
    async deleteSession() {
        if (this.driver) {
            console.log(`⏳ 正在关闭 WebDriver 会话 for ${this.udid}...`);
            try {
                // 可以加一个短暂暂停,确保操作完成
                // await this.driver.pause(500);
                await this.driver.deleteSession();
                console.log(`✅ WebDriver 会话已关闭 for ${this.udid}`);
                this.driver = null; // 清理引用
            } catch (error) {
                 console.error(`❌ 关闭 WebDriver 会话失败 for ${this.udid}: ${error.message}`);
                 this.driver = null; // 即使失败也清理引用
                 // throw error; // 可以选择是否向上抛出错误
            }
        } else {
             console.log(`ℹ️ WebDriver 会话 for ${this.udid} 已关闭或未初始化.`);
         }
    }
}

module.exports = { MobileApp, config, generateAllureReport, clearFolder };

6.2 多设备测试脚本 (my.test.js)

这个 Jest 测试脚本使用 mobileAppUtils.js 中的 MobileApp 类来初始化多个设备,并在每个设备上运行相同的测试用例。同时集成了 jest-allure 来生成报告。

// my.test.js
const { MobileApp, config, generateAllureReport, clearFolder } = require('./mobileAppUtils');
const { Severity } = require('jest-allure/dist/Reporter'); // 引入 Severity
const fs = require('fs');
const path = require('path');
// 注意:确保你已安装 jest-allure: npm install --save-dev jest-allure
// 并在 jest.config.js 中配置了 reporter: ["default", "jest-allure"]
// 或者在 package.json 的 jest 配置中添加 setupFilesAfterEnv: ["jest-allure/dist/setup"]

// 定义要测试的设备列表
const devices = [
    {
        udid: '127.0.0.1:5555', // 第一个设备的 UDID (模拟器或真机)
        appPackage: 'com.kmxs.reader', // 第一个 App 的包名
        appActivity: '.ui.LauncherUI' // 第一个 App 的启动 Activity
    },
    // {
    //     udid: 'emulator-5554', // 第二个设备的 UDID
    //     appPackage: 'com.example.anotherapp',  // 第二个 App 的包名
    //     appActivity: '.MainActivity' // 第二个 App 的启动 Activity
    // }
    // 可以添加更多设备...
];

// 动态创建 Capabilities
function createCapabilities(device) {
    return {
        platformName: 'Android',
        'appium:automationName': 'UiAutomator2',
        'appium:deviceName': `Device_${device.udid.replace(/[:.]/g, '_')}`, // 给设备起个名
        'appium:appPackage': device.appPackage,
        'appium:appActivity': device.appActivity,
        'appium:noReset': true,
        'appium:unicodeKeyboard': true, // 推荐开启
        'appium:resetKeyboard': true,   // 推荐开启
        'appium:udid': device.udid,
        // 可以添加其他需要的 capabilities, 例如:
        // 'appium:newCommandTimeout': 180, // 命令超时时间 (秒)
        // 'appium:autoGrantPermissions': true, // 自动授予权限 (需要 Appium 1.9.0+)
    };
}

describe('Appium 自动化测试 - 多设备/多App', () => {
    let mobileApps = []; // 存储所有设备的 MobileApp 实例

    // 在所有测试开始前执行 (每个设备初始化一次)
    beforeAll(async () => {
        console.log(`\n===== [BeforeAll] 开始初始化 ${devices.length} 个设备 =====`);
        // 清理可能存在的旧 Allure 结果
        const allureResultsPath = path.resolve(__dirname, 'allure-results');
        await clearFolder(allureResultsPath);
        if (!fs.existsSync(allureResultsPath)) {
            fs.mkdirSync(allureResultsPath, { recursive: true });
        }


        const initPromises = devices.map(async (device) => {
            const capabilities = createCapabilities(device);
            const wdOpts = {
                hostname: config.appiumHost,
                port: config.appiumPort,
                logLevel: 'warn', // 测试运行时可以降低 WebDriverIO 日志级别
                // capabilities 在 MobileApp 构造函数中合并
            };
            const mobileApp = new MobileApp(capabilities, wdOpts);
            mobileApps.push(mobileApp); // 添加到数组
            try {
                await mobileApp.init(); // 初始化连接
                // 初始化成功后,可以选择是否立即启动 App
                // await mobileApp.launchApp();
                console.log(`✅ [BeforeAll] 设备 ${device.udid} 初始化成功`);
            } catch (error) {
                console.error(`❌ [BeforeAll] 设备 ${device.udid} 初始化失败:`, error);
                 // 将错误附加到报告中(如果 reporter 可用)
                 if (typeof reporter !== 'undefined') {
                     reporter.description(`设备 ${device.udid} 初始化失败`).addAttachment("初始化错误", error.stack || error.message, "text/plain");
                 }
                // 抛出错误会阻止测试运行,根据需要决定是否抛出
                throw new Error(`设备 ${device.udid} 初始化失败,测试无法继续`);
            }
        });

        await Promise.all(initPromises); // 等待所有设备初始化完成
        console.log(`===== [BeforeAll] 所有设备初始化完成 =====\n`);

    }, 120000 * devices.length); // 增加超时时间,每个设备给 120 秒

    // 在所有测试结束后执行
    afterAll(async () => {
        console.log(`\n===== [AfterAll] 开始清理会话并生成报告 =====`);
        const cleanupPromises = mobileApps.map(async (mobileApp) => {
             const udid = mobileApp.udid; // 从实例获取 udid
             const appPackage = mobileApp.appPackage; // 从实例获取 appPackage
            try {
                await mobileApp.deleteSession(); // 关闭会话
            } catch (deleteSessionError) {
                console.error(`❌ [AfterAll] 关闭设备 ${udid} 会话失败:`, deleteSessionError);
            }
        });

        await Promise.all(cleanupPromises); // 等待所有会话关闭

        // **重要:** 确保所有测试完成且会话关闭后,再生成 Allure 报告
        try {
            // 只需要生成一次报告,它会包含所有测试的结果
             // 假设 udid 和 appPackage 只是用于报告文件夹命名的一部分
             // 可以选择第一个设备的信息,或者一个通用的名称
             const firstDevice = devices[0] || { udid: 'multi', appPackage: 'device' };
             await generateAllureReport(firstDevice.udid, firstDevice.appPackage);
        } catch (generateReportError) {
            console.error(`❌ [AfterAll] 生成 Allure 报告时出错:`, generateReportError);
        }
        console.log(`===== [AfterAll] 清理完成 =====\n`);
    });

    // === 测试用例 ===
    // 每个 'it' 块都会在 beforeAll 初始化的所有设备上尝试执行

    // 测试用例 1: 卸载 ATX Agent (如果存在)
    it('卸载 ATX agent (如果已安装)', async () => {
        // 使用 Promise.all 并行执行卸载操作
        const uninstallPromises = mobileApps.map(async (mobileApp) => {
            const udid = mobileApp.udid;
            // 使用 jest-allure 的 reporter (确保已正确配置)
            if (typeof reporter !== 'undefined') {
                reporter
                    .description(`设备 ${udid}: 卸载 ATX agent (如果存在)`)
                    .story("Setup")
                    .severity(Severity.TRIVIAL) // 调整严重性
                    .testId(`Uninstall-ATX-${udid}`);
                reporter.startStep(`设备 ${udid}: 开始检查并卸载 ATX agent`);
            } else {
                console.warn("jest-allure reporter 未定义,无法记录步骤和详情。请检查 Jest 配置。");
            }

            try {
                await mobileApp.uninstallAtx(udid);
                if (typeof reporter !== 'undefined') reporter.endStep(); // 标记步骤成功
                // Jest 断言:这里我们期望卸载操作不抛出错误
                expect(true).toBe(true);
            } catch (error) {
                console.error(`❌ 设备 ${udid}: 卸载 ATX agent 时出错:`, error);
                if (typeof reporter !== 'undefined') {
                    reporter.addAttachment("卸载错误日志", error.stack || error.message, "text/plain");
                    reporter.endStep('failed'); // 标记步骤失败
                }
                 // 让测试失败
                // expect(false).toBeTruthy(`设备 ${udid}: 卸载 ATX agent 失败: ${error.message}`);
                 throw error; // 抛出错误使测试失败
            }
        });
        await Promise.all(uninstallPromises); // 等待所有设备的卸载操作完成
    });

    // 测试用例 2: 点击 "福利" 按钮
    it('点击 "福利" 按钮', async () => {
        const clickPromises = mobileApps.map(async (mobileApp) => {
            const udid = mobileApp.udid;
            if (typeof reporter !== 'undefined') {
                reporter
                    .description(`设备 ${udid}: 点击 "福利" 按钮`)
                    .feature("主页功能")
                    .story("浏览福利")
                    .severity(Severity.NORMAL)
                    .testId(`Click-Welfare-${udid}`);
                reporter.startStep(`设备 ${udid}: 开始查找并点击 "福利" 按钮`);
            }

            try {
                // 使用封装的方法查找并点击
                // 注意 XPath 可能需要根据实际 App 调整
                const welfareButtonXPath = '//android.widget.TextView[@text="福利"]';
                await mobileApp.clickElement(welfareButtonXPath);
                if (typeof reporter !== 'undefined') reporter.endStep();
                expect(true).toBe(true); // 操作成功
            } catch (error) {
                console.error(`❌ 设备 ${udid}: 点击 "福利" 按钮时出错:`, error);
                if (typeof reporter !== 'undefined') {
                    // 失败时截图并附加到报告
                    try {
                        const screenshotPath = path.resolve(__dirname, `allure-results/error_click_welfare_${udid}_${Date.now()}.png`);
                        await mobileApp.driver.saveScreenshot(screenshotPath);
                        const screenshotBuffer = fs.readFileSync(screenshotPath);
                        reporter.addAttachment(`错误截图_${udid}`, screenshotBuffer, "image/png");
                    } catch (screenshotError) {
                        console.error(`截图失败: ${screenshotError.message}`);
                    }
                    reporter.addAttachment("点击错误日志", error.stack || error.message, "text/plain");
                    reporter.endStep('failed');
                }
                // expect(false).toBeTruthy(`设备 ${udid}: 点击 "福利" 按钮失败: ${error.message}`);
                 throw error;
            }
        });
        await Promise.all(clickPromises);
    });

    // 测试用例 3: 对 "我的" 元素截图
    it('对 "我的" 元素截图', async () => {
        const screenshotPromises = mobileApps.map(async (mobileApp) => {
            const udid = mobileApp.udid;
             if (typeof reporter !== 'undefined') {
                reporter
                    .description(`设备 ${udid}: 对 "我的" Tab 进行截图`)
                    .feature("个人中心")
                    .severity(Severity.MINOR)
                    .testId(`Screenshot-Mine-${udid}`);
                reporter.startStep(`设备 ${udid}: 开始查找 "我的" 元素并截图`);
            }

            try {
                // 查找 "我的" Tab 元素
                const mineTabXPath = '//android.widget.TextView[@text="我的"]';
                const mineTabElement = await mobileApp.findElementByXPath(mineTabXPath);

                // 定义截图保存路径 (保存到 allure-results 以便自动附加)
                const screenshotFileName = `mine_tab_${udid}_${Date.now()}.png`;
                const screenshotPath = path.resolve(__dirname, 'allure-results', screenshotFileName);

                // 使用封装的方法截图
                await mobileApp.saveElementScreenShot(mineTabElement, screenshotPath);

                // 读取截图文件为 Buffer
                const screenshotBuffer = await fs.promises.readFile(screenshotPath);

                if (screenshotBuffer.length > 0 && typeof reporter !== 'undefined') {
                     // 将截图作为附件添加到 Allure 报告
                     reporter.addAttachment(`"我的" Tab 截图 - ${udid}`, screenshotBuffer, "image/png");
                     reporter.endStep();
                 } else if (screenshotBuffer.length === 0) {
                     console.error(`⚠️ 设备 ${udid}: 保存的截图文件为空 ${screenshotPath}`);
                     if (typeof reporter !== 'undefined') reporter.endStep('broken'); // 标记为损坏
                 } else {
                     console.warn("无法添加截图到 Allure 报告 (reporter 未定义或截图为空)");
                     if (typeof reporter !== 'undefined') reporter.endStep();
                 }
                expect(screenshotBuffer.length).toBeGreaterThan(0); // 断言截图文件不为空

            } catch (error) {
                console.error(`❌ 设备 ${udid}: 对 "我的" 元素截图时出错:`, error);
                 if (typeof reporter !== 'undefined') {
                     try {
                         const screenshotPath = path.resolve(__dirname, `allure-results/error_screenshot_mine_${udid}_${Date.now()}.png`);
                         await mobileApp.driver.saveScreenshot(screenshotPath);
                         const screenshotBuffer = fs.readFileSync(screenshotPath);
                         reporter.addAttachment(`错误截图_${udid}`, screenshotBuffer, "image/png");
                     } catch (screenshotError) {
                         console.error(`截图失败: ${screenshotError.message}`);
                     }
                    reporter.addAttachment("截图错误日志", error.stack || error.message, "text/plain");
                    reporter.endStep('failed');
                }
                // expect(false).toBeTruthy(`设备 ${udid}: 截图失败: ${error.message}`);
                 throw error;
            }
        });
        await Promise.all(screenshotPromises);
    });

});

6.3 集成 Allure 报告 Integrating Allure Reports

  • 安装:
    npm install --save-dev jest-allure allure-commandline
  • 配置 Jest: 在 jest.config.js 或 package.json 的 jest 部分配置 reporter。
    // jest.config.js
    module.exports = {
      // ... 其他配置
      reporters: ['default', 'jest-allure'],
      setupFilesAfterEnv: ['jest-allure/dist/setup'],
      // ...
    };
  • 生成报告:
    1. 运行 Jest 测试 (npm test),测试结果 (XML 文件) 会默认生成在 ./allure-results 目录下。
    2. 使用 allure 命令行工具生成 HTML 报告:
      npx allure generate ./allure-results --clean -o ./allure-report
    3. 打开报告:
      npx allure open ./allure-report
  • 在测试代码中使用 reporter: 如 my.test.js 示例所示,可以通过全局的 reporter 对象添加描述、步骤、附件、严重性等信息。
  • 注意: generateAllureReport 函数应在所有测试执行完毕 之后 (afterAll 钩子中),并且在清理 allure-results 目录 之前 调用。清理 allure-results 应在报告生成 之后 进行。

7. 实现多线程/并行测试 ⚡ Implementing Multi-threading/Parallel Testing

直接在单个 Jest 进程中使用 ESM 运行多个 WebDriverIO 实例(尤其是在多设备上)可能会遇到资源竞争、端口冲突或 ESM 加载的复杂性问题。一种解决方法是将测试任务分发到不同的 Node.js 进程中执行。

7.1 背景:ESM 与 Jest 多线程限制 Background: ESM & Jest Multi-threading Limitations

  • Jest 的并行执行 (--maxWorkers > 1) 在启用 --experimental-vm-modules 时可能行为不稳定或导致上述错误。
  • 每个 WebDriverIO 实例都需要连接到一个独立的 Appium 会话,并行运行时需要确保 Appium 服务器能够处理并发请求,或者为每个进程启动独立的 Appium 服务 (可能需要不同端口)。

7.2 解决方案:脚本拆分与 process.argv Solution: Script Splitting & process.argv

这种方法通过一个主控脚本 (runTests.js) 来启动多个独立的 Jest 进程,每个进程负责一个或一部分测试任务(例如,一个设备)。通过命令行参数 (process.argv) 将设备信息等传递给子进程。

7.3 启动脚本 (runTests.js)

这个脚本接收测试文件名前缀和设备 IP 列表作为参数,为每个设备启动一个单独的 Jest 进程来运行匹配的测试文件。

// runTests.js
const { spawn } = require('cross-spawn'); // 使用 cross-spawn 保证跨平台兼容性
const path = require('path');
const fs = require('fs');
const { clearFolder } = require('./mobileAppUtils'); // 引入清理函数

// 获取命令行参数: node runTests.js <filePrefix> [deviceIP1] [deviceIP2] ...
const args = process.argv.slice(2);
const filePrefix = args[0]; // 测试文件名前缀
const deviceIPs = args.slice(1); // 设备 IP 列表

// 查找匹配前缀的测试文件
async function findTestFiles(prefix) {
    if (!prefix) return []; // 如果没有提供前缀,返回空数组
    const files = await fs.promises.readdir(__dirname); // 在当前目录下查找
    // 过滤出以 prefix 开头并以 .test.js 结尾的文件
    const testFiles = files.filter(file => file.startsWith(prefix) && file.endsWith('.test.js'));
    return testFiles.map(file => path.resolve(__dirname, file)); // 返回文件的绝对路径
}

// 为单个设备运行测试文件
async function runTest(testFilePath, deviceIP) {
    return new Promise((resolve, reject) => {
        console.log(`\n🚀 [RunTest] 开始为设备 ${deviceIP} 执行测试文件: ${path.basename(testFilePath)}`);

        // 准备传递给 Jest 进程的环境变量和参数
        const jestArgs = [
            '--experimental-vm-modules', // 开启 ESM 支持
            path.resolve(__dirname, 'node_modules', 'jest-cli', 'bin', 'jest.js'), // Jest CLI 路径
            testFilePath, // 要运行的测试文件绝对路径
            '--runInBand', // 在子进程内串行执行测试(如果一个文件包含多个 describe/it)
            // 通过 Jest 的 globals 或 testEnvironmentOptions 传递设备 IP 比命令行参数更优雅
            // 但这里我们遵循原文,使用自定义参数,需要在测试文件中解析 process.env.DEVICE_IP
            // 或者使用 jest --testEnvironmentOptions='{"deviceIP":"' + deviceIP + '"}'
        ];

        // 设置环境变量传递设备 IP
        const env = {
            ...process.env,
            NODE_OPTIONS: '--experimental-vm-modules', // 确保子进程也有此选项
            DEVICE_IP: deviceIP // 将设备 IP 设置为环境变量
        };

        // 使用 spawn 启动 Jest 进程
        const jestProcess = spawn(
            'node', // 运行 node
            jestArgs,
            {
                env: env, // 传递环境变量
                stdio: 'inherit', // 将子进程的 stdio 连接到父进程 (实时看到输出)
                shell: false // 通常不需要 shell: true
            }
        );

        // 监听进程关闭事件
        jestProcess.on('close', (code) => {
            if (code === 0) {
                console.log(`✅ [RunTest] 设备 ${deviceIP} 测试完成: ${path.basename(testFilePath)}`);
                resolve(); // 成功
            } else {
                console.error(`❌ [RunTest] 设备 ${deviceIP} 测试失败,退出码: ${code}. 文件: ${path.basename(testFilePath)}`);
                reject(new Error(`测试失败,设备: ${deviceIP}, 文件: ${path.basename(testFilePath)}`)); // 失败
            }
        });

        // 监听进程错误事件 (例如命令找不到)
        jestProcess.on('error', (err) => {
            console.error(`❌ [RunTest] 启动 Jest 进程失败 (设备 ${deviceIP}): ${err.message}`);
            reject(err);
        });
    });
}

// 主执行函数
async function main() {
    console.log("===== [Main] 开始执行测试任务 =====");
    if (!filePrefix) {
        console.error("错误: 请提供测试文件名前缀作为第一个参数。");
        console.log("用法: node runTests.js <filePrefix> [deviceIP1] [deviceIP2] ...");
        process.exit(1);
    }

    // 先清理 allure-results 文件夹
    const allureResultsPath = path.resolve(__dirname, 'allure-results');
    console.log(`🧹 [Main] 清理 Allure 结果文件夹: ${allureResultsPath}`);
    await clearFolder(allureResultsPath);
     // 清理后确保目录存在,因为 jest-allure 需要写入
     if (!fs.existsSync(allureResultsPath)) {
         fs.mkdirSync(allureResultsPath, { recursive: true });
     }

    try {
        const testFiles = await findTestFiles(filePrefix);

        if (testFiles.length === 0) {
            console.error(`❌ [Main] 没有找到匹配前缀 "${filePrefix}" 的测试文件 (*.test.js)`);
            process.exit(1);
        }

        // 目前只取找到的第一个测试文件来运行,可以扩展为运行所有找到的文件
        const testFileToRun = testFiles[0];
        console.log(`ℹ️ [Main] 将在指定设备上运行测试文件: ${path.basename(testFileToRun)}`);

        let targetDevices = deviceIPs;
        if (targetDevices.length === 0) {
            console.warn("⚠️ [Main] 未提供设备 IP,将使用默认设备: 127.0.0.1:5555");
            targetDevices = ['127.0.0.1:5555']; // 默认设备
        }

        // 创建所有测试运行的 Promise
        const testPromises = targetDevices.map(device => runTest(testFileToRun, device));

        // 等待所有测试进程完成
        // 使用 Promise.allSettled 可以知道每个任务的成功或失败状态
        const results = await Promise.allSettled(testPromises);

        // 检查是否有失败的测试
        const failedTests = results.filter(result => result.status === 'rejected');

        if (failedTests.length > 0) {
            console.error(`\n❌ [Main] ${failedTests.length} 个设备的测试执行失败:`);
            failedTests.forEach(result => console.error(`  - ${result.reason.message || result.reason}`));
            console.log("===== [Main] 部分测试执行失败 =====");
            process.exit(1); // 以失败状态退出
        } else {
            console.log("\n✅ [Main] 所有设备的测试均已成功完成。");
            console.log("===== [Main] 测试任务完成 =====");
            // 可以在这里自动生成 Allure 报告
            // try {
            //     console.log("\n📊 [Main] 开始生成 Allure 报告...");
            //     const reportCmd = `npx allure generate ${allureResultsPath} --clean -o ./allure-report`;
            //     await new Promise((resolve, reject) => {
            //         exec(reportCmd, { shell: true }, (err, stdout, stderr) => {
            //             if (err) {
            //                 console.error("生成 Allure 报告失败:", stderr || err);
            //                 reject(err);
            //             } else {
            //                 console.log("Allure 报告已生成:", stdout);
            //                 resolve();
            //             }
            //         });
            //     });
            // } catch (reportError) {
            //     console.error("生成 Allure 报告时出错:", reportError);
            // }
        }

    } catch (error) {
        console.error('\n❌ [Main] 执行过程中发生意外错误:', error);
        process.exit(1);
    }
}

// 运行主函数
main();

// 注意:在 runTests.js 结束时清理 allure-results 可能不是最佳时机,
// 因为 Allure 报告生成可能还没完成(如果选择在 main 函数末尾生成)。
// 建议在下次运行 runTests.js 之前,或者在 CI/CD 流程的单独步骤中进行清理。
// clearFolder(path.resolve(__dirname, 'allure-results')).catch(console.error);

如何在测试脚本中获取设备 IP?

在 my.test.js (或由 runTests.js 调用的其他测试文件) 中,可以通过 process.env.DEVICE_IP 来获取由 runTests.js 设置的环境变量:

// 在 my.test.js 或类似的测试文件顶部
const currentDeviceIP = process.env.DEVICE_IP || 'default-device-ip'; // 获取设备 IP

// 在创建 capabilities 时使用
function createCapabilities() {
    return {
        // ...
        'appium:udid': currentDeviceIP, // 使用环境变量中的 IP/UDID
        // ...
    };
}

// 在测试描述或报告中使用
describe(`Appium 测试 - 设备: ${currentDeviceIP}`, () => {
    // ...
    it(`设备 ${currentDeviceIP}: 执行某个操作`, async () => {
        // ...
    });
});

7.4 package.json 示例 Example package.json

{
  "name": "appium-jest-webdriverio-project",
  "version": "1.0.0",
  "description": "Appium automation tests with Jest and WebDriverIO",
  "main": "index.js",
  "scripts": {
    "start-tests": "node runTests.js",
    "test": "npm run start-tests",
    "test:my": "node runTests.js my 127.0.0.1:5555", // 示例:运行 my*.test.js 在指定设备
    "test:all-devices": "node runTests.js my 127.0.0.1:5555 192.168.1.101:5555", // 示例:在多个设备上运行
    "report:generate": "allure generate ./allure-results --clean -o ./allure-report",
    "report:open": "allure open ./allure-report",
    "debug:single": "cross-env NODE_OPTIONS='--experimental-vm-modules' DEVICE_IP='127.0.0.1:5555' jest my.test.js --runInBand" // 单独调试一个文件
  },
  "devDependencies": {
    "@babel/core": "^7.24.0", // 版本可能需要根据你的项目调整
    "@babel/preset-env": "^7.24.0",
    "@babel/register": "^7.23.7", // 如果使用 Babel 动态编译
    "@wdio/allure-reporter": "^8.35.1", // WebDriverIO 的 Allure 报告器 (如果直接用 WDIO Testrunner)
    "allure-commandline": "^2.27.0", // Allure 命令行工具
    "babel-jest": "^29.7.0", // Babel 集成 Jest
    "cross-env": "^7.0.3", // 跨平台设置环境变量
    "cross-spawn": "^7.0.3", // 跨平台执行命令
    "jest": "^29.7.0", // Jest 测试框架
    "jest-allure": "^0.1.3", // Jest 的 Allure 报告集成
    "jest-cli": "^29.7.0", // Jest 命令行接口
    "rimraf": "^5.0.5" // 用于清理文件夹 (可选)
  },
  "dependencies": {
    "@xmldom/xmldom": "^0.8.10", // XML 解析 (如果需要 getPageSource)
    "webdriverio": "^8.35.1", // WebDriverIO 客户端
    "xpath": "^0.0.34" // XPath 解析 (如果需要)
    // 其他项目依赖...
  },
  "jest": {
    "reporters": [
      "default",
      "jest-allure"
    ],
    "setupFilesAfterEnv": [
      "jest-allure/dist/setup"
    ],
    "testEnvironment": "node" // 明确测试环境
    // 如果使用 Babel:
    // "transform": {
    //   "^.+\\.js$": "babel-jest"
    // }
  }
}