حالات الاستخدام

التعامل مع اختبار CAPTCHA في اختبار التكامل المستمر

عندما يصطدم اختبار E2E بصفحة محمية بـ CAPTCHA داخل خط CI/CD، يتوقف المسار في انتظار تدخل بشري لن يأتي أبداً، فيسقط الاختبار في كل تشغيل. الحل المباشر: خزّن مفتاح CaptchaAI API كسرّ داخل بيئة CI، ودَع مجموعة الاختبار تطلب رمز الحل برمجياً وتحقنه في النموذج ثم تتابع تنفيذها دون توقف. النتيجة اختبارات انحدار كاملة تمرّ على مسارات تسجيل الدخول والنماذج المحمية تلقائياً، بالجودة نفسها التي يتوقعها مهندس ضمان الجودة من بقية المجموعة.


لماذا تسقط اختبارات CI عند أول صفحة CAPTCHA

خطوط CI/CD مصمَّمة لتعمل من دون يد بشرية على الإطلاق، بينما وُضِع اختبار CAPTCHA أصلاً ليطلب تفاعلاً بشرياً. هذا التعارض هو جوهر المشكلة: أي سيناريو E2E يمرّ بنموذج تسجيل دخول أو نموذج تواصل محمي سيتجمّد عند الويدجت، وينتهي بمهلة انتهاء أو فشل تأكيد.

الحلول الالتفافية الشائعة كلها هشّة. تعطيل CAPTCHA في بيئة staging يعني أنك تختبر مساراً مختلفاً عمّا يراه المستخدم في الإنتاج. وإدراج مفاتيح اختبار مكشوفة يترك ثغرة قد تتسرب. الأسلوب الأنظف أن تُبقي الصفحة على حالتها الحقيقية، وتضيف إلى المجموعة خدمة حل تُرجِع رمزاً صالحاً أثناء التشغيل: يُخزَّن مفتاح CaptchaAI API كسرّ ضمن CI، فتحصل الاختبارات على الرمز وتُكمل السيناريو كما لو أن مستخدماً حقيقياً اجتاز التحقق.

هذا الدليل موجَّه لمثالين شائعين في السوق: reCAPTCHA v2 على شاشة تسجيل الدخول، وCloudflare Turnstile على نموذج التواصل — وكلاهما مدعوم بشكل كامل في CaptchaAI.


كيف يتدفق الحل داخل المسار

قبل كتابة أي سطر، تخيّل مسار البيانات: يبدأ التشغيل من دفعة Git، يلتقطه مشغّل CI الذي يفتح Chrome في وضع headless، تصل الاختبارات إلى الصفحة المحمية فتستدعي CaptchaAI للحصول على الرمز، ثم يُحقن الرمز ويكتمل السيناريو ويُرفع تقرير الاختبار.

┌──────────────┐     ┌──────────────┐     ┌────────────┐     ┌──────────────┐
│ Git Push     │────▶│ CI Runner    │────▶│ E2E Tests  │────▶│ Test Report  │
│              │     │ (headless    │     │ + CAPTCHA  │     │              │
│              │     │  Chrome)     │     │ solving    │     │              │
└──────────────┘     └──────────────┘     └────────────┘     └──────────────┘
                                                │
                                                ▼
                                         ┌────────────┐
                                         │ CaptchaAI  │
                                         │ API        │
                                         └────────────┘

نقطة الاتصال الوحيدة بين اختباراتك والخدمة هي استدعاء واحد يُرسِل بيانات الويدجت ويستطلع النتيجة. نُغلِّف هذا الاستدعاء في مساعد صغير حتى تبقى ملفات الاختبار نظيفة.


بناء مساعد حل CAPTCHA للـ CI

المساعد التالي يقرأ المفتاح من متغيّر البيئة CAPTCHAAI_API_KEY، يُرسِل المهمة إلى نقطة النهاية in.php، ثم يستطلع res.php حتى يجهز الرمز أو تنتهي المهلة. صُمِّم خصيصاً لبيئة CI: يفشل بوضوح إذا كان المفتاح غير مضبوط، ويرفع خطأ مهلة صريحاً بدل التعليق إلى ما لا نهاية.

import os
import time
import requests


class CICaptchaSolver:
    """CAPTCHA solver designed for CI environments."""
    BASE = "https://ocr.captchaai.com"

    def __init__(self):
        self.api_key = os.environ.get("CAPTCHAAI_API_KEY")
        if not self.api_key:
            raise EnvironmentError("CAPTCHAAI_API_KEY not set")

    def solve(self, params, initial_wait=10, timeout=120):
        params["key"] = self.api_key
        params["json"] = 1
        resp = requests.post(f"{self.BASE}/in.php", data=params).json()
        if resp["status"] != 1:
            raise Exception(f"CAPTCHA submit failed: {resp['request']}")

        task_id = resp["request"]
        time.sleep(initial_wait)
        deadline = time.time() + timeout

        while time.time() < deadline:
            result = requests.get(
                f"{self.BASE}/res.php",
                params={"key": self.api_key, "action": "get", "id": task_id, "json": 1},
            ).json()
            if result["request"] == "CAPCHA_NOT_READY":
                time.sleep(5)
                continue
            if result["status"] == 1:
                return result["request"]
            raise Exception(f"CAPTCHA solve failed: {result['request']}")

        raise TimeoutError("CAPTCHA solve timed out in CI")

    def solve_recaptcha(self, sitekey, pageurl):
        return self.solve({
            "method": "userrecaptcha",
            "googlekey": sitekey,
            "pageurl": pageurl,
        })

    def solve_turnstile(self, sitekey, pageurl):
        return self.solve({
            "method": "turnstile",
            "sitekey": sitekey,
            "pageurl": pageurl,
        })

لاحظ أن الرمز الناتج عن reCAPTCHA يُحقن لاحقاً في الحقل g-recaptcha-response، بينما يذهب رمز Turnstile إلى الحقل cf-turnstile-response — لكل نوع حقله الخاص، ولا يجوز الخلط بينهما.


الدمج مع pytest

نربط المساعد بمجموعة الاختبار عبر تجهيزتين (fixtures): واحدة تُنشئ الحلّال مرة واحدة لكل جلسة، وأخرى تفتح متصفح Chrome بلا واجهة لكل اختبار على حدة. أعلام --no-sandbox و--disable-dev-shm-usage ضرورية لتشغيل Chrome بثبات داخل حاويات CI.

conftest.py

import pytest
from selenium import webdriver
from selenium.webdriver.chrome.options import Options


@pytest.fixture(scope="session")
def captcha_solver():
    return CICaptchaSolver()


@pytest.fixture(scope="function")
def browser():
    options = Options()
    options.add_argument("--headless")
    options.add_argument("--no-sandbox")
    options.add_argument("--disable-dev-shm-usage")
    options.add_argument("--disable-gpu")
    driver = webdriver.Chrome(options=options)
    driver.set_window_size(1920, 1080)
    yield driver
    driver.quit()

ملف الاختبار

يغطّي هذا الملف سيناريوهين واقعيين: تسجيل دخول محمي بـ reCAPTCHA v2 (بحالتَي نجاح وكلمة مرور خاطئة)، ونموذج تواصل محمي بـ Cloudflare Turnstile. في كل حالة يُطلب الرمز من CaptchaAI، ثم يُحقن في الحقل المناسب عبر execute_script، ثم يُرسَل النموذج ويُتحقق من النتيجة.

import time
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC


class TestLoginFlow:
    SITEKEY = "6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-"
    LOGIN_URL = "https://staging.example.com/login"

    def test_login_with_captcha(self, browser, captcha_solver):
        browser.get(self.LOGIN_URL)

        # Fill credentials
        browser.find_element(By.ID, "username").send_keys("testuser")
        browser.find_element(By.ID, "password").send_keys("testpass123")

        # Solve CAPTCHA
        token = captcha_solver.solve_recaptcha(self.SITEKEY, self.LOGIN_URL)
        browser.execute_script(
            f'document.querySelector("[name=g-recaptcha-response]").value = "{token}";'
        )

        # Submit
        browser.find_element(By.ID, "login-btn").click()
        time.sleep(3)

        # Verify login success
        assert "dashboard" in browser.current_url.lower()

    def test_login_wrong_password(self, browser, captcha_solver):
        browser.get(self.LOGIN_URL)
        browser.find_element(By.ID, "username").send_keys("testuser")
        browser.find_element(By.ID, "password").send_keys("wrongpass")

        token = captcha_solver.solve_recaptcha(self.SITEKEY, self.LOGIN_URL)
        browser.execute_script(
            f'document.querySelector("[name=g-recaptcha-response]").value = "{token}";'
        )

        browser.find_element(By.ID, "login-btn").click()
        time.sleep(3)

        error = browser.find_element(By.CSS_SELECTOR, ".error-message")
        assert error.is_displayed()


class TestContactForm:
    SITEKEY = "0x4AAAA..."
    FORM_URL = "https://staging.example.com/contact"

    def test_contact_form_submission(self, browser, captcha_solver):
        browser.get(self.FORM_URL)

        browser.find_element(By.ID, "name").send_keys("CI Test")
        browser.find_element(By.ID, "email").send_keys("ci@test.com")
        browser.find_element(By.ID, "message").send_keys("Automated CI test")

        token = captcha_solver.solve_turnstile(self.SITEKEY, self.FORM_URL)
        browser.execute_script(
            f'document.querySelector("[name=cf-turnstile-response]").value = "{token}";'
        )

        browser.find_element(By.CSS_SELECTOR, "button[type='submit']").click()

        WebDriverWait(browser, 10).until(
            EC.presence_of_element_located((By.CSS_SELECTOR, ".success-message"))
        )

تشغيل الاختبارات في GitHub Actions

يمرّر سير العمل التالي مفتاح API إلى الاختبارات عبر secrets.CAPTCHAAI_API_KEY، فلا يظهر المفتاح في الكود ولا في السجلّات. يُثبِّت Chrome وChromeDriver، يشغّل مجموعة E2E، ثم يرفع تقرير HTML كأثر (artifact) حتى في حال الفشل بفضل if: always().

name: E2E Tests with CAPTCHA

on:
  push:
    branches: [main, staging]
  pull_request:
    branches: [main]

jobs:
  e2e-tests:
    runs-on: ubuntu-latest

    steps:

      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Install Chrome
        uses: browser-actions/setup-chrome@v1
        with:
          chrome-version: stable

      - name: Install ChromeDriver
        uses: nanasess/setup-chromedriver@v2

      - name: Install dependencies
        run: |
          pip install selenium requests pytest pytest-html

      - name: Run E2E tests
        env:
          CAPTCHAAI_API_KEY: ${{ secrets.CAPTCHAAI_API_KEY }}
        run: |
          pytest tests/e2e/ -v --html=report.html --self-contained-html

      - name: Upload test report
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: e2e-report
          path: report.html

الإعداد في GitLab CI

على GitLab يُشغَّل Chrome كخدمة منفصلة، ويُقرأ المفتاح من متغيّرات المشروع المحمية. لا تُدرِج CAPTCHAAI_API_KEY في الملف مطلقاً؛ عرّفه من إعدادات المشروع كمتغيّر مقنَّع (masked).

e2e_tests:
  stage: test
  image: python:3.11
  services:

    - selenium/standalone-chrome:latest
  variables:
    SELENIUM_REMOTE_URL: "http://selenium__standalone-chrome:4444/wd/hub"
  script:

    - pip install selenium requests pytest
    - pytest tests/e2e/ -v
  artifacts:
    when: always
    reports:
      junit: report.xml

التكامل مع Jenkins

يستخدم المسار التالي مخزن بيانات الاعتماد في Jenkins عبر credentials('captchaai-api-key')، فيُحقن المفتاح في البيئة دون كتابته صراحةً. تُنشر نتائج JUnit في مرحلة post لتظهر في لوحة النتائج حتى عند فشل الاختبارات.

pipeline {
    agent any
    environment {
        CAPTCHAAI_API_KEY = credentials('captchaai-api-key')
    }
    stages {
        stage('Setup') {
            steps {
                sh 'pip install selenium requests pytest'
            }
        }
        stage('E2E Tests') {
            steps {
                sh 'pytest tests/e2e/ -v --junitxml=results.xml'
            }
        }
    }
    post {
        always {
            junit 'results.xml'
        }
    }
}

التحكم في تكلفة الحل داخل CI

تعتمد خطط CaptchaAI على عدد الـ threads المتزامنة لا على عدد عمليات الحل؛ فباقة BASIC تبدأ من 15 دولاراً شهرياً بخمسة threads مع عدد غير محدود من عمليات الحل خلال الشهر، وترتقي الباقات حتى VIP-3 بسعر 7,500 دولار و5,000 thread. هذا النموذج مريح لفرق CI: طالما بقيت عمليات الحل المتزامنة ضمن حصة الـ threads، فلن تدفع مقابل كل عملية حل على حدة.

تخيّل فريق ضمان جودة في شركة تجارة إلكترونية بالرياض يشغّل انحداراً ليلياً على مسارَي تسجيل الدخول وإتمام الشراء. لو حاول تشغيل اختبارات CAPTCHA في كل طلب سحب لعشرات المطورين، لتضخّم عدد عمليات الحل بلا فائدة حقيقية. الأفضل حصر هذه الاختبارات في الدمج إلى الفرع الرئيسي أو في تشغيل ليلي مجدول، وتخطّيها في بناء طلبات السحب اليومية.

حل فقط عند الحاجة

تتيح الدالة التالية إيقاف اختبارات CAPTCHA عبر متغيّر بيئة واحد، أو تخطّيها تلقائياً حين لا يكون المفتاح مضبوطاً — وهو ما يفيد في بناء طلبات السحب (PR builds) حيث لا تريد استهلاك حصة الحل.

import os

def should_run_captcha_tests():
    """Skip CAPTCHA tests in certain environments."""
    if os.environ.get("SKIP_CAPTCHA_TESTS"):
        return False
    if not os.environ.get("CAPTCHAAI_API_KEY"):
        return False
    return True


# In test
import pytest

@pytest.mark.skipif(
    not should_run_captcha_tests(),
    reason="CAPTCHA tests disabled or API key not set"
)
class TestWithCaptcha:
    def test_login(self, browser, captcha_solver):
        pass

التحقق من الرصيد قبل تشغيل المجموعة

تجهيزة autouse التالية تفحص الرصيد مرة واحدة قبل بدء الجلسة، وتتخطّى المجموعة كلها إذا هبط الرصيد عن حدّ آمن — فتتجنّب تشغيلاً كاملاً يفشل في منتصفه بسبب نفاد الرصيد.

@pytest.fixture(scope="session", autouse=True)
def check_captcha_balance(captcha_solver):
    import requests
    resp = requests.get(
        f"{captcha_solver.BASE}/res.php",
        params={"key": captcha_solver.api_key, "action": "getbalance"},
    )
    balance = float(resp.text)
    if balance < 0.50:
        pytest.skip(f"CaptchaAI balance too low: ${balance:.2f}")

حل مشكلات التشغيل الشائعة

المشكلة السبب الإجراء
CAPTCHAAI_API_KEY not set لم يُضبط السر في بيئة CI أضِف المفتاح إلى أسرار CI
يتعطّل Chrome داخل CI غياب العلامة --no-sandbox أضِف أعلام Chrome الخاصة بوضع headless
الاختبارات تنجح محلياً وتفشل في CI اختلاف إصدار المتصفح ثبّت إصدار Chrome داخل CI
انتهاء مهلة حل CAPTCHA بطء شبكة CI ارفع قيمة المعامل timeout
ارتفاع تكلفة التشغيل عمليات حل كثيرة في كل تشغيل استخدم SKIP_CAPTCHA_TESTS لبناء طلبات السحب (PR)

الأسئلة الشائعة

هل يبطئ حل CAPTCHA زمن تشغيل خط CI بشكل ملحوظ؟

يضيف كل حل بضع ثوانٍ لأن الخدمة تستطلع النتيجة حتى تجهز. لتقليل الأثر، شغّل اختبارات CAPTCHA على التوازي عبر pytest-xdist بحيث تنتظر عدة اختبارات في الوقت نفسه بدل التسلسل، وتعامل CaptchaAI مع الطلبات المتزامنة ضمن حصة الـ threads.

ماذا أفعل إذا تغيّر sitekey في بيئة staging؟

اقرأ مفتاح الموقع من متغيّر بيئة بدل تثبيته في الاختبار، وحدّثه من إعدادات CI عند تغيّر البيئة. هكذا لا تحتاج إلى تعديل الكود، وتبقى الاختبارات صالحة عبر staging والإنتاج.

كيف أمنع تسريب مفتاح API في سجلّات CI؟

استعمل إدارة الأسرار في منصّتك: GitHub Secrets أو GitLab CI Variables (مقنّعة) أو Jenkins Credentials، ولا تضع المفتاح داخل الكود مطلقاً. تُخفي هذه الأدوات القيمة تلقائياً من سجلّات التشغيل.

هل يتعامل CaptchaAI مع reCAPTCHA v2 وTurnstile في المجموعة نفسها؟

نعم. كلا النوعين مدعوم بشكل كامل، وتستدعي كلاً منهما عبر الدالة المناسبة (solve_recaptcha أو solve_turnstile) مع حقن الرمز في حقله الصحيح — g-recaptcha-response أو cf-turnstile-response.

كيف أضبط تكلفة الحل عبر عدد كبير من عمليات التشغيل؟

احصر اختبارات CAPTCHA في الدمج إلى الفرع الرئيسي أو في تشغيل ليلي مجدول، وتخطّها في بناء طلبات السحب عبر SKIP_CAPTCHA_TESTS. وبما أن الفوترة تعتمد على الـ threads لا على عدد عمليات الحل، فإن ضبط التزامن مع حصة باقتك يبقي التكلفة ثابتة ومتوقّعة.


أدلة ذات صلة


أضِف حل CAPTCHA إلى خط CI الخاص بك — ابدأ مع CaptchaAI.

التعليقات غير مفعّلة لهذا المقال.