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

cypress captcha - How to Solve CAPTCHA in Cypress Tests With 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. На задачу он не влияет. Здесь важны три значения, и все они разные:

ПараметрПо умолчаниюПрименяется к
taskTimeout60000 mscy.task, то есть само решение капчи
responseTimeout30000 mscy.request, то есть прямой вызов API
defaultCommandTimeout4000 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_GOOGLEKEYsitekey оказался пустым или принадлежит виджету 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.