test(e2e): playwright browser harness against 2captcha demo

This commit is contained in:
2026-06-07 14:08:40 +01:00
parent 021e4400ad
commit b16fff8700
4 changed files with 476 additions and 1 deletions
+10 -1
View File
@@ -1,4 +1,4 @@
.PHONY: build test e2e e2e-live lint tidy .PHONY: build test e2e e2e-live e2e-browser lint tidy
build: build:
go build -o bin/captcha-solver-mcp ./cmd/captcha-solver-mcp go build -o bin/captcha-solver-mcp ./cmd/captcha-solver-mcp
test: test:
@@ -7,6 +7,15 @@ e2e:
go test -tags e2e ./test/e2e/... go test -tags e2e ./test/e2e/...
e2e-live: e2e-live:
CAPTCHA_E2E_LIVE=1 go test -tags e2e,live ./test/e2e/... CAPTCHA_E2E_LIVE=1 go test -tags e2e,live ./test/e2e/...
# Live browser e2e against the 2captcha demo (network + Chromium required).
# Prereqs: npm install && npx playwright install chromium
e2e-browser:
CAPTCHA_E2E_LIVE=1 \
CAPTCHA_LLM_PROVIDER=openai-compatible \
CAPTCHA_LLM_BASE_URL=https://llmgw.h.raczylo.com \
CAPTCHA_LLM_MODEL=openai/gpt-5.4 \
CAPTCHA_LLM_TIMEOUT=120s \
go test -tags 'e2e live' -run Browser -v ./test/e2e/...
lint: lint:
golangci-lint run golangci-lint run
tidy: tidy:
+95
View File
@@ -0,0 +1,95 @@
//go:build e2e && live
package e2e
import (
"os"
"os/exec"
"path/filepath"
"testing"
"time"
)
// runBrowserScript shells out to a Playwright .mjs harness under test/e2e/playwright.
// It gates on CAPTCHA_E2E_LIVE=1, requires `node` and an installed Playwright Chromium,
// inherits the ambient CAPTCHA_LLM_* gateway config, and asserts a clean (exit 0) run.
func runBrowserScript(t *testing.T, script string) {
t.Helper()
if os.Getenv("CAPTCHA_E2E_LIVE") != "1" {
t.Skip("CAPTCHA_E2E_LIVE != 1; skipping live browser e2e")
}
if os.Getenv("CAPTCHA_LLM_PROVIDER") == "" || os.Getenv("CAPTCHA_LLM_MODEL") == "" {
t.Skip("CAPTCHA_LLM_PROVIDER / CAPTCHA_LLM_MODEL not set; skipping live browser e2e")
}
if _, err := exec.LookPath("node"); err != nil {
t.Skip("node not found on PATH; skipping live browser e2e")
}
if !chromiumInstalled() {
t.Skip("Playwright Chromium not installed (run: npx playwright install chromium); skipping")
}
// cwd is test/e2e so the script path "playwright/<script>" resolves and pnpm's
// node_modules above it is reachable for the `playwright` import.
cmd := exec.Command("node", filepath.Join("playwright", script))
cmd.Dir = "."
cmd.Env = append(cmd.Environ(),
"CAPTCHA_LLM_TIMEOUT="+envOr("CAPTCHA_LLM_TIMEOUT", "120s"),
)
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
// Generous ceiling: live network + a vision model + (recaptcha) several rounds.
timer := time.AfterFunc(5*time.Minute, func() {
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
})
defer timer.Stop()
if err := cmd.Run(); err != nil {
t.Fatalf("browser script %s failed: %v", script, err)
}
}
// chromiumInstalled reports whether a Playwright Chromium build is present in the
// default browsers cache. Playwright also honors PLAYWRIGHT_BROWSERS_PATH.
func chromiumInstalled() bool {
dirs := []string{}
if p := os.Getenv("PLAYWRIGHT_BROWSERS_PATH"); p != "" {
dirs = append(dirs, p)
}
if home, err := os.UserHomeDir(); err == nil {
dirs = append(dirs,
filepath.Join(home, "Library", "Caches", "ms-playwright"), // macOS
filepath.Join(home, ".cache", "ms-playwright"), // linux
)
}
for _, d := range dirs {
matches, _ := filepath.Glob(filepath.Join(d, "chromium-*"))
if len(matches) > 0 {
return true
}
}
return false
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
// TestBrowserSolveText drives the RELIABLE path: the live 2captcha text-captcha demo,
// solved via the MCP server + a real vision model, verified by the demo's own success
// indicator. Fails if the demo does not accept any prediction after retries.
func TestBrowserSolveText(t *testing.T) {
runBrowserScript(t, "solve-text.mjs")
}
// TestBrowserRecaptchaV2 drives the BEST-EFFORT path: the live 2captcha reCAPTCHA v2
// demo. The script returns 0 whether or not a token is obtained (Google often resists
// headless), failing only on a real script/transport error.
func TestBrowserRecaptchaV2(t *testing.T) {
runBrowserScript(t, "solve-recaptcha-v2.mjs")
}
+222
View File
@@ -0,0 +1,222 @@
// BEST-EFFORT browser e2e: attempt the LIVE 2captcha reCAPTCHA v2 demo.
//
// server (brain) ── solve_grid_captcha / plan_interaction ──┐
// Playwright (hands) ── checkbox/screenshot/clicks/verify ──> reads g-recaptcha-response
//
// Target: https://2captcha.com/demo/recaptcha-v2 (real Google reCAPTCHA v2). Google
// aggressively resists headless/automation, so obtaining a token is NOT guaranteed. This
// script is BEST-EFFORT: it logs the outcome and EXITS 0 even with no token. It exits
// non-zero ONLY on a real script/transport error (e.g. server crash, navigation failure).
//
// AUTHORIZED USE ONLY — this targets 2captcha's public reCAPTCHA demo page.
//
// Required env (passed straight through to the spawned server):
// CAPTCHA_LLM_PROVIDER=openai-compatible
// CAPTCHA_LLM_BASE_URL=https://llmgw.h.raczylo.com
// CAPTCHA_LLM_MODEL=openai/gpt-5.4
// CAPTCHA_LLM_TIMEOUT=120s (optional)
// CAPTCHA_LLM_API_KEY=... (optional; gateway is keyless)
// CAPTCHA_BIN=/abs/path (optional; defaults to repo bin/captcha-solver-mcp)
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { chromium } from 'playwright';
import { McpStdioClient } from '../../../examples/mcp-client.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, '../../..');
const TARGET_URL = 'https://2captcha.com/demo/recaptcha-v2';
const CAPTCHA_BIN = process.env.CAPTCHA_BIN || path.join(REPO_ROOT, 'bin', 'captcha-solver-mcp');
const HEADLESS = process.env.CAPTCHA_E2E_HEADLESS !== '0';
const MAX_ROUNDS = 5; // dynamic grids re-render; cap the loop.
const serverEnv = {
CAPTCHA_LLM_PROVIDER: process.env.CAPTCHA_LLM_PROVIDER || 'openai-compatible',
CAPTCHA_LLM_BASE_URL: process.env.CAPTCHA_LLM_BASE_URL,
CAPTCHA_LLM_API_KEY: process.env.CAPTCHA_LLM_API_KEY,
CAPTCHA_LLM_MODEL: process.env.CAPTCHA_LLM_MODEL,
CAPTCHA_LLM_TIMEOUT: process.env.CAPTCHA_LLM_TIMEOUT || '120s',
};
function log(...args) {
console.log('[recaptcha-v2]', ...args);
}
// Centroids come back from the Go imageutil.Point struct, which has no JSON tags,
// so it serializes with capitalized keys {X,Y}. Tolerate both shapes.
function centroidXY(c) {
if (!c) return null;
const x = c.x ?? c.X;
const y = c.y ?? c.Y;
if (typeof x !== 'number' || typeof y !== 'number') return null;
return { x, y };
}
async function readToken(page) {
return page.evaluate(() => {
const el = document.querySelector('textarea[name="g-recaptcha-response"]');
return el ? el.value : '';
});
}
async function main() {
const mcp = new McpStdioClient(CAPTCHA_BIN, serverEnv);
await mcp.initialize();
// Coherent identity + matching headers + evasion init script.
const fp = await mcp.callTool('generate_fingerprint', {
os: 'macos',
browser: 'chrome',
locale: 'en-US',
seed: 42,
});
const stealth = await mcp.callTool('get_stealth_script', { engine: 'playwright' });
log('stealth evasions:', stealth.included_evasions);
const headers = (fp.header_set && fp.header_set.headers) || {};
const userAgent = headers['User-Agent'] || headers['user-agent'];
const [vpW, vpH] = (fp.fingerprint && fp.fingerprint.viewport
? fp.fingerprint.viewport
: '1366x768'
)
.split('x')
.map((n) => parseInt(n, 10));
const locale = (fp.fingerprint && fp.fingerprint.locale) || 'en-US';
const browser = await chromium.launch({ headless: HEADLESS });
const context = await browser.newContext({
userAgent,
locale,
viewport: { width: vpW || 1366, height: vpH || 768 },
extraHTTPHeaders: headers,
});
await context.addInitScript({ content: stealth.script });
const page = await context.newPage();
let token = '';
try {
await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
// Click the "I'm not a robot" checkbox inside the anchor iframe.
const anchor = page.frameLocator('iframe[title="reCAPTCHA"]');
await anchor.locator('#recaptcha-anchor').click({ timeout: 20000 });
await page.waitForTimeout(1500);
// If the checkbox alone passed (low-friction path), we're done.
token = await readToken(page);
if (token) {
log('token obtained from checkbox alone (no image challenge).');
} else {
// Image challenge lives in the "challenge" iframe.
const challenge = page.frameLocator('iframe[title*="challenge"]');
for (let round = 1; round <= MAX_ROUNDS; round++) {
let instruction = '';
try {
instruction = (
await challenge.locator('.rc-imageselect-instructions').innerText({ timeout: 8000 })
)
.replace(/\s+/g, ' ')
.trim();
} catch {
log(`round ${round}: no challenge instructions visible; stopping.`);
break;
}
const gridTable = challenge.locator('table');
const box = await gridTable.boundingBox();
if (!box) {
log(`round ${round}: could not locate the grid table; stopping.`);
break;
}
const cells = await challenge.locator('table td').count();
const cols = Math.max(1, Math.round(Math.sqrt(cells)));
const rows = Math.max(1, Math.round(cells / cols));
const png = await gridTable.screenshot();
const screenshot = `data:image/png;base64,${png.toString('base64')}`;
const grid = await mcp.callTool('solve_grid_captcha', {
screenshot,
instruction,
rows,
cols,
image_width: Math.round(box.width),
image_height: Math.round(box.height),
captcha_type: 'recaptcha',
});
log(
`round ${round}: instruction="${instruction}" tiles=${JSON.stringify(grid.tiles)} ` +
`conf=${grid.confidence} recheck=${grid.recheck_recommended}`,
);
if (grid.tiles && grid.tiles.length > 0) {
// Map tiles -> page-absolute click targets (prefer brain centroids).
const clickActions = grid.tiles.map((tileIndex, i) => {
const c = centroidXY(grid.centroids && grid.centroids[i]);
const localX = c ? c.x : ((tileIndex % cols) + 0.5) * (box.width / cols);
const localY = c ? c.y : (Math.floor(tileIndex / cols) + 0.5) * (box.height / rows);
return { type: 'click', x: Math.round(box.x + localX), y: Math.round(box.y + localY) };
});
const plan = await mcp.callTool('plan_interaction', { actions: clickActions, seed: 7 });
for (const step of plan.steps) {
if (step.type === 'move') await page.mouse.move(step.x, step.y);
else if (step.type === 'down') await page.mouse.down();
else if (step.type === 'up') await page.mouse.up();
if (step.at_ms != null) await page.waitForTimeout(8);
}
}
if (grid.recheck_recommended && grid.tiles && grid.tiles.length > 0) {
await page.waitForTimeout(900); // dynamic tiles fade in; re-screenshot next round.
continue;
}
// Verify the current selection.
try {
await challenge.locator('#recaptcha-verify-button').click({ timeout: 8000 });
} catch {
log(`round ${round}: verify button not clickable; stopping.`);
break;
}
await page.waitForTimeout(1800);
token = await readToken(page);
if (token) break;
// No token yet — Google likely served a fresh challenge; loop and re-solve.
log(`round ${round}: no token yet; re-checking for a new challenge.`);
}
}
token = token || (await readToken(page));
} catch (err) {
// A genuine script/transport error -> non-zero exit.
console.error('[recaptcha-v2] ERROR:', err && err.stack ? err.stack : err);
mcp.close();
await browser.close();
process.exit(1);
}
mcp.close();
await browser.close();
if (token) {
log(`OUTCOME: token obtained (length=${token.length}). reCAPTCHA v2 solved.`);
} else {
log(
'OUTCOME: no token obtained. Real Google reCAPTCHA commonly resists headless automation; ' +
'this is expected for the best-effort path. Exiting 0.',
);
}
process.exit(0); // best-effort: success regardless of token, only errors fail.
}
main().catch((err) => {
console.error('[recaptcha-v2] ERROR:', err && err.stack ? err.stack : err);
process.exit(1);
});
+149
View File
@@ -0,0 +1,149 @@
// RELIABLE browser e2e: solve the LIVE 2captcha text-captcha demo end-to-end.
//
// server (brain) ── solve_text_captcha ──┐
// Playwright (hands) ── screenshot/type/submit ──> 2captcha demo verifies the answer
//
// The 2captcha demo at https://2captcha.com/demo/normal is the ground truth: it renders a
// distorted-text image, accepts a typed answer, and shows a success message only when the
// answer is correct. We screenshot the image element, ask the MCP server's vision OCR to
// read it, type the prediction, click "Check", and assert the demo's own success indicator.
//
// AUTHORIZED USE ONLY — this targets 2captcha's public, self-verifying demo page.
//
// Required env (passed straight through to the spawned server):
// CAPTCHA_LLM_PROVIDER=openai-compatible
// CAPTCHA_LLM_BASE_URL=https://llmgw.h.raczylo.com
// CAPTCHA_LLM_MODEL=openai/gpt-5.4
// CAPTCHA_LLM_TIMEOUT=120s (optional; defaults to 120s here)
// CAPTCHA_LLM_API_KEY=... (optional; gateway is keyless)
// CAPTCHA_BIN=/abs/path (optional; defaults to repo bin/captcha-solver-mcp)
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { chromium } from 'playwright';
import { McpStdioClient } from '../../../examples/mcp-client.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, '../../..');
const TARGET_URL = 'https://2captcha.com/demo/normal';
const CAPTCHA_BIN = process.env.CAPTCHA_BIN || path.join(REPO_ROOT, 'bin', 'captcha-solver-mcp');
const HEADLESS = process.env.CAPTCHA_E2E_HEADLESS !== '0';
const MAX_ATTEMPTS = 3; // initial + up to 2 fresh retries; OCR is stochastic.
// Selectors verified against the live DOM (CSS-module hashes are partial-matched).
const SEL = {
image: 'img[class*="_captchaImage"]',
input: '#simple-captcha-field',
submit: 'form[class*="_widgetForm"] button[type="submit"]',
success: '[class*="_successMessage"]',
error: '[class*="_errorMessage"], [class*="_alertError"]',
};
const serverEnv = {
CAPTCHA_LLM_PROVIDER: process.env.CAPTCHA_LLM_PROVIDER || 'openai-compatible',
CAPTCHA_LLM_BASE_URL: process.env.CAPTCHA_LLM_BASE_URL,
CAPTCHA_LLM_API_KEY: process.env.CAPTCHA_LLM_API_KEY,
CAPTCHA_LLM_MODEL: process.env.CAPTCHA_LLM_MODEL,
CAPTCHA_LLM_TIMEOUT: process.env.CAPTCHA_LLM_TIMEOUT || '120s',
};
function log(...args) {
console.log('[solve-text]', ...args);
}
async function main() {
const mcp = new McpStdioClient(CAPTCHA_BIN, serverEnv);
await mcp.initialize();
// Stealth init script the page never sees as automation.
const stealth = await mcp.callTool('get_stealth_script', { engine: 'playwright' });
log('stealth evasions:', stealth.included_evasions);
const browser = await chromium.launch({ headless: HEADLESS });
const context = await browser.newContext();
await context.addInitScript({ content: stealth.script });
const page = await context.newPage();
let passed = false;
const predictions = [];
try {
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
// Fresh load each attempt so any per-load captcha state resets.
await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
const img = page.locator(SEL.image).first();
await img.waitFor({ state: 'visible', timeout: 30000 });
// Screenshot just the image element -> base64 PNG for the OCR tool.
const pngBuf = await img.screenshot({ type: 'png' });
const dataUri = `data:image/png;base64,${pngBuf.toString('base64')}`;
// The demo's normal captcha is a 5-char alphanumeric, case-insensitive.
const solved = await mcp.callTool('solve_text_captcha', {
image: dataUri,
length: 5,
case_sensitive: false,
});
const prediction = (solved.text || '').trim();
predictions.push(prediction);
log(`attempt ${attempt}/${MAX_ATTEMPTS}: predicted="${prediction}" confidence=${solved.confidence}`);
if (!prediction) {
log(`attempt ${attempt}: empty prediction; retrying`);
continue;
}
const input = page.locator(SEL.input);
await input.fill('');
await input.fill(prediction);
await page.locator(SEL.submit).click();
// Wait for either the success or the error indicator to appear.
const outcome = await Promise.race([
page
.locator(SEL.success)
.first()
.waitFor({ state: 'visible', timeout: 15000 })
.then(() => 'success')
.catch(() => null),
page
.locator(SEL.error)
.first()
.waitFor({ state: 'visible', timeout: 15000 })
.then(() => 'error')
.catch(() => null),
]);
if (outcome === 'success') {
const msg = (await page.locator(SEL.success).first().innerText()).trim();
log(`attempt ${attempt}: SUCCESS — demo says: "${msg}"`);
passed = true;
break;
}
const errText = (await page.locator(SEL.error).first().innerText().catch(() => '')).trim();
log(`attempt ${attempt}: NOT accepted (demo: "${errText || 'no success indicator'}")`);
}
} finally {
mcp.close();
await browser.close();
}
if (!passed) {
console.error(
`[solve-text] FAIL: demo did not accept any prediction after ${MAX_ATTEMPTS} attempts. ` +
`predictions=${JSON.stringify(predictions)}`,
);
process.exit(1);
}
log(`PASS: predictions tried=${JSON.stringify(predictions)} — demo confirmed correct.`);
process.exit(0);
}
main().catch((err) => {
console.error('[solve-text] ERROR:', err && err.stack ? err.stack : err);
process.exit(1);
});