如何在 Cypress 测试中用 cy.task 识别验证码

cypress captcha - How to Solve CAPTCHA in Cypress Tests With cy.task

Cypress 的验证码问题,首先是运行环境的问题,其次才是识别的问题。你的 spec 代码运行在被测浏览器内部,所以与识别工具通信的 Node 客户端不能放在那里。改成把它注册成 cypress.config.js 里的一个 task,然后从 spec 里调用这个 task,并自己把 token 写进隐藏字段。让它跑通需要三样东西:task、调高的超时,以及直接写 DOM 而不是用 Cypress 点击。本文把这三样都讲一遍。

你需要什么

  • Cypress 10 或更高版本,cypress.config.js 和 setupNodeEvents 就是从这个版本开始有的。更旧的版本用的是旧的 plugins 文件,思路完全一样
  • Node.js 18 或更高版本
  • CapSkip 正在运行并且可以访问。Local 模式监听 127.0.0.1 的 8080 端口,适合在同一台机器上跑测试;Server 模式监听你的内网或公网 IP,这样 CI runner 或另一台机器就能调用它。两种模式都配置在 连接设置
  • 识别工具的客户端,作为开发依赖安装
# The client only ever runs in the Node half of Cypress.
npm install --save-dev capskip

为什么识别不能放在 spec 里

Cypress 分成两个进程,直白写法之所以失败,原因全在这里。你的 spec 文件会被打包并在浏览器里执行,和被测应用挨在一起。cypress.config.js 里的一切则运行在浏览器之外的 Node 中。

所以在 spec 顶部 require 识别工具的客户端,等于把一个 Node HTTP 客户端拉进了浏览器包里。就算打包器放行,浏览器也会拦下这次调用:从你应用的源发往 127.0.0.1 的 8080 端口属于跨源请求,而识别工具并不会发送让它合法的 CORS 头。

Cypress 给了你两扇通往 Node 的门,两扇都可以用:

  • cy.task 会运行你在配置里注册的任意函数。SDK 就该放在这里,因为它的轮询和退避逻辑随之运行在 Node 中,那正是它被设计运行的地方。
  • cy.request 会从 Cypress 的 Node 进程发起 HTTP 调用,而不是从浏览器发起,所以 Cypress 文档才说它完全绕过 CORS。如果你更想直接打原始 API、省掉这个依赖,用它就很合适。

第 1 步:把识别注册成一个 task

一个函数,注册一次,每个 spec 都能用。

// npm install --save-dev capskip
const { defineConfig } = require('cypress');
const { CapSkip } = require('capskip');

// Local mode. Point host at a server IP to share one solver.
const solver = new CapSkip({ host: '127.0.0.1', port: 8080 });

module.exports = defineConfig({
  // 60000 is the default, and a v2 solve can outlast it.
  taskTimeout: 180000,

  e2e: {
    setupNodeEvents(on) {
      on('task', {
        async solveRecaptcha({ sitekey, url }) {
          const result = await solver.recaptcha(sitekey, url);
          return result.code;   // the token
        },
      });
    },
  },
});

关于 task 有一条规则,几乎每个人第一次都要为它搭进去一个小时:task 必须返回一个值或者 null,绝不能返回 undefined。忘了写 return,Cypress 就会让命令失败,并提示 task 返回了 undefined,看起来像是识别工具坏了,可识别工具其实根本没被调用。

第 2 步:调用 task 并注入 token

从页面上读出 sitekey,交给 task,再把答案放到小组件本该放的位置。

// cypress/e2e/login.cy.js
it('logs in through the reCAPTCHA', () => {
  cy.visit('/login');

  cy.get('[data-sitekey]')
    .invoke('attr', 'data-sitekey')
    .then((sitekey) => {
      const url = 'https://example.com/login';

      cy.task('solveRecaptcha', { sitekey, url }).then((token) => {
        // The widget writes into a hidden textarea. Do the same.
        cy.document().then((doc) => {
          doc.getElementById('g-recaptcha-response').value = token;
        });
      });
    });

  cy.get('button[type=submit]').click();
  cy.contains('Welcome back');
});

注意这里是直接写 DOM。响应字段是一个隐藏的 textarea,而 Cypress 不肯往它认为不可见的元素里输入,所以用 get 加 type 的直白写法会先卡在可见性上,根本走不到 token 那一步。走 cy.document 就绕开了这一点,做法和小组件自己的 JavaScript 完全一样。

如果小组件的 div 上带有 data-callback 属性,就用 token 去调用那个函数,而不是点击提交按钮。这样构建的页面从来不会接上普通的表单提交,所以点击不会有任何反应。

真正咬人的超时是 taskTimeout

这是最常被算在识别工具头上的失败,而修复只需要在配置里改一行。

Cypress 默认给一个 task 60 秒 的时间。reCAPTCHA v2 的任务在最初的 15 到 20 秒里不会就绪,v3 需要 10 到 15 秒,机器繁忙时两者都会被拉长。到达上限时,Cypress 会杀掉这条命令,测试失败时给出的超时指向你的 task,而不是验证码。

旁边还有一个坑:调错了数字。大多数关于 Cypress 超时的建议都指向 defaultCommandTimeout,它是 4000 毫秒,管的是 DOM 命令,对 task 毫无作用。这里有三个值需要关心,而且它们各自独立:

选项默认值适用于
taskTimeout60000 mscy.task,也就是识别本身
responseTimeout30000 mscy.request,也就是原始 API 调用
defaultCommandTimeout4000 msDOM 命令,不是上面这两者

可以像上面的配置那样全局设置,也可以在只有某一个测试需要余量时按调用设置:

// Same task, a longer leash for this one call.
cy.task('solveRecaptcha', { sitekey, url }, { timeout: 180000 });

180 秒是个合理的上限。它大约是一次正常识别的十倍,并且刻意低于 SDK 自己的 300 秒 reCAPTCHA 轮询上限,这样 Cypress 会让真正卡住的测试失败,而不是跟在一个还在等待的客户端后面干耗。如果你更希望客户端先放弃,就把 recaptchaTimeout 调到低于 task 超时的值。

或者跳过 SDK,直接用 cy.request

这个 API 兼容 2captcha,所以两次调用就能干完全部的活。由于 cy.request 运行在 Node 里,浏览器的同源规则根本不会介入。

// No task registration needed. Both calls happen in Node.
function pollForToken(id, tries = 20) {
  return cy.request({
    method: 'POST',
    url: 'http://127.0.0.1:8080/res.php',
    form: true,
    body: { key: 'capskip', action: 'get', id },
  }).then((res) => {
    const text = res.body.trim();
    if (text !== 'CAPCHA_NOT_READY') return text.replace('OK|', '');
    if (tries === 0) throw new Error('gave up waiting for ' + id);
    return cy.wait(5000).then(() => pollForToken(id, tries - 1));
  });
}

有两个细节要分清。res.php 返回的纯文本响应在成功时是 OK|TOKEN ,任务还在运行时则是裸字符串 CAPCHA_NOT_READY,这是一个状态,不是错误。另外,结果只能读取一次,所以它一到就存下来,不要问第二次。每个参数和每条错误字符串都列在 API 文档.

让测试跑在 CI 上,而识别工具原地不动

在笔记本上通过的测试套件,往往就是在第一次 push 时栽在这里。GitHub Actions runner、GitLab job 或 Jenkins agent 都有自己的回环地址,那上面的 8080 端口没有任何东西在监听。Local 模式按定义就只限本机。

答案是 Server 模式。CapSkip 会监听你的内网或公网 IP,而不是回环地址,配置则从环境变量里读取这个地址。

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

// Same client, different address. The spec never changes.
const solver = new CapSkip({
  host: process.env.CAPSKIP_HOST || '127.0.0.1',
  port: Number(process.env.CAPSKIP_PORT || 8080),
});

SDK 自己就会读取 CAPSKIP_HOST 和 CAPSKIP_PORT,所以上面那个兜底值只是为了防住启动时没有这两个变量的 runner。建议给识别工具所在的机器配一个静态公网 IP,具体步骤见 连接设置。这仍然是你自己的硬件,仍然不计量:变的只是进程监听的位置。

常见错误及其含义

症状原因修复
提示 solveRecaptcha 这个 task 未注册注册在了错误的代码块里,或者配置根本没有导出在 e2e 键下的 setupNodeEvents 里注册
等待你的 task 60000ms 之后超时taskTimeout 还停在默认值把它调到 180000,全局设置或按调用设置都行
task 返回了 undefined处理函数里没有 return 语句返回 token,没有结果时返回 null
元素不可见,Cypress 无法输入响应字段是一个隐藏的 textarea改用 cy.document 写入这个值
ERROR_GOOGLEKEYsitekey 是空的,或者它属于一个 Turnstile 小组件识别之前先把这个属性打印出来;Turnstile 有自己的 method 值
每个测试都报 NetworkException那个主机和端口上没有任何东西在监听Local 模式只走回环地址;在 CI 里请用 Server 模式
本地全绿,CI 全红runner 到不了你机器的回环地址把 CAPSKIP_HOST 指向一个能访问到的地址

常见问题

我是不是在测试环境里直接把验证码关掉就行?

如果小组件是你自己的,那就关掉。在预发布构建里用一个功能开关或测试用 sitekey,比识别更便宜也更快,还能让测试套件保持确定性。识别在三种情况下才值得做:验证码属于别人、预发布必须和生产完全一致,或者被测的就是挑战流程本身。 验证码演示页面 对第三种情况很有用,因为你可以让 spec 指向一个行为和真东西一样的小组件。

这在 Cypress 组件测试里管用吗?

没什么用。组件测试挂载的是一个组件,背后没有真实页面,也没有服务器,所以 token 没有可供校验的对象。如果你想让它可用,可以在 component 键下也注册这个 task,但挑战相关的活儿还是留在端到端 spec 里,那里才有真正要提交的请求。

录制到 Cypress Cloud 的运行能访问识别工具吗?

Cypress Cloud 只记录结果,并不执行你的测试,所以这个问题其实取决于哪台机器在跑浏览器。在你的笔记本上就是 Local 模式。在托管 runner 上则需要 Server 模式,以及一条通往识别工具地址的路由,两种情况下录制的表现完全一样。

并行的 Cypress 运行一次能同时压多少个识别任务?

有多少个 spec 文件在跑就能压多少个。每个 Cypress 进程都持有自己的客户端、等待自己的 task,所以客户端这边没什么要配置的。SDK 从 250 毫秒开始轮询,然后退避到 pollingInterval 上限,这样即使同时有好几个在飞,快的识别依然很快。因为活儿是在你自己的硬件上干的,加机器是容量决策,而不是账单决策。

简短版结论

把客户端放进 cypress.config.js,以 task 的形式暴露出来,把 taskTimeout 调到 180000,再通过 cy.document 把 token 写进隐藏字段。整个集成就这些,而会出问题的地方是底下那两条 Cypress 规则:spec 代码就是浏览器代码,task 要么返回,要么失败。

自己运行识别工具,才让重试一个不稳定的挑战成为合理选择,而不是为它单独做预算,而且这个道理适用于你部署 验证码识别工具的任何地方,测试套件里或生产环境中都一样。客户端选项在 Node.js 集成指南中有详细介绍,而 Cypress 与其他所有运行器共有的浏览器端细节,请参见 Playwright 指南.