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

testcafe captcha - How to Solve a CAPTCHA in TestCafe Tests (Node.js SDK)

在 TestCafe 里,一个验证码步骤比在多数框架里都要短,因为 TestCafe 的测试代码本来就跑在 Node 里。你直接在测试文件里调用识别工具,然后用一个 ClientFunction 把 token 写进页面。不过在这一切之前有一件事要先确认,而弄错它就会白白浪费掉一个下午:你这次运行用的是 native automation,还是那个会重写 URL 的老代理。在代理上,还没等识别工具靠近,reCAPTCHA 就已经是坏的了。

你需要什么

  • TestCafe 3.0 或更高版本、Node.js 18 或更高版本,以及 CapSkip 的 Node.js SDK。
  • 一个 Chromium 内核的浏览器,也就是 Chrome 或 Edge。native automation 不覆盖 Firefox 和 Safari。
  • 被测表单所在页面的 URL,以及它的 sitekey。
  • 如果测试运行器和识别工具在同一台机器上,CapSkip 用 Local 模式运行;如果不在,就用 Server 模式。两种模式都记录在 连接设置.
# npm install capskip
npm install --save-dev testcafe
npm install capskip

先确认一件事:是 native automation 还是代理?

TestCafe 有两种驱动浏览器的方式,它们在验证码面前的表现完全不同。最早的那种是一个叫 hammerhead 的 Web 代理。它坐在浏览器和站点之间,把自己的自动化脚本注入到每一个页面里,并把资源上的每一个 URL 都重写成指回代理。正是这一点让 TestCafe 不需要 driver 就能支持任何浏览器,也正是这一点让 reCAPTCHA 失效。

代理上会出现两种故障,两种都已经在 hammerhead 上报过,也都不是你在测试里能修的。一是 reCAPTCHA 试图从 Google 的源启动一个 web worker,浏览器拒绝了,因为文档的源现在是代理自己的主机和端口。二是经代理送出的页面,每次拿回的 reCAPTCHA v3 分数都是 0.1,而多数站点会直接把这个分数判成机器人。

native automation 取代了这一整套。TestCafe 改为通过 DevTools 协议驱动 Chromium,链路里没有代理,也完全不再重写 URL。它在 v2.5.0 里作为实验特性落地,从 v3.0.0 起成为默认。如果你的测试套件用的是较新的 TestCafe,并且跑在 Chrome 上,那你已经在用它了。

所以排查的第一步,是确认没有什么东西把它关掉了。TestCafe 在 Firefox 和 Safari 上会自动禁用 native automation,而名为 disable-native-automation 的命令行开关以及它在配置文件里的同名项,在 Chromium 上同样会把它关掉。常见的情况是:某个测试套件多年前为了绕开别的问题加上了这个开关。写任何识别代码之前,先把它搜出来。

# Run in Chrome, which uses native automation by default.
npx testcafe chrome tests/checkout.js

# This flag puts you back on the proxy and breaks reCAPTCHA.
# npx testcafe chrome tests/checkout.js --disable-native-automation

有一点值得直说,因为 TestCafe 自己也是这么说的:如果被测站点是你自己的,最好的答案是根本不去识别。Google 提供了一个永远能通过的 v2 测试 sitekey,而在 reCAPTCHA 控制台里配一个阈值放宽的独立 v3 密钥,也就是五分钟的事。TestCafe 自己的 reCAPTCHA 实践指南 把两种做法都讲到了。识别是留给那扇门被关上的场景:流程中间夹了一个第三方结账页、预发环境和生产环境共用密钥,或者一个必须跑在真实站点上的冒烟测试。

第 1 步:从页面里读出 sitekey

TestCafe 的 Selector 是惰性的,而且会自动重试,所以在小组件渲染之前写下的选择器,等它出现时依然能解析到。把 sitekey 从小组件元素上取出来,而不是把字面值粘进测试里,这样同一个测试在密钥轮换之后依然可用。

// npm install capskip
import { Selector } from 'testcafe';

const PAGE_URL = 'https://example.com/page-with-recaptcha';

fixture('Checkout').page(PAGE_URL);

test('submits behind reCAPTCHA', async t => {
    const widget = Selector('.g-recaptcha');
    const sitekey = await widget.getAttribute('data-sitekey');
});

第 2 步:在测试文件里完成识别

这里正是 TestCafe 比那些跑在浏览器一侧的测试框架省事的地方。你的测试函数就是普通的 Node 代码,所以 SDK 只是一个普通的 import,调用也只是一个普通的 await。不需要搭桥,也不需要注册 task,而从 Cypress 转过来的人往往以为这些是免不了的。

// npm install capskip
import { CapSkip } from 'capskip';

// Local mode. Change only the host to talk to a solver
// running on another machine.
const solver = new CapSkip({ host: '127.0.0.1', port: 8080 });

const result = await solver.recaptcha(sitekey, PAGE_URL);
const token = result.code;

这一个方法就覆盖了 reCAPTCHA v2、Invisible、Enterprise 和 v3。各个变体是第三个参数里的选项,而不是各自独立的调用:invisible 设为 1、enterprise 设为 1,或者把 version 设为 v3 并给一个 action 字符串。Turnstile 和极验(GeeTest)有自己的方法,形态完全一样。每一个参数都列在 CapSkip API 文档.

第 3 步:用 ClientFunction 把 token 写进去

响应字段是一个隐藏的 textarea,所以常规的输入动作碰不到它。TestCafe 的动作只对可见元素生效,这是刻意的设计。ClientFunction 则是把你的代码放进页面里执行,对于一个真实用户从来不会去输入的字段来说,这才是对的工具。

这里有个坑,几乎每个人都会踩一次。 ClientFunction 看不到外层测试里的变量。 函数体会被序列化后送到浏览器,所以从外层作用域捕获的 token,在运行时会变成一个未定义的标识符。把它作为参数传进去,或者声明成依赖。

// The token is a parameter, not a closure variable.
import { ClientFunction } from 'testcafe';

const injectToken = ClientFunction(value => {
    const field = document.getElementById('g-recaptcha-response');
    field.value = value;
    field.dispatchEvent(new Event('change', { bubbles: true }));
});

await injectToken(token);

TestCafe 的建议是不要用 client function 去永久改变站点的行为,这条建议值得遵守。为一次运行往一个表单字段里写一个值,并不属于这种情况。你是在填一个字段,而不是在给页面的行为打补丁,而且这个值在运行结束的那一刻就消失了。

有些表单等的是一个回调,而不是去读 textarea。如果小组件声明了 data-callback 属性,就在同一个 ClientFunction 里带上 token 调用那个函数,页面接下来的行为会和面对真人时一模一样。

完整可运行示例

完整的测试。读出 sitekey、识别、注入、提交、断言。

// npm install capskip
import { Selector, ClientFunction } from 'testcafe';
import { CapSkip, NetworkException } from 'capskip';

const PAGE_URL = 'https://example.com/page-with-recaptcha';
const solver = new CapSkip({ host: '127.0.0.1', port: 8080 });

const injectToken = ClientFunction(value => {
    const field = document.getElementById('g-recaptcha-response');
    field.value = value;
    field.dispatchEvent(new Event('change', { bubbles: true }));
});

fixture('Checkout').page(PAGE_URL);

test('submits the protected form', async t => {
    const sitekey = await Selector('.g-recaptcha').getAttribute('data-sitekey');

    let token;
    try {
        token = (await solver.recaptcha(sitekey, PAGE_URL)).code;
    } catch (err) {
        if (err instanceof NetworkException) {
            throw new Error('CapSkip is not reachable on 127.0.0.1:8080.');
        }
        throw err;
    }

    await injectToken(token);
    await t.click(Selector('button[type=submit]'));
    await t.expect(Selector('.thank-you').exists).ok();
});

尽可能晚一点再识别。token 是一次性的,大约两分钟就过期,所以在一个先于另外三个测试执行的 fixture 钩子里识别出来的 token,等第四个测试提交它时早就已经死了。把调用放进真正需要它的那个测试里。

超时,以及真正会咬人的那一个

一次 reCAPTCHA 识别要花几十秒,比 TestCafe 的好几个默认超时都长。好消息是,大家第一时间想到的那几个超时其实都不相干。你的识别调用是测试函数内部的一个 await,而不是一个页面动作,所以 10 秒的选择器超时和 3 秒的断言超时都看不到它。

真正起作用的限制是测试执行超时,它限定单个测试最长能跑多久。它没有默认值,所以只有当有人设了它,它才会咬人。如果你的 CI 配置里传了测试执行超时,要确保这个值在测试本身要做的所有事情之外,还给一次慢识别留出余量。SDK 自己也有上限:recaptchaTimeout 默认 300 秒,一次识别超过它就会抛出 TimeoutException。

把识别工具放到别处运行

测试迟早会挪到 CI 上,而 CI 的 runner 不是你的电脑。上面的代码里除了 host 字符串,什么都不用改。

CapSkip 有两种连接模式。Local 绑定 127.0.0.1,只响应该设备,这正是你写测试时想要的。Server 绑定你的内网地址或公网 IP,于是构建代理、容器或虚拟机都能通过 API 调用同一台 Windows 机器。静态公网 IP 能让这个地址保持稳定。两种模式下它都是你自己的硬件,也都不按量计费,所以一个每晚识别五百次的测试套件,和一个只识别五次的套件花的钱一模一样。

// Same SDK, same call. Only the host moves.
const solver = new CapSkip({
    host: process.env.CAPSKIP_HOST || '127.0.0.1',
    port: 8080,
    apiKey: process.env.CAPSKIP_API_KEY,
});

SDK 会自行从环境里读取 CAPSKIP_HOST、CAPSKIP_PORT 和 CAPSKIP_API_KEY,所以一个 CI 任务只要两个变量、不改一行代码,就能让同一个测试文件指向远端的识别工具。一旦识别工具监听在网络地址上,就打开密钥校验,并给每个 runner 配一个自己的密钥,这样撤销其中一个不会波及其他。两种模式都详细记录在 CapSkip 设置指南.

常见错误及其含义

你所看到的原因修复
构造 Worker 失败:脚本无法从当前的源访问hammerhead 代理夹在链路里,所以页面的源不是站点本身去掉 disable-native-automation 开关,改用 Chrome 或 Edge 运行
每次拿回的 v3 分数都是 0.1同一个原因。无论测试怎么做,代理都会把分数钉死同一个修复。native automation 会把代理彻底去掉
抛出 ReferenceError,说 token 未定义ClientFunction 的函数体读不到外层作用域的变量把 token 作为参数传入,或者声明成依赖
输入动作在响应字段上失败那个 textarea 是隐藏的,而动作需要可见元素改用 ClientFunction 直接设置这个值
表单拒绝了一个看起来没问题的 token识别是在钩子里做的,比提交早了好几分钟在测试内部识别,紧挨着提交之前
NetworkExceptionCapSkip 没在运行,或者 host 填错了启动应用,或者把 host 指向服务器地址
TimeoutException识别耗时超过了 recaptchaTimeout把它调到默认的 300 秒以上
ValidationExceptionsitekey 或页面 URL 缺失,或者格式不对在调用前把两者都打印出来,并确认 sitekey 是线上正在用的那个

常见问题

我需要像 Cypress 那样写一个 task 或插件吗?

不需要。Cypress 把你的测试代码跑在浏览器里,所以任何需要 Node 的东西都得跨一座桥。TestCafe 从一开始就把测试代码跑在 Node 里,只有 ClientFunction 的函数体会送到浏览器,所以调用识别工具就是一次普通的 import。同一件事在 Cypress 上的做法写在 Cypress 验证码识别指南.

我能在 Firefox 或 Safari 上做这件事吗?

测试可以跑,但要预料到小组件本身会出问题,因为 TestCafe 在这些浏览器上会退回到代理,而这正是 reCAPTCHA 活不下来的那种配置。把带验证码的测试留在 Chrome 或 Edge 上,让跨浏览器矩阵去覆盖那些没有小组件的页面。

这套做法对 Turnstile 同样适用吗?

适用,但有两处不同。方法名是 turnstile 而不是 recaptcha,要填的字段是名为 cf-turnstile-response 的隐藏 input。完整的挑战页面还需要 data 和 pagedata 两个值,以及随 token 一起返回的 user agent,这些都写在 Cloudflare Turnstile 识别器页面.

验证码测试应该每次提交都跑吗?

通常不该,理由是速度而不是成本。这里的识别不按量计费,但每个测试几十秒会让拉取请求的检查变得很慢。给它们打上标签,放进每晚或发布前的任务里跑,让快速套件继续指向一个使用测试密钥的构建。

简短版结论

先确认 native automation 是开着的,因为老代理本身就会让 reCAPTCHA 失效。用 Selector 读出 sitekey,既然测试文件本来就是 Node,就直接在里面调用识别工具,再通过 ClientFunction 把 token 写进去,值要作为参数传入。紧挨着提交之前再做识别。

Node.js 这一侧其余的接口面写在 Node.js 验证码识别页面。关于这种验证码类型的一切细节都在 reCAPTCHA v2 识别页面。同一件事在基于 WebDriver 的测试框架上的做法写在 WebdriverIO 指南.

在把这套东西接进 CI 之前,还有最后一件事。CapSkip 是一个 本地验证码识别工具 运行在你已经拥有的硬件上,所以一个每晚识别一千次的套件,和一个只识别十次的套件花的钱一样。