Как решать капчу в тестах Cypress с помощью cy.task

Проблема с капчей в Cypress сначала является проблемой среды выполнения и только потом проблемой распознавания. Код вашего спека выполняется внутри тестируемого браузера, поэтому Node-клиент, который общается с решателем, там жить не может. Вместо этого зарегистрируйте его как задачу в cypress.config.js и вызывайте эту задачу из спека, а токен записывайте в скрытое поле сами. Работает это благодаря трём вещам: задача, увеличенный таймаут и прямая запись в DOM вместо клика средствами Cypress. В этом руководстве разберём все три.
Что понадобится
- Cypress 10 или новее: именно там появились cypress.config.js и setupNodeEvents. В более старых версиях используется старый файл plugins, но идея остаётся той же
- Node.js 18 или новее
- Запущенный и доступный CapSkip. Local mode слушает 127.0.0.1 на порту 8080, когда тесты идут на той же машине, а Server mode слушает ваш сетевой или публичный IP, чтобы к нему мог обратиться CI-раннер или другая машина. Оба режима настраиваются в разделе Настройки подключения
- Клиент решателя, установленный как dev-зависимость
# The client only ever runs in the Node half of Cypress. npm install --save-dev capskip
Почему решение капчи нельзя выполнять в спеке
Cypress делится на два процесса, и именно в этом причина того, что наивный вариант не работает. Ваш спек-файл собирается бандлером и выполняется внутри браузера, рядом с приложением. Всё, что находится в cypress.config.js, выполняется в Node, за его пределами.
Поэтому подключение клиента решателя в начале спека затягивает Node-клиент HTTP в браузерный бандл. Даже если бандлер это пропустит, браузер всё равно заблокирует вызов: запрос с origin вашего приложения на 127.0.0.1 порт 8080 является кросс-доменным, а решатель не отправляет заголовки CORS, которые сделали бы его допустимым.
Cypress даёт две двери в Node, и обе вполне подходят:
- cy.task запускает произвольную функцию, которую вы зарегистрировали в конфиге. Именно здесь место SDK, потому что его логика опроса и увеличения интервалов выполняется тогда в Node, для которого она и была написана.
- cy.request выполняет HTTP-запрос из Node-процесса Cypress, а не из браузера, поэтому документация Cypress и говорит, что он полностью обходит CORS. Хороший вариант, если вы предпочитаете обращаться к сырому API и не тянуть зависимость.
Шаг 1: регистрируем решение капчи как задачу
Одна функция, зарегистрированная один раз и доступная любому спеку.
// 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
},
});
},
},
});Одно правило о задачах, которое в первый раз стоит всем часа времени: задача должна возвращать значение или null, но никогда undefined. Забудете return, и Cypress завалит команду с сообщением о том, что задача вернула undefined, а читается это так, будто сломался решатель, хотя решатель даже не вызывался.
Шаг 2: вызываем задачу и подставляем токен
Считайте sitekey со страницы, передайте его в задачу и положите ответ туда, куда его положил бы сам виджет.
// 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 падает на проверке видимости ещё до того, как дело дойдёт до токена. Через cy.document это обходится ровно так же, как это сделал бы собственный JavaScript виджета.
Если у div виджета есть атрибут data-callback, вызывайте эту функцию с токеном вместо клика по кнопке отправки. На страницах, сделанных таким образом, обычная отправка формы вообще не подключена, так что клик ничего не даёт.
На деле всё упирается в taskTimeout
Это тот отказ, в котором чаще всего винят решатель, а исправляется он одной строкой в конфиге.
Cypress отводит задаче 60 секунд по умолчанию. Задача reCAPTCHA v2 не бывает готова первые 15-20 секунд, v3 занимает 10-15, а на загруженной машине оба варианта могут растянуться. Когда потолок достигнут, Cypress убивает команду, и тест падает с таймаутом, в котором названа ваша задача, а не капча.
Рядом стоит другая ловушка: поднять не то число. Большинство советов про таймауты Cypress указывают на defaultCommandTimeout, который равен 4000 миллисекундам и управляет командами DOM. На задачу он не влияет. Здесь важны три значения, и все они разные:
| Параметр | По умолчанию | Применяется к |
|---|---|---|
| taskTimeout | 60000 ms | cy.task, то есть само решение капчи |
| responseTimeout | 30000 ms | cy.request, то есть прямой вызов API |
| defaultCommandTimeout | 4000 ms | Команды DOM, а не два предыдущих случая |
Задайте его глобально, как в конфиге выше, или для отдельного вызова, когда запас нужен только одному тесту:
// Same task, a longer leash for this one call.
cy.task('solveRecaptcha', { sitekey, url }, { timeout: 180000 });Разумный потолок: 180 секунд. Это примерно в десять раз больше обычного решения, и он намеренно ниже собственного лимита опроса reCAPTCHA в SDK, равного 300 секундам, чтобы Cypress завалил действительно зависший тест, а не висел следом за клиентом, который всё ещё ждёт. Если вы предпочитаете, чтобы первым сдавался клиент, уменьшите recaptchaTimeout до значения меньше вашего таймаута задачи.
Или обойтись без SDK и использовать cy.request
API совместим с 2captcha, поэтому всю работу делают два вызова. Поскольку cy.request выполняется в Node, правила origin браузера здесь вообще не участвуют.
// 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, когда решатель остаётся на месте
Именно здесь набор, который проходит на вашем ноутбуке, падает при первом же пуше. У раннера GitHub Actions, у задания GitLab или у агента Jenkins есть свой собственный loopback-адрес, и на порту 8080 там никто не слушает. Local mode по определению работает только в пределах машины.
Ответ: Server mode. CapSkip слушает ваш сетевой или публичный IP вместо loopback, а конфиг берёт адрес из окружения.
// 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, поэтому запасной вариант выше нужен на всякий случай, для раннера, который стартует без них. Для машины с решателем рекомендуется статический публичный IP, а шаги описаны в разделе Настройки подключения. Это по-прежнему ваше оборудование и по-прежнему без тарификации: изменилось только то, где слушает процесс.
Частые ошибки и что они означают
| Симптом | Причина | Исправить |
|---|---|---|
| Задача solveRecaptcha не зарегистрирована | Зарегистрирована не в том блоке, либо конфиг не экспортирован | Регистрируйте внутри setupNodeEvents для ключа e2e |
| Истекло время ожидания вашей задачи: 60000 мс | taskTimeout всё ещё имеет значение по умолчанию | Поднимите его до 180000, глобально или для отдельного вызова |
| Задача вернула undefined | В обработчике нет оператора return | Возвращайте токен или null, когда возвращать нечего |
| Элемент невидим, поэтому Cypress не может печатать | Поле ответа представляет собой скрытую textarea | Записывайте значение через cy.document |
ERROR_GOOGLEKEY | sitekey оказался пустым или принадлежит виджету Turnstile | Выведите атрибут в лог перед решением; у Turnstile свой метод |
| NetworkException в каждом тесте | По этому хосту и порту никто не слушает | Local mode работает только через loopback; из CI используйте Server mode |
| Локально зелёные, в CI красные | Раннер не может достучаться до loopback вашей машины | Направьте CAPSKIP_HOST на доступный адрес |
Часто задаваемые вопросы
А может, просто отключить капчу в тестовом окружении?
Если виджет ваш, то да. Фича-флаг или тестовый sitekey в staging-сборке дешевле и быстрее решения капчи и сохраняют детерминированность набора. Решать капчу имеет смысл в трёх случаях: капча принадлежит кому-то другому, staging должен точно повторять продакшен или тестируется сам сценарий с проверкой. Демо-страницы капчи полезны для третьего случая, потому что вы можете направить спек на виджет, который ведёт себя как настоящий.
Работает ли это в компонентных тестах Cypress?
Не особенно. Компонентный тест монтирует компонент без реальной страницы и без сервера за ней, поэтому токену просто не с чем сверяться. Зарегистрируйте задачу под ключом component, если хотите, чтобы она была доступна, но работу с капчей держите в end-to-end спеках, где есть реальный запрос для отправки.
Дотянется ли до решателя прогон, записанный в Cypress Cloud?
Cypress Cloud записывает результаты, а не выполняет ваши тесты, так что вопрос на самом деле про ту машину, где запущен браузер. На вашем ноутбуке это Local mode. На хостинговом раннере нужен Server mode и маршрут до адреса решателя, а запись работает одинаково в обоих случаях.
Сколько решений может выдать параллельный прогон Cypress одновременно?
Столько, сколько у вас параллельно идёт спек-файлов. Каждый процесс Cypress держит своего клиента и ждёт свою задачу, поэтому на стороне клиента настраивать нечего. SDK начинает опрос с 250 миллисекунд и увеличивает интервал до потолка pollingInterval, что оставляет быстрое решение быстрым даже когда их несколько в работе. Поскольку работа идёт на вашем собственном оборудовании, добавление машин упирается в мощность, а не в счёт за решения.
Коротко
Положите клиента в cypress.config.js, выставьте его наружу как задачу, поднимите taskTimeout до 180000 и запишите токен в скрытое поле через cy.document. Это и есть вся интеграция, а ломается всё на двух правилах Cypress, которые лежат в её основе: код спека выполняется в браузере, и задача либо возвращает значение, либо падает.
Запуск решателя на собственном оборудовании позволяет просто повторить нестабильную проверку, а не закладывать её в бюджет, и этот довод работает везде, где вы используете распознавание капчи, в тестовом наборе или в продакшене. Параметры клиента подробно разбирает руководство по интеграции с Node.js, а браузерные детали, которые у Cypress общие с любым другим раннером, описывает руководство по Playwright.
