Как решать капчу в Nightwatch.js (очередь команд)

nightwatch captcha - How to Solve CAPTCHAs in Nightwatch.js (Command Queue)

Решение капчи в Nightwatch должно выполняться внутри очереди команд, а не рядом с ней. Nightwatch не выполняет команды браузера там, где вы их написали. Он ставит их в очередь и разбирает её уже после того, как тестовая функция завершилась, поэтому вызов решения капчи, написанный между двумя командами браузера, срабатывает сразу же: страница ещё не загружена, а виджета ещё нет. Лечится это через browser.perform плюс один глобальный параметр, который придётся поднять, потому что решение капчи занимает больше десяти секунд, отведённых Nightwatch на асинхронный колбэк.

Что понадобится

  • Nightwatch 3 с рабочим драйвером: либо локальный chromedriver, либо удалённый эндпоинт WebDriver.
  • Запущенный CapSkip на машине с Windows и Node-клиент, установленный в том же проекте, что и ваши тесты.
  • Sitekey и URL страницы. Считывайте sitekey прямо с виджета, а не прописывайте его в коде: на стейджинге и в проде он редко совпадает.
  • Режим Server во всех случаях, когда тесты выполняются не на той машине, где работает решатель, а это любой раннер CI. Это одна настройка в разделе настроек подключения.
# npm install capskip
npm install --save-dev nightwatch
npm install capskip

Шаг 1: почему обычный вызов решения капчи срабатывает слишком рано

Каждая команда браузера в тесте Nightwatch представляет собой инструкцию, добавленную в очередь. Тестовая функция сначала выполняется сверху вниз и строит эту очередь, и только потом Nightwatch начинает её исполнять. Обычный JavaScript между командами в очередь не попадает, поэтому он работает уже на этапе построения. Вот и вся проблема, уместившаяся в одно предложение.

// WRONG. The solve starts while the queue is still being
// built, so it runs before browser.url() has navigated.
module.exports = {
  "signup form": function (browser) {
    browser.url("https://example.com/page-with-recaptcha");

    solver.recaptcha(sitekey, pageUrl).then((r) => {
      // fires first, against a page that is not open yet
    });

    browser.click("#submit");
  },
};

Симптом сбивает с толку, потому что ошибки не возникает. Капча решается, тест иногда проходит, а токен относится к загрузке страницы, которой не было. При этом у Nightwatch есть документированный способ поместить свой код в очередь: browser.perform, колбэк которого описан как функция, выполняемая в составе очереди.

// RIGHT. perform() queues the callback, so it runs in
// sequence with the commands either side of it.
browser.url("https://example.com/page-with-recaptcha");

browser.perform(async function () {
  const { code } = await solver.recaptcha(sitekey, pageUrl);
  return code;
});

browser.click("#submit");

Второй вариант: асинхронный тест. Если объявить тестовую функцию через async, команды API начинают возвращать промис, и await у каждой из них сохраняет порядок без perform. Работают оба подхода. Ошибка в том, чтобы их смешивать: внешний промис с await среди команд браузера без await возвращает вас к первому примеру.

Шаг 2: поднимите asyncHookTimeout, иначе решение капчи оборвётся на десятой секунде

Именно на этом обычно теряют полдня. Асинхронное выполнение внутри perform ограничено глобальным параметром asyncHookTimeout, а его значение по умолчанию равно 10000 миллисекунд. Решение reCAPTCHA регулярно занимает от пятнадцати до сорока пяти секунд. Поэтому колбэк прерывается, пока решатель ещё работает, и в ошибке речь идёт о тайм-ауте, а не о капче.

Поднимите значение в конфиге Nightwatch: глобально или отдельно для каждого окружения.

// nightwatch.conf.js
module.exports = {
  globals: {
    // Default is 10000, which is shorter than most solves.
    asyncHookTimeout: 120000,

    // waitFor commands default to 5000. The widget is not
    // the slow part, but give it room on a cold CI runner.
    waitForConditionTimeout: 15000,
  },
};

На стороне клиента задайте значение ниже, чем asyncHookTimeout, чтобы было понятно, какой потолок действует на самом деле. Node-клиент опрашивает результат по собственному графику: он стартует с 250 миллисекунд и постепенно увеличивает паузу до pollingInterval, поэтому обычно возвращает ответ быстрее, чем цикл, написанный вручную.

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

const solver = new CapSkip({
  host: process.env.CAPSKIP_HOST || "127.0.0.1",
  port: 8080,
  // Seconds. Keep this under the 120s asyncHookTimeout above.
  recaptchaTimeout: 90,
  pollingInterval: 3,
});

Шаг 3: подставляйте токен через execute, а не через setValue

Поле ответа, которое читает reCAPTCHA, представляет собой скрытую textarea. WebDriver отказывается взаимодействовать с элементами, которые считает недоступными для взаимодействия, поэтому setValue на нём падает с ошибкой element not interactable. Обычное решение состоит в подстановке значения через саму страницу, и в Nightwatch для этого есть execute, который принимает тело функции, массив аргументов и необязательный колбэк.

browser.perform(async function () {
  const { code } = await solver.recaptcha(sitekey, pageUrl);

  // The function is serialized and run in the page, so it
  // cannot close over anything. Pass values in the array.
  await browser.execute(
    function (token) {
      document.getElementById("g-recaptcha-response").value = token;
    },
    [code]
  );
});

Если форма вызывает колбэк по завершении, а не читает поле в момент отправки, вызовите его в том же скрипте. Невидимая reCAPTCHA почти всегда работает именно так, а варианты виджета меняют только передаваемые опции: invisible или enterprise со значением 1, version со значением v3 и action, либо turnstile и geetest вместо recaptcha. Полный набор параметров описан на странице сервиса распознавания капчи для Node.js.

Шаг 4: тест целиком

Клиент создаётся один раз на уровне модуля, вне теста, поэтому он не пересобирается для каждого тест-кейса. Sitekey читается со страницы, а не прописан в коде, и именно поэтому один и тот же тест работает и на стейджинге, и в проде.

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

const solver = new CapSkip({
  host: process.env.CAPSKIP_HOST || "127.0.0.1",
  port: 8080,
  recaptchaTimeout: 90,
});

const PAGE = "https://example.com/page-with-recaptcha";

describe("signup", function () {
  it("submits through the reCAPTCHA", async function (browser) {
    await browser.url(PAGE);
    await browser.waitForElementPresent(".g-recaptcha", 15000);

    // Read the sitekey off the widget that is actually there.
    const sitekey = await browser.getAttribute(
      ".g-recaptcha",
      "data-sitekey"
    );

    await browser.perform(async function () {
      const { code } = await solver.recaptcha(sitekey.value, PAGE);
      await browser.execute(
        function (token) {
          document.getElementById("g-recaptcha-response").value = token;
        },
        [code]
      );
    });

    // Submit straight after. The token is not a long-lived value.
    await browser.click("#submit");
    await browser.assert.textContains(".result", "Thanks");
  });
});

Решайте капчу как можно позже и сразу отправляйте форму. Токен reCAPTCHA живёт около двух минут, а набор тестов, который решает капчу в before-хуке и отправляет форму тремя тест-кейсами позже, отправляет уже просроченный токен. Про этот интервал стоит прочитать один раз: сколько живёт токен reCAPTCHA.

Шаг 5: запуск на CI и какой режим подключения для этого нужен

Тесты Nightwatch редко остаются на той машине, где они написаны. Как только они переезжают на раннер CI, в контейнер или на узел Selenium Grid, адрес обратной петли перестаёт означать ваш собственный компьютер. Режима подключения два. Local привязывается к 127.0.0.1 и отвечает только этому устройству. Server привязывается к вашему сетевому или публичному IP, поэтому раннер, контейнер или узел grid обращаются к той же машине с Windows по API. Оба режима находятся в разделе Настройки подключения, и режим Server меняет только то, какой адрес слушает решатель. Это по-прежнему ваше железо, и платы за каждое решение по-прежнему нет.

Где выполняется тестовый процессКакой режим подключения
Ваша собственная машина, локальный chromedriverРежим Local. 127.0.0.1 действительно верен
Сборочный агент в вашей сетиServer mode с локальным адресом решателя
Облачный раннер CIServer mode со статическим публичным IP и правилом брандмауэра
Контейнер, решатель на хостеРежим Server. Loopback внутри контейнера ведёт в сам контейнер

Одно различие, на котором спотыкаются в Grid: вызов решателя делает тестовый процесс, а не браузер. Поэтому важен тот адрес, до которого дотягивается процесс Node, а сетевые настройки узла grid к этому отношения не имеют. Это разделение подробно разобрано в руководстве по Selenium Grid, а уровень драйвера, лежащий под обоими вариантами, описан на странице сервиса распознавания капчи для Selenium.

Частые ошибки и что они означают

Что вы видитеПричинаИсправить
Решение капчи логируется до перехода на страницуВызов находится вне очереди, поэтому выполняется во время её построенияОберните его в browser.perform
Тайм-аут почти ровно на десятой секундеasyncHookTimeout остался со значением по умолчанию 10000Поднимите его в globals выше тайм-аута клиента
Ошибка element not interactable на поле ответаЭто скрытая textarea, и WebDriver не станет в неё печататьЗадайте значение через browser.execute
Токен отклонён, хотя тест прошёлОн истёк между решением и отправкойРешайте капчу непосредственно перед отправкой, а не в хуке
NetworkException после переезда набора тестов на CIРешателя нет на раннереРежим Server и заданный CAPSKIP_HOST на раннере
ERROR_GOOGLEKEY внутри ApiExceptionSitekey взят не от того виджета или из URL iframeСчитывайте data-sitekey с того элемента, который решаете
CAPCHA_NOT_READY при самописном опросеРезультат прочитали до того, как он был готовДоверьте опрос клиенту. Он сам увеличивает интервал

Последний ответ пишется именно так, как выглядит, и пропущенная буква не является опечаткой с нашей стороны: API действительно возвращает его в таком виде. Полностью это объясняется в руководстве по CAPCHA_NOT_READY.

FAQ

Нужен ли для этого плагин Nightwatch?

Нет. Решатель подключается как обычный Node-пакет, который вы подключаете через require в тестовом файле, поэтому регистрировать пользовательскую команду не нужно и в массив plugins добавлять нечего. Если всё же захочется её написать, оборачивать стоит только пару perform и execute: это примерно восемь строк, которые избавят от повторов в разных тестах.

Можно ли решить капчу один раз в глобальном before-хуке и переиспользовать токен?

Нет, и сразу по двум причинам. Токен истекает примерно за две минуты, а набор тестов любого размера живёт дольше. Кроме того, токен привязан к той загрузке страницы, которая его породила, поэтому второму тест-кейсу, который снова открывает страницу, нужен свой. Решайте капчу в каждом тест-кейсе и как можно позже внутри него. Платы за каждое решение нет, так что это стоит вам только нескольких секунд реального времени.

Мои тесты идут параллельно. Меняет ли это что-нибудь?

Только арифметику. У каждого воркера своя очередь и свой вызов решения, поэтому четыре воркера означают четыре одновременных решения. Координировать ничего не нужно, и ничто не выстраивается в очередь за общим балансом, потому что потолок задаёт машина с CapSkip, а не количество кредитов. Немного поднимите asyncHookTimeout, если в неудачный день машина обрабатывает четыре запроса разом.

Чем это отличается от того же самого в WebdriverIO?

WebdriverIO выполняет свои команды там, где вы их написали, поэтому вызов решения капчи между двумя из них оказывается ровно на нужном месте без дополнительных усилий. Nightwatch ставит команды в очередь, и именно поэтому существует perform и поэтому ему здесь отведён целый раздел. Всё, что происходит после получения токена, в обоих случаях одинаково. Полный разбор второго варианта находится в руководстве по WebdriverIO.

Коротко

Помещайте решение капчи внутрь browser.perform, потому что всё, что находится вне очереди, выполняется во время её построения, а не тогда, когда вы задумали. Поднимите asyncHookTimeout выше собственного тайм-аута клиента, потому что значение по умолчанию 10000 короче одного решения. Подставляйте токен через execute, потому что поле ответа скрыто. Отправляйте форму сразу после решения. Задавайте CAPSKIP_HOST из переменных окружения и запускайте CapSkip в режиме Server везде, где тесты работают не на той машине, где стоит решатель.

Что стоит знать, прежде чем добавлять решение капчи в каждый тест набора: CapSkip работает как локальный сервис распознавания капчи, поэтому прогон тестов с двумястами решениями стоит ровно столько же, сколько прогон с одним.