如何在 WebdriverIO 测试中识别验证码(Node.js)

webdriverio captcha - How to Solve CAPTCHA in WebdriverIO Tests With Node.js

在 WebdriverIO 里,一个验证码步骤是一次 Node.js 调用,不是浏览器里的调用。你在测试进程里完成识别,通过 browser.execute 把 token 交给页面,再提交表单。有两件事最容易让人栽跟头,而且都跟识别工具本身无关。把识别放进浏览器上下文,它就完全没办法连到你的机器。让 Mocha 保持默认的三十秒超时,测试会在识别进行到一半时挂掉,报错信息什么都说明不了。下面是这个自定义命令、这一处配置改动,以及一份能跑通的 spec。

你需要什么

  • WebdriverIO 8 或更新版本,配合 Mocha 框架。下面的内容全部是异步的,因为旧的同步模式已经没有了。
  • Node.js 18 或更高版本,并在测试项目里安装好 CapSkip 包。
  • 一个真的会出验证挑战的页面。一个永远会通过的测试用 sitekey,没法验证这里说的任何东西。
  • 如果测试跑在你自己的机器上,让 CapSkip 以 Local 模式运行;如果跑在 CI runner 或 grid 上,则以 Server 模式运行。两者的说明都在 连接设置.
# Install into the project that runs wdio, not into the browser image.
npm install capskip

在 Node 里识别,而不是在浏览器里

这个错误值得先说清楚,因为 WebdriverIO 很容易让人犯这个错。browser.execute 命令会把你的函数序列化,传到浏览器里,在页面内部执行。那里面没有模块加载器,所以 SDK 根本没法用。就算能用,页面本来就是你正在测试的对象,把识别工具的地址交给它,也不是你想做的事。

更重要的原因是路由。只要浏览器不在你自己的机器上(而在 grid 或云设备提供商那里它从来就不在),浏览器里的回环地址指的就是浏览器所在的那台主机,你的识别工具并不在那儿。真正知道怎么连到 CapSkip 的,是运行 wdio 的那个 Node 进程,所以识别要留在那里,只有识别完成的 token 才会传进页面。

第一步:注册一个自定义命令

与其在每个 spec 里都导入 SDK,不如在配置的 before 钩子里加一个命令。这样每个测试里的 browser 对象上都能用它,识别工具的地址也只需要写在一个地方。

// npm install capskip
const { CapSkip } = require('capskip');

exports.config = {
  framework: 'mocha',

  before: function () {
    const solver = new CapSkip({
      host: process.env.CAPSKIP_HOST || '127.0.0.1',
      port: Number(process.env.CAPSKIP_PORT || 8080),
    });

    browser.addCommand('solveRecaptcha', async function (sitekey) {
      const result = await solver.recaptcha(sitekey, await this.getUrl());
      return result.code;   // the token, ready to inject
    });
  },
};

在 addCommand 内部,this 指向的就是 browser 作用域,这也是 getUrl 在那里能用的原因。这个细节很关键:API 需要小组件所在页面的 URL,向 browser 要这个 URL,就意味着 spec 不用再重复一遍它已经导航过的地址。

从环境变量里读取 host,而不是把它写死。这样同一套测试,不改代码就既能跑在你笔记本上的识别工具,也能跑在 CI 共用的那个上面。

第二步:注入 token 并提交

Google 把识别结果放进一个 id 为 g-recaptcha-response 的隐藏 textarea 里。因为它是隐藏的,用多少次 setValue 都碰不到它。直接设置它的值,然后像人一样去提交表单。

const token = await browser.solveRecaptcha('YOUR_SITEKEY');

// Put the token where the page already expects to find it.
await browser.execute((value) => {
  document.getElementById('g-recaptcha-response').value = value;
}, token);

await $('button[type="submit"]').click();

这覆盖了最常见的情况:表单在你按下提交时会读取那个 textarea。但也有一些页面在小组件上声明了 data-callback,根本不看 textarea。如果你测的是这种页面,就在设置完值之后,在同一个 browser.execute 代码块里调用那个回调,因为填一个没人读的字段,什么都改变不了。

第三步:调高 Mocha 的超时时间

WebdriverIO 默认给 Mocha 设置的是 30000 毫秒超时,对点点点这类操作绰绰有余,但对识别一次挑战来说远远不够。一个本该通过的测试会在这里失败,而报错说的是 Mocha,跟识别没有半点关系,这足以让人对着错误的方向找上一下午。

exports.config = {
  framework: 'mocha',
  mochaOpts: {
    // The 30000 default expires mid solve. Give it room.
    timeout: 120000,
  },
};

把这两个限制按正确的顺序设好。SDK 会在 recaptchaTimeout(默认 300 秒)之后放弃,Mocha 会在它自己的超时之后放弃。把 SDK 的值设得比 Mocha 低,你会得到一个指名识别失败的 TimeoutException;设得比它高,Mocha 会先把测试杀掉,你能知道的就只有某个东西太慢了。对一个必须保持快速的套件来说,90 秒的识别超时配上 120000 毫秒的 Mocha 超时,是一对合理的数字。

跑在 CI 或 grid 上

先搞清楚到底是哪台机器需要连到识别工具,因为答案并不是看起来那么明显的那台。浏览器从来不会跟 CapSkip 通信,运行 wdio 的进程才会。所以不管是在 GitHub Actions runner 上、容器里,还是在笔记本上驱动一个云端浏览器,真正的调用方都是那个 runner,把它指向它自己的回环地址,什么也找不到。

模式监听地址适用场景
本地127.0.0.1,仅限该设备你在跟识别工具同一台机器上运行 wdio
服务器你的内网地址或公网 IPCI runner、容器、共用的测试套件、grid

Server 模式覆盖了第二行里的所有情况。在应用里切换监听地址,在 runner 上设置 CAPSKIP_HOST,每个 job 就能共用同一个识别工具。如果调用方位于你自己网络之外,值得用一个静态公网 IP。这些都不改变产品本身是什么:它依然是你自己的硬件,依然不按量计费,所以离开回环地址,改变的只是它运行的位置,别的什么都没变。CapSkip 是 Windows 应用,所以实际情况就是你的 runner 都要调用同一台 Windows 机器。

完整的 spec

describe('protected signup form', () => {
  it('submits with a solved challenge', async () => {
    await browser.url('https://example.com/page-with-recaptcha');

    await $('#email').setValue('[email protected]');

    // Solve here, submit two lines later. The token is short lived.
    const token = await browser.solveRecaptcha('YOUR_SITEKEY');

    await browser.execute((value) => {
      document.getElementById('g-recaptcha-response').value = value;
    }, token);

    await $('button[type="submit"]').click();

    await expect($('.signup-success')).toBeDisplayed();
  });
});

注意这里面真正跟验证码有关的部分有多少:三行代码就撑起了整个集成,剩下的都是你本来就要写的测试。把识别和提交放在同一个测试体里,这样表单读到 token 的时候,它才只有几秒钟大。

常见错误

你所看到的原因修复
Mocha 30000ms 超时被触发识别耗时超过了默认值调高 mochaOpts.timeout,把 recaptchaTimeout 设得比它低
browser.execute 里面 solver 未定义函数是在页面里跑的,不是在 Node 里在调用 execute 之前完成识别,只把 token 传进去
CI 上抛 NetworkException,本地却没事runner 连不到识别工具切换到 Server 模式,在 runner 上设置 CAPSKIP_HOST
表单拒绝了一个识别得干干净净的 token页面用的是回调,根本不看 textarea设置完值之后调用小组件的回调
ERROR_GOOGLEKEY把 Turnstile 的 sitekey 传给了 reCAPTCHA 方法Turnstile 小组件要用 turnstile 方法

完整的错误码列表,以及每种错误的触发条件,都在 CapSkip API 文档.

常见问题

我能在 browser.execute 里面调用识别工具吗?

不能,而且失败的原因有两个,各自独立。你传进去的函数会被序列化,在页面里执行,那里没有模块加载器,也没有 SDK。就算有,浏览器往往根本就在另一台主机上,你想拨的那个地址也不属于你。要在 Node 进程里完成识别,把识别好的 token 作为参数传进去。

这套办法在远程 grid 或云端浏览器上也能用吗?

能,而且正是这种架构,才让识别必须放在 Node 里做。你的测试进程跑在本地或者某个 runner 上,能直接连到识别工具,而浏览器待在别的地方,收到的从头到尾只有一个 token。让 CapSkip 以 Server 模式运行,这样不管是哪台机器在跑这套测试都能连到它,spec 本身不需要改动。

我可以在 before 钩子里识别一次,然后重复使用这个 token 吗?

不行。一个 reCAPTCHA token 只能用一次,有效期大约两分钟,所以第二个用它的测试会被拒绝,跑在一个耗时较长的 spec 后面的第一个测试同样会被拒绝。在每个需要 token 的测试里各自识别一次。共用的识别工具处理这些额外调用不会多花一分钱,囤积 token 省不下什么。

我用的是 Cucumber 或 Jasmine,不是 Mocha,有什么不一样?

只有超时设置的名字不一样。自定义命令、注入方式,以及 Server 模式的问题,全都一模一样。把 cucumberOpts.timeout 或者 jasmineOpts.defaultTimeoutInterval 调高,而不是 mochaOpts.timeout,并且不管你设的是哪一个,都让识别工具的超时保持在它下面。

简短版结论

在 before 钩子里注册一个自定义命令,把框架的超时时间设在识别耗时之上,用 browser.execute 注入 token,而不是尝试直接输入它。要了解客户端接口,请看 Node.js 验证码识别页面,要了解 WebDriver 那一侧,请看 Selenium 验证码识别页面,要了解 token 到底是什么,请看 reCAPTCHA v2 识别页面。测试套件正是按量计费最伤人的地方,因为一个在每次 pull request 上都跑的套件,一周里要对同一个表单识别几百次。而这正是自己拥有硬件之后改变的地方: 无限量验证码识别工具 跑在你已经拥有的硬件上,无论 CI 一天跑两次还是一小时跑两次,成本都一样。