Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

playwright-turnstile

Test Cloudflare Turnstile in Playwright.

npm npm downloads CI License: MIT

Zero runtime dependencies. Talks to no service. Works with no account.

Why

Every Turnstile E2E suite reimplements the same four things, usually badly:

  • Flaky waits. waitForTimeout(3000) passes locally and fails in CI, because Turnstile's latency tracks the visitor's risk score and runner egress scores worse than your laptop.
  • Finding the sitekey. Implicit render puts it in data-sitekey. Explicit render passes it as a JS argument to turnstile.render(), where no selector will find it.
  • Widget vs. WAF interstitial. A widget sits in your form and yields a token. A /cdn-cgi/challenge-platform interstitial replaces the page and has no form to fill. Suites that conflate them wait forever for an element that was never coming.
  • The callback. Setting input.value is not enough — a form that enables its submit button from data-callback stays disabled, and your test fails one step later with a misleading error.

Install

npm i -D playwright-turnstile

Quickstart

import { test as base } from "@playwright/test";
import { turnstileFixture } from "playwright-turnstile/fixture";

const test = base.extend(turnstileFixture());

test("signs in through Turnstile", async ({ page, turnstile }) => {
  await page.goto("/login");
  await page.getByLabel("Email").fill("qa@example.com");

  await turnstile.waitForToken();          // no arbitrary sleep

  await page.getByRole("button", { name: "Sign in" }).click();
  await expect(page).toHaveURL(/dashboard/);
});

Use Cloudflare's official test sitekeys — no account needed

If you control the app under test, most suites should stop reading here.

Cloudflare publishes dummy keys that work on any domain including localhost, resolve instantly, cost nothing and never touch the network. Point your staging build at one and the widget stops being a problem.

Key Type Behaviour
1x00000000000000000000AA sitekey, visible Always passes
1x00000000000000000000BB sitekey, invisible Always passes
2x00000000000000000000AB sitekey, visible Always fails
2x00000000000000000000BB sitekey, invisible Always fails
3x00000000000000000000FF sitekey, visible Forces an interactive challenge
1x0000000000000000000000000000000AA secret Always passes validation
2x0000000000000000000000000000000AA secret Always fails validation
3x0000000000000000000000000000000AA secret Returns timeout-or-duplicate

Exported for convenience:

import { TEST_SITEKEYS, TEST_SECRETS } from "playwright-turnstile";

Swap both halves — a test secret rejects real tokens and a live secret rejects the dummy token, and a mismatched pair fails siteverify in a way that sends you hunting the wrong bug. Use alwaysBlocks deliberately to cover your error path, and forcesInteractive to check your layout survives a challenge.

If you cannot redeploy staging, rewrite the key in the browser instead:

await page.route("**/login", async (route) => {
  const res = await route.fetch();
  const body = (await res.text()).replace(/data-sitekey="[^"]*"/g, `data-sitekey="${TEST_SITEKEYS.alwaysPasses}"`);
  await route.fulfill({ response: res, body });
});

This only works if the server verifying the token also runs a test secret. Both halves have to agree.

Cloudflare's testing docs →

API

Every function takes a Playwright Page as its first argument.

Function Returns
detect(page) { sitekey, action?, cData?, responseField, explicit } or null
sitekeyOf(page) The sitekey, or null
tokenOf(page, field?) Current token, or ""
waitForToken(page, { timeout?, responseField? }) The token once populated
injectToken(page, token, { responseField?, callback? }) — sets value, fires events and the callback
isWafChallenge(page) true for a /cdn-cgi/challenge-platform interstitial
expectTurnstileSolved(page) The token, or throws explaining why not
solveAndInject(page, solver) Detect → solve via your adapter → inject

The turnstile fixture exposes the same surface as methods, bound to the page.

Solver adapters

For the cases test keys cannot cover — a staging environment whose Turnstile configuration you do not control, or synthetic monitoring that must exercise the real production path.

A solver is one function. Nothing here is tied to a vendor, and there is no default:

export type SolverAdapter = (input: {
  sitekey: string;
  url: string;
  action?: string;
  cData?: string;
}) => Promise<string>;   // resolves to the token

Write one against any provider. Here is a complete adapter for SolveGate:

const solveGate: SolverAdapter = async ({ sitekey, url, action }) => {
  const r = await fetch("https://api.solvegate.io/v1/solve", {
    method: "POST",
    headers: {
      authorization: `Bearer ${process.env.SOLVEGATE_KEY}`,
      "content-type": "application/json",
    },
    body: JSON.stringify({ gate: "turnstile", sitekey, url, action }),
  });
  if (!r.ok) throw new Error(`solve failed: ${r.status}`);
  return (await r.json()).token;
};

const test = base.extend(turnstileFixture({ solver: solveGate }));
// then, in a test:  await turnstile.solve();

That is the whole integration. Swap the URL and header for any other service and it works the same way.

Recipes

GitHub Actions — prefer test keys in CI; keep any solver key in secrets.

- run: npx playwright test
  env:
    TURNSTILE_SITEKEY: "1x00000000000000000000AA"
    TURNSTILE_SECRET: "1x0000000000000000000000000000000AA"

Assert the failure path — point staging at 2x00000000000000000000AB and check your app shows the right error rather than hanging.

Synthetic monitoringsolveAndInject against production on a schedule, then assert the post-login page renders.

See cloudflare-turnstile-e2e-example for a complete runnable pipeline.

Troubleshooting

waitForToken times out. Check isWafChallenge(page) first — if it is true you are on an interstitial and no token is coming. Otherwise the widget may have errored; Turnstile reports client errors through error-callback as numeric codes such as 300030.

Submit stays disabled after injecting. The page uses a callback you have not named. Pass it: injectToken(page, token, { callback: "onTurnstileSuccess" }).

Token rejected as timeout-or-duplicate. Tokens are single-use and valid for 300 seconds. Request one at the moment you submit, and never cache.

Works headed, fails headless. Expected — headless egress scores worse. Use test sitekeys in CI rather than fighting it.

Intended use

For testing and monitoring properties you own or are explicitly authorised to test. Do not point a solver at third-party sites in breach of their terms.

Contributing

Issues and PRs welcome. npm test runs the suite against real Chromium via page.setContent() — no network, no Cloudflare script, no account.

License

MIT

About

Playwright helper for Cloudflare Turnstile: find the widget, read its sitekey and action, wait for a token, inject one, and run suites against Cloudflare's official test sitekeys. Works with no account; pluggable solver adapter for staging sites you own.

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages