From 379f629b657c5457fa8cc0333df43b0d83d67464 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Ad=C3=A1mek?= Date: Wed, 12 Aug 2026 11:59:13 +0200 Subject: [PATCH] fix(playwright): update handleCloudflareChallenge for new Cloudflare challenge markup (#4019) `handleCloudflareChallenge` stopped detecting the Cloudflare challenge page again. The current challenge template no longer has a `.diagnostic-wrapper` element in the footer (the ray ID now sits under `.footer > .footer-inner > .footer-wrapper > div > .ray-id`), so the default `isChallengeCallback` selector never matches. The helper returned without doing anything and the crawler failed on the blocked 403, which is how the `camoufox-cloudflare` e2e test against grabjobs.co caught it. This is the same drift as #3717, one iteration later. The detection selector now relies only on the stable outer classes (`.footer .footer-inner .ray-id`), which matches both the old and the current markup. I verified it against the live challenge page served by grabjobs.co (a 403 with `cf-mitigated: challenge`, rendered in a headless browser). The Turnstile widget selector used for the checkbox click still matches, so it stays as is. The e2e actor's own `isBlocked` check used the same dead selector and would report a false green (same situation as in #3717), so it is aligned too. Added unit tests for the challenge detection: current markup, old markup, and a negative case. --- .../src/internals/utils/playwright-utils.ts | 6 +- test/core/playwright_utils.test.ts | 66 +++++++++++++++++++ test/e2e/camoufox-cloudflare/actor/main.js | 2 +- 3 files changed, 70 insertions(+), 4 deletions(-) diff --git a/packages/playwright-crawler/src/internals/utils/playwright-utils.ts b/packages/playwright-crawler/src/internals/utils/playwright-utils.ts index c7295524b6b2..5fdb3bb89c81 100644 --- a/packages/playwright-crawler/src/internals/utils/playwright-utils.ts +++ b/packages/playwright-crawler/src/internals/utils/playwright-utils.ts @@ -755,9 +755,9 @@ async function handleCloudflareChallenge( options.isChallengeCallback ??= async () => { return await page.evaluate(async () => { - // Cloudflare nests the ray ID under varying wrapper elements, so we match by descendants - // instead of a direct-child chain (e.g. a `.footer-wrapper` was inserted in between). - return !!document.querySelector('.footer .footer-inner .diagnostic-wrapper .ray-id'); + // Cloudflare keeps reshuffling the wrapper elements between `.footer-inner` and `.ray-id`, + // so only the stable outer classes are matched. + return !!document.querySelector('.footer .footer-inner .ray-id'); }); }; diff --git a/test/core/playwright_utils.test.ts b/test/core/playwright_utils.test.ts index b765e5462739..83ab2a8a5e55 100644 --- a/test/core/playwright_utils.test.ts +++ b/test/core/playwright_utils.test.ts @@ -433,4 +433,70 @@ describe('playwrightUtils', () => { await browser.close(); } }); + + describe('handleCloudflareChallenge() challenge detection', () => { + // not a named export, only reachable via the internal `playwrightUtils` object + const { handleCloudflareChallenge } = playwrightUtils.playwrightUtils; + let browser: Browser; + let page: Page; + + // no clicking or waiting, so an unsolved challenge fails fast instead of spending ~20s in the click loop + const fastOptions = () => ({ + sleepSecs: 0, + preChallengeSleepSecs: 0, + clickCallback: async () => {}, + }); + + // markup as rendered by the current (2026) Cloudflare challenge template + const currentChallengeBody = ` +
+

example.com

+

Performing security verification

+
+
+ `; + + // the older template, with the `.diagnostic-wrapper` element + const oldChallengeBody = ` +
+

example.com

+
+
+ `; + + beforeAll(async () => { + browser = await chromium.launch({ headless: true }); + page = await browser.newPage(); + }); + + afterAll(async () => { + await browser.close(); + }); + + test('detects the current challenge markup', async () => { + await page.setContent(`${currentChallengeBody}`); + await expect( + handleCloudflareChallenge(page, 'https://example.com', undefined, fastOptions()), + ).rejects.toThrow(/Blocked by Cloudflare/); + }); + + test('detects the old challenge markup', async () => { + await page.setContent(`${oldChallengeBody}`); + await expect( + handleCloudflareChallenge(page, 'https://example.com', undefined, fastOptions()), + ).rejects.toThrow(/Blocked by Cloudflare/); + }); + + test('resolves without detection on a regular page', async () => { + await page.setContent('

Welcome

'); + await expect( + handleCloudflareChallenge(page, 'https://example.com', undefined, fastOptions()), + ).resolves.toBeUndefined(); + }); + }); }); diff --git a/test/e2e/camoufox-cloudflare/actor/main.js b/test/e2e/camoufox-cloudflare/actor/main.js index 4a55d2e8cbb8..3667ebd6ae82 100644 --- a/test/e2e/camoufox-cloudflare/actor/main.js +++ b/test/e2e/camoufox-cloudflare/actor/main.js @@ -52,7 +52,7 @@ await Actor.main(async () => { async requestHandler({ page, parseWithCheerio }) { const isBlocked = await page .evaluate(async () => { - return !!document.querySelector('.footer .footer-inner .diagnostic-wrapper .ray-id'); + return !!document.querySelector('.footer .footer-inner .ray-id'); }) .catch(() => false); const $ = await parseWithCheerio();