开发插件
这是一份开发 Appium 插件的高层指南——大多数 Appium 用户并不需要知道或关心这些内容。如果你还不太熟悉面向用户视角的 Appium 插件,可以先看看插件列表,动手试用几个, 了解插件大致能做哪些事情。插件是一套强大的机制,用来增强 Appium 的功能或改变 Appium 的运行方式。它们可以分发给其他 Appium 用户,并以各种有趣的方式扩展 Appium 的生态!(此外,开发插件与开发Appium 驱动有很大重叠,所以你也不妨看看开发驱动指南获取更多灵感。)
创建插件之前¶
在创建插件之前,最好对你希望插件完成什么、以及在 Appium 平台的限制下是否可能实现有一个大致的判断。通读本指南能帮你理解哪些事情是可行的。总体而言,Appium 的插件系统非常强大,我们并没有人为地去限制插件能做什么(这也是所有插件都必须由负责启动 Appium 服务器的系统管理员显式启用的主要原因——插件很强大,只应在你明确信任它时才使用!)
可供参考的其他插件¶
有大量开源的 Appium 插件可供查阅。我们强烈建议在动手写自己的插件之前,先看看其他插件的代码。 Appium 团队在 Appium 的 GitHub 仓库中维护了一套官方插件。其他开源插件的链接可以在插件列表中找到。
插件的基本要求¶
如果你想让某个东西成为一个合法的 Appium 插件,以下是它必须做到(或满足)的事情。
带 Appium 扩展元数据的 Node.js 包¶
所有 Appium 插件本质上都是 Node.js 包,因此必须有一个合法的 package.json。你的插件并不局限于用 Node.js 编写,但它必须提供一个用 Node.js 编写的适配器,以便 Appium 能够加载它。
你的 package.json 必须将 appium 声明为 peerDependency。对依赖版本的要求应尽量宽松(除非你明确知道你的插件只能与某些特定版本的 Appium 配合工作)。以 Appium 2 为例,写成 ^2.0.0 即可,表示你的插件兼容所有 2.x 版本的 Appium。
你的 package.json 必须包含一个 appium 字段,如下所示(我们称之为"Appium 扩展元数据"):
必需的子字段如下:
pluginName:你的插件的简短名称。mainClass:从main字段具名导出(CommonJS 风格)的类。它必须是继承自 Appium 的BasePlugin的类(见下文)。
继承 Appium 的 BasePlugin 类¶
归根结底,你的插件之所以好写,是因为定义"重写命令"的各种模式所需的繁重工作已经替你完成了。这一切都封装在一个 Appium 导出的类中,名为 BasePlugin。它从 appium/plugin 导出,你可以用下面任一方式导入它,并创建继承它的你自己的类:
import {BasePlugin} from 'appium/plugin';
// or: const {BasePlugin} = require('appium/plugin');
export class MyPlugin extends BasePlugin {
// class methods here
}
注意
让你的插件可用¶
基本就是这样!一个导出插件类的 Node.js 包,再加上正确的 Appium 扩展元数据,你就拥有了一个 Appium 插件!虽然它还什么都不做,但你已经可以在 Appium 中加载它、启用它了……
要让其他用户能用你的插件,你可以通过 NPM 发布它。发布之后,用户就可以通过 Appium CLI 安装它:
当然,最好先测试你的插件。观察它在 Appium 中如何工作的一个方式,是先以本地方式安装它:
另外别忘了,启动 Appium 服务器之前,要先启用插件,所以请务必告知用户这一点:
插件的开发流程¶
如何开发你的插件完全由你决定。不过,直接在 Appium 内部运行会很方便,无需进行繁琐的发布与安装操作。最直接的做法是把最新版本的 Appium 作为 devDependency(在较新版本的 NPM 中,它已经作为
peerDependency 被引入,也足够了),同时把你的插件也作为依赖引入,例如:
这样,你就可以在本地运行 Appium(npm exec appium 或 npx appium),并且由于你的插件与它并列列为依赖,它会被自动"安装"并可用。你可以按这种方式设计你的 e2e 测试;如果用 Node.js 编写测试,你还可以直接导入 Appium 的启动服务方法,在 Node 中启停 Appium 服务器。
当然,你也随时可以按上文所说的方式以本地源安装。
每次修改插件代码后,你都需要重启 Appium 服务器,才能确保它加载最新的代码。与驱动一样,如果你希望Appium 在新会话启动时尝试重新 require 你的插件模块,可以设置APPIUM_RELOAD_EXTENSIONS环境变量。
插件实现的标准思路¶
以下是创建插件时你多半会想做的事情。
在构造函数中初始化状态¶
如果你定义了自己的构造函数,需要调用 super,确保所有标准状态被正确初始化:
这里的 args 参数是包含启动 Appium 服务器所用全部 CLI 参数的对象。
拦截并处理特定的 Appium 命令¶
这是 Appium 插件最常见的用法——修改或替换本应由当前驱动处理的一个或多个命令的执行。要覆盖默认的命令处理逻辑,你需要在类中实现与所要处理的 Appium 命令同名的 async 方法(和驱动本身的实现方式完全一致)。想知道都有哪些命令名?它们定义在 Appium base driver 的
routes.js 文件中,当然你还可以按下一节介绍的方式添加更多命令。
每个命令方法都会收到以下参数:
next:指向一个async函数的引用,该函数封装了一系列行为链——如果当前插件未处理该命令,这些行为便会随之发生。你可以在逻辑中的任意位置选择调用链中的下一个行为(只需确保在某处包含await next()即可),也可以选择不调用。如果不调用,就意味着默认行为(或注册在该插件之后的其他插件)不会被执行。driver:该对象代表负责处理当前会话的驱动程序。您可以根据需要使用它来执行各项操作,例如调用驱动程序的其他方法、检查功能配置或设置等。...args:一个包含用户在调用命令时所传入的所有参数的数组(通过展开语法获取)。
例如,如果我们想覆盖 setUrl 命令,只在原有行为上追加一些日志,可以这样实现:
async setUrl(next, driver, url) {
this.log(`Let's 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;
}
拦截并处理所有 Appium 命令¶
你可能会遇到这样一种情况:需要处理所有命令,以便检查其中的有效载荷,从而决定是否要采取某种行动。这种情况下,你可以实现 async handle,任何没有被你具名方法处理的命令都会交给这个方法处理。它接收以下参数(语义与上文完全一致):
nextdrivercmdName—— 表示正在运行的命令的字符串...args
例如我们想在一个插件中记录所有 Appium 命令的耗时。我们可以这样在插件类中实现 handle:
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 命令时有一个坑。 Appium 驱动可以开启一种特殊的"代理"模式:在该模式下,Appium 服务器进程会查看进来的 URL,并决定是否把它们转发给某个上游 WebDriver 服务器。可能出现这样的情况:插件想要处理的某个命令,恰好属于被代理到上游服务器的命令。这时就会有问题——插件根本得不到处理该命令的机会! Appium 对这种情况的处理方式如下:
- 当一个命令进来时,主协议处理器在决定是否代理该命令之前,会先检查是否有插件要处理它。
- 如果没有插件要处理该命令——一切照旧,请求是否被代理取决于驱动的代理模式。
- 如果有插件要处理该命令,那么代理行为此时会被跳过,并被包装成传给插件的
next函数。所以,如果你的插件里有该命令的处理器,又希望确保驱动的默认代理行为确实发生,只需在你插件处理器所做的事情之外(或之中)await next()即可。
抛出 WebDriver 专用错误¶
WebDriver 规范定义了一组错误代码,以便在发生错误时随命令响应一并返回。 Appium 为每个错误码都创建了对应的错误类,因此你可以在命令内部抛出相应的错误,Appium 会按照协议规范把它正确地响应给用户。要获得这些错误类,从 appium/driver 导入即可:
向 Appium 日志写消息¶
你当然可以随时用 console.log,但 Appium 为你提供了一个更贴心的日志器 this.logger(它有 .info、.debug、.log、.warn、.error 等不同日志级别的方法)。如果你想在插件上下文之外(比如脚本或辅助文件中)创建 Appium 日志器,也可以自行构造:
Appium 插件的进阶能力¶
这些是你的插件可以实现的功能,旨在利用插件的额外特性,或让插件能更便捷地完成其任务。
为自定义命令行参数添加 schema¶
如果你希望插件在 Appium 服务器启动时从命令行接收数据,就可以添加自定义 CLI 参数(例如由服务器 管理员设定的端口,这类参数不适合通过能力传入)。
插件的这部分机制与驱动基本一致,更多细节请参见开发驱动文档中的对应章节。
唯一的区别是:构造 CLI 参数名时,前缀要用 --plugin-<name>。例如,你的插件名为 pluggo,其中定义了一个名为 electro-port 的 CLI 参数,那么在启动 Appium 时可以通过
--plugin-pluggo-electro-port 来设置它。
通过配置文件设置参数同样是支持的,与驱动一样,只是要写在 plugin 字段下。例如
添加插件脚本¶
有时你可能希望插件用户能够在会话上下文之外运行脚本(例如运行一个预先构建插件某些部分的脚本)。为支持这一点,你可以在 Appium 扩展元数据的 scripts 字段中添加一个"脚本名 → JS 文件"的映射。假设你在项目的 scripts 目录下创建了一个名为 plugin-prebuild.js 的脚本,那么可以像这样添加 scripts 字段:
假设你的插件名为 myplugin,现在,插件用户就可以运行 appium plugin run myplugin prebuild,你的脚本便会执行。
添加新的 Appium 命令¶
如果您希望提供某种功能,而该功能无法对应到驱动程序所支持的现有命令中,那么您可以采用以下两种方式之一来创建新命令——这与驱动程序中的做法相同:
- 扩展 WebDriver 协议,并编写客户端插件来访问这些扩展
- 用新的模块和方法扩展 WebDriver BiDi 协议,客户端通过 BiDi 接口访问
- 通过定义 Execute Method 来重载 Execute Script 命令
扩展 HTTP 协议¶
如果你想采用第一种方式,可以引导 Appium 识别新方法,并将它们添加到其允许的 HTTP 路由和命令名称集合中。具体做法是:在你的驱动类中,将静态变量 newMethodMap 赋值为一个对象,该对象的结构应与 Appium 中 routes.js 所定义的对象一致。例如,以下是 FakePlugin 示例驱动程序中 newMethodMap 的部分内容:
static newMethodMap = {
'/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},
},
};
注意
示例中,我们添加了几个新路由,总共新增了 3 条命令。若想查看更多以这种方式定义命令的示例,建议查阅 routes.js 文件。接下来,你只需按照实现其他任何 Appium 命令的方式,去实现这些命令的处理程序即可。
此外,请注意针对命令的特殊 neverProxy 键;对于插件而言,通常建议将其设置为 true。这是因为,当驱动程序处于“代理模式”时,它可能并未明确拒绝代理某些命令(由于是新增的,因而未知的)。将 neverProxy 设置为 true,可确保 Appium 绝不会代理这些请求路径,从而保证它们始终由您的插件来处理——即便驱动程序正处于代理模式下,也不例外。
通过 newMethodMap 添加新命令的不足之处在于,使用标准 Appium 客户端的用户将无法直接调用专为这些端点设计的、便捷的客户端函数。因此,针对你想要支持的每种编程语言,你都需要创建并发布相应的客户端插件(相关说明或示例可在各客户端的文档中找到)。
扩展 BiDi 协议¶
您还可以通过 WebDriver BiDi(基于 WebSocket 的协议)来提供新的命令。 BiDi 命令由两部分组成:“模块”(本质上是一个容器或命名空间)以及“命令”(即您所定义的新命令的名称)。
与第一种方法类似,你可以通过在驱动类中添加一个名为 newBidiCommands 的静态字段,来让 Appium 识别你新增的 BiDi 命令。该字段的格式与 newMethodMap 类似。它主要封装了以下信息:BiDi 模块、BiDi 命令名称、指向驱动实例中负责处理该命令的方法的引用,以及该命令所需的必选参数和可选参数。以下是在一个虚构驱动程序中实现 newBidiCommands 的示例:
static newBidiCommands = {
'appium:video': {
startFramerateCapture: {
command: 'startFrameCap',
params: {
required: ['videoSource'],
optional: ['showOnScreen'],
}
},
stopFramerateCapture: {
command: 'stopFrameCap',
},
}
};
在这个虚构的示例中,我们定义了两个新的 BiDi 命令:appium:video.startFramerateCapture 和 appium:video.stopFramerateCapture。第一个命令包含一个必选参数和一个可选参数,而第二个命令则不需要任何参数。当你在插件类中实现了 startFrameCap 和 stopFrameCap 方法后,每当客户端触发这些 BiDi 命令时,相应的方法就会被调用。这些方法的签名如下所示:
async startFrameCap(next: () => Promise<any>, driver: DriverClass, videoSource: string, showOnScreen: boolean): Promise<any>;
async stopFrameCap(next: () => Promise<any>, driver: DriverClass): Promise<any>;
与添加或重写现有的 HTTP 协议命令类似,这些方法在注入时会包含 next 和 driver 两个参数。 driver 对象代表当前拥有该会话的驱动程序;调用 await next() 则会执行(或返回)在插件未激活时本应执行的行为——即驱动程序自身对该方法的处理逻辑,或者是其他已激活插件中同名命令的执行链。
请注意,目前如果启用了 BiDi 代理,插件将无法覆盖由该代理处理的 BiDi 方法。此外,由于这些属于自定义 BiDi 命令,我们的模块名称中应包含厂商前缀(我们选择了 appium:,您可以而且应该,根据您的扩展功能,选择一个合适的名称)。
重载 Execute Script¶
除了上述做法外,还有另一种选择:重载一个所有 WebDriver 客户端均可调用的命令——“执行脚本”(Execute Script)。请务必阅读“构建驱动程序”指南中关于添加新命令的章节,以了解其基本运作机制。在插件场景下,其运作方式仅有细微差别。让我们来看一个取自 Appium “模拟插件”(fake-plugin)的示例:
static executeMethodMap = {
'fake: plugMeIn': {
command: 'plugMeIn',
params: {required: ['socket']},
},
};
async plugMeIn(next, driver, socket) {
return `Plugged in to ${socket}`;
}
async execute(next, driver, script, args) {
return await this.executeMethod(next, driver, script, args);
}
这里有三个让这套机制运转起来的关键组件,它们都定义在插件类内部:
executeMethodMap,定义方式与驱动完全相同executeMethodMap中所声明命令方法的实现(本例中为plugMeIn)- 覆盖/处理
execute命令。与其他插件命令处理器一样,前两个参数是next和driver,随后是脚本名和参数。BasePlugin提供了一个辅助方法,我们可以把这些参数原样传给它调用。
覆盖驱动定义的 Execute Method 的效果符合直觉:如果你的插件定义了与驱动同名的 Execute Method,会先调用你的命令(本例中为 plugMeIn)。如果需要,你可以通过 next 执行驱动的原有行为。
触发 BiDi 事件¶
你的插件触发自定义 BiDi 事件的方式,与 Appium 驱动完全一致。
构建 Appium Doctor 检查¶
你的用户可以运行 appium plugin doctor <pluginName> 来执行安装与健康检查。关于这一能力的更多信息,请访问构建 Doctor 检查指南。
更新 Appium 服务器对象¶
You probably don't normally need to update the Appium server object (which is an
Express server having already been
configured
in a variety of ways). 但你可以为服务器添加新的路由来支撑你的插件的需求。要更新服务器,你必须在类中实现 static async updateServer 方法。该方法接收三个参数:
expressApp:Express app 对象httpServer:Node HTTP 服务器对象cliArgs:启动 Appium 服务器所用 CLI 参数的映射
在 updateServer 方法中,你可以随心所欲地处理这些对象。建议参考 BaseDriver 代码中创建及使用这些对象的方式,以确保不会破坏或覆盖任何标准且关键的功能。当然,如果你执意要这样做也是可以的,但务必进行充分测试以验证结果!警告:这属于高级功能,需要你具备 Express 相关知识;同时请务必小心,确保所做的修改不会影响 Appium 服务器其他部分的正常运行!
updateServer 在 Appium 自身的路由注册完成后执行,因此此处通过 expressApp.use(...) 添加的中间件无法捕获那些已被 Appium 占用的路径请求——Express 是按照注册顺序来匹配中间件和路由的。如果某些中间件必须能够处理这些路径的请求(例如日志记录或身份验证),请改用 httpServer.frontRouter:Appium 会在所有路由注册之前挂载该路由,因此挂载到它上面的任何内容(即使是在 updateServer 内部添加的)也都会在路由匹配之前执行。需要注意的是,这仍然发生在 Appium 基础中间件(如 CORS 处理、WebSocket 升级处理、请求体解析等)之后;因此,如果请求已被 Appium 本身拒绝(例如 CORS 预检失败或请求体解析失败),这些中间件将无法捕获到此类请求。
static async updateServer(expressApp, httpServer, cliArgs) {
httpServer.frontRouter.use((req, res, next) => {
console.log(`Incoming request: ${req.method} ${req.url}`);
next();
});
}
处理会话意外关闭¶
在开发插件时,你可能需要在会话结束时执行一些清理逻辑。通常,你可以通过为 deleteSession 添加处理程序来实现这一点。这种方法在大多数情况下都有效,但如果会话未能正常结束,则不适用。 Appium 有时会判定会话已异常终止;在这种情况下,Appium 会在你的插件类中查找名为 onUnexpectedShutdown 的方法并将其调用(该方法接收两个参数:第一个是当前的会话驱动程序,第二个是导致终止的错误对象)。这让你有机会执行必要的清理操作。考虑到该函数不会被 await(即不会等待其执行完成),你可以按如下方式进行实现:
async onUnexpectedShutdown(driver, cause) {
try {
// do some cleanup
} catch (e) {
// log any errors; don't allow anything to be thrown as they will be unhandled rejections
}
}
向同一会话中运行的驱动程序或其他插件发送消息¶
尽管驱动程序和插件彼此并不了解,但它们仍可在同一会话的上下文中,通过 IPC(进程间通信)通道发送消息,供其他驱动程序或插件接收。针对此功能的开发说明对于驱动程序和插件而言是相同的,请参阅“驱动程序开发指南”中有关驱动程序 IPC 开发的文档。