Tim QA yang menonaktifkan CAPTCHA di staging supaya CI cepat hijau sering kaget: bug baru muncul justru di production, persis saat reCAPTCHA atau Turnstile aktif. Cypress bisa menghindari itu — task handler Node.js mengirim challenge ke CaptchaAI, custom command menyuntikkan token ke DOM, dan suite E2E Anda menguji alur yang sama persis dengan yang dilihat pengguna.
Setelah mengikuti panduan ini, Anda akan punya:
- Task handler Node.js yang bicara langsung ke API CaptchaAI dari
setupNodeEvents - Custom command
cy.solveCaptcha()dancy.solveTurnstile()siap pakai di test suite mana pun - Retry logic dan pipeline CI/CD yang tidak flaky gara-gara CAPTCHA
Kenapa Menonaktifkan CAPTCHA di Staging Berisiko
| Pendekatan | Konsekuensi |
|---|---|
| Nonaktifkan CAPTCHA di staging | Bug integrasi ketahuan setelah rilis |
| Pakai test key yang selalu lulus | Token injection dan callback tidak teruji |
| Solve CAPTCHA asli via CaptchaAI | Staging = production, byte demi byte |
Instalasi dan Konfigurasi Awal
Prasyarat
Sebelum mulai, siapkan tiga hal ini:
- Node.js versi LTS terbaru dan project Cypress yang sudah bisa dijalankan (
npx cypress open) - API key CaptchaAI aktif — ambil dari dashboard akun Anda
- Akses untuk menambahkan environment variable di CI (GitHub Actions atau setara)
Install Cypress seperti biasa:
npm install cypress --save-dev
Atur cypress.config.js untuk Task CAPTCHA
Daftarkan task solveCaptcha di setupNodeEvents — titik masuk Node.js ke API CaptchaAI:
// cypress.config.js
const { defineConfig } = require("cypress");
module.exports = defineConfig({
e2e: {
baseUrl: "https://your-app.com",
defaultCommandTimeout: 120000,
responseTimeout: 120000,
setupNodeEvents(on, config) {
on("task", {
solveCaptcha({ siteUrl, sitekey, type }) {
return solveCaptchaTask(siteUrl, sitekey, type);
},
});
return config;
},
},
env: {
CAPTCHAAI_KEY: "YOUR_API_KEY",
},
});
Bangun Task Handler CaptchaAI di Node.js
Handler ini bicara ke API CaptchaAI: kirim challenge ke in.php, lalu polling res.php tiap 5 detik sampai token siap.
// cypress/plugins/captcha-solver.js
const https = require("https");
function httpPost(url, data) {
return new Promise((resolve, reject) => {
const params = new URLSearchParams(data).toString();
const options = {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
};
const req = https.request(url, options, (res) => {
let body = "";
res.on("data", (c) => (body += c));
res.on("end", () => resolve(JSON.parse(body)));
});
req.on("error", reject);
req.write(params);
req.end();
});
}
function httpGet(url) {
return new Promise((resolve, reject) => {
https.get(url, (res) => {
let body = "";
res.on("data", (c) => (body += c));
res.on("end", () => resolve(JSON.parse(body)));
}).on("error", reject);
});
}
async function solveCaptchaTask(siteUrl, sitekey, type = "recaptcha_v2") {
const API = "https://ocr.captchaai.com";
const key = process.env.CAPTCHAAI_KEY || "YOUR_API_KEY";
const submitData = {
key,
pageurl: siteUrl,
json: "1",
};
if (type === "turnstile") {
submitData.method = "turnstile";
submitData.sitekey = sitekey;
} else {
submitData.method = "userrecaptcha";
submitData.googlekey = sitekey;
}
const submitResp = await httpPost(`${API}/in.php`, submitData);
if (submitResp.status !== 1) {
throw new Error(`Submit failed: ${submitResp.request}`);
}
const taskId = submitResp.request;
// Poll for result
for (let i = 0; i < 60; i++) {
await new Promise((r) => setTimeout(r, 5000));
const params = new URLSearchParams({
key,
action: "get",
id: taskId,
json: "1",
});
const result = await httpGet(`${API}/res.php?${params}`);
if (result.request === "CAPCHA_NOT_READY") continue;
if (result.status !== 1) throw new Error(`Solve failed: ${result.request}`);
return result.request; // The CAPTCHA token
}
throw new Error("CAPTCHA solve timeout");
}
module.exports = { solveCaptchaTask };
Daftarkan Task Handler ke Config
Import solveCaptchaTask dan hubungkan ke task solveCaptcha:
// cypress.config.js
const { solveCaptchaTask } = require("./cypress/plugins/captcha-solver");
module.exports = defineConfig({
e2e: {
setupNodeEvents(on, config) {
on("task", {
solveCaptcha({ siteUrl, sitekey, type }) {
return solveCaptchaTask(siteUrl, sitekey, type);
},
});
},
},
});
Custom Command: solveCaptcha dan solveTurnstile
cy.solveCaptcha() untuk reCAPTCHA v2, cy.solveTurnstile() untuk Turnstile — keduanya baca data-sitekey, panggil task Node.js, lalu suntik token.
// cypress/support/commands.js
Cypress.Commands.add("solveCaptcha", (options = {}) => {
cy.get("[data-sitekey]", { timeout: 10000 }).then(($el) => {
const sitekey = options.sitekey || $el.attr("data-sitekey");
const siteUrl = options.siteUrl || cy.url();
cy.url().then((url) => {
cy.task("solveCaptcha", {
siteUrl: url,
sitekey,
type: options.type || "recaptcha_v2",
}).then((token) => {
// Inject token
cy.window().then((win) => {
const responseEl = win.document.querySelector(
"#g-recaptcha-response"
);
if (responseEl) {
responseEl.value = token;
}
// Set all hidden response fields
win.document
.querySelectorAll('[name="g-recaptcha-response"]')
.forEach((el) => {
el.value = token;
});
// Trigger callback if exists
if (win.___grecaptcha_cfg) {
const clients = win.___grecaptcha_cfg.clients;
for (const key in clients) {
const client = clients[key];
if (client && typeof client.callback === "function") {
client.callback(token);
}
}
}
});
});
});
});
});
Cypress.Commands.add("solveTurnstile", (options = {}) => {
cy.get("[data-sitekey]", { timeout: 10000 }).then(($el) => {
const sitekey = options.sitekey || $el.attr("data-sitekey");
cy.url().then((url) => {
cy.task("solveCaptcha", {
siteUrl: url,
sitekey,
type: "turnstile",
}).then((token) => {
cy.window().then((win) => {
const input = win.document.querySelector(
'input[name="cf-turnstile-response"]'
);
if (input) input.value = token;
});
});
});
});
});
Skenario Tes E2E dengan CAPTCHA Aktif
Skenario Login dengan reCAPTCHA v2
Panggil cy.solveCaptcha() persis sebelum submit:
// cypress/e2e/login.cy.js
describe("Login with reCAPTCHA", () => {
it("should log in through a CAPTCHA-protected form", () => {
cy.visit("/login");
cy.get("#username").type("testuser");
cy.get("#password").type("securepassword123");
// Solve the CAPTCHA
cy.solveCaptcha();
// Submit
cy.get('button[type="submit"]').click();
// Verify login success
cy.url().should("include", "/dashboard");
cy.get(".welcome-message").should("contain", "Welcome, testuser");
});
});
Skenario Registrasi dengan CAPTCHA
Form multi-field, pola yang sama:
// cypress/e2e/register.cy.js
describe("Registration with CAPTCHA", () => {
it("completes registration with all fields + CAPTCHA", () => {
cy.visit("/register");
cy.get("#first-name").type("Test");
cy.get("#last-name").type("User");
cy.get("#email").type("test@example.com");
cy.get("#password").type("StrongPass!123");
cy.get("#confirm-password").type("StrongPass!123");
cy.solveCaptcha();
cy.get("#register-btn").click();
cy.url().should("include", "/verify-email");
});
});
Skenario Checkout dengan Cloudflare Turnstile
Untuk Turnstile pakai cy.solveTurnstile(), bukan solveCaptcha():
describe("Checkout with Turnstile", () => {
it("processes payment through Turnstile-protected checkout", () => {
cy.visit("/cart");
cy.get(".checkout-btn").click();
cy.get("#card-number").type("4242424242424242");
cy.get("#expiry").type("12/26");
cy.get("#cvc").type("123");
cy.solveTurnstile();
cy.get("#pay-now").click();
cy.get(".confirmation").should("contain", "Order confirmed");
});
});
Retry dan Penanganan Error saat Solve Gagal
Solve bisa gagal karena sebab yang sifatnya sementara, bukan karena CAPTCHA-nya sendiri bermasalah:
- Timeout jaringan singkat ke API CaptchaAI
- Rate limit sesaat saat banyak job paralel submit bersamaan
- Race condition antara token siap dan render ulang DOM sebelum token sempat disuntikkan
Bungkus task dengan retry supaya kegagalan sesaat seperti ini tidak menggagalkan seluruh test run:
// cypress/support/commands.js
Cypress.Commands.add("solveCaptchaWithRetry", (options = {}) => {
const maxRetries = options.retries || 3;
function attempt(retryCount) {
return cy.task("solveCaptcha", {
siteUrl: options.siteUrl,
sitekey: options.sitekey,
type: options.type || "recaptcha_v2",
}).then((token) => {
if (!token && retryCount < maxRetries) {
cy.log(`CAPTCHA retry ${retryCount + 1}/${maxRetries}`);
cy.wait(2000);
return attempt(retryCount + 1);
}
return token;
});
}
return attempt(0);
});
Jalankan Tes CAPTCHA di Pipeline CI/CD
GitHub Actions
CAPTCHAAI_KEY masuk sebagai secret, bukan hardcode:
name: E2E Tests
on: [push, pull_request]
jobs:
cypress:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- name: Run Cypress tests
uses: cypress-io/github-action@v6
env:
CAPTCHAAI_KEY: ${{ secrets.CAPTCHAAI_KEY }}
with:
wait-on: "http://localhost:3000"
start: npm start
Tes API-Level dengan Jest (Opsional)
Untuk tim yang juga menguji CaptchaAI di level API murni, panggil solveCaptchaTask dari Jest:
// Untuk tim yang juga menggunakan Jest untuk tes CAPTCHA di level API
const { solveCaptchaTask } = require("../cypress/plugins/captcha-solver");
test("CaptchaAI solves reCAPTCHA v2", async () => {
const token = await solveCaptchaTask(
"https://www.google.com/recaptcha/api2/demo",
"6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-",
"recaptcha_v2"
);
expect(token).toBeDefined();
expect(token.length).toBeGreaterThan(50);
}, 120000);
Alokasi Thread untuk Runner Paralel
Untuk job Cypress paralel — termasuk runner di region Singapura (ap-southeast-1) atau Jakarta (asia-southeast2) — hitung thread CaptchaAI, bukan jumlah runner: satu job aktif = satu thread. BASIC ($15/bulan, 5 thread) cukup untuk pipeline kecil; matrix testing lebih besar naik ke STANDARD ($30/bulan, 15 thread). Billing per thread dengan solve tanpa batas membuat biaya tetap flat walau volume test naik.
Pantau pemakaian thread lewat dashboard CaptchaAI sebelum menaikkan concurrency runner di CI, lalu naikkan plan bila kebutuhan thread paralel Anda mendekati batas plan yang sedang dipakai.
Ringkasan Alur Empat Langkah
Berapa pun jumlah skenario tes yang Anda tambahkan, alur intinya selalu sama:
- Task handler kirim challenge ke
in.php - Simpan
taskIddari respons - Polling
res.phptiap 5 detik sampai token siap - Custom command suntik token ke DOM lalu submit form
Hafalkan urutan ini dan debugging jadi jauh lebih cepat — begitu ada langkah yang macet, Anda langsung tahu di titik mana harus mencari log.
Masalah Umum dan Solusi Cepat
| Gejala | Penyebab | Solusi |
|---|---|---|
cy.task timed out |
Solve melewati batas waktu default | Naikkan taskTimeout di config |
| Token ditolak backend | Kedaluwarsa sebelum sempat di-submit | Perpendek jeda solve ke submit |
data-sitekey tidak ditemukan |
CAPTCHA dimuat dinamis (lazy render) | Tambahkan cy.wait() atau cy.intercept() |
Callback grecaptcha tidak terpicu |
Situs pakai nama callback kustom | Periksa ___grecaptcha_cfg di DevTools |
| Lolos lokal, gagal di CI | CAPTCHAAI_KEY tidak ter-set di CI |
Tambahkan sebagai secret CI |
Sebagian besar kegagalan di atas selesai dengan menaikkan timeout atau memastikan CAPTCHAAI_KEY benar-benar ter-set di environment CI — jarang perlu debug lebih dalam di sisi CaptchaAI sendiri.
Pertanyaan Umum
Kenapa butuh custom command retry terpisah, bukan cukup naikkan timeout saja?
Naikkan timeout hanya membuat Cypress menunggu lebih lama untuk kegagalan yang sama. solveCaptchaWithRetry mencoba ulang solve dari awal saat gagal karena sebab sementara — timeout jaringan atau rate limit — dua masalah yang beda akar.
Apakah setup ini jalan untuk Cypress component testing?
Tidak. Component testing tidak memuat halaman penuh, jadi tidak ada data-sitekey nyata untuk dibaca — pakai ini hanya untuk E2E.
Berapa thread CaptchaAI yang dibutuhkan untuk CI paralel?
Hitung dari jumlah job yang solve bersamaan, bukan jumlah test total. Satu job aktif = satu thread.
Apa beda penanganan reCAPTCHA v2 dan Turnstile di custom command ini?
solveCaptcha() menyuntikkan token ke g-recaptcha-response dan memicu callback grecaptcha. solveTurnstile() menulis token ke cf-turnstile-response — tidak ada callback JS setara.
Kenapa test tetap gagal walau token sudah berhasil di-inject?
Paling sering delay solve-ke-submit terlalu panjang sehingga token kedaluwarsa, atau elemen submit berpindah di DOM sebelum cy.get() sempat klik.