{"id":25458,"date":"2026-08-30T09:08:00","date_gmt":"2026-08-30T09:08:00","guid":{"rendered":"https:\/\/capskip.com\/?p=25458"},"modified":"2026-08-30T09:08:00","modified_gmt":"2026-08-30T09:08:00","slug":"webdriverio-captcha","status":"publish","type":"post","link":"https:\/\/capskip.com\/zh\/webdriverio-captcha\/","title":{"rendered":"\u5982\u4f55\u5728 WebdriverIO \u6d4b\u8bd5\u4e2d\u8bc6\u522b\u9a8c\u8bc1\u7801\uff08Node.js\uff09"},"content":{"rendered":"<p>A WebdriverIO captcha step is a Node.js call, not a browser one. You solve in the test process, hand the token to the page through browser.execute, and submit the form. Two things catch people out, and neither is about the solver. Put the solve inside the browser context and it has no way to reach your machine at all. Leave Mocha on its default thirty second timeout and the test dies halfway through a solve with a message that explains nothing. Here is the custom command, the one config change, and a spec that works.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">What you need<\/h2>\n<ul>\n<li>WebdriverIO 8 or newer with the Mocha framework. Everything below is async, because the old synchronous mode is gone.<\/li>\n<li>Node.js 18 or later, and the CapSkip package installed in the test project.<\/li>\n<li>A page that actually serves a challenge. A test sitekey that always passes will not exercise any of this.<\/li>\n<li>CapSkip running in Local mode if the tests run on your own machine, or in Server mode if they run on a CI runner or a grid. Both are described under <a href=\"https:\/\/capskip.com\/setup-guide\/#connection-settings\">connection settings<\/a>.<\/li>\n<\/ul>\n<div data-no-translation>\n<pre data-enlighter-language=\"bash\" class=\"EnlighterJSRAW\"># Install into the project that runs wdio, not into the browser image.\nnpm install capskip<\/pre>\n<\/div>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Solve in Node, not in the browser<\/h2>\n<p>This is the mistake worth getting out of the way first, because WebdriverIO makes it easy to make. The browser.execute command serialises your function, ships it to the browser, and runs it inside the page. There is no module loader in there, so the SDK is simply not available. Even if it were, the page is the thing you are testing, and handing it the address of your solver is not a thing you want to do.<\/p>\n<p>The stronger reason is routing. Once the browser is anywhere but your own machine, and on a grid or a cloud device provider it never is, the loopback address inside that browser belongs to the browser host. Your solver is not there. The Node process running wdio is the one that knows how to reach CapSkip, so the solve stays there and only the finished token crosses into the page.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 1: register a custom command<\/h2>\n<p>Rather than importing the SDK into every spec, add one command in the before hook of your config. It becomes available on the browser object in every test, and the address of the solver lives in exactly one place.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"js\" class=\"EnlighterJSRAW\">\/\/ npm install capskip\nconst { CapSkip } = require('capskip');\n\nexports.config = {\n  framework: 'mocha',\n\n  before: function () {\n    const solver = new CapSkip({\n      host: process.env.CAPSKIP_HOST || '127.0.0.1',\n      port: Number(process.env.CAPSKIP_PORT || 8080),\n    });\n\n    browser.addCommand('solveRecaptcha', async function (sitekey) {\n      const result = await solver.recaptcha(sitekey, await this.getUrl());\n      return result.code;   \/\/ the token, ready to inject\n    });\n  },\n};<\/pre>\n<\/div>\n<p>Inside addCommand the this value is the browser scope, which is why getUrl works there. That detail is doing real work: the API wants the URL of the page the widget sits on, and asking the browser for it means the spec never has to repeat a URL it already navigated to.<\/p>\n<p>Read the host from the environment instead of hardcoding it. The same suite then runs against a solver on your laptop and against a shared one from CI without a code change.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 2: inject the token and submit<\/h2>\n<p>Google puts the answer in a hidden textarea with the id g-recaptcha-response. It is hidden, so no amount of setValue will touch it. Set the value directly and then submit the form the way a person would.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"js\" class=\"EnlighterJSRAW\">const token = await browser.solveRecaptcha('YOUR_SITEKEY');\n\n\/\/ Put the token where the page already expects to find it.\nawait browser.execute((value) =&gt; {\n  document.getElementById('g-recaptcha-response').value = value;\n}, token);\n\nawait $('button[type=&quot;submit&quot;]').click();<\/pre>\n<\/div>\n<p>That covers the common case, which is a form that reads the textarea when you press submit. Some pages instead declare a data-callback on the widget and never look at the textarea at all. When that is what you are testing, call the callback from the same browser.execute block after setting the value, because filling a field nobody reads changes nothing.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Step 3: raise the Mocha timeout<\/h2>\n<p>WebdriverIO defaults Mocha to a 30000 millisecond timeout, which is generous for clicking things and much too short for solving a challenge. A test that would otherwise pass fails here, and the failure names Mocha rather than anything to do with the solve, which sends people looking in the wrong place for an afternoon.<\/p>\n<div data-no-translation>\n<pre data-enlighter-language=\"js\" class=\"EnlighterJSRAW\">exports.config = {\n  framework: 'mocha',\n  mochaOpts: {\n    \/\/ The 30000 default expires mid solve. Give it room.\n    timeout: 120000,\n  },\n};<\/pre>\n<\/div>\n<p>Set the two limits in the right order. The SDK gives up after recaptchaTimeout, which defaults to 300 seconds, and Mocha gives up after its own timeout. Keep the SDK below Mocha and you get a TimeoutException naming the solve. Keep it above and Mocha kills the test first, and all you learn is that something took too long. A solver timeout of 90 seconds under a Mocha timeout of 120000 milliseconds is a sane pair for a suite that has to stay quick.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Running it on CI or a grid<\/h2>\n<p>Work out which machine needs to reach the solver, because it is not the obvious one. The browser never talks to CapSkip. The process running wdio does. So on a GitHub Actions runner, a container, or a laptop driving a cloud browser, the caller is that runner, and pointing it at its own loopback address finds nothing.<\/p>\n<table>\n<thead>\n<tr>\n<th>Mode<\/th>\n<th>Listens on<\/th>\n<th>Use it when<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Local<\/td>\n<td>127.0.0.1, that device only<\/td>\n<td>You run wdio on the same machine as the solver<\/td>\n<\/tr>\n<tr>\n<td>Server<\/td>\n<td>Your network address or public IP<\/td>\n<td>CI runners, containers, a shared suite, a grid<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p>Server mode covers everything in the second row. Switch the listen address in the app, set CAPSKIP_HOST on the runner, and every job shares one solver. A static public IP is worth having when the callers sit outside your network. None of this changes what the product is: it is still your hardware and still unmetered, so moving off the loopback address moves where it runs and nothing else. CapSkip is a Windows application, so in practice that is one Windows box your runners call into.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">The full spec<\/h2>\n<div data-no-translation>\n<pre data-enlighter-language=\"js\" class=\"EnlighterJSRAW\">describe('protected signup form', () =&gt; {\n  it('submits with a solved challenge', async () =&gt; {\n    await browser.url('https:\/\/example.com\/page-with-recaptcha');\n\n    await $('#email').setValue('test@example.com');\n\n    \/\/ Solve here, submit two lines later. The token is short lived.\n    const token = await browser.solveRecaptcha('YOUR_SITEKEY');\n\n    await browser.execute((value) =&gt; {\n      document.getElementById('g-recaptcha-response').value = value;\n    }, token);\n\n    await $('button[type=&quot;submit&quot;]').click();\n\n    await expect($('.signup-success')).toBeDisplayed();\n  });\n});<\/pre>\n<\/div>\n<p>Notice how little of that is about CAPTCHA. Three lines carry the whole integration, and the rest is the test you were going to write anyway. Keep the solve and the submit in the same test body so the token is seconds old when the form reads it.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">Common errors<\/h2>\n<table>\n<thead>\n<tr>\n<th>What you see<\/th>\n<th>Cause<\/th>\n<th>Fix<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Mocha timeout of 30000ms exceeded<\/td>\n<td>The solve outlasted the default<\/td>\n<td>Raise mochaOpts.timeout and lower recaptchaTimeout under it<\/td>\n<\/tr>\n<tr>\n<td>solver is not defined inside browser.execute<\/td>\n<td>The function ran in the page, not in Node<\/td>\n<td>Solve before the execute call and pass only the token in<\/td>\n<\/tr>\n<tr>\n<td>NetworkException from CI, fine locally<\/td>\n<td>The runner cannot reach the solver<\/td>\n<td>Switch to Server mode and set CAPSKIP_HOST on the runner<\/td>\n<\/tr>\n<tr>\n<td>Form rejects a token that solved cleanly<\/td>\n<td>The page uses a callback and ignores the textarea<\/td>\n<td>Call the widget callback after setting the value<\/td>\n<\/tr>\n<tr>\n<td>ERROR_GOOGLEKEY<\/td>\n<td>A Turnstile sitekey went to the reCAPTCHA method<\/td>\n<td>Use the turnstile method for Turnstile widgets<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p>The full list of codes and what triggers each one is in <a href=\"https:\/\/capskip.com\/api-docs\/\">the CapSkip API documentation<\/a>.<\/p>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">FAQ<\/h2>\n<details style=\"border:1px solid #e2e5ee;border-radius:10px;padding:14px 18px;margin:0 0 12px;\">\n<summary style=\"cursor:pointer;\">\n<h3 style=\"font-size:1.15rem;line-height:1.4;display:inline;margin:0;\">Can I call the solver inside browser.execute?<\/h3>\n<\/summary>\n<p style=\"margin:12px 0 0;\">No, and it fails for two separate reasons. The function you pass is serialised and executed in the page, where there is no module loader and no SDK. Even with one, the browser is often on another host entirely, so the address you would be dialling is not yours. Solve in the Node process and pass the finished token in as an argument.<\/p>\n<\/details>\n<details style=\"border:1px solid #e2e5ee;border-radius:10px;padding:14px 18px;margin:0 0 12px;\">\n<summary style=\"cursor:pointer;\">\n<h3 style=\"font-size:1.15rem;line-height:1.4;display:inline;margin:0;\">Does this work with a remote grid or a cloud browser?<\/h3>\n<\/summary>\n<p style=\"margin:12px 0 0;\">Yes, and that setup is the reason the solve belongs in Node. Your test process runs locally or on a runner and reaches the solver directly, while the browser sits somewhere else and only ever receives a token. Run CapSkip in Server mode so whichever machine executes the suite can reach it, and nothing about the spec changes.<\/p>\n<\/details>\n<details style=\"border:1px solid #e2e5ee;border-radius:10px;padding:14px 18px;margin:0 0 12px;\">\n<summary style=\"cursor:pointer;\">\n<h3 style=\"font-size:1.15rem;line-height:1.4;display:inline;margin:0;\">Should I solve once in a before hook and reuse the token?<\/h3>\n<\/summary>\n<p style=\"margin:12px 0 0;\">No. A reCAPTCHA token is single use and stays valid for roughly two minutes, so the second test to use it gets rejected and the first one to run after a slow spec gets rejected too. Solve inside each test that needs one. A shared solver handles the extra calls without a per solve cost, so there is nothing to save by hoarding tokens.<\/p>\n<\/details>\n<details style=\"border:1px solid #e2e5ee;border-radius:10px;padding:14px 18px;margin:0 0 12px;\">\n<summary style=\"cursor:pointer;\">\n<h3 style=\"font-size:1.15rem;line-height:1.4;display:inline;margin:0;\">I use Cucumber or Jasmine instead of Mocha. What changes?<\/h3>\n<\/summary>\n<p style=\"margin:12px 0 0;\">Only the name of the timeout setting. The custom command, the injection and the Server mode question are all identical. Raise cucumberOpts.timeout or jasmineOpts.defaultTimeoutInterval instead of mochaOpts.timeout, and keep the solver timeout underneath whichever one you set.<\/p>\n<\/details>\n<h2 style=\"font-size:1.6rem;line-height:1.35;\">The short version<\/h2>\n<p>Register one custom command in the before hook, raise the framework timeout above the solve, and inject the token with browser.execute rather than trying to type it. For the client surface see <a href=\"https:\/\/capskip.com\/nodejs-captcha-solver\/\">the Node.js CAPTCHA solver page<\/a>, for the WebDriver side see <a href=\"https:\/\/capskip.com\/selenium-captcha-solver\/\">the Selenium CAPTCHA solver page<\/a>, and for what the token actually is see <a href=\"https:\/\/capskip.com\/recaptcha-v2-solver\/\">the reCAPTCHA v2 solver page<\/a>. Test suites are where metered pricing hurts most, because a suite that runs on every pull request solves the same form hundreds of times a week. That is what changes with ownership: <a href=\"https:\/\/capskip.com\/\">unlimited captcha solver<\/a> capacity on hardware you already have costs the same whether CI runs twice a day or twice an hour.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>\u8bc6\u522b\u5c5e\u4e8e\u6d4b\u8bd5\u8fdb\u7a0b\uff0c\u800c\u4e0d\u662f browser.execute \u5185\u90e8\u3002\u4e00\u4e2a\u81ea\u5b9a\u4e49\u547d\u4ee4\uff0c\u4e00\u5904 config \u6539\u52a8\uff0c\u518d\u52a0\u4e00\u4efd\u80fd\u901a\u8fc7\u53d7\u4fdd\u62a4\u8868\u5355\u7684 spec\u3002<\/p>","protected":false},"author":1,"featured_media":25457,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"WebdriverIO CAPTCHA: Solve It in a Custom Command | CapSkip","rank_math_description":"A WebdriverIO captcha step runs in Node, not the browser. Register one custom command, raise the Mocha timeout, inject the token. Full working spec inside.","rank_math_focus_keyword":"webdriverio captcha","footnotes":""},"categories":[70],"tags":[],"class_list":["post-25458","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-captcha"],"_links":{"self":[{"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/posts\/25458","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/comments?post=25458"}],"version-history":[{"count":1,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/posts\/25458\/revisions"}],"predecessor-version":[{"id":25461,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/posts\/25458\/revisions\/25461"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/media\/25457"}],"wp:attachment":[{"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/media?parent=25458"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/categories?post=25458"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/capskip.com\/zh\/wp-json\/wp\/v2\/tags?post=25458"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}