Appium 插件开发完全指南:从零构建可分发、可扩展的 BasePlugin 插件
Appium 插件开发完全指南从零构建可分发、可扩展的 BasePlugin 插件【免费下载链接】appiumCross-platform automation framework for all kinds of apps, built on top of the W3C WebDriver protocol项目地址: https://gitcode.com/GitHub_Trending/ap/appium导读本文基于 Appium 官方文档《Building Plugins》编写结合当前仓库中 packages/base-plugin、packages/fake-plugin、packages/base-driver/lib/basedriver 等源码系统讲解如何在 Appium 2.x 中开发自己的插件。读完本文你将掌握插件的元数据声明与加载机制、如何拦截与替换 Appium 命令包括代理模式下的处理、如何通过newMethodMap/newBidiCommands/ Execute Methods 三种方式扩展协议、以及如何管理插件生命周期、日志、IPC 通信与服务器对象。本文适合对 Appium 内部机制有一定了解的开发者普通使用 Appium 的用户无需关心这些内容。前置阅读如果你还不熟悉插件是什么建议先阅读插件生态列表了解插件能做什么。插件系统与驱动Driver开发高度重叠建议同时参考构建驱动指南。插件是什么Appium 插件是一套强大的系统用于增强或改变 Appium 的功能。它们可以分发给其他 Appium 用户以各种有趣的方式扩展 Appium 的生态。插件的核心特征包括本质是 Node.js 包所有插件归根结底都是 Node.js 包必须带有有效的package.json。虽然你的插件实现不限于 Node.js但必须提供一个 Node.js 编写的适配层以便 Appium 能加载它。按需启用opt-in插件系统极其强大Appium 官方没有刻意限制插件的能力。正因如此所有插件都必须在启动 Appium 服务器时由管理员显式启用——插件很强大只应在明确信任时使用。与驱动高度重叠插件和驱动的开发模式有很大重合本文涉及的许多概念如命令拦截、Execute Methods在构建驱动时同样适用。创建插件前的准备工作在动手写插件之前先想清楚两件事明确目标你的插件要完成什么功能是否能在 Appium 平台限制下实现参考现有插件Appium 团队在官方仓库中维护了一批官方插件如 fake-plugin、images-plugin、storage-plugin、relaxed-caps-plugin 等强烈建议在写自己的插件之前先阅读这些插件的代码。插件的基本要求要成为一个合法的 Appium 插件以下条件是必须满足的。带有 Appium 扩展元数据的 Node.js 包你的package.json必须满足声明appium为peerDependency版本要求尽量宽松。对于 Appium 2形如^2.0.0表示插件适用于任何以 2.x 开头的 Appium 版本。当前仓库中 fake-plugin 的 package.json 声明的是appium: ^3.0.0-beta.0。包含appium字段即“Appium 扩展元数据”{ ...: ..., appium: { pluginName: fake, mainClass: FakePlugin } }必需的子字段pluginName插件的短名称。例如 fake-plugin 的 package.json 中为fake。mainClassmain字段导出的命名导出CommonJS 风格必须是一个继承自 AppiumBasePlugin的类详见下文。对照仓库中的真实示例fake-plugin 的 package.json 的appium字段还包含可选的schema自定义 CLI 参数 schema和scripts插件脚本appium: { pluginName: fake, mainClass: FakePlugin, schema: ./build/lib/fake-plugin-schema.js, scripts: { fake-error: ./build/lib/scripts/fake-error.js, fake-success: ./build/lib/scripts/fake-success.js } }继承 Appium 的BasePlugin类Appium 已经把定义命令覆盖模式的大部分“脏活”封装成了BasePlugin类你只需继承它即可。BasePlugin从appium/plugin导出plugin.js 中实际转发自appium/base-pluginimport {BasePlugin} from appium/plugin; // 或者const {BasePlugin} require(appium/plugin); export class MyPlugin extends BasePlugin { // 类方法写在这里 }从源码看BasePlugin本身非常精简packages/base-plugin/lib/plugin.tsexport class BasePlugin extends ExtensionCore implements Plugin { static newMethodMap: MethodMapBasePluginMapType {}; static executeMethodMap: ExecuteMethodMapBasePluginMapType {}; name: string; cliArgs: Recordstring, unknown; constructor(name: string, cliArgs: Recordstring, unknown {}, driverId: string | null null) { super(); if (driverId) { this.updateLogPrefix(${generateDriverLogPrefix(this)} ${driverId}); } this.name name; this.cliArgs cliArgs; this.logger this.log; } // ... }它继承自ExtensionCorepackages/base-driver/lib/basedriver/extension-core.ts后者提供了日志this.log、BiDi 事件发射eventEmitter、BiDi 命令执行、IPC 订阅ipcSubscribe/onIpcInit等基础能力。constructor接收三个参数插件名、启动 Appium 服务器时的全部 CLI 参数、以及可选的 driverId用于生成带会话标识的日志前缀。提示在下面所有代码示例中凡涉及示例方法的定义都假定它们是写在插件类内部的为了简洁不再显式写出类包裹结构。让插件可用安装与激活只要满足上述两点Node.js 包 正确的扩展元数据 导出插件类你就已经拥有一个 Appium 插件了虽然它现在什么也不做但已经可以被 Appium 加载、激活。通过 NPM 发布后安装appium plugin install --sourcenpm plugin-package-on-npm本地安装测试推荐在开发阶段使用appium plugin install --sourcelocal /path/to/your/plugin激活插件插件必须在 Appium 服务器启动时激活appium --use-pluginsplugin-name其中plugin-name是元数据中的pluginName。用户可以通过appium plugin list --installed查看已安装插件。插件的开发工作流如何开发你的插件完全取决于你自己但有一种便捷方式可以避免反复“发布→安装”将最新版 Appium 作为devDependency在新版 npm 中仅作为peerDependency也足够同时把插件自身以file:.引用{ devDependencies: { ...: ..., appium: ^2.0.0, your-plugin: file:. } }之后你可以直接npm exec appium或npx appium在本地运行 Appium。由于插件被列为依赖它会被自动“安装”并可用。你可以用这种方式设计 e2e 测试如果用 Node.js 写测试也可以直接导入 Appium 的启动服务器方法在 Node 中控制服务器的启动与停止。热更新每次修改插件代码后需要重启 Appium 服务器才能加载最新代码。与驱动类似你可以设置APPIUM_RELOAD_EXTENSIONS环境变量让 Appium 在新会话启动时尝试重新 require 你的插件模块。标准插件实现模式下面这些是你创建插件时几乎一定会用到的模式。在构造函数中初始化状态如果你自定义构造函数必须调用super以确保标准状态被正确初始化constructor(...args) { super(...args); // 在这里做你自己的初始化 }这里的args是包含启动 Appium 服务器所用全部 CLI 参数的对象。仓库中的 FakePlugin 构造函数 正是这种用法调用super(name, cliArgs)后再初始化插件自身状态this.fakeThing PLUGIN_FAKE_THING并启动一个时钟协程。拦截并处理特定 Appium 命令这是插件最常见的行为——修改或替换由当前驱动处理的一个或多个命令。要覆盖默认命令处理只需在类中实现与 Appium 命令同名的async方法驱动自身也是这么实现的。想知道有哪些命令名它们定义在 Appium base driver 的协议路由中。当前仓库中不再是单一的routes.js而是拆分为多个文件packages/base-driver/lib/protocol/routes/appium.ts、jsonwp.ts、mjsonwp.ts、w3c.ts 等以 appium.ts 为例export const APPIUM_ROUTES { /appium/sessions: { GET: {command: getAppiumSessions}, }, /session/:sessionId/appium/context: { GET: {command: getCurrentContext}, POST: {command: setContext, payloadParams: {required: [name]}}, }, // ... }每个命令方法接收以下参数next一个async函数的引用封装了“如果本插件不处理该命令会发生的行为链”。你可以在逻辑的任何位置决定是否调用await next()。如果不调用默认行为以及注册在本插件之后的其他插件就不会执行。driver处理当前会话的驱动对象。你可以用它做任何需要的事例如调用其他驱动方法、检查 capabilities 或 settings。...args用户应用于该命令的参数展开数组。例如覆盖setUrl命令在导航前后增加日志async setUrl(next, driver, url) { this.log(Lets get the page source for some reason before navigating to ${url}!); await driver.getPageSource(); const result await next(); this.log(We can also log after the original behaviour); return result; }仓库中 FakePlugin.findElement 是真实范例先next()拿到原始结果再在结果上附加fake: true字段async findElement(next, _driver, ...args) { this.log.info(Before findElement is run with args ${JSON.stringify(args)}); const originalRes await next(); this.log.info(After findElement is run); originalRes.fake true; return originalRes; }拦截并处理所有 Appium 命令有时你需要处理所有命令例如检查载荷以决定是否采取行动。此时可以实现async handle任何未被你的具名方法处理的命令都会交给它参数为nextdrivercmdName—— 表示正在运行的命令名的字符串...args例如为所有 Appium 命令记录耗时async handle(next, driver, cmdName, ...args) { const start Date.now(); try { const result await next(); } finally { const elapsedMs Date.now() - start; this.log(Command ${cmdName} took ${elapsedMs}); } return result; }与驱动代理模式协作处理 Appium 命令时有一个“坑”驱动可以开启特殊的proxy 模式此时 Appium 服务器进程会检查传入 URL并决定是否将其转发给上游 WebDriver 服务器。如果插件想处理的命令恰好是被代理的命令插件就永远没有机会处理它Appium 的解决方案是命令进入时主协议处理器在决定是否代理之前先检查是否有插件会处理该命令。如果插件不会处理该命令——一切照常根据驱动的代理模式决定是否转发。如果插件会处理该命令——代理行为被暂时跳过并封装为传给插件的next函数。因此如果你希望默认的驱动代理确实发生只需在插件处理器中调用await next()即可可以代替或叠加你的自定义逻辑。抛出 WebDriver 特定错误WebDriver 规范定义了一组错误码用于在命令出错时伴随响应返回。Appium 为每个错误码都创建了对应的错误类你可以在命令内抛出合适的错误协议层会据此向用户返回正确的响应。从appium/driver导入这些错误类import {errors} from appium/driver; throw new errors.NoSuchElementError();向 Appium 日志输出消息你当然可以用console.log但 Appium 提供了更完善的日志器this.logger它上面有.info、.debug、.log、.warn、.error方法对应不同日志级别。注意源码中 BasePlugin 构造函数 将this.logger指向了this.log因此在ExtensionCore中this.log是通过logger.getLogger(logPrefix)动态创建的命名日志器见 extension-core.ts。如果你想在插件上下文之外比如脚本或辅助文件中创建 Appium 日志器可以自行构造import {logging} from appium/support; const log logging.getLogger(MyPlugin);插件的更多进阶能力下面这些是插件可以做、以利用额外特性或让工作更便捷的能力。为自定义命令行参数添加 schema你可以添加自定义 CLI 参数让插件在 Appium 服务器启动时从命令行接收数据例如应由服务器管理员设置、而不适合通过 capabilities 传入的端口号。这与驱动的方式基本一致细节可参考构建驱动指南中的对应章节。唯一的区别是 CLI 参数名要加--plugin-name前缀。例如插件名为pluggo、CLI 参数名为electro-port启动 Appium 时用--plugin-pluggo-electro-port设置。与驱动一样也支持通过配置文件设置参数但位于plugin字段下{ server: { plugin: { pluggo: { electro-port: 1234 } } } }在 fake-plugin 的 package.json 中appium.schema指向./build/lib/fake-plugin-schema.js即 fake-plugin-schema.ts插件类通过this.cliArgs读取这些参数——FakePlugin.getFakePluginArgs 直接返回this.cliArgs而BasePlugin构造函数正是把 CLI 参数存为this.cliArgs的。添加插件脚本有时你希望插件的用户能在会话之外运行脚本例如预构建插件某些部分的脚本。为此可以在 Appium 扩展元数据的scripts字段中添加脚本名与 JS 文件的映射。假设你的项目scripts目录下有一个plugin-prebuild.js{ scripts: { prebuild: ./scripts/plugin-prebuild.js } }假设插件名为myplugin用户就可以运行appium plugin run myplugin prebuild来执行你的脚本。fake-plugin 的 package.json 定义了fake-error和fake-success两个脚本对应 scripts/fake-error.ts 与 scripts/fake-success.ts。添加新的 Appium 命令如果驱动现有命令无法满足你的功能需求你可以像驱动一样通过三种方式创建新命令扩展 WebDriver HTTP 协议并创建客户端侧插件访问扩展端点扩展 WebDriver BiDiWebSocket 协议协议添加新的模块与方法客户端通过 BiDi 接口访问通过定义 Execute Methods 重载 Execute Script 命令。扩展 HTTP 协议newMethodMap第一种方式让 Appium 识别新方法并把它们加入允许的 HTTP 路由与命令名集合。做法是在插件类中把newMethodMap静态变量赋值为与 Appium 路由对象同构的对象。下面是 FakePlugin 中newMethodMap的真实代码文档中的示例即源于此static newMethodMap: MethodMapFakePlugin { /session/:sessionId/fake_data: { GET: {command: getFakeSessionData, neverProxy: true}, POST: { command: setFakeSessionData, payloadParams: {required: [data]}, neverProxy: true, }, }, /session/:sessionId/fakepluginargs: { GET: {command: getFakePluginArgs, neverProxy: true}, }, };TypeScript 提示如果使用 TypeScript这类静态成员对象应定义为as const。这个示例添加了几个新路由共 3 个新命令。想了解更复杂的命令定义方式可研读 packages/base-driver/lib/protocol/routes 下的路由文件。之后你只需像实现其他 Appium 命令一样实现这些命令处理器即可例如 getFakeSessionDataasync getFakeSessionData(_next, driver) { await sleep(1); return driver.fakeSessionData ?? null; }注意特殊的neverProxy键对插件来说通常应设为true因为你的插件可能作用于一个处于代理模式的驱动而该驱动并没有为这些新增的、因此未知的命令拒绝代理。neverProxy: true会让 Appium 永不代理这些路由从而确保即使驱动处于代理模式插件也能处理它们。newMethodMap的缺点是使用标准 Appium 客户端的用户不会有针对这些端点的现成客户端函数。因此你需要为每种想支持的语言创建并发布客户端侧插件相关说明与示例可参考各客户端文档。扩展 BiDi 协议newBidiCommands你还可以通过 WebDriver BiDi基于 WebSocket协议提供新命令。BiDi 命令由两部分组成module模块本质是容器/命名空间和command新命令的名称。与方法一相同通过向插件类添加静态字段newBidiCommands让 Appium 识别新命令。其格式与newMethodMap类似封装了 BiDi 模块、BiDi 命令名、处理命令的插件实例方法引用以及必选/可选参数。示例static newBidiCommands { appium:video: { startFramerateCapture: { command: startFrameCap, params: { required: [videoSource], optional: [showOnScreen], } }, stopFramerateCapture: { command: stopFrameCap, }, } };这个示例定义了两个新 BiDi 命令appium:video.startFramerateCapture和appium:video.stopFramerateCapture。前者有一个必选参数和一个可选参数后者无参数。当客户端触发 BiDi 命令时会调用你在插件类上实现的startFrameCap和stopFrameCap方法其签名如下async startFrameCap(next: () Promiseany, driver: DriverClass, videoSource: string, showOnScreen: boolean): Promiseany; async stopFrameCap(next: () Promiseany, driver: DriverClass): Promiseany;与覆盖 HTTP 协议命令相同这些方法被注入了next和driver参数。driver表示当前拥有会话的驱动调用await next()会执行/返回“插件未激活时应发生的行为”即驱动自身对该方法的处理或其他活动插件中同名命令构成的行为链。仓库中 FakePlugin.newBidiCommands 定义了appium:fake模块下的 4 个命令getPluginThing、setPluginThing、doSomeMath、doSomeMath2。其底层执行逻辑在 extension-core.ts 的 executeBidiCommand先校验模块/方法是否存在再按required/optional顺序组装参数最终以(next, driver, ...args)的插件签名调用处理器BiDi 命令的moduleName.methodName校验在ensureBidiCommandExists中完成未实现的处理器会抛出NotYetImplementedError。注意两点目前如果驱动开启了 BiDi 代理插件无法覆盖由代理处理的 BiDi 方法另外由于是自定义 BiDi 命令模块名应包含厂商前缀示例中用了appium:你可以也应该选择适合自己的前缀。重载 Execute Script 命令另一种方式重载所有 WebDriver 客户端都已具备的 Execute Script 命令。请先阅读构建驱动指南中“添加新命令”一节了解其总体机制插件的方式只有细微差别。以下是 fake-plugin 的真实示例文档中的示例即源于此static executeMethodMap: ExecuteMethodMapFakePlugin { fake: plugMeIn: { command: plugMeIn, params: {required: [socket]}, }, }; async plugMeIn(_next, _driver, socket: string): Promisestring { await sleep(1); return Plugged in to ${socket}; } async execute(next, driver, script, args) { return await this.executeMethod(next, driver as any, script, args); }这里有三个重要组成部分全部定义在插件类内部executeMethodMap与驱动的定义方式完全一致将script名称映射到命令元数据命令方法名 参数要求。命令方法的实现即executeMethodMap中声明的plugMeIn。execute命令的覆盖/处理与任何插件命令处理器一样前两个参数是next和driver后面是脚本名与参数。BasePlugin提供了辅助方法executeMethod直接传入这些参数调用即可。BasePlugin.executeMethod的实现packages/base-plugin/lib/plugin.ts很值得一看它在executeMethodMap中查找脚本对应的命令元数据如果插件不认识该脚本会记录一条Plugin did not know how to handle method ... Passing control to next日志并await next()把控制权交给下一个处理器如果认识则通过validateExecuteMethodParams来自appium/base-driver校验参数再以(next, driver, ...args)签名调用命令方法。覆盖驱动的 Execute Methods 行为符合直觉如果插件与驱动定义了同名的 Execute Method你的命令本例的plugMeIn会先被调用。你可以选择通过next执行驱动的原始行为。发射 BiDi 事件插件可以像 Appium 驱动一样发射自定义 BiDi 事件。底层通过ExtensionCore的eventEmitter实现例如 FakePlugin.getPluginThing 在返回数据的同时发射了appium:fake.pluginThingRetrieved事件async getPluginThing() { this.eventEmitter.emit(bidiEvent, { method: appium:fake.pluginThingRetrieved, params: {}, }); return this.pluginThing; }FakePlugin.startClock 则每 250ms 发射一次appium:clock.currentTime事件。构建 Appium Doctor 检查你的用户可以通过appium plugin doctor pluginName运行安装与健康检查。关于此能力的详细信息请参考构建 Doctor 检查指南。更新 Appium 服务器对象通常你不需要更新 Appium 服务器对象它是一个已经过多种方式配置的 Express 服务器参见 packages/base-driver/lib/express/server.ts。但有时你需要这样做例如为插件添加新路由。为此必须在类中实现static async updateServer方法它接收三个参数expressAppExpress 应用对象httpServerNode HTTP 服务器对象cliArgs启动 Appium 服务器所用的 CLI 参数映射你可以在updateServer内部对它们做任何事。建议先参考 BaseDriver 代码了解这些对象如何创建与使用以免撤销或覆盖任何标准且重要的功能。如果执意要改请自行测试后果警告这属于高级特性需要掌握 Express 知识并且要小心不要影响 Appium 服务器其他部分的运行。frontRouter的正确用法updateServer在 Appium 自身路由注册之后运行因此这里的expressApp.use(...)不会看到 Appium 已占用的路径的请求——Express 按注册顺序匹配中间件与路由。对于也必须看到这些路由请求的中间件如日志或鉴权应改用httpServer.frontRouterAppium 在任何路由注册之前就挂载了这个 Router见 server.ts 中app.use(frontRouter)及注释“mounted before any route, so middleware added later (e.g. viaupdateServer) still runs first”所以挂在它上面的任何东西——即使在updateServer内部添加的——也会在路由匹配之前运行。注意这仍然晚于 Appium 自身的基线中间件CORS、WebSocket 升级处理、body 解析等因此它看不到 Appium 已经拒绝的请求例如 CORS 预检失败或 body 解析失败的请求static async updateServer(expressApp, httpServer, cliArgs) { httpServer.frontRouter.use((req, res, next) { console.log(Incoming request: ${req.method} ${req.url}); next(); }); }仓库中的真实范例是 FakePlugin.updateServer用expressApp.all(/fake, ...)添加路由并通过httpServer.frontRouter.use(...)设置全局响应头static async updateServer(expressApp, httpServer, cliArgs) { expressApp.all(/fake, FakePlugin.fakeRoute); expressApp.all(/unexpected, FakePlugin.unexpectedData); expressApp.all(/cliArgs, (req, res) { res.send(JSON.stringify(cliArgs)); }); // global middleware, exercised via httpServer.frontRouter httpServer.frontRouter.use((_req, res, next) { res.set(x-fake-plugin-pre-server, true); next(); }); }处理意外的会话关闭开发插件时你可能希望在会话结束时添加一些清理逻辑自然的方式是为deleteSession添加处理器。这在大多数情况下有效但当会话没有干净地结束时Appium 判定会话意外终止Appium 会查找插件类中名为onUnexpectedShutdown的方法并调用它——第一个参数是当前会话驱动第二个参数是代表关闭原因的 error 对象——给你机会做必要的清理。注意该函数不会被await因此实现时要注意async onUnexpectedShutdown(driver, cause) { try { // 做一些清理 } catch (e) { // 记录错误不要抛出任何东西否则会成为未处理的 rejection } }仓库中的 FakePlugin.onUnexpectedShutdown 会停止时钟并把关闭原因保存到静态变量中供/unexpected端点查询async onUnexpectedShutdown(_driver, cause) { this._clockRunning false; FakePlugin._unexpectedData Session ended because ${cause}; }向同一会话中的驱动或其他插件发送消息IPC虽然驱动与插件彼此互不了解但在同一个会话内它们可以通过 IPC 通道发送可被其他驱动或插件监听的消息。开发者使用说明对驱动和插件完全一致详见构建驱动指南中的 IPC 开发文档。仓库中的 IPC 实现位于 packages/base-driver/lib/basedriver/ipc.tsAppiumIpc按 topic 组织消息默认单条消息最大 1MBDEF_MAX_OBJ_SIZE_BYTES 1024 * 1024每个会话默认最多 1000 个 topicDEF_MAX_TOPICS 1000可通过--max-ipc-topics服务器参数调整发布时用structuredClone克隆数据且发布者不会收到自己发布的消息。插件侧的使用入口在 extension-core.ts通过onIpcInit()生命周期钩子中调用this.ipcSubscribe(topic)订阅通过assignIpc在会话创建时注入 IPC 对象。FakePlugin.onIpcInit 是完整范例订阅clockLifecycletopic 感知驱动的时钟状态并订阅pluginMathtopicdoSomeMath2 则通过ipcPluginMath.publish(result)发布计算结果。小结回顾 Appium 插件开发的关键要点能力实现方式仓库参考声明插件元数据package.json的appium字段 peerDependencyfake-plugin/package.json插件基类继承BasePlugin从appium/plugin导入base-plugin/lib/plugin.ts拦截指定命令实现与命令同名的方法(next, driver, ...args)FakePlugin.findElement拦截所有命令实现handle(next, driver, cmdName, ...args)见本文示例扩展 HTTP 协议静态newMethodMapneverProxyFakePlugin.newMethodMap扩展 BiDi 协议静态newBidiCommandsFakePlugin.newBidiCommands重载 Execute Script静态executeMethodMap 实现executeFakePlugin.executeMethodMap自定义 CLI 参数appium.schema--plugin-name-argfake-plugin-schema.ts插件脚本元数据scripts字段 appium plugin runfake-plugin/package.json更新服务器静态updateServerhttpServer.frontRouterFakePlugin.updateServer意外关闭清理实现onUnexpectedShutdown(driver, cause)FakePlugin.onUnexpectedShutdown会话内消息onIpcInitipcSubscribe/publishextension-core.ts、ipc.ts实践建议先读代码再动手仓库中的 fake-plugin 几乎覆盖了本文全部特性是绝佳的学习样板images-plugin、storage-plugin、relaxed-caps-plugin 则是面向真实业务场景的官方插件。本地优先开发阶段用file:.依赖 npx appium快速迭代配合APPIUM_RELOAD_EXTENSIONS减少重启。代理模式陷阱新增命令务必设置neverProxy: true覆盖现有命令时若希望代理行为仍发生记得await next()。测试与分发参照仓库各插件的 e2e/unit 测试结构组织你的测试发布到 NPM 后用户通过appium plugin install安装、appium --use-pluginspluginName激活。【免费下载链接】appiumCross-platform automation framework for all kinds of apps, built on top of the W3C WebDriver protocol项目地址: https://gitcode.com/GitHub_Trending/ap/appium创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考