Integrations

Penanganan CAPTCHA iOS dengan XCUITest dan CaptchaAI

Jawaban singkat: XCUITest tidak bisa mengeksekusi JavaScript di WKWebView, jadi ia tidak akan pernah bisa menyelesaikan reCAPTCHA v2 sendirian. Pasang test hook kecil di build debug yang memanggil CaptchaAI dari luar WebView, lalu suntikkan token hasilnya kembali ke halaman — form bisa dikirim dan skenario end-to-end Anda lanjut tanpa campur tangan manual.

Panduan ini membahas urutan lengkapnya: kenapa XCUITest butuh test hook terpisah, cara mendeteksi CAPTCHA di WKWebView saat test berjalan, mengirim sitekey ke layanan solver backend, dan menyuntikkan token lewat evaluasi JavaScript — lengkap dengan contoh kode Swift untuk sisi aplikasi/test dan Python untuk backend solver-nya.

Arsitektur Solusi: Test Hook, Solver Backend, dan CaptchaAI

XCUITest memang tidak bisa langsung mengeksekusi JavaScript di WKWebView — itu batasan frameworknya, bukan sesuatu yang bisa dikonfigurasi ulang. Solusinya adalah endpoint helper yang dipanggil aplikasi selama pengujian berjalan, sehingga eksekusi JavaScript tetap terjadi di sisi aplikasi, bukan dari XCUITest itu sendiri. Tiga komponen ini yang saling berkomunikasi:

Komponen Peran
XCUITest Mengemudikan UI, memicu solve CAPTCHA lewat test helper
Test API Helper Menerima sitekey + URL, memanggil CaptchaAI, mengembalikan token
App Test Hook Mengeksekusi JavaScript di WKWebView untuk deteksi/injeksi
CaptchaAI API Menyelesaikan CAPTCHA challenge

Jika CI Anda berjalan di region seperti AWS ap-southeast-1 (Singapura) atau GCP asia-southeast2 (Jakarta), latensi ke API CaptchaAI tetap rendah. Permintaan HTTP dikirim dari layanan solver di mesin CI, bukan dari dalam Simulator, sehingga kondisi jaringan Simulator sama sekali tidak jadi faktor.

Studi Kasus: reCAPTCHA v2 Menghadang Alur Pendaftaran di WKWebView

Skenario ini akrab bagi tim QA di startup fintech, e-commerce, maupun agensi automation Indonesia yang menguji aplikasi iOS dengan form pendaftaran berbasis WKWebView. Form-nya memuat reCAPTCHA v2, dan selama pengujian otomatis berjalan, CAPTCHA ini menghentikan progres test sampai ada yang menyelesaikannya.

Terapkan arsitektur di atas, dan solusinya akan:

  • Mendeteksi CAPTCHA di WebView selama eksekusi test berjalan
  • Mengekstrak sitekey secara terprogram, bukan manual
  • Mengirim sitekey itu ke CaptchaAI untuk diselesaikan
  • Menyuntikkan token hasilnya agar form bisa dikirim

Lingkungan yang dipakai di panduan ini: Xcode 15+, Swift, XCUITest, macOS test runner, CaptchaAI API.

Langkah 1: Pasang Test Hook di Build Debug Aplikasi

Di pengontrol WKWebView aplikasi Anda, tambahkan handler CAPTCHA khusus mode uji yang bisa dipicu lewat accessibility identifier atau URL scheme. Handler ini cukup menjalankan tiga tugas: deteksi CAPTCHA di halaman, kirim sitekey ke solver backend, lalu suntikkan token yang dikembalikan:

// 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

Langkah 2: Bangun Layanan Solver Backend

Jalankan layanan solver terpisah — di mesin CI atau laptop Anda sendiri — yang menjembatani test hook dengan API CaptchaAI. Layanan ini menerima sitekey dan URL halaman, mengirim task ke in.php, lalu polling res.php sampai token siap diambil:

# 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)

Catatan biaya: kalau tim QA Anda menjalankan beberapa worker XCUITest paralel di CI, paket CaptchaAI dihitung per thread yang sedang diproses, bukan per solve — begitu satu thread selesai, ia langsung bebas mengambil task berikutnya tanpa biaya tambahan. Paket BASIC ($15/bulan, 5 thread) biasanya cukup untuk beberapa job paralel di pipeline kecil; tim dengan CI lebih besar umumnya naik ke STANDARD ($30/bulan, 15 thread) atau ADVANCE ($90/bulan, 50 thread) begitu jumlah worker bertambah.

Langkah 3: Sambungkan Alur Solve ke Skenario XCUITest

Di skenario XCUITest Anda, picu alur solve CAPTCHA persis saat WebView yang memuat CAPTCHA selesai tampil:

// 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))
    }
}

Pola waitForExistence di atas penting: solve CaptchaAI butuh beberapa detik hingga puluhan detik tergantung beban, jadi test Anda harus menunggu penanda selesai, bukan berasumsi token sudah siap begitu tombol ditekan.

Kendala yang Sering Muncul dan Solusinya

Kendala Penyebab Solusi
evaluateJavaScript mengembalikan nil WebView belum selesai memuat halaman Tunggu webView.isLoading == false sebelum menyuntikkan JavaScript
Backend tidak terjangkau dari Simulator localhost tidak bisa diakses dari sandbox Simulator Pakai 127.0.0.1 atau IP jaringan Mac Anda; periksa juga App Transport Security
Callback reCAPTCHA tidak terpicu setelah token disuntik Callback tersembunyi di objek ___grecaptcha_cfg.clients yang bertingkat Iterasi seluruh properti ___grecaptcha_cfg.clients secara rekursif
XCUITest timeout menunggu solve selesai Waktu solve CaptchaAI melebihi batas timeout default test Set timeout test ke 120 detik atau lebih untuk skenario terkait CAPTCHA

Pertanyaan Umum seputar Penanganan CAPTCHA di XCUITest

Bisakah XCUITest mengeksekusi JavaScript langsung di WKWebView?

Tidak. XCUITest berinteraksi dengan elemen lewat accessibility tree, tapi sama sekali tidak bisa mengevaluasi JavaScript. Anda perlu test hook di kode aplikasi (khusus build debug) yang memanggil evaluateJavaScript dari sisi WKWebView untuk menjembatani gap ini.

Apakah pendekatan test hook ini juga berfungsi untuk Cloudflare Turnstile, bukan hanya reCAPTCHA v2?

Bisa. Test hook di atas hanya bertugas mendeteksi sitekey dan menyuntik token — logika yang sama berlaku untuk Cloudflare Turnstile atau GeeTest v3, Anda tinggal sesuaikan selector di detectCaptcha dan parameter method yang dikirim ke CaptchaAI.

Berapa thread CaptchaAI yang saya butuhkan untuk menjalankan beberapa XCUITest secara paralel di CI?

Tergantung jumlah worker paralel yang berjalan bersamaan. Setiap thread menangani satu CAPTCHA yang sedang diproses, dan begitu selesai langsung bebas mengambil task berikutnya — jadi jumlah thread yang Anda perlukan kira-kira sama dengan jumlah worker paralel puncak, bukan jumlah total test dalam suite.

Bagaimana jika CAPTCHA muncul di WebView SDK pihak ketiga yang tidak saya kendalikan?

Kalau Anda tidak mengontrol WebView-nya — misalnya SDK pembayaran pihak ketiga — test hook di atas tidak bisa dipasang di sana. Gunakan Appium sebagai gantinya: Appium menyediakan kemampuan execute_script di semua context WebView tanpa perlu hook sisi aplikasi.

Apa yang terjadi jika solve CaptchaAI belum selesai saat XCUITest mencapai batas waktu?

Task belum selesai bukan berarti gagal — layanan solver backend tetap polling sampai statusnya 1 (selesai) atau muncul error eksplisit dari CaptchaAI. Yang membatasi justru assertion waitForExistence di XCUITest sendiri. Dua hal yang perlu dipastikan:

  • Timeout waitForExistence di-set minimal 120 detik untuk skenario terkait CAPTCHA
  • Loop polling di backend punya batas iterasi sendiri, supaya request yang benar-benar gagal tidak menggantung selamanya

Artikel Terkait

Komentar dinonaktifkan untuk artikel ini.