يعترض تحدي reCAPTCHA v2 مسار اختبارات iOS الآلية عند كل نموذج تسجيل أو بوابة دفع تُحمَّل داخل WKWebView. المسار للتعامل معه يمر بثلاث خطوات واضحة: اكتشاف الكابتشا داخل محتوى الويب، وإرساله إلى CaptchaAI للحصول على الرمز، ثم حقن هذا الرمز ليُكمل النموذج إرساله تلقائيًا.
العقبة الجوهرية أن XCUITest يتعامل مع عناصر الواجهة فقط ولا ينفّذ JavaScript داخل WKWebView مباشرة. لذلك نبني جسرًا صغيرًا بين طبقة الاختبار ومحتوى الويب، ونترك مهمة الحل لخدمة CaptchaAI عبر خدمة مصاحبة تعمل على جهاز الاختبار. النتيجة اختبارات E2E تعبر تحديات reCAPTCHA دون أي تدخل يدوي، سواء على جهازك المحلي أو على بيئة CI.
متى يكون هذا الأسلوب مناسبًا (ومتى لا يكون)
قبل بناء أي جسر، تأكد أنك تحتاجه فعلًا. الجدول التالي يلخّص الحالات التي يستحق فيها هذا النهج جهده:
| الحالة | هل هذا الأسلوب مناسب؟ | السبب |
|---|---|---|
تطبيق iOS يحمّل تدفقات حرجة داخل WKWebView |
نعم | تحتاج جسرًا بين XCUITest ومحتوى الويب |
| اختبار Safari أو صفحة ويب فقط | غالبًا لا | Playwright أو Selenium أبسط هنا |
| فريق iOS يريد اختبارات E2E مستقرة على بيئة CI | نعم | يمكن عزل منطق الحل داخل مساعد اختبار واضح |
| تطبيق native بالكامل بلا WebView | لا | لا توجد حاجة لهذا التعقيد أصلًا |
الخلاصة: هذا الأسلوب موجّه لفرق iOS التي تعتمد على WebView لعرض نماذج محمية بالكابتشا وتريد إبقاء اختباراتها الآلية مستقرة دون خطوات يدوية.
لماذا لا يستطيع XCUITest حل الكابتشا وحده
يتعامل XCUITest مع الأزرار والحقول ومكوّنات الواجهة، لكنه لا يملك وصولًا مباشرًا إلى شجرة DOM داخل WKWebView ولا ينفّذ JavaScript فيها. حل reCAPTCHA يتطلب قراءة مفتاح الموقع من الصفحة وكتابة الرمز في حقلها بعد الحل، وكلاهما يجري في طبقة الويب لا في طبقة الواجهة الأصلية.
لسد هذه الفجوة نوزّع المسؤوليات على أربعة مكوّنات، يستدعي أحدها الآخر أثناء الاختبار:
| المكوّن | الدور |
|---|---|
| XCUITest | يقود الواجهة ويشغّل تدفق حل الكابتشا عبر مساعد الاختبار |
| نقطة نهاية مساعدة للاختبار | تتلقى مفتاح الموقع وعنوان الصفحة، وتستدعي CaptchaAI، وتعيد الرمز |
| خطاف الاختبار داخل التطبيق | يقيّم JavaScript في WKWebView للكشف عن الكابتشا وحقن الرمز |
| واجهة CaptchaAI | تحل تحدي reCAPTCHA وتعيد التوكن |
سيناريو واقعي من بيئة عربية
تخيّل تطبيق متجر إلكتروني يخدم السوق في مصر والخليج، ويحمّل شاشة إنشاء الحساب داخل WKWebView لإعادة استخدام نموذج الويب نفسه عبر منصات متعددة. النموذج محميّ بـ reCAPTCHA v2 لمنع الحسابات الآلية.
في كل تشغيل لمجموعة اختبارات القبول، يتوقف الاختبار عند شاشة التسجيل لأن الكابتشا يعترض التقدم، فيضطر المهندس إلى حلّه يدويًا أو تعطيل الاختبار. الحل المطلوب يجب أن:
- يكتشف اختبار CAPTCHA في WebView أثناء تنفيذ الاختبار
- يستخرج مفتاح الموقع برمجياً
- يحلها عبر CaptchaAI
- يقوم بإدخال الرمز المميز حتى يتمكن النموذج من الإرسال
البيئة: Xcode 15+، Swift، XCUITest، مشغل اختبار macOS، CaptchaAI API.
الخطوة 1: أضف خطاف اختبار إلى التطبيق
في وحدة تحكم WKWebView داخل تطبيقك، أضِف معالج CAPTCHA لوضع الاختبار يمكن تشغيله عبر معرّفات إمكانية الوصول أو نظام URL. غلّفه بـ #if DEBUG حتى لا يصل إلى الإصدار الإنتاجي:
// CaptchaTestHelper.swift — Add to app target (test build only)
import WebKit
#if DEBUG
class CaptchaTestHelper {
private let webView: WKWebView
init(webView: WKWebView) {
self.webView = webView
}
func detectCaptcha(completion: @escaping (String?, String?) -> Void) {
let script = """
(function() {
var el = document.querySelector('.g-recaptcha');
if (el) {
return JSON.stringify({
sitekey: el.getAttribute('data-sitekey'),
pageurl: window.location.href
});
}
return null;
})();
"""
webView.evaluateJavaScript(script) { result, error in
guard let jsonString = result as? String,
let data = jsonString.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: String] else {
completion(nil, nil)
return
}
completion(json["sitekey"], json["pageurl"])
}
}
func injectToken(_ token: String, completion: @escaping (Bool) -> Void) {
let script = """
document.getElementById('g-recaptcha-response').value = '\(token)';
try {
var clients = ___grecaptcha_cfg.clients;
Object.keys(clients).forEach(function(k) {
Object.keys(clients[k]).forEach(function(j) {
if (clients[k][j] && clients[k][j].callback) {
clients[k][j].callback('\(token)');
}
});
});
} catch(e) {}
true;
"""
webView.evaluateJavaScript(script) { _, error in
completion(error == nil)
}
}
func solveCaptchaViaBackend(
sitekey: String, pageurl: String,
completion: @escaping (Result<String, Error>) -> Void
) {
guard let url = URL(string: "http://localhost:3000/api/solve-captcha") else {
return
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body: [String: String] = [
"captchaType": "recaptcha_v2",
"sitekey": sitekey,
"pageurl": pageurl
]
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
URLSession.shared.dataTask(with: request) { data, _, error in
if let error = error {
completion(.failure(error))
return
}
guard let data = data,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let token = json["token"] as? String else {
completion(.failure(NSError(domain: "", code: -1,
userInfo: [NSLocalizedDescriptionKey: "No token"])))
return
}
completion(.success(token))
}.resume()
}
}
#endif
يستخرج detectCaptcha مفتاح الموقع وعنوان الصفحة، ويكتب injectToken الرمز في حقل g-recaptcha-response ثم يستدعي callback الخاص بـ reCAPTCHA، بينما يمرّر solveCaptchaViaBackend الطلب إلى خدمة الحل المحلية.
الخطوة 2: شغّل خدمة الحل الخلفية
شغّل خدمة حل محلية أثناء الاختبار تتواصل مع CaptchaAI عبر نقطتَي in.php وres.php، مع الاستطلاع الدوري للنتيجة حتى يجهز الرمز:
# ios_test_solver.py — Run on test machine during XCUITest execution
import os
import time
import requests
from flask import Flask, request, jsonify
app = Flask(__name__)
API_KEY = os.environ.get("CAPTCHAAI_API_KEY", "YOUR_API_KEY")
@app.route("/api/solve-captcha", methods=["POST"])
def solve():
data = request.json
sitekey = data["sitekey"]
pageurl = data["pageurl"]
# Submit to CaptchaAI
resp = requests.get("https://ocr.captchaai.com/in.php", params={
"key": API_KEY,
"method": "userrecaptcha",
"googlekey": sitekey,
"pageurl": pageurl,
"json": "1",
})
result = resp.json()
if result.get("status") != 1:
return jsonify({"error": result.get("request")}), 400
task_id = result["request"]
# Poll
for _ in range(30):
time.sleep(5)
poll = requests.get("https://ocr.captchaai.com/res.php", params={
"key": API_KEY,
"action": "get",
"id": task_id,
"json": "1",
})
poll_result = poll.json()
if poll_result.get("status") == 1:
return jsonify({"token": poll_result["request"]})
if poll_result.get("request") != "CAPCHA_NOT_READY":
return jsonify({"error": poll_result["request"]}), 400
return jsonify({"error": "Timeout"}), 408
if __name__ == "__main__":
app.run(host="0.0.0.0", port=3000)
تبقي هذه الخدمة مفتاح الـ API خارج كود التطبيق، وتقرأه من متغيّر البيئة CAPTCHAAI_API_KEY بدل تضمينه في المصدر.
الخطوة 3: اربط التدفق داخل XCUITest
في اختبار XCUITest، شغّل تدفق حل الكابتشا بمجرد أن يُحمَّل WebView الذي يحتوي التحدي، ثم انتظر مؤشّر اكتمال الحل قبل إرسال النموذج:
// CaptchaUITests.swift
import XCTest
class CaptchaUITests: XCTestCase {
func testRegistrationWithCaptcha() throws {
let app = XCUIApplication()
app.launchArguments.append("--captcha-test-mode")
app.launch()
// Navigate to registration
app.buttons["Register"].tap()
// Wait for WebView to load
let webView = app.webViews.firstMatch
XCTAssertTrue(webView.waitForExistence(timeout: 15))
// Trigger CAPTCHA solve via test helper button
// (The app shows this button only in test mode)
let solveButton = app.buttons["SolveCaptchaTestHelper"]
if solveButton.waitForExistence(timeout: 5) {
solveButton.tap()
// Wait for solve completion indicator
let solved = app.staticTexts["CaptchaSolved"]
XCTAssertTrue(solved.waitForExistence(timeout: 120),
"CAPTCHA should be solved within 2 minutes")
}
// Continue with form submission
app.buttons["SubmitForm"].tap()
// Verify success
let success = app.staticTexts["Registration Complete"]
XCTAssertTrue(success.waitForExistence(timeout: 10))
}
}
لاحظ مهلة الانتظار الطويلة (120 ثانية) عند حل الكابتشا، فهي أطول من بقية خطوات الواجهة لأن الحل يجري عبر الشبكة.
معالجة المشكلات الشائعة
| المشكلة | السبب | الإصلاح |
|---|---|---|
| لا يتم اكتشاف CAPTCHA داخل WebView | السكربت يبحث عن محدد خاطئ أو أن الأداة لم تُحمّل بعد | أضف انتظارًا لتحميل الصفحة وتحقق من المحددات داخل WKWebView |
| تم الحصول على token لكن النموذج لا يُرسل | callback أو حقل g-recaptcha-response لم يُحدَّث بالشكل المتوقع |
اختبر دالة injectToken على الصفحة نفسها وتحقق من callback الفعلي |
| خدمة الحل المحلية لا تستجيب أثناء الاختبار | الخادم المساعد غير شغّال أو المنفذ غير صحيح | تأكد من تشغيل الخدمة قبل XCUITest ومطابقة localhost:3000 |
| الاختبار ينجح محليًا ويفشل على CI | CI لا يملك الشبكة أو الأسرار اللازمة للوصول إلى CaptchaAI | وفّر المفتاح والمتطلبات الشبكية نفسها التي تستخدمها محليًا |
الأسئلة الشائعة
هل يقتصر هذا الأسلوب على reCAPTCHA v2؟
لا. البنية نفسها تعمل مع أنواع أخرى يدعمها CaptchaAI، ويكفي تعديل قيمة method في خدمة الحل ومحدّد الكشف داخل السكربت. من الأنواع المدعومة:
- reCAPTCHA v3
- Cloudflare Turnstile وCloudflare Challenge
- GeeTest v3
- كابتشا الصور وOCR والشبكات (grid)
لاحظ أن hCaptcha وFunCaptcha غير مدعومَين حاليًا، فلا تبنِ عليهما هذا المسار.
كم تبلغ تكلفة حل الكابتشا في مجموعة الاختبارات؟
يعتمد CaptchaAI تسعيرًا قائمًا على عدد الـ Threads المتزامنة لا على كل عملية حل، وتبدأ الباقات من BASIC بسعر 15 دولارًا شهريًا مع 5 threads. أبرز ما يهم فريق الاختبار:
- الفوترة على الـ Threads المتزامنة، لا على كل عملية حل.
- حلول غير محدودة لكل thread خلال الشهر.
- باقة BASIC تكفي عادةً لمجموعة اختبارات CI صغيرة إلى متوسطة.
راجع صفحة الأسعار لأحدث الباقات قبل الاعتماد.
هل يعمل هذا على المحاكي (Simulator) أم يلزم جهاز حقيقي؟
يعمل على الاثنين معًا، لكن انتبه إلى إعدادات الشبكة على كل بيئة:
- على المحاكي قد لا يصل
localhostإلى خدمة الحل، فاستخدم127.0.0.1أو عنوان IP الخاص بجهاز Mac. - فعّل إعدادات App Transport Security للسماح بالاتصال المحلي أثناء الاختبار فقط.
- على الجهاز الحقيقي، تأكد أن الجهاز وخدمة الحل على الشبكة نفسها.
كيف أضمن ألا يصل كود مساعد الاختبار إلى الإصدار الإنتاجي؟
غلّف كل كود المساعد داخل توجيهات #if DEBUG، فيُحذف تلقائيًا من إصدارات الإطلاق (release). بهذا يبقى خطاف الاختبار متاحًا في بناء الاختبار وحده دون أي أثر في التطبيق المنشور.
الخطوات التالية
- ابدأ مع CaptchaAI: حلّ أول كابتشا في خمس دقائق
- دليل حل reCAPTCHA v2 عبر الـ API خطوة بخطوة
- حل Cloudflare Turnstile عبر واجهة الـ API
- حل GeeTest v3 عبر واجهة الـ API