From 6c6dbf94536ee60b11df21c86bac50083d939400 Mon Sep 17 00:00:00 2001 From: dzmitry-duboyski Date: Thu, 6 Aug 2026 13:47:20 +0300 Subject: [PATCH 01/21] Restructure test suite and import the SDK by package name Split tests by type at the top level: unit/ (no network, fetch mocked, no API key) and integration/ (real API). Previously unit tests lived in sync/ and async/ while the single integration file sat loose in the tests/ root, and the npm scripts told the two apart by regex-matching the word "integration" anywhere in the path. Select the suites by directory instead. This also fixes a side effect of the old flag: --testPathIgnorePatterns=integration overrode the Jest default of ["/node_modules/"], so node_modules stopped being ignored during unit runs. Tests now import the SDK by package name rather than by relative path to src/. Node resolves the package self-reference through the "exports" map in package.json and Jest honours it, so the suite exercises the same entry points a consumer gets. With direct file imports an export could be dropped from src/index.js, or the "exports" map broken, and every test would still pass while the package was unusable. Co-Authored-By: Claude Opus 5 --- package.json | 4 ++-- tests/{integration.test.js => integration/client.test.js} | 3 +-- tests/{ => unit}/async/client.test.js | 6 ++---- tests/{ => unit}/async/coordinates.test.js | 5 ++--- tests/{ => unit}/async/geetest.test.js | 5 ++--- tests/{ => unit}/async/image_to_text.test.js | 5 ++--- tests/{ => unit}/async/recaptcha_v2.test.js | 5 ++--- tests/{ => unit}/async/recaptcha_v2_enterprise.test.js | 5 ++--- tests/{ => unit}/async/recaptcha_v3.test.js | 5 ++--- tests/{ => unit}/async/tencent.test.js | 5 ++--- tests/{ => unit}/async/turnstile.test.js | 5 ++--- tests/{ => unit}/async/yandex_smartcaptcha.test.js | 5 ++--- tests/{ => unit}/sync/client.test.js | 6 ++---- tests/{ => unit}/sync/coordinates.test.js | 3 +-- tests/{ => unit}/sync/geetest.test.js | 3 +-- tests/{ => unit}/sync/image_to_text.test.js | 3 +-- tests/{ => unit}/sync/recaptcha_v2.test.js | 3 +-- tests/{ => unit}/sync/recaptcha_v2_enterprise.test.js | 3 +-- tests/{ => unit}/sync/recaptcha_v3.test.js | 4 +--- tests/{ => unit}/sync/tencent.test.js | 3 +-- tests/{ => unit}/sync/turnstile.test.js | 3 +-- tests/{ => unit}/sync/yandex_smartcaptcha.test.js | 3 +-- 22 files changed, 34 insertions(+), 58 deletions(-) rename tests/{integration.test.js => integration/client.test.js} (92%) rename tests/{ => unit}/async/client.test.js (96%) rename tests/{ => unit}/async/coordinates.test.js (78%) rename tests/{ => unit}/async/geetest.test.js (88%) rename tests/{ => unit}/async/image_to_text.test.js (76%) rename tests/{ => unit}/async/recaptcha_v2.test.js (82%) rename tests/{ => unit}/async/recaptcha_v2_enterprise.test.js (78%) rename tests/{ => unit}/async/recaptcha_v3.test.js (78%) rename tests/{ => unit}/async/tencent.test.js (80%) rename tests/{ => unit}/async/turnstile.test.js (78%) rename tests/{ => unit}/async/yandex_smartcaptcha.test.js (77%) rename tests/{ => unit}/sync/client.test.js (96%) rename tests/{ => unit}/sync/coordinates.test.js (95%) rename tests/{ => unit}/sync/geetest.test.js (96%) rename tests/{ => unit}/sync/image_to_text.test.js (94%) rename tests/{ => unit}/sync/recaptcha_v2.test.js (97%) rename tests/{ => unit}/sync/recaptcha_v2_enterprise.test.js (95%) rename tests/{ => unit}/sync/recaptcha_v3.test.js (91%) rename tests/{ => unit}/sync/tencent.test.js (95%) rename tests/{ => unit}/sync/turnstile.test.js (95%) rename tests/{ => unit}/sync/yandex_smartcaptcha.test.js (95%) diff --git a/package.json b/package.json index fc621e2..dea53f6 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ ], "scripts": { "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", - "test:unit": "node --experimental-vm-modules node_modules/jest/bin/jest.js --testPathIgnorePatterns=integration", - "test:integration": "node --experimental-vm-modules node_modules/jest/bin/jest.js --testPathPattern=integration" + "test:unit": "node --experimental-vm-modules node_modules/jest/bin/jest.js tests/unit", + "test:integration": "node --experimental-vm-modules node_modules/jest/bin/jest.js tests/integration" }, "keywords": [ "captcha-solver", diff --git a/tests/integration.test.js b/tests/integration/client.test.js similarity index 92% rename from tests/integration.test.js rename to tests/integration/client.test.js index f56ccf6..14de05a 100644 --- a/tests/integration.test.js +++ b/tests/integration/client.test.js @@ -3,8 +3,7 @@ * is set in the environment. Run with `npm run test:integration`. */ -import { CaptchaClient } from '../src/client.js'; -import * as Tasks from '../src/tasks.js'; +import { CaptchaClient, Tasks } from 'captcha-sdk'; describe('CaptchaClient integration', () => { let client; diff --git a/tests/async/client.test.js b/tests/unit/async/client.test.js similarity index 96% rename from tests/async/client.test.js rename to tests/unit/async/client.test.js index 8c55863..d41da00 100644 --- a/tests/async/client.test.js +++ b/tests/unit/async/client.test.js @@ -1,14 +1,12 @@ /** * Generic tests for CaptchaClient (transport, error handling, polling) -- * not tied to any specific captcha type, written in async/await style. - * Mirrors tests/sync/client.test.js. + * Mirrors tests/unit/sync/client.test.js. * See .test.js in this directory for per-type solve() tests. */ import { jest } from '@jest/globals'; -import { CaptchaClient } from '../../src/client.js'; -import { ApiError, NetworkError, TimeoutError, ValidationError } from '../../src/exceptions.js'; -import * as Tasks from '../../src/tasks.js'; +import { ApiError, CaptchaClient, NetworkError, Tasks, TimeoutError, ValidationError } from 'captcha-sdk'; describe('CaptchaClient (async)', () => { let client; diff --git a/tests/async/coordinates.test.js b/tests/unit/async/coordinates.test.js similarity index 78% rename from tests/async/coordinates.test.js rename to tests/unit/async/coordinates.test.js index be3f442..f5d0f77 100644 --- a/tests/async/coordinates.test.js +++ b/tests/unit/async/coordinates.test.js @@ -1,11 +1,10 @@ /** * Async solve() test for CoordinatesTask. - * Task serialization is covered once in tests/sync/coordinates.test.js. + * Task serialization is covered once in tests/unit/sync/coordinates.test.js. */ import { jest } from '@jest/globals'; -import { CaptchaClient } from '../../src/client.js'; -import * as Tasks from '../../src/tasks.js'; +import { CaptchaClient, Tasks } from 'captcha-sdk'; test('solve', async () => { const client = new CaptchaClient({ clientKey: 'test_key', pollingInterval: 10 }); diff --git a/tests/async/geetest.test.js b/tests/unit/async/geetest.test.js similarity index 88% rename from tests/async/geetest.test.js rename to tests/unit/async/geetest.test.js index cc9b815..c8795c0 100644 --- a/tests/async/geetest.test.js +++ b/tests/unit/async/geetest.test.js @@ -1,11 +1,10 @@ /** * Async solve() tests for GeeTestProxyless (v3 and v4). - * Task serialization is covered once in tests/sync/geetest.test.js. + * Task serialization is covered once in tests/unit/sync/geetest.test.js. */ import { jest } from '@jest/globals'; -import { CaptchaClient } from '../../src/client.js'; -import * as Tasks from '../../src/tasks.js'; +import { CaptchaClient, Tasks } from 'captcha-sdk'; test('solve v3', async () => { const client = new CaptchaClient({ clientKey: 'test_key', pollingInterval: 10 }); diff --git a/tests/async/image_to_text.test.js b/tests/unit/async/image_to_text.test.js similarity index 76% rename from tests/async/image_to_text.test.js rename to tests/unit/async/image_to_text.test.js index af33013..521eec1 100644 --- a/tests/async/image_to_text.test.js +++ b/tests/unit/async/image_to_text.test.js @@ -1,11 +1,10 @@ /** * Async solve() test for ImageToText. - * Task serialization is covered once in tests/sync/image_to_text.test.js. + * Task serialization is covered once in tests/unit/sync/image_to_text.test.js. */ import { jest } from '@jest/globals'; -import { CaptchaClient } from '../../src/client.js'; -import * as Tasks from '../../src/tasks.js'; +import { CaptchaClient, Tasks } from 'captcha-sdk'; test('solve', async () => { const client = new CaptchaClient({ clientKey: 'test_key', pollingInterval: 10 }); diff --git a/tests/async/recaptcha_v2.test.js b/tests/unit/async/recaptcha_v2.test.js similarity index 82% rename from tests/async/recaptcha_v2.test.js rename to tests/unit/async/recaptcha_v2.test.js index 7c17522..8b8d2e8 100644 --- a/tests/async/recaptcha_v2.test.js +++ b/tests/unit/async/recaptcha_v2.test.js @@ -1,12 +1,11 @@ /** * Async solve() test for RecaptchaV2Proxyless. - * Task serialization is covered once in tests/sync/recaptcha_v2.test.js -- + * Task serialization is covered once in tests/unit/sync/recaptcha_v2.test.js -- * it doesn't depend on which style (sync/async) is used to call solve(). */ import { jest } from '@jest/globals'; -import { CaptchaClient } from '../../src/client.js'; -import * as Tasks from '../../src/tasks.js'; +import { CaptchaClient, Tasks } from 'captcha-sdk'; test('solve', async () => { const client = new CaptchaClient({ clientKey: 'test_key', pollingInterval: 10 }); diff --git a/tests/async/recaptcha_v2_enterprise.test.js b/tests/unit/async/recaptcha_v2_enterprise.test.js similarity index 78% rename from tests/async/recaptcha_v2_enterprise.test.js rename to tests/unit/async/recaptcha_v2_enterprise.test.js index 4ab0bd0..1519b49 100644 --- a/tests/async/recaptcha_v2_enterprise.test.js +++ b/tests/unit/async/recaptcha_v2_enterprise.test.js @@ -1,11 +1,10 @@ /** * Async solve() test for RecaptchaV2EnterpriseProxyless. - * Task serialization is covered once in tests/sync/recaptcha_v2_enterprise.test.js. + * Task serialization is covered once in tests/unit/sync/recaptcha_v2_enterprise.test.js. */ import { jest } from '@jest/globals'; -import { CaptchaClient } from '../../src/client.js'; -import * as Tasks from '../../src/tasks.js'; +import { CaptchaClient, Tasks } from 'captcha-sdk'; test('solve', async () => { const client = new CaptchaClient({ clientKey: 'test_key', pollingInterval: 10 }); diff --git a/tests/async/recaptcha_v3.test.js b/tests/unit/async/recaptcha_v3.test.js similarity index 78% rename from tests/async/recaptcha_v3.test.js rename to tests/unit/async/recaptcha_v3.test.js index 905fe94..1d3932a 100644 --- a/tests/async/recaptcha_v3.test.js +++ b/tests/unit/async/recaptcha_v3.test.js @@ -1,11 +1,10 @@ /** * Async solve() test for RecaptchaV3Proxyless. - * Task serialization is covered once in tests/sync/recaptcha_v3.test.js. + * Task serialization is covered once in tests/unit/sync/recaptcha_v3.test.js. */ import { jest } from '@jest/globals'; -import { CaptchaClient } from '../../src/client.js'; -import * as Tasks from '../../src/tasks.js'; +import { CaptchaClient, Tasks } from 'captcha-sdk'; test('solve', async () => { const client = new CaptchaClient({ clientKey: 'test_key', pollingInterval: 10 }); diff --git a/tests/async/tencent.test.js b/tests/unit/async/tencent.test.js similarity index 80% rename from tests/async/tencent.test.js rename to tests/unit/async/tencent.test.js index e2a565a..ac86cb2 100644 --- a/tests/async/tencent.test.js +++ b/tests/unit/async/tencent.test.js @@ -1,11 +1,10 @@ /** * Async solve() test for TencentTaskProxyless. - * Task serialization is covered once in tests/sync/tencent.test.js. + * Task serialization is covered once in tests/unit/sync/tencent.test.js. */ import { jest } from '@jest/globals'; -import { CaptchaClient } from '../../src/client.js'; -import * as Tasks from '../../src/tasks.js'; +import { CaptchaClient, Tasks } from 'captcha-sdk'; test('solve', async () => { const client = new CaptchaClient({ clientKey: 'test_key', pollingInterval: 10 }); diff --git a/tests/async/turnstile.test.js b/tests/unit/async/turnstile.test.js similarity index 78% rename from tests/async/turnstile.test.js rename to tests/unit/async/turnstile.test.js index f41e7c9..6606b08 100644 --- a/tests/async/turnstile.test.js +++ b/tests/unit/async/turnstile.test.js @@ -1,11 +1,10 @@ /** * Async solve() test for TurnstileProxyless. - * Task serialization is covered once in tests/sync/turnstile.test.js. + * Task serialization is covered once in tests/unit/sync/turnstile.test.js. */ import { jest } from '@jest/globals'; -import { CaptchaClient } from '../../src/client.js'; -import * as Tasks from '../../src/tasks.js'; +import { CaptchaClient, Tasks } from 'captcha-sdk'; test('solve', async () => { const client = new CaptchaClient({ clientKey: 'test_key', pollingInterval: 10 }); diff --git a/tests/async/yandex_smartcaptcha.test.js b/tests/unit/async/yandex_smartcaptcha.test.js similarity index 77% rename from tests/async/yandex_smartcaptcha.test.js rename to tests/unit/async/yandex_smartcaptcha.test.js index 9418288..80a303f 100644 --- a/tests/async/yandex_smartcaptcha.test.js +++ b/tests/unit/async/yandex_smartcaptcha.test.js @@ -1,11 +1,10 @@ /** * Async solve() test for YandexSmartCaptchaTaskProxyless. - * Task serialization is covered once in tests/sync/yandex_smartcaptcha.test.js. + * Task serialization is covered once in tests/unit/sync/yandex_smartcaptcha.test.js. */ import { jest } from '@jest/globals'; -import { CaptchaClient } from '../../src/client.js'; -import * as Tasks from '../../src/tasks.js'; +import { CaptchaClient, Tasks } from 'captcha-sdk'; test('solve', async () => { const client = new CaptchaClient({ clientKey: 'test_key', pollingInterval: 10 }); diff --git a/tests/sync/client.test.js b/tests/unit/sync/client.test.js similarity index 96% rename from tests/sync/client.test.js rename to tests/unit/sync/client.test.js index 5a68bec..437f1de 100644 --- a/tests/sync/client.test.js +++ b/tests/unit/sync/client.test.js @@ -1,15 +1,13 @@ /** * Generic tests for CaptchaClient (transport, error handling, polling) -- * not tied to any specific captcha type, written in promise-chain style - * (.then/.catch, no async/await). Mirrors tests/async/client.test.js. + * (.then/.catch, no async/await). Mirrors tests/unit/async/client.test.js. * See .test.js in this directory for per-type task * serialization tests. */ import { jest } from '@jest/globals'; -import { CaptchaClient } from '../../src/client.js'; -import { ApiError, NetworkError, TimeoutError, ValidationError } from '../../src/exceptions.js'; -import * as Tasks from '../../src/tasks.js'; +import { ApiError, CaptchaClient, NetworkError, Tasks, TimeoutError, ValidationError } from 'captcha-sdk'; describe('CaptchaClient (sync-style)', () => { let client; diff --git a/tests/sync/coordinates.test.js b/tests/unit/sync/coordinates.test.js similarity index 95% rename from tests/sync/coordinates.test.js rename to tests/unit/sync/coordinates.test.js index 10faaf0..8ade0e2 100644 --- a/tests/sync/coordinates.test.js +++ b/tests/unit/sync/coordinates.test.js @@ -4,8 +4,7 @@ */ import { jest } from '@jest/globals'; -import { CaptchaClient } from '../../src/client.js'; -import * as Tasks from '../../src/tasks.js'; +import { CaptchaClient, Tasks } from 'captcha-sdk'; describe('CoordinatesTask', () => { test('toDict includes body and comment', () => { diff --git a/tests/sync/geetest.test.js b/tests/unit/sync/geetest.test.js similarity index 96% rename from tests/sync/geetest.test.js rename to tests/unit/sync/geetest.test.js index e86877e..ab32136 100644 --- a/tests/sync/geetest.test.js +++ b/tests/unit/sync/geetest.test.js @@ -4,8 +4,7 @@ */ import { jest } from '@jest/globals'; -import { CaptchaClient } from '../../src/client.js'; -import * as Tasks from '../../src/tasks.js'; +import { CaptchaClient, Tasks } from 'captcha-sdk'; describe('GeeTest v3 and v4', () => { test('toDict for v3 excludes version field', () => { diff --git a/tests/sync/image_to_text.test.js b/tests/unit/sync/image_to_text.test.js similarity index 94% rename from tests/sync/image_to_text.test.js rename to tests/unit/sync/image_to_text.test.js index 3d14f53..70d21c7 100644 --- a/tests/sync/image_to_text.test.js +++ b/tests/unit/sync/image_to_text.test.js @@ -4,8 +4,7 @@ */ import { jest } from '@jest/globals'; -import { CaptchaClient } from '../../src/client.js'; -import * as Tasks from '../../src/tasks.js'; +import { CaptchaClient, Tasks } from 'captcha-sdk'; describe('ImageToText', () => { test('toDict includes basic fields', () => { diff --git a/tests/sync/recaptcha_v2.test.js b/tests/unit/sync/recaptcha_v2.test.js similarity index 97% rename from tests/sync/recaptcha_v2.test.js rename to tests/unit/sync/recaptcha_v2.test.js index f659bf8..61abe6c 100644 --- a/tests/sync/recaptcha_v2.test.js +++ b/tests/unit/sync/recaptcha_v2.test.js @@ -5,8 +5,7 @@ */ import { jest } from '@jest/globals'; -import { CaptchaClient } from '../../src/client.js'; -import * as Tasks from '../../src/tasks.js'; +import { CaptchaClient, Tasks } from 'captcha-sdk'; describe('RecaptchaV2Proxyless', () => { test('toDict includes required fields', () => { diff --git a/tests/sync/recaptcha_v2_enterprise.test.js b/tests/unit/sync/recaptcha_v2_enterprise.test.js similarity index 95% rename from tests/sync/recaptcha_v2_enterprise.test.js rename to tests/unit/sync/recaptcha_v2_enterprise.test.js index 09752f6..b2b2f83 100644 --- a/tests/sync/recaptcha_v2_enterprise.test.js +++ b/tests/unit/sync/recaptcha_v2_enterprise.test.js @@ -4,8 +4,7 @@ */ import { jest } from '@jest/globals'; -import { CaptchaClient } from '../../src/client.js'; -import * as Tasks from '../../src/tasks.js'; +import { CaptchaClient, Tasks } from 'captcha-sdk'; describe('RecaptchaV2EnterpriseProxyless', () => { test('toDict includes required fields', () => { diff --git a/tests/sync/recaptcha_v3.test.js b/tests/unit/sync/recaptcha_v3.test.js similarity index 91% rename from tests/sync/recaptcha_v3.test.js rename to tests/unit/sync/recaptcha_v3.test.js index c80db48..3be0ee6 100644 --- a/tests/sync/recaptcha_v3.test.js +++ b/tests/unit/sync/recaptcha_v3.test.js @@ -4,9 +4,7 @@ */ import { jest } from '@jest/globals'; -import { CaptchaClient } from '../../src/client.js'; -import { ValidationError } from '../../src/exceptions.js'; -import * as Tasks from '../../src/tasks.js'; +import { CaptchaClient, Tasks, ValidationError } from 'captcha-sdk'; describe('RecaptchaV3Proxyless', () => { test('requires minScore', () => { diff --git a/tests/sync/tencent.test.js b/tests/unit/sync/tencent.test.js similarity index 95% rename from tests/sync/tencent.test.js rename to tests/unit/sync/tencent.test.js index 9e1a4e8..fe43129 100644 --- a/tests/sync/tencent.test.js +++ b/tests/unit/sync/tencent.test.js @@ -4,8 +4,7 @@ */ import { jest } from '@jest/globals'; -import { CaptchaClient } from '../../src/client.js'; -import * as Tasks from '../../src/tasks.js'; +import { CaptchaClient, Tasks } from 'captcha-sdk'; describe('TencentTaskProxyless', () => { test('toDict includes required fields', () => { diff --git a/tests/sync/turnstile.test.js b/tests/unit/sync/turnstile.test.js similarity index 95% rename from tests/sync/turnstile.test.js rename to tests/unit/sync/turnstile.test.js index f3667bc..13b737b 100644 --- a/tests/sync/turnstile.test.js +++ b/tests/unit/sync/turnstile.test.js @@ -4,8 +4,7 @@ */ import { jest } from '@jest/globals'; -import { CaptchaClient } from '../../src/client.js'; -import * as Tasks from '../../src/tasks.js'; +import { CaptchaClient, Tasks } from 'captcha-sdk'; describe('TurnstileProxyless', () => { test('toDict includes required fields', () => { diff --git a/tests/sync/yandex_smartcaptcha.test.js b/tests/unit/sync/yandex_smartcaptcha.test.js similarity index 95% rename from tests/sync/yandex_smartcaptcha.test.js rename to tests/unit/sync/yandex_smartcaptcha.test.js index 5bfca21..805fbba 100644 --- a/tests/sync/yandex_smartcaptcha.test.js +++ b/tests/unit/sync/yandex_smartcaptcha.test.js @@ -4,8 +4,7 @@ */ import { jest } from '@jest/globals'; -import { CaptchaClient } from '../../src/client.js'; -import * as Tasks from '../../src/tasks.js'; +import { CaptchaClient, Tasks } from 'captcha-sdk'; describe('YandexSmartCaptchaTaskProxyless', () => { test('toDict includes required fields', () => { From 14c1854da1720ee339b105973cbd9dd9871158e1 Mon Sep 17 00:00:00 2001 From: dzmitry-duboyski Date: Thu, 6 Aug 2026 13:47:55 +0300 Subject: [PATCH 02/21] Add public API contract test Every other test imports the package by name, so a broken main entry point already fails the suite loudly. What stays uncovered is the rest of the "exports" map: the ./tasks and ./exceptions subpaths, and the fact that undeclared subpaths remain private. Asserts that all three entry points resolve, that __version__ matches the version in package.json, that the subpaths and the main entry hand back the same class objects (otherwise instanceof would break for anyone mixing import styles), and that captcha-sdk/client stays unreachable even though the file exists. The class lists are spelled out explicitly rather than derived from the modules under test: comparing an export against itself always passes. Adding a captcha type means extending the list by hand, which is the point where making a class public becomes a deliberate decision. Co-Authored-By: Claude Opus 5 --- tests/unit/public-api.test.js | 98 +++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 tests/unit/public-api.test.js diff --git a/tests/unit/public-api.test.js b/tests/unit/public-api.test.js new file mode 100644 index 0000000..045029d --- /dev/null +++ b/tests/unit/public-api.test.js @@ -0,0 +1,98 @@ +/** + * Contract tests for the package's public surface. + * + * Every other test file imports the SDK by package name, so a broken main + * entry point would already fail the suite loudly. What those tests do NOT + * cover is the rest of the "exports" map in package.json: the ./tasks and + * ./exceptions subpaths, and the fact that undeclared subpaths stay private. + * That is what this file guards. + */ + +import fs from 'node:fs'; + +import * as pkg from 'captcha-sdk'; +import * as tasksEntry from 'captcha-sdk/tasks'; +import * as exceptionsEntry from 'captcha-sdk/exceptions'; + +const TASK_CLASSES = [ + 'BaseTask', + 'RecaptchaV2Proxyless', + 'RecaptchaV2', + 'RecaptchaV2EnterpriseProxyless', + 'RecaptchaV2Enterprise', + 'RecaptchaV3Proxyless', + 'TurnstileProxyless', + 'Turnstile', + 'GeeTestProxyless', + 'GeeTest', + 'ImageToText', + 'YandexSmartCaptchaTaskProxyless', + 'YandexSmartCaptchaTask', + 'CoordinatesTask', + 'TencentTaskProxyless', + 'TencentTask' +]; + +const ERROR_CLASSES = [ + 'CaptchaError', + 'ApiError', + 'NetworkError', + 'TimeoutError', + 'ValidationError' +]; + +describe('main entry point ("captcha-sdk")', () => { + test('exposes the client, the task namespace and every error class', () => { + expect(typeof pkg.CaptchaClient).toBe('function'); + expect(typeof pkg.Tasks).toBe('object'); + + for (const name of ERROR_CLASSES) { + expect(typeof pkg[name]).toBe('function'); + } + }); + + test('Tasks namespace exposes every task class', () => { + for (const name of TASK_CLASSES) { + expect(typeof pkg.Tasks[name]).toBe('function'); + } + }); + + test('__version__ matches the version in package.json', () => { + const { version } = JSON.parse( + fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8') + ); + + expect(pkg.__version__).toBe(version); + }); +}); + +describe('subpath entry points', () => { + test('"captcha-sdk/tasks" exposes every task class', () => { + for (const name of TASK_CLASSES) { + expect(typeof tasksEntry[name]).toBe('function'); + } + }); + + test('"captcha-sdk/exceptions" exposes every error class', () => { + for (const name of ERROR_CLASSES) { + expect(typeof exceptionsEntry[name]).toBe('function'); + } + }); + + test('subpaths and the main entry expose the same classes', () => { + for (const name of TASK_CLASSES) { + expect(tasksEntry[name]).toBe(pkg.Tasks[name]); + } + for (const name of ERROR_CLASSES) { + expect(exceptionsEntry[name]).toBe(pkg[name]); + } + }); +}); + +describe('module privacy', () => { + test('internal modules are not reachable as subpaths', async () => { + // Only ".", "./tasks" and "./exceptions" are declared in "exports"; + // ./client must stay private even though the file exists. + await expect(import('captcha-sdk/client')).rejects.toThrow(); + }); +}); From f7cdc5f9c3b55f3c9acdd8a7368d3c55e60c9d4c Mon Sep 17 00:00:00 2001 From: dzmitry-duboyski Date: Thu, 6 Aug 2026 13:48:13 +0300 Subject: [PATCH 03/21] Add Jest config with coverage collection settings collectCoverageFrom: ['src/**/*.js'] makes coverage count every file in src/, not only the ones imported from tests. Without it a new file that nothing imports drops out of the report silently instead of showing 0%. That was not hypothetical: src/index.js was invisible to the report while tests imported src/ files directly, so coverage read 97.5% with the package entry point entirely unexercised. coverageReporters is narrowed to text (console) and lcov (upload target for Coveralls); the default set also wrote clover.xml and a JSON dump that nothing consumes. Co-Authored-By: Claude Opus 5 --- jest.config.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 jest.config.js diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..adf4568 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,18 @@ +/** + * Jest-конфигурация. Файл в ESM-синтаксисе, потому что в package.json + * задано "type": "module". + * + * Запуск требует флага --experimental-vm-modules (см. скрипты в package.json). + */ + +export default { + testEnvironment: 'node', + + // Считать покрытие по всем файлам src/, а не только по тем, которые + // импортированы из тестов. Иначе новый файл, который никто не подключил, + // молча выпадет из отчёта вместо того, чтобы показать 0%. + collectCoverageFrom: ['src/**/*.js'], + + // text -- для вывода в консоль, lcov -- для выгрузки в Coveralls. + coverageReporters: ['text', 'lcov'] +}; From a669676590f2c7945c7a636cdc410f0ee1ad6e27 Mon Sep 17 00:00:00 2001 From: dzmitry-duboyski Date: Thu, 6 Aug 2026 13:48:13 +0300 Subject: [PATCH 04/21] Add GitHub Actions workflow for tests and coverage Runs unit tests on Node 18/20/22, matching engines.node ">=18", on push to main and on every pull request. Coverage is uploaded to Coveralls from the Node 20 job only -- the number does not vary by Node version, so three uploads would just add noise. Integration tests deliberately do not run on pull_request: they spend real account balance, and secrets are unavailable to forked PRs, so the tests would skip themselves and report a false green. They run on a nightly schedule and on demand instead. The integration job fails fast when CAPTCHA_API_KEY is missing. Without that guard an unset secret looks like a passing run, since the tests skip themselves and the job goes green having verified nothing. Co-Authored-By: Claude Opus 5 --- .github/workflows/tests.yml | 76 +++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..8940daf --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,76 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + schedule: + # Ночной прогон integration-тестов против реального API + - cron: '0 3 * * *' + workflow_dispatch: + +jobs: + unit: + name: Unit (Node ${{ matrix.node-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # Соответствует engines.node: ">=18" в package.json + node-version: [18, 20, 22] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - run: npm ci + + - name: Run unit tests with coverage + run: npm run test:unit -- --coverage + + # Покрытие выгружается один раз, а не с каждой версии Node -- + # цифра от версии не зависит, дубли только зашумят отчёт. + - name: Upload coverage to Coveralls + if: matrix.node-version == 20 + uses: coverallsapp/github-action@v2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + file: coverage/lcov.info + + integration: + name: Integration (real API) + # Только по расписанию и вручную. На pull_request не запускается намеренно: + # тесты тратят реальный баланс аккаунта, а в PR из форков секрет всё равно + # недоступен -- тесты молча пропустятся и дадут ложно-зелёный результат. + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - run: npm ci + + - name: Fail early if the API key is missing + # Без ключа integration-тесты пропускают сами себя и джоб зеленеет, + # ничего не проверив. Лучше упасть явно. + env: + CAPTCHA_API_KEY: ${{ secrets.CAPTCHA_API_KEY }} + run: | + if [ -z "$CAPTCHA_API_KEY" ]; then + echo "::error::secrets.CAPTCHA_API_KEY is not set -- integration tests would silently skip" + exit 1 + fi + + - name: Run integration tests + env: + CAPTCHA_API_KEY: ${{ secrets.CAPTCHA_API_KEY }} + run: npm run test:integration From 00432dce53af8e0fce404acf634c68c85a6e6b9a Mon Sep 17 00:00:00 2001 From: dzmitry-duboyski Date: Thu, 6 Aug 2026 13:48:30 +0300 Subject: [PATCH 05/21] Document the test suite and the pending coverage badge work tests/README.md covers what each suite checks, how the sync/async split avoids duplicating serialization tests, how to run everything manually, and what CI does automatically. todo.md tracks the coverage badge, which is deferred because the remaining steps need the web UI: enabling the repository on Coveralls and a first workflow run on main. The badge markdown is ready there. Co-Authored-By: Claude Opus 5 --- tests/README.md | 320 ++++++++++++++++++++++++++++++++++++++++++++++++ todo.md | 40 ++++++ 2 files changed, 360 insertions(+) create mode 100644 tests/README.md create mode 100644 todo.md diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..6adaa59 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,320 @@ +# Тесты SDK + +Документация по тестам `captcha-sdk`: что покрыто, как запускать вручную и что должно выполняться в CI. + +## Оглавление + +- [Структура каталога](#структура-каталога) +- [Типы тестов](#типы-тестов) +- [Как тесты импортируют SDK](#как-тесты-импортируют-sdk) +- [Unit-тесты](#unit-тесты) + - [Как разделены sync и async](#как-разделены-sync-и-async) + - [Тесты клиента](#тесты-клиента-clienttestjs) + - [Тесты по типам капч](#тесты-по-типам-капч) + - [Контракт публичного API](#контракт-публичного-api) +- [Integration-тесты](#integration-тесты) +- [Ручной запуск](#ручной-запуск) +- [Покрытие](#покрытие) +- [CI / пайплайн](#ci--пайплайн) + +## Структура каталога + +Верхний уровень делит тесты по **типу**, вложенный — по **стилю вызова**: + +``` +tests/ +├── unit/ # без сети, fetch замокан, ключ не нужен +│ ├── public-api.test.js # контракт публичных точек входа пакета +│ ├── sync/ # стиль промис-цепочек (.then/.catch) +│ │ ├── client.test.js +│ │ ├── coordinates.test.js +│ │ ├── geetest.test.js +│ │ ├── image_to_text.test.js +│ │ ├── recaptcha_v2.test.js +│ │ ├── recaptcha_v2_enterprise.test.js +│ │ ├── recaptcha_v3.test.js +│ │ ├── tencent.test.js +│ │ ├── turnstile.test.js +│ │ └── yandex_smartcaptcha.test.js +│ └── async/ # те же сценарии в стиле async/await +│ └── (те же 10 файлов) +└── integration/ # против реального API (нужен ключ, тратит баланс) + └── client.test.js +``` + +Раннер — Jest 29 в режиме ESM (`node --experimental-vm-modules`), настройки в [jest.config.js](../jest.config.js) в корне проекта. Используется дефолтный `testMatch`, то есть подхватывается любой файл `*.test.js`. Наборы отбираются по пути каталога (`jest tests/unit` / `jest tests/integration`), поэтому новый файл не требует ничего настраивать — он попадает в нужный набор по своему расположению. + +## Типы тестов + +| Тип | Где | Сеть | Нужен API-ключ | Кол-во | +|---|---|---|---|---| +| Unit | `tests/unit/` | нет, `fetch` замокан | нет | 21 сьют / 81 тест | +| Integration | `tests/integration/` | да, реальный API | да | 1 сьют / 2 теста | + +Unit-тесты полностью изолированы: сетевой слой подменяется либо через `global.fetch = jest.fn(...)`, либо через `jest.spyOn(client, '_request')`. Никаких внешних запросов и никаких списаний с баланса. + +## Как тесты импортируют SDK + +Все тесты подключают SDK **по имени пакета**, как это делает пользователь, а не по прямым путям к файлам: + +```javascript +import { CaptchaClient, Tasks } from 'captcha-sdk'; +``` + +Это работает без дополнительной настройки: Node поддерживает self-reference — пакет может импортировать сам себя по имени, если в `package.json` есть `name` и `exports`. Резолвер Jest это уважает, включая запрет на незаявленные подпути. + +Почему так, а не `../../../src/client.js`: + +- **Тесты проверяют то, что получает пользователь.** Прямые импорты обходят [src/index.js](../src/index.js) и карту `exports`. С ними можно удалить экспорт из `index.js` или сломать `exports` в `package.json` — и вся сюита останется зелёной, хотя пакет не заработает ни у кого. +- **Покрытие становится честным.** При прямых импортах `index.js` не загружался ни одним тестом и просто выпадал из отчёта Jest — покрытие показывало 97.5 %, умалчивая о непроверенной точке входа. +- **Нет хрупких `../../../`.** При переносе каталогов правится только `jest.config.js`, а не 21 файл. + +Моки от способа импорта не зависят: `global.fetch` подменяется глобально, а `jest.spyOn(client, '_request')` работает на уровне экземпляра. + +## Unit-тесты + +### Как разделены sync и async + +SDK предоставляет один и тот же промисный API, который можно использовать двумя стилями. Подкаталоги отражают именно стиль вызова, а не разные реализации: + +- `tests/unit/sync/` — вызовы через `.then()/.catch()`, тест возвращает промис; +- `tests/unit/async/` — те же вызовы через `async/await`. + +Чтобы не дублировать проверки, действует правило: + +- **Сериализация задач** (`task.toDict()`) проверяется **один раз** — в `tests/unit/sync/<тип>.test.js`. Она не зависит от стиля вызова. +- **`solve()`** проверяется **в обоих** каталогах — это и есть смысл разделения: убедиться, что промисы SDK корректно работают в обоих стилях. + +Поэтому файлы в `async/` заметно короче: там только `solve()`. + +### Тесты клиента (`client.test.js`) + +`unit/sync/client.test.js` и `unit/async/client.test.js` — зеркальные, по 12 тестов каждый. Проверяют транспорт, обработку ошибок и polling, вне привязки к конкретному типу капчи: + +| Тест | Что проверяет | +|---|---| +| `throws ValidationError when clientKey is missing` | конструктор `CaptchaClient` без `clientKey` кидает `ValidationError` | +| `creates task and returns taskId` | `createTask()` возвращает `taskId` из ответа API | +| `sends languagePool when provided` | при передаче `languagePool` поле уходит в теле запроса | +| `does not send languagePool when not provided` | без `languagePool` поле в тело **не** попадает | +| `getTaskResult returns full API response` | `getTaskResult()` отдаёт весь ответ (`status`, `solution`), а не только решение | +| `getBalance returns balance as float` | строка `"10.50"` из API приводится к числу `10.5` | +| `solve polls until status is ready and returns solution` | `solve()` опрашивает API до `status: 'ready'` и возвращает `solution` | +| `throws TimeoutError when timeout exceeded` | при превышении `timeout` бросается `TimeoutError` | +| `throws ApiError when a task fails during polling` | ошибка, пришедшая **на этапе polling**, превращается в `ApiError` | +| `throws ApiError when errorId is not zero` | ненулевой `errorId` в ответе → `ApiError` | +| `ApiError carries the string errorCode, not the numeric errorId` | в `error.errorCode` лежит строковый код (`ERROR_KEY_DOES_NOT_EXIST`), а не число | +| `throws NetworkError on fetch failure` | падение `fetch` оборачивается в `NetworkError` | + +### Тесты по типам капч + +Для каждого типа капчи проверяется, что класс задачи сериализуется в тело запроса ровно по контракту API, и что `solve()` возвращает ожидаемую форму решения. + +Общее для всех типов сериализации: +- поле `type` соответствует типу задачи API (`RecaptchaV2TaskProxyless`, `TurnstileTask` и т.д.); +- обязательные поля попадают в `toDict()`; +- `null`/`undefined` поля **вырезаются** и не уходят в запрос; +- прокси-варианты классов добавляют `proxyType`/`proxyAddress`/`proxyPort`/`proxyLogin`/`proxyPassword`. + +| Файл | Тестов (sync / async) | Что специфичного проверяется | +|---|---|---| +| `recaptcha_v2.test.js` | 6 / 1 | имена полей `recaptchaDataSValue` и `apiDomain` (а не `dataSValue`); `isInvisible`, `userAgent`; прокси-вариант. `solve()` проходит через промежуточный `status: 'processing'` — ровно 3 запроса | +| `recaptcha_v2_enterprise.test.js` | 4 / 1 | `enterprisePayload` как объект, `isInvisible`; точное совпадение `toDict()` для минимального набора полей | +| `recaptcha_v3.test.js` | 4 / 1 | `minScore` обязателен — без него конструктор кидает `ValidationError`; `pageAction`, `apiDomain` | +| `turnstile.test.js` | 4 / 1 | имена полей `data` и `pageData` (а не `cData`) | +| `geetest.test.js` | 6 / 2 | v3: `gt` + `challenge`, поле `version` отсутствует. v4: `version: 4` + `initParameters`. Плюс `geetestApiServerSubdomain`. `solve()` тестируется отдельно для v3 и v4 — у них разная форма решения | +| `yandex_smartcaptcha.test.js` | 4 / 1 | `userAgent`, `cookies`; прокси-вариант | +| `tencent.test.js` | 4 / 1 | `appId`, опциональный `captchaScript` | +| `image_to_text.test.js` | 3 / 1 | параметр конструктора `case_` сериализуется в поле `case` (обход зарезервированного слова); `numeric`, `phrase`, `minLength`/`maxLength`, `comment`, `imgInstructions` | +| `coordinates.test.js` | 5 / 1 | варианты `imgType`: `smart_captcha` (Yandex) и `pazl_smart_captcha`; `imgInstructions`; лимиты `minClicks`/`maxClicks` | + +Форма решения, ожидаемая в `solve()`, отличается по типам: `gRecaptchaResponse` (reCAPTCHA), `token` (Turnstile, Yandex), `text` (ImageToText), `coordinates` (Coordinates), `challenge`/`validate`/`seccode` (GeeTest v3), `captcha_output` и др. (GeeTest v4), `ticket`/`randstr` (Tencent). + +### Контракт публичного API + +`tests/unit/public-api.test.js` — 7 тестов, охраняющих карту `exports` из [package.json](../package.json): + +```json +"exports": { + ".": "./src/index.js", + "./tasks": "./src/tasks.js", + "./exceptions": "./src/exceptions.js" +} +``` + +Остальные тесты уже импортируют пакет по имени, поэтому сломанный главный вход уронит всю сюиту сам по себе. Этот файл закрывает то, что иначе осталось бы непроверенным: + +| Тест | Что проверяет | +|---|---| +| `exposes the client, the task namespace and every error class` | из `.` доступны `CaptchaClient`, `Tasks` и все 5 классов ошибок | +| `Tasks namespace exposes every task class` | в `Tasks` присутствуют все 16 классов задач | +| `__version__ matches the version in package.json` | `__version__` не разошёлся с `version` при релизе | +| `"captcha-sdk/tasks" exposes every task class` | подпуть `./tasks` резолвится и отдаёт все классы | +| `"captcha-sdk/exceptions" exposes every error class` | подпуть `./exceptions` резолвится и отдаёт все классы | +| `subpaths and the main entry expose the same classes` | это **те же самые** объекты, а не дубликаты модуля — иначе `instanceof` ломался бы у тех, кто смешивает способы импорта | +| `internal modules are not reachable as subpaths` | `captcha-sdk/client` отвергается: файл существует, но в `exports` не заявлен и должен остаться приватным | + +Список классов в тесте задан явными массивами, а не выведен из самого модуля. Это намеренно: сравнение экспорта с самим собой всегда проходит и ничего не проверяет. При добавлении нового типа капчи массив нужно дополнить руками — это и есть точка, где решение «сделать класс публичным» фиксируется явно. + +## Integration-тесты + +`tests/integration/client.test.js` — единственный файл, который ходит в реальный API `https://api.captcha-solver.com`. + +Два теста: + +1. `getBalance returns a number` — запрашивает баланс аккаунта, таймаут 15 с. +2. `solve reCAPTCHA v2` — создаёт реальную задачу reCAPTCHA v2 и ждёт токен, таймаут 120 с. **Расходует баланс аккаунта.** + +### Как включаются + +Тесты сами себя пропускают, если не задана переменная окружения `CAPTCHA_API_KEY`: тело каждого теста начинается с `if (!apiKey) return;`. Поэтому без ключа запуск проходит зелёным, но по факту ничего не проверяет — в выводе будет строка `CAPTCHA_API_KEY not set. Skipping integration tests.` + +Переменные: + +| Переменная | Обязательна | По умолчанию | +|---|---|---| +| `CAPTCHA_API_KEY` | да | — (без неё тесты пропускаются) | +| `RECAPTCHA_V2_URL` | нет | `https://recaptcha-demo.appspot.com/recaptcha-v2-checkbox.php` | +| `RECAPTCHA_V2_SITE_KEY` | нет | `6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI` | + +> **Важно:** этот файл **не подключает dotenv** — `.env` автоматически не читается. `.env.example` рассчитан на скрипты из [examples/](../examples/), где есть `import 'dotenv/config'`. Для тестов переменные нужно передавать через окружение (см. ниже). + +## Ручной запуск + +Один раз перед всем: + +```bash +npm install +``` + +### Все тесты + +```bash +npm test +``` + +Запускает и unit-, и integration-тесты (последние — в режиме пропуска, если нет ключа). + +### Только unit-тесты + +```bash +npm run test:unit +``` + +Ничего не требует: ни сети, ни ключа. Прогон занимает ~7 секунд, ожидаемый результат — `21 passed, 81 tests`. + +### Только integration-тесты + +PowerShell: + +```powershell +$env:CAPTCHA_API_KEY = "ваш_ключ" +npm run test:integration +``` + +bash / cmd: + +```bash +CAPTCHA_API_KEY=ваш_ключ npm run test:integration +``` + +Без установленной переменной команда отработает, но оба теста фактически будут пустыми. + +### Отдельный файл или отдельный тест + +Аргументы после `--` пробрасываются в Jest: + +```bash +# один файл +npm test -- tests/unit/sync/geetest.test.js + +# все тесты одного каталога +npm test -- tests/unit/async + +# один тест по имени +npm test -- -t "getBalance returns balance as float" + +# подробный вывод по каждому тесту +npm run test:unit -- --verbose + +# режим наблюдения при разработке +npm run test:unit -- --watch + +# покрытие +npm run test:unit -- --coverage +``` + +Можно вызывать Jest и напрямую, но тогда флаг `--experimental-vm-modules` нужно указывать самому: + +```bash +node --experimental-vm-modules node_modules/jest/bin/jest.js tests/unit/sync/geetest.test.js +``` + +## Покрытие + +```bash +npm run test:unit -- --coverage +``` + +Текущее состояние: + +``` +File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s +All files | 97.53 | 83.63 | 100 | 97.5 | + client.js | 95.45 | 80.76 | 100 | 95.34 | 35,53 + exceptions.js | 100 | 100 | 100 | 100 | + index.js | 100 | 100 | 100 | 100 | + tasks.js | 100 | 84.52 | 100 | 100 | 26,38,71-77,89,107 +``` + +Непокрытое — ветка HTTP-ошибки (`!response.ok`) и обработка `AbortError` в `_request()`, плюс часть дефолтных значений параметров в конструкторах задач. + +Считается **только по unit-тестам**. Integration в подсчёт не входят намеренно: без ключа они пропускаются, и цифра скакала бы в зависимости от того, был ли доступен `CAPTCHA_API_KEY`. + +Два параметра в [jest.config.js](../jest.config.js) важны именно для покрытия: + +- `collectCoverageFrom: ['src/**/*.js']` — считать по всем файлам `src/`, а не только по импортированным из тестов. Без этого новый файл, который никто не подключил, молча выпал бы из отчёта вместо того, чтобы показать 0 %. Ровно так до перехода на импорт по имени пакета из отчёта выпадал `src/index.js`, и покрытие выглядело как 97.5 % при полностью непроверенной точке входа. +- `coverageReporters: ['text', 'lcov']` — `text` для вывода в консоль, `lcov` для выгрузки в Coveralls из CI. + +Артефакты пишутся в `coverage/` (в `.gitignore`). + +Флаг обязателен: SDK — чистый ESM (`"type": "module"`), без него Jest не сможет загрузить модули. Предупреждение `ExperimentalWarning: VM Modules` в выводе — норма. + +Требуется Node.js 18+ (см. `engines` в `package.json`): SDK опирается на глобальный `fetch`. + +## CI / пайплайн + +Конфигурация — [.github/workflows/tests.yml](../.github/workflows/tests.yml). Два джоба: + +| Джоб | Команда | Когда запускается | Секреты | +|---|---|---|---| +| `unit` | `npm run test:unit -- --coverage` | push в `main`, любой pull request, расписание, ручной запуск | не нужны | +| `integration` | `npm run test:integration` | **только** по расписанию (ежедневно в 03:00 UTC) и вручную через workflow_dispatch | `CAPTCHA_API_KEY` | + +### Почему джобы разделены + +- **Unit-тесты — обязательный блокирующий шаг.** Детерминированные, без сети и секретов, ~7 секунд. Безопасны на любом PR, включая форки. Гоняются на матрице Node `18 / 20 / 22` — в соответствии с `engines.node: ">=18"`. +- **Integration-тесты не запускаются на pull request намеренно.** Они тратят реальный баланс аккаунта и зависят от доступности внешнего API. Главное же — в PR из форка секреты недоступны, поэтому тесты пропустили бы сами себя и дали **ложно-зелёный** результат вместо честного «не проверено». + +### Две детали, которые легко упустить + +**Покрытие выгружается только с одной версии Node** (`if: matrix.node-version == 20`). Цифра от версии Node не зависит, а три параллельные выгрузки одного и того же отчёта только зашумят историю в Coveralls. + +**Джоб `integration` падает явно, если секрет не подставился.** Перед запуском тестов есть шаг-предохранитель: + +```yaml +- name: Fail early if the API key is missing + run: | + if [ -z "$CAPTCHA_API_KEY" ]; then + echo "::error::secrets.CAPTCHA_API_KEY is not set -- integration tests would silently skip" + exit 1 + fi +``` + +Без него отсутствие ключа выглядело бы как успешный прогон: тесты пропускают себя сами и джоб зеленеет, ничего не проверив. Это ровно тот случай, когда молчаливый пропуск опаснее падения. + +### Чего в пайплайне пока нет + +- Шага линтинга — линтер в проекте не настроен. +- Публикации в npm. +- Бейджей в README — вынесено в [todo.md](../todo.md) вместе с подключением Coveralls (требует действий в веб-интерфейсе). diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..9fd6b4e --- /dev/null +++ b/todo.md @@ -0,0 +1,40 @@ +# TODO + +## Бейдж покрытия тестами в README + +**Статус:** отложено. Вся техническая подготовка сделана — осталось только то, что требует действий в веб-интерфейсе. + +### Что уже готово + +- [x] `jest.config.js` с `collectCoverageFrom: ['src/**/*.js']` — покрытие считается по всем файлам `src/`, а не только по импортированным из тестов. +- [x] Тесты импортируют SDK по имени пакета (`captcha-sdk`), а не по прямым путям к файлам — покрытие отражает реальный публичный API. +- [x] `tests/unit/public-api.test.js` — проверка контракта всех трёх точек входа из `exports`. +- [x] CI-workflow `.github/workflows/tests.yml` — прогоняет unit-тесты и выгружает `coverage/lcov.info` в Coveralls. + +### Что осталось сделать + +- [ ] **Подключить репозиторий к Coveralls.** Зайти на [coveralls.io](https://coveralls.io), авторизоваться через GitHub, включить репозиторий `captcha-solver-api/javascript-sdk` в списке. Отдельный секрет не нужен — GitHub Action использует встроенный `GITHUB_TOKEN`. +- [ ] **Дождаться первого прогона CI на `main`.** До него бейдж будет отдавать `unknown`. +- [ ] **Добавить бейджи в начало `README.md`** (сразу под заголовком, перед описанием): + + ```markdown + [![Tests](https://github.com/captcha-solver-api/javascript-sdk/actions/workflows/tests.yml/badge.svg)](https://github.com/captcha-solver-api/javascript-sdk/actions/workflows/tests.yml) + [![Coverage Status](https://coveralls.io/repos/github/captcha-solver-api/javascript-sdk/badge.svg?branch=main)](https://coveralls.io/github/captcha-solver-api/javascript-sdk?branch=main) + ``` + +### Почему Coveralls, а не Codecov + +Для GitHub Actions Coveralls не требует отдельного секрета — хватает встроенного `GITHUB_TOKEN`. Codecov с 2024 года требует токен даже для публичных репозиториев, а такой секрет всё равно не будет доступен в PR из форков — то есть для внешних контрибьюторов выгрузка покрытия будет молча ломаться. + +### На что обратить внимание + +Покрытие считается **только по unit-тестам** (`npm run test:unit`). Integration-тесты в подсчёт не входят намеренно: без `CAPTCHA_API_KEY` они пропускаются, и цифра покрытия скакала бы в зависимости от того, был ли доступен ключ. + +--- + +## Идеи на будущее (не приоритет) + +- [ ] `coverageThreshold` в `jest.config.js` — чтобы CI падал при просадке покрытия ниже порога. Имеет смысл включать после того, как бейдж заработает и станет понятен реальный базовый уровень. +- [ ] Линтер (ESLint) — сейчас в проекте не настроен, в CI отдельного шага линтинга нет. +- [ ] Тест на содержимое публикуемого пакета (`npm pack`) — проверить, что в тарбол попадает всё нужное из `files: ["src/"]`. +- [ ] agent.md From 6c246a4ec14b97e246077203c0ea499b70c2045e Mon Sep 17 00:00:00 2001 From: dzmitry-duboyski Date: Thu, 6 Aug 2026 13:48:30 +0300 Subject: [PATCH 06/21] Clarify that the install command pulls from GitHub Co-Authored-By: Claude Opus 5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c7be66a..e06c97e 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Official JavaScript SDK for the Captcha Solver API. Solve reCAPTCHA v2/v3, Cloud ## Installation ```bash -# npm install captcha-sdk +# npm install captcha-sdk from github.com npm install git+https://github.com/captcha-solver-api/javascript-sdk.git ``` From 8ce5c53c7a9b321f14d772fa541b2bc2a05c041c Mon Sep 17 00:00:00 2001 From: dzmitry-duboyski Date: Thu, 6 Aug 2026 14:11:36 +0300 Subject: [PATCH 07/21] Fix .npmignore The previous contents were "./tests" and ".claude". The "./" prefix is not valid in npm ignore patterns, which are gitignore-style, so the tests entry matched nothing. It had no visible effect because "files": ["src/"] in package.json is a whitelist and takes precedence, but the file was not harmless: while an .npmignore exists npm stops falling back to .gitignore when packing, so coverage/ and .env lost the exclusion they were relying on. List every dev-only path explicitly. Verified with npm pack --dry-run that this file on its own yields the same 7-file tarball as the "files" whitelist, so the two layers agree instead of contradicting each other. Co-Authored-By: Claude Opus 5 --- .npmignore | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .npmignore diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..49b9b96 --- /dev/null +++ b/.npmignore @@ -0,0 +1,37 @@ +# Что попадает в публикуемый пакет, в первую очередь определяет поле +# "files": ["src/"] в package.json -- это белый список, и он имеет приоритет +# над этим файлом. Здесь -- второй рубеж на случай, если "files" уберут или +# расширят: тогда лишнее не утечёт в тарбол молча. +# +# Важно: пока этот файл существует, npm НЕ использует .gitignore при упаковке. +# Поэтому всё, что не должно публиковаться, нужно перечислять именно здесь, +# даже если оно уже есть в .gitignore. +# +# Синтаксис -- как в .gitignore. Префикс "./" не работает: писать "tests", +# а не "./tests". + +# Тесты и их конфигурация +tests/ +jest.config.js +coverage/ + +# CI и локальные настройки инструментов +.github/ +.claude/ + +# Примеры и ассеты репозитория: нужны на GitHub, но не потребителю пакета +examples/ +assets/ + +# Переменные окружения +.env +.env.example + +# Документы для контрибьюторов: место им на GitHub, а не в тарболе +CONTRIBUTING.md +CODE_OF_CONDUCT.md +todo.md + +# npm и так всегда включает package.json, README, LICENSE и файл из "main", +# и всегда исключает .git, node_modules, .npmrc и npm-debug.log -- +# перечислять их здесь не нужно. From d878b0ac423cfe059c9c1a8a651f2af760eab442 Mon Sep 17 00:00:00 2001 From: dzmitry-duboyski Date: Thu, 6 Aug 2026 14:16:10 +0300 Subject: [PATCH 08/21] Ignore .claude/ Claude Code writes a local permission allowlist there. It is full of absolute paths to a machine-specific temp directory and one-off command entries, so it is useless to anyone else working on the repository. It stays listed in .npmignore as well: while an .npmignore exists npm does not consult .gitignore when packing, so the two files have to repeat each other. Co-Authored-By: Claude Opus 5 --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 598c4ef..e7f433a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ node_modules/ *.log .DS_Store dist/ -coverage/ \ No newline at end of file +coverage/ +.claude/ From f7daddc12ddf3f233b23b90c0ae1ea94099d764a Mon Sep 17 00:00:00 2001 From: dzmitry-duboyski Date: Thu, 6 Aug 2026 16:01:46 +0300 Subject: [PATCH 09/21] Explain the package-name import rule in test headers The whole suite depends on importing 'captcha-sdk' rather than reaching into src/ by relative path, but nothing in the files said so. A contributor seeing the unusual import could "correct" it to ../../../src/client.js, keep every test green, and quietly remove the only thing that catches a broken entry point or "exports" map. Note it where the temptation is, in each test file header. public-api.test.js is left alone -- its header already covers the same ground. Co-Authored-By: Claude Opus 5 --- tests/integration/client.test.js | 4 ++++ tests/unit/async/client.test.js | 4 ++++ tests/unit/async/coordinates.test.js | 4 ++++ tests/unit/async/geetest.test.js | 4 ++++ tests/unit/async/image_to_text.test.js | 4 ++++ tests/unit/async/recaptcha_v2.test.js | 4 ++++ tests/unit/async/recaptcha_v2_enterprise.test.js | 4 ++++ tests/unit/async/recaptcha_v3.test.js | 4 ++++ tests/unit/async/tencent.test.js | 4 ++++ tests/unit/async/turnstile.test.js | 4 ++++ tests/unit/async/yandex_smartcaptcha.test.js | 4 ++++ tests/unit/sync/client.test.js | 4 ++++ tests/unit/sync/coordinates.test.js | 4 ++++ tests/unit/sync/geetest.test.js | 4 ++++ tests/unit/sync/image_to_text.test.js | 4 ++++ tests/unit/sync/recaptcha_v2.test.js | 4 ++++ tests/unit/sync/recaptcha_v2_enterprise.test.js | 4 ++++ tests/unit/sync/recaptcha_v3.test.js | 4 ++++ tests/unit/sync/tencent.test.js | 4 ++++ tests/unit/sync/turnstile.test.js | 4 ++++ tests/unit/sync/yandex_smartcaptcha.test.js | 4 ++++ 21 files changed, 84 insertions(+) diff --git a/tests/integration/client.test.js b/tests/integration/client.test.js index 14de05a..8001d69 100644 --- a/tests/integration/client.test.js +++ b/tests/integration/client.test.js @@ -1,6 +1,10 @@ /** * Real-API integration tests. Skipped automatically unless CAPTCHA_API_KEY * is set in the environment. Run with `npm run test:integration`. + * + * Imports the SDK by package name on purpose, not by relative path into + * src/: that is what makes the suite exercise the package's real entry + * points. See tests/README.md for why. Do not "fix" it to ../../../src/. */ import { CaptchaClient, Tasks } from 'captcha-sdk'; diff --git a/tests/unit/async/client.test.js b/tests/unit/async/client.test.js index d41da00..5385f46 100644 --- a/tests/unit/async/client.test.js +++ b/tests/unit/async/client.test.js @@ -3,6 +3,10 @@ * not tied to any specific captcha type, written in async/await style. * Mirrors tests/unit/sync/client.test.js. * See .test.js in this directory for per-type solve() tests. + * + * Imports the SDK by package name on purpose, not by relative path into + * src/: that is what makes the suite exercise the package's real entry + * points. See tests/README.md for why. Do not "fix" it to ../../../src/. */ import { jest } from '@jest/globals'; diff --git a/tests/unit/async/coordinates.test.js b/tests/unit/async/coordinates.test.js index f5d0f77..9f9b6bc 100644 --- a/tests/unit/async/coordinates.test.js +++ b/tests/unit/async/coordinates.test.js @@ -1,6 +1,10 @@ /** * Async solve() test for CoordinatesTask. * Task serialization is covered once in tests/unit/sync/coordinates.test.js. + * + * Imports the SDK by package name on purpose, not by relative path into + * src/: that is what makes the suite exercise the package's real entry + * points. See tests/README.md for why. Do not "fix" it to ../../../src/. */ import { jest } from '@jest/globals'; diff --git a/tests/unit/async/geetest.test.js b/tests/unit/async/geetest.test.js index c8795c0..87b10cb 100644 --- a/tests/unit/async/geetest.test.js +++ b/tests/unit/async/geetest.test.js @@ -1,6 +1,10 @@ /** * Async solve() tests for GeeTestProxyless (v3 and v4). * Task serialization is covered once in tests/unit/sync/geetest.test.js. + * + * Imports the SDK by package name on purpose, not by relative path into + * src/: that is what makes the suite exercise the package's real entry + * points. See tests/README.md for why. Do not "fix" it to ../../../src/. */ import { jest } from '@jest/globals'; diff --git a/tests/unit/async/image_to_text.test.js b/tests/unit/async/image_to_text.test.js index 521eec1..2420d96 100644 --- a/tests/unit/async/image_to_text.test.js +++ b/tests/unit/async/image_to_text.test.js @@ -1,6 +1,10 @@ /** * Async solve() test for ImageToText. * Task serialization is covered once in tests/unit/sync/image_to_text.test.js. + * + * Imports the SDK by package name on purpose, not by relative path into + * src/: that is what makes the suite exercise the package's real entry + * points. See tests/README.md for why. Do not "fix" it to ../../../src/. */ import { jest } from '@jest/globals'; diff --git a/tests/unit/async/recaptcha_v2.test.js b/tests/unit/async/recaptcha_v2.test.js index 8b8d2e8..5afb0de 100644 --- a/tests/unit/async/recaptcha_v2.test.js +++ b/tests/unit/async/recaptcha_v2.test.js @@ -2,6 +2,10 @@ * Async solve() test for RecaptchaV2Proxyless. * Task serialization is covered once in tests/unit/sync/recaptcha_v2.test.js -- * it doesn't depend on which style (sync/async) is used to call solve(). + * + * Imports the SDK by package name on purpose, not by relative path into + * src/: that is what makes the suite exercise the package's real entry + * points. See tests/README.md for why. Do not "fix" it to ../../../src/. */ import { jest } from '@jest/globals'; diff --git a/tests/unit/async/recaptcha_v2_enterprise.test.js b/tests/unit/async/recaptcha_v2_enterprise.test.js index 1519b49..51cebf1 100644 --- a/tests/unit/async/recaptcha_v2_enterprise.test.js +++ b/tests/unit/async/recaptcha_v2_enterprise.test.js @@ -1,6 +1,10 @@ /** * Async solve() test for RecaptchaV2EnterpriseProxyless. * Task serialization is covered once in tests/unit/sync/recaptcha_v2_enterprise.test.js. + * + * Imports the SDK by package name on purpose, not by relative path into + * src/: that is what makes the suite exercise the package's real entry + * points. See tests/README.md for why. Do not "fix" it to ../../../src/. */ import { jest } from '@jest/globals'; diff --git a/tests/unit/async/recaptcha_v3.test.js b/tests/unit/async/recaptcha_v3.test.js index 1d3932a..9ba4f65 100644 --- a/tests/unit/async/recaptcha_v3.test.js +++ b/tests/unit/async/recaptcha_v3.test.js @@ -1,6 +1,10 @@ /** * Async solve() test for RecaptchaV3Proxyless. * Task serialization is covered once in tests/unit/sync/recaptcha_v3.test.js. + * + * Imports the SDK by package name on purpose, not by relative path into + * src/: that is what makes the suite exercise the package's real entry + * points. See tests/README.md for why. Do not "fix" it to ../../../src/. */ import { jest } from '@jest/globals'; diff --git a/tests/unit/async/tencent.test.js b/tests/unit/async/tencent.test.js index ac86cb2..84f6f70 100644 --- a/tests/unit/async/tencent.test.js +++ b/tests/unit/async/tencent.test.js @@ -1,6 +1,10 @@ /** * Async solve() test for TencentTaskProxyless. * Task serialization is covered once in tests/unit/sync/tencent.test.js. + * + * Imports the SDK by package name on purpose, not by relative path into + * src/: that is what makes the suite exercise the package's real entry + * points. See tests/README.md for why. Do not "fix" it to ../../../src/. */ import { jest } from '@jest/globals'; diff --git a/tests/unit/async/turnstile.test.js b/tests/unit/async/turnstile.test.js index 6606b08..1f39d21 100644 --- a/tests/unit/async/turnstile.test.js +++ b/tests/unit/async/turnstile.test.js @@ -1,6 +1,10 @@ /** * Async solve() test for TurnstileProxyless. * Task serialization is covered once in tests/unit/sync/turnstile.test.js. + * + * Imports the SDK by package name on purpose, not by relative path into + * src/: that is what makes the suite exercise the package's real entry + * points. See tests/README.md for why. Do not "fix" it to ../../../src/. */ import { jest } from '@jest/globals'; diff --git a/tests/unit/async/yandex_smartcaptcha.test.js b/tests/unit/async/yandex_smartcaptcha.test.js index 80a303f..43020c1 100644 --- a/tests/unit/async/yandex_smartcaptcha.test.js +++ b/tests/unit/async/yandex_smartcaptcha.test.js @@ -1,6 +1,10 @@ /** * Async solve() test for YandexSmartCaptchaTaskProxyless. * Task serialization is covered once in tests/unit/sync/yandex_smartcaptcha.test.js. + * + * Imports the SDK by package name on purpose, not by relative path into + * src/: that is what makes the suite exercise the package's real entry + * points. See tests/README.md for why. Do not "fix" it to ../../../src/. */ import { jest } from '@jest/globals'; diff --git a/tests/unit/sync/client.test.js b/tests/unit/sync/client.test.js index 437f1de..818a945 100644 --- a/tests/unit/sync/client.test.js +++ b/tests/unit/sync/client.test.js @@ -4,6 +4,10 @@ * (.then/.catch, no async/await). Mirrors tests/unit/async/client.test.js. * See .test.js in this directory for per-type task * serialization tests. + * + * Imports the SDK by package name on purpose, not by relative path into + * src/: that is what makes the suite exercise the package's real entry + * points. See tests/README.md for why. Do not "fix" it to ../../../src/. */ import { jest } from '@jest/globals'; diff --git a/tests/unit/sync/coordinates.test.js b/tests/unit/sync/coordinates.test.js index 8ade0e2..76db8d4 100644 --- a/tests/unit/sync/coordinates.test.js +++ b/tests/unit/sync/coordinates.test.js @@ -1,6 +1,10 @@ /** * Tests for CoordinatesTask task serialization (generic click captcha + * Yandex SmartCaptcha image mode), plus a solve() test in promise-chain style. + * + * Imports the SDK by package name on purpose, not by relative path into + * src/: that is what makes the suite exercise the package's real entry + * points. See tests/README.md for why. Do not "fix" it to ../../../src/. */ import { jest } from '@jest/globals'; diff --git a/tests/unit/sync/geetest.test.js b/tests/unit/sync/geetest.test.js index ab32136..6977c5e 100644 --- a/tests/unit/sync/geetest.test.js +++ b/tests/unit/sync/geetest.test.js @@ -1,6 +1,10 @@ /** * Tests for GeeTestProxyless / GeeTest task serialization (v3 and v4), * plus solve() tests in promise-chain style. + * + * Imports the SDK by package name on purpose, not by relative path into + * src/: that is what makes the suite exercise the package's real entry + * points. See tests/README.md for why. Do not "fix" it to ../../../src/. */ import { jest } from '@jest/globals'; diff --git a/tests/unit/sync/image_to_text.test.js b/tests/unit/sync/image_to_text.test.js index 70d21c7..d06036b 100644 --- a/tests/unit/sync/image_to_text.test.js +++ b/tests/unit/sync/image_to_text.test.js @@ -1,6 +1,10 @@ /** * Tests for ImageToText task serialization, plus a solve() test in * promise-chain style. + * + * Imports the SDK by package name on purpose, not by relative path into + * src/: that is what makes the suite exercise the package's real entry + * points. See tests/README.md for why. Do not "fix" it to ../../../src/. */ import { jest } from '@jest/globals'; diff --git a/tests/unit/sync/recaptcha_v2.test.js b/tests/unit/sync/recaptcha_v2.test.js index 61abe6c..8d944ed 100644 --- a/tests/unit/sync/recaptcha_v2.test.js +++ b/tests/unit/sync/recaptcha_v2.test.js @@ -2,6 +2,10 @@ * Tests for RecaptchaV2Proxyless / RecaptchaV2 task serialization, plus a * solve() test in promise-chain style. Serialization is covered once here -- * it doesn't depend on which style (sync/async) is used to call solve(). + * + * Imports the SDK by package name on purpose, not by relative path into + * src/: that is what makes the suite exercise the package's real entry + * points. See tests/README.md for why. Do not "fix" it to ../../../src/. */ import { jest } from '@jest/globals'; diff --git a/tests/unit/sync/recaptcha_v2_enterprise.test.js b/tests/unit/sync/recaptcha_v2_enterprise.test.js index b2b2f83..304113b 100644 --- a/tests/unit/sync/recaptcha_v2_enterprise.test.js +++ b/tests/unit/sync/recaptcha_v2_enterprise.test.js @@ -1,6 +1,10 @@ /** * Tests for RecaptchaV2EnterpriseProxyless / RecaptchaV2Enterprise task * serialization, plus a solve() test in promise-chain style. + * + * Imports the SDK by package name on purpose, not by relative path into + * src/: that is what makes the suite exercise the package's real entry + * points. See tests/README.md for why. Do not "fix" it to ../../../src/. */ import { jest } from '@jest/globals'; diff --git a/tests/unit/sync/recaptcha_v3.test.js b/tests/unit/sync/recaptcha_v3.test.js index 3be0ee6..a5e05d9 100644 --- a/tests/unit/sync/recaptcha_v3.test.js +++ b/tests/unit/sync/recaptcha_v3.test.js @@ -1,6 +1,10 @@ /** * Tests for RecaptchaV3Proxyless task serialization, plus a solve() test * in promise-chain style. + * + * Imports the SDK by package name on purpose, not by relative path into + * src/: that is what makes the suite exercise the package's real entry + * points. See tests/README.md for why. Do not "fix" it to ../../../src/. */ import { jest } from '@jest/globals'; diff --git a/tests/unit/sync/tencent.test.js b/tests/unit/sync/tencent.test.js index fe43129..a2fa661 100644 --- a/tests/unit/sync/tencent.test.js +++ b/tests/unit/sync/tencent.test.js @@ -1,6 +1,10 @@ /** * Tests for TencentTaskProxyless / TencentTask task serialization, plus a * solve() test in promise-chain style. + * + * Imports the SDK by package name on purpose, not by relative path into + * src/: that is what makes the suite exercise the package's real entry + * points. See tests/README.md for why. Do not "fix" it to ../../../src/. */ import { jest } from '@jest/globals'; diff --git a/tests/unit/sync/turnstile.test.js b/tests/unit/sync/turnstile.test.js index 13b737b..191adcf 100644 --- a/tests/unit/sync/turnstile.test.js +++ b/tests/unit/sync/turnstile.test.js @@ -1,6 +1,10 @@ /** * Tests for TurnstileProxyless / Turnstile task serialization, plus a * solve() test in promise-chain style. + * + * Imports the SDK by package name on purpose, not by relative path into + * src/: that is what makes the suite exercise the package's real entry + * points. See tests/README.md for why. Do not "fix" it to ../../../src/. */ import { jest } from '@jest/globals'; diff --git a/tests/unit/sync/yandex_smartcaptcha.test.js b/tests/unit/sync/yandex_smartcaptcha.test.js index 805fbba..8a89350 100644 --- a/tests/unit/sync/yandex_smartcaptcha.test.js +++ b/tests/unit/sync/yandex_smartcaptcha.test.js @@ -1,6 +1,10 @@ /** * Tests for YandexSmartCaptchaTaskProxyless / YandexSmartCaptchaTask task * serialization, plus a solve() test in promise-chain style. + * + * Imports the SDK by package name on purpose, not by relative path into + * src/: that is what makes the suite exercise the package's real entry + * points. See tests/README.md for why. Do not "fix" it to ../../../src/. */ import { jest } from '@jest/globals'; From 58def37c323d74e541b051ccfc6d211ac15cac6e Mon Sep 17 00:00:00 2001 From: dzmitry-duboyski Date: Fri, 7 Aug 2026 19:54:38 +0300 Subject: [PATCH 10/21] Do not fail the unit job when the coverage upload fails The first live run went red on a PR where every test passed: the Coveralls step returned "Couldn't find a repository matching this job" because the repository is not connected on coveralls.io yet. Uploading coverage is auxiliary. A Coveralls outage, or a repository that is not wired up yet, should not block merging a pull request whose tests are green. A stale badge is visible enough on its own. Co-Authored-By: Claude Opus 5 --- .github/workflows/tests.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8940daf..747f24f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -34,12 +34,18 @@ jobs: # Покрытие выгружается один раз, а не с каждой версии Node -- # цифра от версии не зависит, дубли только зашумят отчёт. + # + # fail-on-error: false -- выгрузка покрытия вспомогательная, и её сбой + # не должен красить джоб, в котором все тесты прошли. Иначе недоступность + # Coveralls или неподключённый репозиторий блокируют мёрж рабочего PR. + # Устаревший бейдж заметен и сам по себе. - name: Upload coverage to Coveralls if: matrix.node-version == 20 uses: coverallsapp/github-action@v2 with: github-token: ${{ secrets.GITHUB_TOKEN }} file: coverage/lcov.info + fail-on-error: false integration: name: Integration (real API) From 0538aeb13efd753b32872820a6157b1b89e6981e Mon Sep 17 00:00:00 2001 From: dzmitry-duboyski Date: Fri, 7 Aug 2026 20:08:59 +0300 Subject: [PATCH 11/21] Record why the coverage badge is blocked The repository is private and the Coveralls free plan only covers public ones, which is why connecting it returns "No repos found" and the CI upload fails with "Couldn't find a repository matching this job". The plan is to make the repository public later, so the existing setup stays as is. Write the reason down: without it the open item looks abandoned rather than waiting on something specific. Co-Authored-By: Claude Opus 5 --- todo.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/todo.md b/todo.md index 9fd6b4e..f2e0ecc 100644 --- a/todo.md +++ b/todo.md @@ -22,6 +22,16 @@ [![Coverage Status](https://coveralls.io/repos/github/captcha-solver-api/javascript-sdk/badge.svg?branch=main)](https://coveralls.io/github/captcha-solver-api/javascript-sdk?branch=main) ``` +### Чем заблокировано + +**Репозиторий сейчас приватный, а бесплатный план Coveralls работает только с публичными.** Приватные не показываются в списке — при попытке подключить сервис отвечает `No repos found`, а выгрузка из CI падает с `Couldn't find a repository matching this job`. + +Ждём, когда репозиторий станет публичным. После этого настройка заработает как есть, переписывать ничего не нужно. + +До тех пор шаг выгрузки в workflow тихо отваливается и джоб не роняет — за это отвечает `fail-on-error: false`. + +Если решение сделать репозиторий публичным изменится, вариантов два: платный план Coveralls либо генерация бейджа локально через `istanbul-badges-readme` (без внешнего сервиса; чтобы бейдж не устаревал, в CI регенерировать его и падать по `git diff --exit-code README.md`). + ### Почему Coveralls, а не Codecov Для GitHub Actions Coveralls не требует отдельного секрета — хватает встроенного `GITHUB_TOKEN`. Codecov с 2024 года требует токен даже для публичных репозиториев, а такой секрет всё равно не будет доступен в PR из форков — то есть для внешних контрибьюторов выгрузка покрытия будет молча ломаться. From 8c4ffa82f4b1427483bef2ba3eed5e140eacde53 Mon Sep 17 00:00:00 2001 From: dzmitry-duboyski Date: Fri, 7 Aug 2026 20:13:49 +0300 Subject: [PATCH 12/21] Rename the example client variable to captchaSolver `client` said nothing about what the object was, so `client.solve()` and `client.getBalance()` only read clearly with the constructor line in view. `captchaSolver` carries that context to every call site. Covers the README code blocks and both example suites. Prose mentions of "client" and the `clientKey` option are unchanged, as is the tests/ fixture variable -- that one is internal, not something users read to learn the API. Co-Authored-By: Claude Opus 5 --- README.md | 58 +++++++++++------------ examples/async/balance.js | 4 +- examples/async/coordinates.js | 8 ++-- examples/async/geetest_v3.js | 6 +-- examples/async/geetest_v4.js | 6 +-- examples/async/image_to_text.js | 8 ++-- examples/async/recaptcha_v2.js | 6 +-- examples/async/recaptcha_v2_enterprise.js | 6 +-- examples/async/recaptcha_v3.js | 4 +- examples/async/tencent.js | 6 +-- examples/async/turnstile.js | 6 +-- examples/async/yandex_smartcaptcha.js | 6 +-- examples/sync/balance.js | 4 +- examples/sync/coordinates.js | 8 ++-- examples/sync/geetest_v3.js | 6 +-- examples/sync/geetest_v4.js | 6 +-- examples/sync/image_to_text.js | 8 ++-- examples/sync/recaptcha_v2.js | 6 +-- examples/sync/recaptcha_v2_enterprise.js | 6 +-- examples/sync/recaptcha_v3.js | 4 +- examples/sync/tencent.js | 6 +-- examples/sync/turnstile.js | 6 +-- examples/sync/yandex_smartcaptcha.js | 6 +-- 23 files changed, 95 insertions(+), 95 deletions(-) diff --git a/README.md b/README.md index e06c97e..40d67a3 100644 --- a/README.md +++ b/README.md @@ -41,22 +41,22 @@ export CAPTCHA_API_KEY=your_api_key ``` ```javascript import { CaptchaClient } from 'captcha-sdk'; -const client = new CaptchaClient({ clientKey: process.env.CAPTCHA_API_KEY }); +const captchaSolver = new CaptchaClient({ clientKey: process.env.CAPTCHA_API_KEY }); ``` Or just pass the key directly, without an environment variable: ```javascript -const client = new CaptchaClient({ clientKey: 'your_api_key' }); +const captchaSolver = new CaptchaClient({ clientKey: 'your_api_key' }); ``` ## Quick Start Solve a reCAPTCHA v2 in 4 lines. ```javascript import { CaptchaClient, Tasks } from 'captcha-sdk'; -const client = new CaptchaClient({ clientKey: 'your_api_key' }); +const captchaSolver = new CaptchaClient({ clientKey: 'your_api_key' }); const task = new Tasks.RecaptchaV2Proxyless({ websiteURL: 'https://example.com/login', websiteKey: '6Le-xxxxxxxxx' }); -const result = await client.solve(task); +const result = await captchaSolver.solve(task); console.log(result.gRecaptchaResponse); ``` Runnable versions of every example below live in [examples/async](examples/async) (async/await @@ -80,7 +80,7 @@ the same `CaptchaClient`, since JavaScript has no blocking HTTP client to mirror ### reCAPTCHA v2 with proxy ```javascript import { CaptchaClient, Tasks } from 'captcha-sdk'; -const client = new CaptchaClient({ clientKey: 'your_api_key' }); +const captchaSolver = new CaptchaClient({ clientKey: 'your_api_key' }); const task = new Tasks.RecaptchaV2({ websiteURL: 'https://example.com/login', websiteKey: '6Le-xxxxxxxxx', @@ -90,43 +90,43 @@ const task = new Tasks.RecaptchaV2({ proxyLogin: 'user', proxyPassword: 'password' }); -const result = await client.solve(task); +const result = await captchaSolver.solve(task); console.log(result.gRecaptchaResponse); ``` ### reCAPTCHA v2 Enterprise ```javascript import { CaptchaClient, Tasks } from 'captcha-sdk'; -const client = new CaptchaClient({ clientKey: 'your_api_key' }); +const captchaSolver = new CaptchaClient({ clientKey: 'your_api_key' }); const task = new Tasks.RecaptchaV2EnterpriseProxyless({ websiteURL: 'https://example.com/login', websiteKey: '6Le-xxxxxxxxx', enterprisePayload: { s: 'data-s-value' } }); -const result = await client.solve(task); +const result = await captchaSolver.solve(task); console.log(result.gRecaptchaResponse); ``` ### reCAPTCHA v3 ```javascript import { CaptchaClient, Tasks } from 'captcha-sdk'; -const client = new CaptchaClient({ clientKey: 'your_api_key' }); +const captchaSolver = new CaptchaClient({ clientKey: 'your_api_key' }); const task = new Tasks.RecaptchaV3Proxyless({ websiteURL: 'https://example.com/login', websiteKey: '6Le-xxxxxxxxx', minScore: 0.7, pageAction: 'login' }); -const result = await client.solve(task); +const result = await captchaSolver.solve(task); console.log(result.gRecaptchaResponse); ``` ### Cloudflare Turnstile ```javascript import { CaptchaClient, Tasks } from 'captcha-sdk'; -const client = new CaptchaClient({ clientKey: 'your_api_key' }); +const captchaSolver = new CaptchaClient({ clientKey: 'your_api_key' }); const task = new Tasks.TurnstileProxyless({ websiteURL: 'https://example.com/login', websiteKey: '0x4AAAAAAAxxxxxxxx' }); -const result = await client.solve(task); +const result = await captchaSolver.solve(task); console.log(result.token); ``` ### Image to Text @@ -134,50 +134,50 @@ console.log(result.token); import { CaptchaClient, Tasks } from 'captcha-sdk'; import fs from 'fs'; const imageBase64 = fs.readFileSync('captcha.png').toString('base64'); -const client = new CaptchaClient({ clientKey: 'your_api_key' }); +const captchaSolver = new CaptchaClient({ clientKey: 'your_api_key' }); const task = new Tasks.ImageToText({ body: imageBase64, numeric: 1, minLength: 4, maxLength: 6 }); -const result = await client.solve(task); +const result = await captchaSolver.solve(task); console.log(result.text); ``` ### GeeTest v3 ```javascript import { CaptchaClient, Tasks } from 'captcha-sdk'; -const client = new CaptchaClient({ clientKey: 'your_api_key' }); +const captchaSolver = new CaptchaClient({ clientKey: 'your_api_key' }); const task = new Tasks.GeeTestProxyless({ websiteURL: 'https://example.com/login', gt: 'f2ae6cadcf7886856696c46d84d109d1', challenge: '12345678abc90123d45678e90123f45g6' }); -const result = await client.solve(task); +const result = await captchaSolver.solve(task); console.log(result.validate); console.log(result.seccode); ``` ### GeeTest v4 ```javascript import { CaptchaClient, Tasks } from 'captcha-sdk'; -const client = new CaptchaClient({ clientKey: 'your_api_key' }); +const captchaSolver = new CaptchaClient({ clientKey: 'your_api_key' }); const task = new Tasks.GeeTestProxyless({ websiteURL: 'https://example.com/login', version: 4, initParameters: { captcha_id: 'e392e65f912c780f2c3ebac7702651de' } }); -const result = await client.solve(task); +const result = await captchaSolver.solve(task); console.log(result.captcha_output); ``` ### Yandex SmartCaptcha ```javascript import { CaptchaClient, Tasks } from 'captcha-sdk'; -const client = new CaptchaClient({ clientKey: 'your_api_key' }); +const captchaSolver = new CaptchaClient({ clientKey: 'your_api_key' }); const task = new Tasks.YandexSmartCaptchaTaskProxyless({ websiteURL: 'https://example.com/login', websiteKey: 'FEXfAbHQsToo97VidNVk3j4dC74nGW1DgdxK4OoR' }); -const result = await client.solve(task); +const result = await captchaSolver.solve(task); console.log(result.token); ``` Use `Tasks.YandexSmartCaptchaTask` instead for the with-proxy variant (same extra @@ -193,35 +193,35 @@ the "Yandex SmartCaptcha image mode" section in import { CaptchaClient, Tasks } from 'captcha-sdk'; import fs from 'fs'; const body = fs.readFileSync('captcha.png').toString('base64'); -const client = new CaptchaClient({ clientKey: 'your_api_key' }); +const captchaSolver = new CaptchaClient({ clientKey: 'your_api_key' }); const task = new Tasks.CoordinatesTask({ body: body, comment: 'click on the green apple' }); -const result = await client.solve(task); +const result = await captchaSolver.solve(task); console.log(result.coordinates); ``` ### Tencent ```javascript import { CaptchaClient, Tasks } from 'captcha-sdk'; -const client = new CaptchaClient({ clientKey: 'your_api_key' }); +const captchaSolver = new CaptchaClient({ clientKey: 'your_api_key' }); const task = new Tasks.TencentTaskProxyless({ websiteURL: 'https://example.com/login', appId: '190014885' }); -const result = await client.solve(task); +const result = await captchaSolver.solve(task); console.log(result.ticket); ``` ### Check balance ```javascript import { CaptchaClient } from 'captcha-sdk'; -const client = new CaptchaClient({ clientKey: 'your_api_key' }); -const balance = await client.getBalance(); +const captchaSolver = new CaptchaClient({ clientKey: 'your_api_key' }); +const balance = await captchaSolver.getBalance(); console.log(`Balance: ${balance}`); ``` ### Custom timeout and polling ```javascript -const client = new CaptchaClient({ +const captchaSolver = new CaptchaClient({ clientKey: 'your_api_key', timeout: 180000, pollingInterval: 5000 @@ -230,9 +230,9 @@ const client = new CaptchaClient({ ### Error handling ```javascript import { CaptchaClient, ApiError, TimeoutError, NetworkError, ValidationError } from 'captcha-sdk'; -const client = new CaptchaClient({ clientKey: 'your_api_key' }); +const captchaSolver = new CaptchaClient({ clientKey: 'your_api_key' }); try { - const result = await client.solve(task); + const result = await captchaSolver.solve(task); } catch (error) { if (error instanceof ValidationError) { console.log(`Invalid input: ${error.message}`); diff --git a/examples/async/balance.js b/examples/async/balance.js index 37f1dbf..8fa4989 100644 --- a/examples/async/balance.js +++ b/examples/async/balance.js @@ -11,12 +11,12 @@ import { CaptchaClient } from '../../src/index.js'; const apiKey = process.env.CAPTCHA_API_KEY || 'YOUR_API_KEY'; -const client = new CaptchaClient({ clientKey: apiKey }); +const captchaSolver = new CaptchaClient({ clientKey: apiKey }); // Get the current account balance. // Returns a float with the available amount in your account currency. try { - const balance = await client.getBalance(); + const balance = await captchaSolver.getBalance(); console.log('Balance:', balance); } catch (error) { console.error(error); diff --git a/examples/async/coordinates.js b/examples/async/coordinates.js index 7868fbe..04070a1 100644 --- a/examples/async/coordinates.js +++ b/examples/async/coordinates.js @@ -14,7 +14,7 @@ import { CaptchaClient, Tasks } from '../../src/index.js'; const apiKey = process.env.CAPTCHA_API_KEY || 'YOUR_API_KEY'; -const client = new CaptchaClient({ clientKey: apiKey }); +const captchaSolver = new CaptchaClient({ clientKey: apiKey }); // Read and encode the captcha image to base64. // The body must be a pure base64 string without the data:image/...;base64, prefix. @@ -27,7 +27,7 @@ try { body: body, // Base64-encoded captcha image (required) comment: 'click on the green apple' // Text hint for the worker }); - const result = await client.solve(task); + const result = await captchaSolver.solve(task); // Solution contains { coordinates: [{ x: 358, y: 268 }] } console.log('result:', result); } catch (error) { @@ -46,7 +46,7 @@ try { minClicks: 1, // Minimum number of clicks (default 1) maxClicks: 3 // Maximum number of clicks allowed }); - const result = await client.solve(task); + const result = await captchaSolver.solve(task); console.log('result:', result); } catch (error) { console.error(error); @@ -62,7 +62,7 @@ try { imgInstructions: yandexInstructions, // Required for smart_captcha comment: 'select objects in the order of the instruction' }); - const result = await client.solve(task); + const result = await captchaSolver.solve(task); console.log('result:', result); } catch (error) { console.error(error); diff --git a/examples/async/geetest_v3.js b/examples/async/geetest_v3.js index e288ad3..38ecb24 100644 --- a/examples/async/geetest_v3.js +++ b/examples/async/geetest_v3.js @@ -18,7 +18,7 @@ import { CaptchaClient, Tasks } from '../../src/index.js'; const apiKey = process.env.CAPTCHA_API_KEY || 'YOUR_API_KEY'; // GeeTest tasks may take longer. Increase timeout if needed. -const client = new CaptchaClient({ clientKey: apiKey, timeout: 300000, pollingInterval: 10000 }); +const captchaSolver = new CaptchaClient({ clientKey: apiKey, timeout: 300000, pollingInterval: 10000 }); // Fetch a fresh challenge value from the target page. // In production, extract this from the page's initGeetest call or network requests. @@ -44,7 +44,7 @@ try { // initParameters: {...}, // Extra params from initGeetest call // userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...', }); - const result = await client.solve(task); + const result = await captchaSolver.solve(task); // Solution contains { challenge, validate, seccode } console.log('result:', result); } catch (error) { @@ -63,7 +63,7 @@ try { proxyLogin: 'user', // Login for proxy authorization (optional) proxyPassword: 'password' // Password for proxy authorization (optional) }); - const result = await client.solve(task); + const result = await captchaSolver.solve(task); console.log('result:', result); } catch (error) { console.error(error); diff --git a/examples/async/geetest_v4.js b/examples/async/geetest_v4.js index ffd81dc..de26576 100644 --- a/examples/async/geetest_v4.js +++ b/examples/async/geetest_v4.js @@ -13,7 +13,7 @@ import { CaptchaClient, Tasks } from '../../src/index.js'; const apiKey = process.env.CAPTCHA_API_KEY || 'YOUR_API_KEY'; // GeeTest v4 tasks may take longer. Increase timeout if needed. -const client = new CaptchaClient({ clientKey: apiKey, timeout: 300000, pollingInterval: 10000 }); +const captchaSolver = new CaptchaClient({ clientKey: apiKey, timeout: 300000, pollingInterval: 10000 }); // --- Proxyless example --- // v4 drops gt/challenge. The widget is identified by captcha_id inside initParameters. @@ -27,7 +27,7 @@ try { // Optional fields // userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...', }); - const result = await client.solve(task); + const result = await captchaSolver.solve(task); // Solution contains { captcha_id, lot_number, pass_token, gen_time, captcha_output } console.log('result:', result); } catch (error) { @@ -48,7 +48,7 @@ try { proxyLogin: 'user', // Login for proxy authorization (optional) proxyPassword: 'password' // Password for proxy authorization (optional) }); - const result = await client.solve(task); + const result = await captchaSolver.solve(task); console.log('result:', result); } catch (error) { console.error(error); diff --git a/examples/async/image_to_text.js b/examples/async/image_to_text.js index 75e8d56..7b795b3 100644 --- a/examples/async/image_to_text.js +++ b/examples/async/image_to_text.js @@ -14,7 +14,7 @@ import { CaptchaClient, Tasks } from '../../src/index.js'; const apiKey = process.env.CAPTCHA_API_KEY || 'YOUR_API_KEY'; // Image to Text tasks are usually fast. Default timeout is fine. -const client = new CaptchaClient({ clientKey: apiKey }); +const captchaSolver = new CaptchaClient({ clientKey: apiKey }); // Read and encode the captcha image to base64. // The body must be a pure base64 string without the data:image/...;base64, prefix. @@ -29,7 +29,7 @@ try { minLength: 4, // Minimum expected answer length maxLength: 6 // Maximum expected answer length }); - const result = await client.solve(task); + const result = await captchaSolver.solve(task); // Solution contains { text: "aB3fX9" } console.log('result:', result); } catch (error) { @@ -53,7 +53,7 @@ try { comment: 'Enter the result of the equation', // Text hint for the worker imgInstructions: imgInstructions // Optional instruction image for the worker }); - const result = await client.solve(task); + const result = await captchaSolver.solve(task); console.log('result:', result); } catch (error) { console.error(error); @@ -70,7 +70,7 @@ try { minLength: 4, maxLength: 6 }); - const result = await client.solve(task, 'en'); // Picks English-speaking worker pool + const result = await captchaSolver.solve(task, 'en'); // Picks English-speaking worker pool console.log('result:', result); } catch (error) { console.error(error); diff --git a/examples/async/recaptcha_v2.js b/examples/async/recaptcha_v2.js index c861ac4..3154a54 100644 --- a/examples/async/recaptcha_v2.js +++ b/examples/async/recaptcha_v2.js @@ -14,7 +14,7 @@ const apiKey = process.env.CAPTCHA_API_KEY || 'YOUR_API_KEY'; // Create a solver instance with your API key. // Optional: timeout (ms to wait for solution, default 120000) // Optional: pollingInterval (ms between status checks, default 2000) -const client = new CaptchaClient({ clientKey: apiKey }); +const captchaSolver = new CaptchaClient({ clientKey: apiKey }); // --- Proxyless example --- // Solves reCAPTCHA v2 without a proxy. The service uses its own IP addresses. @@ -24,7 +24,7 @@ try { websiteKey: '6Le-xxxxxxxxxxxxxxxxxxxxxxxxxxxx', // data-sitekey attribute value isInvisible: false // Set true for invisible reCAPTCHA }); - const result = await client.solve(task); + const result = await captchaSolver.solve(task); // Solution contains { gRecaptchaResponse: "03AGdBq..." } console.log('result:', result); } catch (error) { @@ -45,7 +45,7 @@ try { proxyLogin: 'user', // Login for proxy authorization (optional) proxyPassword: 'password' // Password for proxy authorization (optional) }); - const result = await client.solve(task); + const result = await captchaSolver.solve(task); console.log('result:', result); } catch (error) { console.error(error); diff --git a/examples/async/recaptcha_v2_enterprise.js b/examples/async/recaptcha_v2_enterprise.js index e629932..14d724e 100644 --- a/examples/async/recaptcha_v2_enterprise.js +++ b/examples/async/recaptcha_v2_enterprise.js @@ -12,7 +12,7 @@ import { CaptchaClient, Tasks } from '../../src/index.js'; const apiKey = process.env.CAPTCHA_API_KEY || 'YOUR_API_KEY'; -const client = new CaptchaClient({ clientKey: apiKey }); +const captchaSolver = new CaptchaClient({ clientKey: apiKey }); // --- Proxyless example --- // Enterprise captchas are loaded via the reCAPTCHA Enterprise API. If the site @@ -29,7 +29,7 @@ try { // userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...', // cookies: 'session=abc123; token=xyz789', }); - const result = await client.solve(task); + const result = await captchaSolver.solve(task); // Solution contains { gRecaptchaResponse: "03AGdBq..." } console.log('result:', result); } catch (error) { @@ -48,7 +48,7 @@ try { proxyPassword: 'password', isInvisible: false }); - const result = await client.solve(task); + const result = await captchaSolver.solve(task); console.log('result:', result); } catch (error) { console.error(error); diff --git a/examples/async/recaptcha_v3.js b/examples/async/recaptcha_v3.js index 04f1e69..1575f22 100644 --- a/examples/async/recaptcha_v3.js +++ b/examples/async/recaptcha_v3.js @@ -13,7 +13,7 @@ import { CaptchaClient, Tasks } from '../../src/index.js'; const apiKey = process.env.CAPTCHA_API_KEY || 'YOUR_API_KEY'; // reCAPTCHA v3 tasks may take longer to solve. Increase timeout if needed. -const client = new CaptchaClient({ clientKey: apiKey, timeout: 180000 }); +const captchaSolver = new CaptchaClient({ clientKey: apiKey, timeout: 180000 }); // reCAPTCHA v3 returns a score instead of a pass/fail challenge. // The higher the minScore you request, the harder and longer the task takes. @@ -28,7 +28,7 @@ try { // isEnterprise: true, // Set true for reCAPTCHA v3 Enterprise // apiDomain: 'www.recaptcha.net', // Set if site loads from recaptcha.net }); - const result = await client.solve(task); + const result = await captchaSolver.solve(task); // Solution contains { gRecaptchaResponse: "03AGdBq..." } console.log('result:', result); } catch (error) { diff --git a/examples/async/tencent.js b/examples/async/tencent.js index 90651b1..91f8dfb 100644 --- a/examples/async/tencent.js +++ b/examples/async/tencent.js @@ -12,7 +12,7 @@ import { CaptchaClient, Tasks } from '../../src/index.js'; const apiKey = process.env.CAPTCHA_API_KEY || 'YOUR_API_KEY'; -const client = new CaptchaClient({ clientKey: apiKey }); +const captchaSolver = new CaptchaClient({ clientKey: apiKey }); // --- Proxyless example --- // The service's own proxies are used to solve the captcha. @@ -24,7 +24,7 @@ try { // Optional fields: // captchaScript: 'https://turing.captcha.qcloud.com/TCaptcha.js', }); - const result = await client.solve(task); + const result = await captchaSolver.solve(task); // Solution contains { appid, ret, ticket, randstr } // Pass all four values together into the page's captcha callback as-is. console.log('result:', result); @@ -45,7 +45,7 @@ try { proxyLogin: 'user', // Login for proxy authorization (optional) proxyPassword: 'password' // Password for proxy authorization (optional) }); - const result = await client.solve(task); + const result = await captchaSolver.solve(task); console.log('result:', result); } catch (error) { console.error(error); diff --git a/examples/async/turnstile.js b/examples/async/turnstile.js index 6083b44..7e4caef 100644 --- a/examples/async/turnstile.js +++ b/examples/async/turnstile.js @@ -12,7 +12,7 @@ import { CaptchaClient, Tasks } from '../../src/index.js'; const apiKey = process.env.CAPTCHA_API_KEY || 'YOUR_API_KEY'; -const client = new CaptchaClient({ clientKey: apiKey }); +const captchaSolver = new CaptchaClient({ clientKey: apiKey }); // --- Proxyless example --- // The token is tied to the User-Agent. If you pass userAgent, use the same @@ -27,7 +27,7 @@ try { // pageData: 'chl-page-data-value', // Value of chlPageData parameter // userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ...', }); - const result = await client.solve(task); + const result = await captchaSolver.solve(task); // Solution contains { token: "0.zxcv..." } console.log('result:', result); } catch (error) { @@ -50,7 +50,7 @@ try { // data: 'custom-cdata-value', // pageData: 'chl-page-data-value', }); - const result = await client.solve(task); + const result = await captchaSolver.solve(task); console.log('result:', result); } catch (error) { console.error(error); diff --git a/examples/async/yandex_smartcaptcha.js b/examples/async/yandex_smartcaptcha.js index 3893ddc..a83749e 100644 --- a/examples/async/yandex_smartcaptcha.js +++ b/examples/async/yandex_smartcaptcha.js @@ -11,7 +11,7 @@ import { CaptchaClient, Tasks } from '../../src/index.js'; const apiKey = process.env.CAPTCHA_API_KEY || 'YOUR_API_KEY'; -const client = new CaptchaClient({ clientKey: apiKey }); +const captchaSolver = new CaptchaClient({ clientKey: apiKey }); // --- Proxyless example --- // The service's own proxies are used to solve the captcha. @@ -24,7 +24,7 @@ try { // userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...', // cookies: 'session=abc123; token=xyz789', }); - const result = await client.solve(task); + const result = await captchaSolver.solve(task); // Solution contains { token: "dV9xNjYyNTU3NjkxO4k9OTQuNVMuMjkuMjM9..." } console.log('result:', result); } catch (error) { @@ -44,7 +44,7 @@ try { proxyLogin: 'user', // Login for proxy authorization (optional) proxyPassword: 'password' // Password for proxy authorization (optional) }); - const result = await client.solve(task); + const result = await captchaSolver.solve(task); console.log('result:', result); } catch (error) { console.error(error); diff --git a/examples/sync/balance.js b/examples/sync/balance.js index 9b5d541..ba0675d 100644 --- a/examples/sync/balance.js +++ b/examples/sync/balance.js @@ -11,11 +11,11 @@ import { CaptchaClient } from '../../src/index.js'; const apiKey = process.env.CAPTCHA_API_KEY || 'YOUR_API_KEY'; -const client = new CaptchaClient({ clientKey: apiKey }); +const captchaSolver = new CaptchaClient({ clientKey: apiKey }); // Get the current account balance. // Returns a float with the available amount in your account currency. -client.getBalance() +captchaSolver.getBalance() .then((balance) => { console.log('Balance:', balance); }) diff --git a/examples/sync/coordinates.js b/examples/sync/coordinates.js index 2f65b93..10c1e98 100644 --- a/examples/sync/coordinates.js +++ b/examples/sync/coordinates.js @@ -14,7 +14,7 @@ import { CaptchaClient, Tasks } from '../../src/index.js'; const apiKey = process.env.CAPTCHA_API_KEY || 'YOUR_API_KEY'; -const client = new CaptchaClient({ clientKey: apiKey }); +const captchaSolver = new CaptchaClient({ clientKey: apiKey }); // Read and encode the captcha image to base64. // The body must be a pure base64 string without the data:image/...;base64, prefix. @@ -27,7 +27,7 @@ const basicTask = new Tasks.CoordinatesTask({ comment: 'click on the green apple' // Text hint for the worker }); -client.solve(basicTask) +captchaSolver.solve(basicTask) .then((result) => { // Solution contains { coordinates: [{ x: 358, y: 268 }] } console.log('result:', result); @@ -48,7 +48,7 @@ const advancedTask = new Tasks.CoordinatesTask({ maxClicks: 3 // Maximum number of clicks allowed }); -client.solve(advancedTask) +captchaSolver.solve(advancedTask) .then((result) => { console.log('result:', result); }) @@ -65,7 +65,7 @@ const yandexTask = new Tasks.CoordinatesTask({ comment: 'select objects in the order of the instruction' }); -client.solve(yandexTask) +captchaSolver.solve(yandexTask) .then((result) => { console.log('result:', result); }) diff --git a/examples/sync/geetest_v3.js b/examples/sync/geetest_v3.js index aa501f8..5527bb2 100644 --- a/examples/sync/geetest_v3.js +++ b/examples/sync/geetest_v3.js @@ -18,7 +18,7 @@ import { CaptchaClient, Tasks } from '../../src/index.js'; const apiKey = process.env.CAPTCHA_API_KEY || 'YOUR_API_KEY'; // GeeTest tasks may take longer. Increase timeout if needed. -const client = new CaptchaClient({ clientKey: apiKey, timeout: 300000, pollingInterval: 10000 }); +const captchaSolver = new CaptchaClient({ clientKey: apiKey, timeout: 300000, pollingInterval: 10000 }); // Fetch a fresh challenge value from the target page. // In production, extract this from the page's initGeetest call or network requests. @@ -38,7 +38,7 @@ fetch('https://target-site.com/path/to/geetest/init') // userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...', }); - client.solve(proxylessTask) + captchaSolver.solve(proxylessTask) .then((result) => { // Solution contains { challenge, validate, seccode } console.log('result:', result); @@ -59,7 +59,7 @@ fetch('https://target-site.com/path/to/geetest/init') proxyPassword: 'password' // Password for proxy authorization (optional) }); - client.solve(proxyTask) + captchaSolver.solve(proxyTask) .then((result) => { console.log('result:', result); }) diff --git a/examples/sync/geetest_v4.js b/examples/sync/geetest_v4.js index 30d599a..652f1b7 100644 --- a/examples/sync/geetest_v4.js +++ b/examples/sync/geetest_v4.js @@ -13,7 +13,7 @@ import { CaptchaClient, Tasks } from '../../src/index.js'; const apiKey = process.env.CAPTCHA_API_KEY || 'YOUR_API_KEY'; // GeeTest v4 tasks may take longer. Increase timeout if needed. -const client = new CaptchaClient({ clientKey: apiKey, timeout: 300000, pollingInterval: 10000 }); +const captchaSolver = new CaptchaClient({ clientKey: apiKey, timeout: 300000, pollingInterval: 10000 }); // --- Proxyless example --- // v4 drops gt/challenge. The widget is identified by captcha_id inside initParameters. @@ -27,7 +27,7 @@ const proxylessTask = new Tasks.GeeTestProxyless({ // userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...', }); -client.solve(proxylessTask) +captchaSolver.solve(proxylessTask) .then((result) => { // Solution contains { captcha_id, lot_number, pass_token, gen_time, captcha_output } console.log('result:', result); @@ -50,7 +50,7 @@ const proxyTask = new Tasks.GeeTest({ proxyPassword: 'password' // Password for proxy authorization (optional) }); -client.solve(proxyTask) +captchaSolver.solve(proxyTask) .then((result) => { console.log('result:', result); }) diff --git a/examples/sync/image_to_text.js b/examples/sync/image_to_text.js index d9b252d..3c99294 100644 --- a/examples/sync/image_to_text.js +++ b/examples/sync/image_to_text.js @@ -14,7 +14,7 @@ import { CaptchaClient, Tasks } from '../../src/index.js'; const apiKey = process.env.CAPTCHA_API_KEY || 'YOUR_API_KEY'; // Image to Text tasks are usually fast. Default timeout is fine. -const client = new CaptchaClient({ clientKey: apiKey }); +const captchaSolver = new CaptchaClient({ clientKey: apiKey }); // Read and encode the captcha image to base64. // The body must be a pure base64 string without the data:image/...;base64, prefix. @@ -29,7 +29,7 @@ const basicTask = new Tasks.ImageToText({ maxLength: 6 // Maximum expected answer length }); -client.solve(basicTask) +captchaSolver.solve(basicTask) .then((result) => { // Solution contains { text: "aB3fX9" } console.log('result:', result); @@ -55,7 +55,7 @@ const advancedTask = new Tasks.ImageToText({ imgInstructions: imgInstructions // Optional instruction image for the worker }); -client.solve(advancedTask) +captchaSolver.solve(advancedTask) .then((result) => { console.log('result:', result); }) @@ -74,7 +74,7 @@ const languagePoolTask = new Tasks.ImageToText({ maxLength: 6 }); -client.solve(languagePoolTask, 'en') // Picks English-speaking worker pool +captchaSolver.solve(languagePoolTask, 'en') // Picks English-speaking worker pool .then((result) => { console.log('result:', result); }) diff --git a/examples/sync/recaptcha_v2.js b/examples/sync/recaptcha_v2.js index 53bc03c..739509d 100644 --- a/examples/sync/recaptcha_v2.js +++ b/examples/sync/recaptcha_v2.js @@ -14,7 +14,7 @@ const apiKey = process.env.CAPTCHA_API_KEY || 'YOUR_API_KEY'; // Create a solver instance with your API key. // Optional: timeout (ms to wait for solution, default 120000) // Optional: pollingInterval (ms between status checks, default 2000) -const client = new CaptchaClient({ clientKey: apiKey }); +const captchaSolver = new CaptchaClient({ clientKey: apiKey }); // --- Proxyless example --- // Solves reCAPTCHA v2 without a proxy. The service uses its own IP addresses. @@ -24,7 +24,7 @@ const proxylessTask = new Tasks.RecaptchaV2Proxyless({ isInvisible: false // Set true for invisible reCAPTCHA }); -client.solve(proxylessTask) +captchaSolver.solve(proxylessTask) .then((result) => { // Solution contains { gRecaptchaResponse: "03AGdBq..." } console.log('result:', result); @@ -47,7 +47,7 @@ const proxyTask = new Tasks.RecaptchaV2({ proxyPassword: 'password' // Password for proxy authorization (optional) }); -client.solve(proxyTask) +captchaSolver.solve(proxyTask) .then((result) => { console.log('result:', result); }) diff --git a/examples/sync/recaptcha_v2_enterprise.js b/examples/sync/recaptcha_v2_enterprise.js index cd5d4ef..e631c1a 100644 --- a/examples/sync/recaptcha_v2_enterprise.js +++ b/examples/sync/recaptcha_v2_enterprise.js @@ -12,7 +12,7 @@ import { CaptchaClient, Tasks } from '../../src/index.js'; const apiKey = process.env.CAPTCHA_API_KEY || 'YOUR_API_KEY'; -const client = new CaptchaClient({ clientKey: apiKey }); +const captchaSolver = new CaptchaClient({ clientKey: apiKey }); // --- Proxyless example --- // Enterprise captchas are loaded via the reCAPTCHA Enterprise API. If the site @@ -29,7 +29,7 @@ const proxylessTask = new Tasks.RecaptchaV2EnterpriseProxyless({ // cookies: 'session=abc123; token=xyz789', }); -client.solve(proxylessTask) +captchaSolver.solve(proxylessTask) .then((result) => { // Solution contains { gRecaptchaResponse: "03AGdBq..." } console.log('result:', result); @@ -50,7 +50,7 @@ const proxyTask = new Tasks.RecaptchaV2Enterprise({ isInvisible: false }); -client.solve(proxyTask) +captchaSolver.solve(proxyTask) .then((result) => { console.log('result:', result); }) diff --git a/examples/sync/recaptcha_v3.js b/examples/sync/recaptcha_v3.js index 811cf0d..bf5057f 100644 --- a/examples/sync/recaptcha_v3.js +++ b/examples/sync/recaptcha_v3.js @@ -13,7 +13,7 @@ import { CaptchaClient, Tasks } from '../../src/index.js'; const apiKey = process.env.CAPTCHA_API_KEY || 'YOUR_API_KEY'; // reCAPTCHA v3 tasks may take longer to solve. Increase timeout if needed. -const client = new CaptchaClient({ clientKey: apiKey, timeout: 180000 }); +const captchaSolver = new CaptchaClient({ clientKey: apiKey, timeout: 180000 }); // reCAPTCHA v3 returns a score instead of a pass/fail challenge. // The higher the minScore you request, the harder and longer the task takes. @@ -28,7 +28,7 @@ const task = new Tasks.RecaptchaV3Proxyless({ // apiDomain: 'www.recaptcha.net', // Set if site loads from recaptcha.net }); -client.solve(task) +captchaSolver.solve(task) .then((result) => { // Solution contains { gRecaptchaResponse: "03AGdBq..." } console.log('result:', result); diff --git a/examples/sync/tencent.js b/examples/sync/tencent.js index f63fad1..ce58b31 100644 --- a/examples/sync/tencent.js +++ b/examples/sync/tencent.js @@ -12,7 +12,7 @@ import { CaptchaClient, Tasks } from '../../src/index.js'; const apiKey = process.env.CAPTCHA_API_KEY || 'YOUR_API_KEY'; -const client = new CaptchaClient({ clientKey: apiKey }); +const captchaSolver = new CaptchaClient({ clientKey: apiKey }); // --- Proxyless example --- // The service's own proxies are used to solve the captcha. @@ -24,7 +24,7 @@ const proxylessTask = new Tasks.TencentTaskProxyless({ // captchaScript: 'https://turing.captcha.qcloud.com/TCaptcha.js', }); -client.solve(proxylessTask) +captchaSolver.solve(proxylessTask) .then((result) => { // Solution contains { appid, ret, ticket, randstr } // Pass all four values together into the page's captcha callback as-is. @@ -47,7 +47,7 @@ const proxyTask = new Tasks.TencentTask({ proxyPassword: 'password' // Password for proxy authorization (optional) }); -client.solve(proxyTask) +captchaSolver.solve(proxyTask) .then((result) => { console.log('result:', result); }) diff --git a/examples/sync/turnstile.js b/examples/sync/turnstile.js index 99bc939..35a5855 100644 --- a/examples/sync/turnstile.js +++ b/examples/sync/turnstile.js @@ -12,7 +12,7 @@ import { CaptchaClient, Tasks } from '../../src/index.js'; const apiKey = process.env.CAPTCHA_API_KEY || 'YOUR_API_KEY'; -const client = new CaptchaClient({ clientKey: apiKey }); +const captchaSolver = new CaptchaClient({ clientKey: apiKey }); // --- Proxyless example --- // The token is tied to the User-Agent. If you pass userAgent, use the same @@ -27,7 +27,7 @@ const proxylessTask = new Tasks.TurnstileProxyless({ // userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ...', }); -client.solve(proxylessTask) +captchaSolver.solve(proxylessTask) .then((result) => { // Solution contains { token: "0.zxcv..." } console.log('result:', result); @@ -52,7 +52,7 @@ const proxyTask = new Tasks.Turnstile({ // pageData: 'chl-page-data-value', }); -client.solve(proxyTask) +captchaSolver.solve(proxyTask) .then((result) => { console.log('result:', result); }) diff --git a/examples/sync/yandex_smartcaptcha.js b/examples/sync/yandex_smartcaptcha.js index af270e0..bd16021 100644 --- a/examples/sync/yandex_smartcaptcha.js +++ b/examples/sync/yandex_smartcaptcha.js @@ -11,7 +11,7 @@ import { CaptchaClient, Tasks } from '../../src/index.js'; const apiKey = process.env.CAPTCHA_API_KEY || 'YOUR_API_KEY'; -const client = new CaptchaClient({ clientKey: apiKey }); +const captchaSolver = new CaptchaClient({ clientKey: apiKey }); // --- Proxyless example --- // The service's own proxies are used to solve the captcha. @@ -24,7 +24,7 @@ const proxylessTask = new Tasks.YandexSmartCaptchaTaskProxyless({ // cookies: 'session=abc123; token=xyz789', }); -client.solve(proxylessTask) +captchaSolver.solve(proxylessTask) .then((result) => { // Solution contains { token: "dV9xNjYyNTU3NjkxO4k9OTQuNVMuMjkuMjM9..." } console.log('result:', result); @@ -46,7 +46,7 @@ const proxyTask = new Tasks.YandexSmartCaptchaTask({ proxyPassword: 'password' // Password for proxy authorization (optional) }); -client.solve(proxyTask) +captchaSolver.solve(proxyTask) .then((result) => { console.log('result:', result); }) From c82b96b2283925b44b150b87f2e023a0cf5dd251 Mon Sep 17 00:00:00 2001 From: dzmitry-duboyski Date: Fri, 7 Aug 2026 21:10:07 +0300 Subject: [PATCH 13/21] Document the examples directory The examples were undocumented: nothing said how to run them, which ones need editing first, or why there are two copies of every scenario. Three READMEs. The parent one carries the shared setup and an index of all 11 scenarios in both styles; each suite README describes its own files and links them to the matching section of the API docs. Four things a reader could previously only learn by hitting them: - Most files run a proxyless and a with-proxy block on every execution, spending balance twice. In sync/ the two even start concurrently. - geetest_v3.js fetches a placeholder init endpoint and cannot run end-to-end unmodified. - The image examples resolve ./captcha.png against the working directory, not the script location, and the repository ships no sample images. - languagePool is the second argument to solve(), not a task field. Co-Authored-By: Claude Opus 5 --- examples/README.md | 195 +++++++++++++++++++++++++++++++++++++++ examples/async/README.md | 153 ++++++++++++++++++++++++++++++ examples/sync/README.md | 163 ++++++++++++++++++++++++++++++++ 3 files changed, 511 insertions(+) create mode 100644 examples/README.md create mode 100644 examples/async/README.md create mode 100644 examples/sync/README.md diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..598d24a --- /dev/null +++ b/examples/README.md @@ -0,0 +1,195 @@ +# Examples + +Runnable scripts for every captcha type the SDK supports. Each file is standalone: it builds a +`CaptchaClient`, creates one or two tasks, waits for the solution and prints it. Nothing here is +imported by the library itself — the directory exists to be read and executed. + +The same 11 scenarios are provided twice, once per calling style: + +| Suite | Style | Look here if | +|---|---|---| +| [async/](async) | `async/await` | your code is `await`-based (the common case) | +| [sync/](sync) | `.then()/.catch()` | you chain promises instead of awaiting them | + +Both suites call the same `CaptchaClient`. The SDK exposes a single promise-based API, and the split +only demonstrates two ways of consuming it — JavaScript has no blocking HTTP client, so there is no +true synchronous variant the way the Python SDK has `requests` vs. `httpx`. + +## Table of contents + +- [Setup](#setup) +- [Running an example](#running-an-example) +- [Example index](#example-index) +- [What each example covers](#what-each-example-covers) +- [Before you run](#before-you-run) + +## Setup + +**1. Install dependencies.** The scripts use `dotenv`, which is a devDependency of the repo: + +```bash +npm install +``` + +**2. Provide an API key.** Copy the template and fill in your key: + +```bash +cp .env.example .env +``` + +``` +CAPTCHA_API_KEY=your_api_key_here +``` + +Every script starts with `import 'dotenv/config'` and reads `process.env.CAPTCHA_API_KEY`, falling +back to the literal `'YOUR_API_KEY'` when the variable is missing. That fallback keeps the script +importable, but the API will reject it — set a real key before expecting results. + +**3. Node.js 18 or newer.** The examples rely on global `fetch` and, in `async/`, on top-level +`await`. + +> The scripts import the SDK by relative path (`../../src/index.js`) because they live inside this +> repository. In your own project, import it by package name instead: +> `import { CaptchaClient, Tasks } from 'captcha-sdk';` + +## Running an example + +Run from the repository root: + +```bash +node examples/async/balance.js +node examples/sync/recaptcha_v2.js +``` + +`balance.js` is the one file that works as-is — it only needs a valid key. Every other example ships +with placeholder values (`https://example.com/login`, `6Le-xxxxxxxxx`, …) that you must replace with +values from your own target page. See [Before you run](#before-you-run). + +Most files contain **two independent blocks** — a proxyless one and a with-proxy one — that both run +on execution. Comment out the block you don't need, or the script will submit two tasks and spend +twice the balance. + +## Example index + +| Captcha type | async | sync | Task classes | Solution fields | +|---|---|---|---|---| +| reCAPTCHA v2 | [recaptcha_v2.js](async/recaptcha_v2.js) | [recaptcha_v2.js](sync/recaptcha_v2.js) | `RecaptchaV2Proxyless`, `RecaptchaV2` | `gRecaptchaResponse` | +| reCAPTCHA v2 Enterprise | [recaptcha_v2_enterprise.js](async/recaptcha_v2_enterprise.js) | [recaptcha_v2_enterprise.js](sync/recaptcha_v2_enterprise.js) | `RecaptchaV2EnterpriseProxyless`, `RecaptchaV2Enterprise` | `gRecaptchaResponse` | +| reCAPTCHA v3 | [recaptcha_v3.js](async/recaptcha_v3.js) | [recaptcha_v3.js](sync/recaptcha_v3.js) | `RecaptchaV3Proxyless` | `gRecaptchaResponse` | +| Cloudflare Turnstile | [turnstile.js](async/turnstile.js) | [turnstile.js](sync/turnstile.js) | `TurnstileProxyless`, `Turnstile` | `token` | +| GeeTest v3 | [geetest_v3.js](async/geetest_v3.js) | [geetest_v3.js](sync/geetest_v3.js) | `GeeTestProxyless`, `GeeTest` | `challenge`, `validate`, `seccode` | +| GeeTest v4 | [geetest_v4.js](async/geetest_v4.js) | [geetest_v4.js](sync/geetest_v4.js) | `GeeTestProxyless`, `GeeTest` | `captcha_id`, `lot_number`, `pass_token`, `gen_time`, `captcha_output` | +| Yandex SmartCaptcha | [yandex_smartcaptcha.js](async/yandex_smartcaptcha.js) | [yandex_smartcaptcha.js](sync/yandex_smartcaptcha.js) | `YandexSmartCaptchaTaskProxyless`, `YandexSmartCaptchaTask` | `token` | +| Tencent | [tencent.js](async/tencent.js) | [tencent.js](sync/tencent.js) | `TencentTaskProxyless`, `TencentTask` | `appid`, `ret`, `ticket`, `randstr` | +| Image to Text | [image_to_text.js](async/image_to_text.js) | [image_to_text.js](sync/image_to_text.js) | `ImageToText` | `text` | +| Coordinates (click) | [coordinates.js](async/coordinates.js) | [coordinates.js](sync/coordinates.js) | `CoordinatesTask` | `coordinates` | +| Account balance | [balance.js](async/balance.js) | [balance.js](sync/balance.js) | — (`getBalance()`) | number | + +## What each example covers + +### reCAPTCHA v2 + +The baseline example, and the best one to read first. Shows the two shapes every proxy-capable type +follows: `RecaptchaV2Proxyless` (the service uses its own IPs) and `RecaptchaV2` with the five proxy +fields — `proxyType`, `proxyAddress`, `proxyPort`, `proxyLogin`, `proxyPassword`. Also documents the +two optional client settings, `timeout` and `pollingInterval`, and the `isInvisible` flag for +invisible widgets. + +### reCAPTCHA v2 Enterprise + +Same two shapes as v2, with `enterprisePayload` added. If the target site passes extra parameters to +`grecaptcha.enterprise.render()`, they must be forwarded in that object — otherwise the returned +token is rejected by the site even though the API call succeeded. + +### reCAPTCHA v3 + +Proxyless only; v3 has no with-proxy variant. `minScore` is required and drives cost and duration: +`0.3` is fastest, `0.7` balanced, `0.9` highest and slowest. `pageAction` should match the action the +site sets in `grecaptcha.execute()` — passing it raises the chance the token is accepted. The client +is created with `timeout: 180000` because v3 tasks run longer than v2. Commented-out lines show +`isEnterprise` and `apiDomain`. + +### Cloudflare Turnstile + +Proxyless and with-proxy variants. The important detail is in the comments: the token is tied to the +User-Agent, so if you pass `userAgent` you must reuse the exact same one when submitting the token. +For Cloudflare Challenge pages, `action`, `data` (the `data-cdata` attribute) and `pageData` (the +`chlPageData` parameter) also have to be extracted from the page and passed along. + +### GeeTest v3 + +The only example that performs a **request of its own before solving**. The `challenge` value is +session-specific and must be fresh for every task, so the script fetches one first and then builds +the task. The fetch URL is a deliberate placeholder — see [Before you run](#before-you-run). Uses +`timeout: 300000` and `pollingInterval: 10000`, since GeeTest is among the slowest types. `version` +is omitted because v3 is the default. + +### GeeTest v4 + +Same task classes as v3, different identification: v4 drops `gt`/`challenge` entirely and identifies +the widget by `captcha_id` inside `initParameters`, with `version: 4` set explicitly. Solution shape +differs from v3 as well — `captcha_output` and friends instead of `validate`/`seccode`. + +### Yandex SmartCaptcha + +Covers the **token** challenge, proxyless and with proxy. `websiteKey` is the sitekey from the page +source or the captcha iframe; `userAgent` and `cookies` are optional. Yandex's **image** challenge is +a different task type — it lives in the coordinates example below. + +### Tencent + +Proxyless and with proxy. `appId` is read from the page source. `captchaScript` is only needed when +the site loads the widget from a non-default script URL. + +### Image to Text + +Three blocks, no proxy variant. *Basic* submits a base64 image and nothing else. *Advanced* adds the +hints that speed up recognition — `numeric`, `phrase`, `minLength`/`maxLength`, `comment` and an +`imgInstructions` image. *With language pool* shows that `languagePool` is the **second argument to +`solve()`**, not a task field: `solve(task, 'en')` picks an English-speaking worker pool (`'en'` or +`'ru'`). + +### Coordinates (click captcha) + +Three blocks, no proxy variant. *Basic* passes an image plus a `comment` telling the worker what to +click. *Advanced* adds an `imgInstructions` image and the `minClicks`/`maxClicks` limits. *Yandex +SmartCaptcha image mode* reuses `CoordinatesTask` with `imgType: 'smart_captcha'` (object selection) +or `'pazl_smart_captcha'` (puzzle) — this is how you solve Yandex's image challenge rather than its +token challenge. + +### Account balance + +The shortest script: one `getBalance()` call returning the available amount as a number. It needs no +target page and no placeholder edits, which makes it the fastest way to confirm that your key and +network access work. + +## Before you run + +Every example except `balance.js` needs something replaced first. + +| Example | What you must supply | +|---|---| +| `balance.js` | Nothing — runs as-is with a valid key | +| `recaptcha_v2.js`, `recaptcha_v2_enterprise.js`, `recaptcha_v3.js` | Real `websiteURL` and `websiteKey` | +| `turnstile.js` | Real `websiteURL` and `websiteKey`; for Challenge pages also `action`, `data`, `pageData` | +| `yandex_smartcaptcha.js` | Real `websiteURL` and `websiteKey` | +| `tencent.js` | Real `websiteURL` and `appId` | +| `geetest_v3.js` | Real `websiteURL` and `gt`, **plus a real init endpoint** (see below) | +| `geetest_v4.js` | Real `websiteURL` and `captcha_id` | +| `image_to_text.js` | `captcha.png` and `captcha_hint.png` in the working directory | +| `coordinates.js` | `captcha.png` and `instruction.png` in the working directory | + +**Image files are resolved against the working directory**, not the script location — `fs.readFileSync('./captcha.png')` +looks in wherever you launched `node` from. Run those two examples from the directory holding your +images, or edit the paths. The repository intentionally ships no sample images. + +**`geetest_v3.js` will not run end-to-end as-is.** It fetches +`https://target-site.com/path/to/geetest/init`, which is a placeholder, not a live endpoint; the +script exits with code 1 when that fetch fails. Point it at your real target page — or wherever it +exposes a fresh `challenge` — before running. The block is there to show *where* that fetch belongs +in the flow. + +Proxy credentials (`1.2.3.4:8080`, `user`/`password`) are placeholders too. If you only want the +proxyless path, comment out the with-proxy block rather than leaving it to fail. + +Full API reference: https://captcha-solver.com/en/docs/captcha-types diff --git a/examples/async/README.md b/examples/async/README.md new file mode 100644 index 0000000..2d03164 --- /dev/null +++ b/examples/async/README.md @@ -0,0 +1,153 @@ +# Examples — async/await + +The 11 SDK scenarios written in `async/await` style. Every file uses **top-level `await`** (possible +because the package is ESM, `"type": "module"`) and wraps each solve in its own `try/catch`, so a +failing block prints the error and the next block still runs. + +Setup, API key and the general gotchas are documented once in the [parent README](../README.md) — +read that first if you haven't. The identical scenarios in promise-chain style live in +[../sync/](../sync). + +Because every block is awaited at the top level, the blocks in a file execute **sequentially**: a +file with a proxyless block and a with-proxy block submits two tasks, one after the other, and +spends balance twice. Comment out the one you don't need. + +## Table of contents + +- [File index](#file-index) +- [What each file does](#what-each-file-does) + +## File index + +| File | What it does | Blocks in the file | +|---|---|---| +| [balance.js](balance.js) | Reads the account balance | single call | +| [recaptcha_v2.js](recaptcha_v2.js) | Solves reCAPTCHA v2 | Proxyless · With proxy | +| [recaptcha_v2_enterprise.js](recaptcha_v2_enterprise.js) | Solves reCAPTCHA v2 Enterprise | Proxyless · With proxy | +| [recaptcha_v3.js](recaptcha_v3.js) | Solves reCAPTCHA v3 with a score threshold | single block | +| [turnstile.js](turnstile.js) | Solves Cloudflare Turnstile | Proxyless · With proxy | +| [geetest_v3.js](geetest_v3.js) | Fetches a fresh `challenge`, then solves GeeTest v3 | Fetch · Proxyless · With proxy | +| [geetest_v4.js](geetest_v4.js) | Solves GeeTest v4 via `captcha_id` | Proxyless · With proxy | +| [yandex_smartcaptcha.js](yandex_smartcaptcha.js) | Solves Yandex SmartCaptcha (token challenge) | Proxyless · With proxy | +| [tencent.js](tencent.js) | Solves a Tencent captcha | Proxyless · With proxy | +| [image_to_text.js](image_to_text.js) | Recognises text on a captcha image | Basic · Advanced · With language pool | +| [coordinates.js](coordinates.js) | Solves a click captcha by coordinates | Basic · Advanced · Yandex image mode | + +## What each file does + +### balance.js + +[Source code](balance.js) · [API documentation](https://captcha-solver.com/en/docs/methods#post-getbalance) + +```javascript +const balance = await captchaSolver.getBalance(); +``` + +One call, returning the available amount as a number. No task, no target page, no placeholders — +the only file here that runs correctly without editing anything, which makes it the quickest check +that your key works. + +### recaptcha_v2.js + +[Source code](recaptcha_v2.js) · [API documentation](https://captcha-solver.com/en/docs/captcha-types#recaptcha-v2) + +The reference file for the proxyless/with-proxy pattern that most other examples repeat. +`RecaptchaV2Proxyless` solves through the service's own IPs; `RecaptchaV2` adds `proxyType`, +`proxyAddress`, `proxyPort`, `proxyLogin` and `proxyPassword`. The client constructor is annotated +with the two optional settings — `timeout` (default 120000 ms) and `pollingInterval` (default +2000 ms) — and the task shows `isInvisible` for invisible widgets. Solution: `gRecaptchaResponse`. + +### recaptcha_v2_enterprise.js + +[Source code](recaptcha_v2_enterprise.js) · [API documentation](https://captcha-solver.com/en/docs/captcha-types#recaptcha-v2-enterprise) + +Same two blocks, plus `enterprisePayload`. Enterprise widgets are rendered through +`grecaptcha.enterprise.render()`, and any extra parameters the site passes there must be forwarded +in that object — omit them and the site rejects an otherwise valid token. Solution: +`gRecaptchaResponse`. + +### recaptcha_v3.js + +[Source code](recaptcha_v3.js) · [API documentation](https://captcha-solver.com/en/docs/captcha-types#recaptcha-v3) + +A single block: v3 has no with-proxy variant. `minScore` is required and controls how hard the task +is — `0.3` fastest, `0.7` balanced, `0.9` highest and slowest — so the client is created with +`timeout: 180000`. `pageAction` should mirror the action the site sets in `grecaptcha.execute()`. +Commented-out lines show `isEnterprise` and `apiDomain` for sites loading from `recaptcha.net`. +Solution: `gRecaptchaResponse`. + +### turnstile.js + +[Source code](turnstile.js) · [API documentation](https://captcha-solver.com/en/docs/captcha-types#cloudflare-turnstile) + +Proxyless and with-proxy blocks. The commented optional fields matter more here than elsewhere: +`action`, `data` (`data-cdata`) and `pageData` (`chlPageData`) are required for Cloudflare Challenge +pages. Note the User-Agent rule spelled out in the comments — the token is bound to it, so if you +pass `userAgent`, the browser or bot submitting the token must send the same one. Solution: `token`. + +### geetest_v3.js + +[Source code](geetest_v3.js) · [API documentation](https://captcha-solver.com/en/docs/captcha-types#geetest-v3) + +The only file that makes a request of its own before solving. `challenge` is session-specific and +must be fresh per task, so the script awaits a `fetch` to the target's init endpoint, destructures +`challenge` out of the JSON, and only then builds the task; if that fetch throws, it logs and calls +`process.exit(1)`. + +That URL — `https://target-site.com/path/to/geetest/init` — is a **placeholder**, so the file does +not run end-to-end unmodified. It marks where the fetch belongs in the flow; point it at your real +source of `challenge` first. + +The client uses `timeout: 300000` and `pollingInterval: 10000`, GeeTest being among the slowest +types. `version` is omitted since v3 is the default. Solution: `challenge`, `validate`, `seccode`. + +### geetest_v4.js + +[Source code](geetest_v4.js) · [API documentation](https://captcha-solver.com/en/docs/captcha-types#geetest-v4) + +Same `GeeTestProxyless`/`GeeTest` classes as v3, but v4 identifies the widget differently: no +`gt`, no `challenge`, instead `version: 4` and `captcha_id` inside `initParameters`. No pre-fetch is +needed. Same extended timeouts. Solution: `captcha_id`, `lot_number`, `pass_token`, `gen_time`, +`captcha_output`. + +### yandex_smartcaptcha.js + +[Source code](yandex_smartcaptcha.js) · [API documentation](https://captcha-solver.com/en/docs/captcha-types#yandex-smartcaptcha) + +The **token** challenge, proxyless and with proxy. `websiteKey` is the sitekey from the page source +or the captcha iframe; `userAgent` and `cookies` are optional. For Yandex's **image** challenge use +[coordinates.js](coordinates.js) instead — different task class entirely. Solution: `token`. + +### tencent.js + +[Source code](tencent.js) · [API documentation](https://captcha-solver.com/en/docs/captcha-types#tencent) + +Proxyless and with proxy. `appId` comes from the page source; `captchaScript` is only needed when +the site loads the widget from a non-default script URL. Solution: `appid`, `ret`, `ticket`, +`randstr`. + +### image_to_text.js + +[Source code](image_to_text.js) · [API documentation](https://captcha-solver.com/en/docs/captcha-types#image-to-text) + +Three blocks, no proxy variant. *Basic* sends just the base64 image. *Advanced* adds the hints that +narrow the search for the worker — `numeric`, `phrase`, `minLength`/`maxLength`, `comment`, and a +`captcha_hint.png` passed as `imgInstructions`. *With language pool* demonstrates the one API detail +that is easy to get wrong: `languagePool` is the **second argument to `solve()`**, not a task field — +`await captchaSolver.solve(task, 'en')`, accepting `'en'` or `'ru'`. + +Reads `./captcha.png` and `./captcha_hint.png` **relative to the working directory**, so run it from +where those files are. Solution: `text`. + +### coordinates.js + +[Source code](coordinates.js) · [API documentation](https://captcha-solver.com/en/docs/captcha-types#coordinates) + +Three blocks, no proxy variant. *Basic* submits an image plus a `comment` telling the worker what to +click. *Advanced* adds an `instruction.png` as `imgInstructions` and constrains the answer with +`minClicks`/`maxClicks`. *Yandex SmartCaptcha image mode* reuses the same `CoordinatesTask` with +`imgType: 'smart_captcha'` (object selection) or `'pazl_smart_captcha'` (puzzle), which is how the +image variant of Yandex SmartCaptcha is solved. + +Reads `./captcha.png` and `./instruction.png` **relative to the working directory**. Solution: +`coordinates`, an array of `{ x, y }` points. diff --git a/examples/sync/README.md b/examples/sync/README.md new file mode 100644 index 0000000..d7a478b --- /dev/null +++ b/examples/sync/README.md @@ -0,0 +1,163 @@ +# Examples — promise chains + +The same 11 SDK scenarios as [../async/](../async), written with `.then()/.catch()` instead of +`await`. Nothing about the SDK changes between the two suites: `CaptchaClient` returns promises, and +these files simply consume them without `await`. + +Setup, API key and the general gotchas are documented once in the [parent README](../README.md) — +read that first if you haven't. + +Two consequences of dropping `await` are worth knowing before you run anything here: + +- **Tasks are named, not inline.** Each block declares its task in a `const` (`proxylessTask`, + `proxyTask`, `basicTask`, …) and then passes it to `captchaSolver.solve(...)`, which keeps the + promise chain readable. +- **Blocks start concurrently.** In `async/`, a top-level `await` makes the proxyless block finish + before the with-proxy block begins. Here nothing blocks, so both `solve()` calls are submitted + almost simultaneously and their results arrive in whatever order the API returns them. Two tasks, + twice the balance — comment out the block you don't need. + +Errors are handled per chain with a trailing `.catch()`; a failing block does not stop the others. + +## Table of contents + +- [File index](#file-index) +- [What each file does](#what-each-file-does) + +## File index + +| File | What it does | Blocks in the file | +|---|---|---| +| [balance.js](balance.js) | Reads the account balance | single call | +| [recaptcha_v2.js](recaptcha_v2.js) | Solves reCAPTCHA v2 | Proxyless · With proxy | +| [recaptcha_v2_enterprise.js](recaptcha_v2_enterprise.js) | Solves reCAPTCHA v2 Enterprise | Proxyless · With proxy | +| [recaptcha_v3.js](recaptcha_v3.js) | Solves reCAPTCHA v3 with a score threshold | single block | +| [turnstile.js](turnstile.js) | Solves Cloudflare Turnstile | Proxyless · With proxy | +| [geetest_v3.js](geetest_v3.js) | Fetches a fresh `challenge`, then solves GeeTest v3 | one nested chain | +| [geetest_v4.js](geetest_v4.js) | Solves GeeTest v4 via `captcha_id` | Proxyless · With proxy | +| [yandex_smartcaptcha.js](yandex_smartcaptcha.js) | Solves Yandex SmartCaptcha (token challenge) | Proxyless · With proxy | +| [tencent.js](tencent.js) | Solves a Tencent captcha | Proxyless · With proxy | +| [image_to_text.js](image_to_text.js) | Recognises text on a captcha image | Basic · Advanced · With language pool | +| [coordinates.js](coordinates.js) | Solves a click captcha by coordinates | Basic · Advanced · Yandex image mode | + +## What each file does + +### balance.js + +[Source code](balance.js) · [API documentation](https://captcha-solver.com/en/docs/methods#post-getbalance) + +```javascript +captchaSolver.getBalance() + .then((balance) => console.log('Balance:', balance)) +``` + +One call resolving to the available amount as a number. No task, no target page, no placeholders — +the only file here that runs correctly without editing anything, and therefore the quickest check +that your key works. + +### recaptcha_v2.js + +[Source code](recaptcha_v2.js) · [API documentation](https://captcha-solver.com/en/docs/captcha-types#recaptcha-v2) + +The reference file for the proxyless/with-proxy pattern most other examples repeat. +`RecaptchaV2Proxyless` solves through the service's own IPs; `RecaptchaV2` adds `proxyType`, +`proxyAddress`, `proxyPort`, `proxyLogin` and `proxyPassword`. The client constructor is annotated +with the two optional settings — `timeout` (default 120000 ms) and `pollingInterval` (default +2000 ms) — and the task shows `isInvisible` for invisible widgets. Solution: `gRecaptchaResponse`. + +### recaptcha_v2_enterprise.js + +[Source code](recaptcha_v2_enterprise.js) · [API documentation](https://captcha-solver.com/en/docs/captcha-types#recaptcha-v2-enterprise) + +Same two chains, plus `enterprisePayload`. Enterprise widgets render through +`grecaptcha.enterprise.render()`, and any extra parameters the site passes there must be forwarded +in that object — omit them and the site rejects an otherwise valid token. Solution: +`gRecaptchaResponse`. + +### recaptcha_v3.js + +[Source code](recaptcha_v3.js) · [API documentation](https://captcha-solver.com/en/docs/captcha-types#recaptcha-v3) + +A single chain: v3 has no with-proxy variant. `minScore` is required and controls how hard the task +is — `0.3` fastest, `0.7` balanced, `0.9` highest and slowest — so the client is created with +`timeout: 180000`. `pageAction` should mirror the action the site sets in `grecaptcha.execute()`. +Commented-out lines show `isEnterprise` and `apiDomain` for sites loading from `recaptcha.net`. +Solution: `gRecaptchaResponse`. + +### turnstile.js + +[Source code](turnstile.js) · [API documentation](https://captcha-solver.com/en/docs/captcha-types#cloudflare-turnstile) + +Proxyless and with-proxy chains. The commented optional fields matter more here than elsewhere: +`action`, `data` (`data-cdata`) and `pageData` (`chlPageData`) are required for Cloudflare Challenge +pages. Note the User-Agent rule spelled out in the comments — the token is bound to it, so if you +pass `userAgent`, the browser or bot submitting the token must send the same one. Solution: `token`. + +### geetest_v3.js + +[Source code](geetest_v3.js) · [API documentation](https://captcha-solver.com/en/docs/captcha-types#geetest-v3) + +Structurally the odd one out. `challenge` is session-specific and must be fresh per task, so the +file opens with a `fetch(...).then(response => response.json())` chain and **nests both solve blocks +inside** the `.then(({ challenge }) => { … })` callback — the tasks cannot be built until the value +arrives. This is the clearest place to see what `await` buys you: the async version expresses the +same dependency as three flat statements. + +That URL — `https://target-site.com/path/to/geetest/init` — is a **placeholder**, so the file does +not run end-to-end unmodified. It marks where the fetch belongs in the flow; point it at your real +source of `challenge` first. + +The client uses `timeout: 300000` and `pollingInterval: 10000`, GeeTest being among the slowest +types. `version` is omitted since v3 is the default. Solution: `challenge`, `validate`, `seccode`. + +### geetest_v4.js + +[Source code](geetest_v4.js) · [API documentation](https://captcha-solver.com/en/docs/captcha-types#geetest-v4) + +Same `GeeTestProxyless`/`GeeTest` classes as v3, but v4 identifies the widget differently: no `gt`, +no `challenge`, instead `version: 4` and `captcha_id` inside `initParameters`. No pre-fetch is +needed, so this file keeps the flat two-chain layout. Same extended timeouts. Solution: +`captcha_id`, `lot_number`, `pass_token`, `gen_time`, `captcha_output`. + +### yandex_smartcaptcha.js + +[Source code](yandex_smartcaptcha.js) · [API documentation](https://captcha-solver.com/en/docs/captcha-types#yandex-smartcaptcha) + +The **token** challenge, proxyless and with proxy. `websiteKey` is the sitekey from the page source +or the captcha iframe; `userAgent` and `cookies` are optional. For Yandex's **image** challenge use +[coordinates.js](coordinates.js) instead — different task class entirely. Solution: `token`. + +### tencent.js + +[Source code](tencent.js) · [API documentation](https://captcha-solver.com/en/docs/captcha-types#tencent) + +Proxyless and with proxy. `appId` comes from the page source; `captchaScript` is only needed when +the site loads the widget from a non-default script URL. Solution: `appid`, `ret`, `ticket`, +`randstr`. + +### image_to_text.js + +[Source code](image_to_text.js) · [API documentation](https://captcha-solver.com/en/docs/captcha-types#image-to-text) + +Three chains, no proxy variant. *Basic* (`basicTask`) sends just the base64 image. *Advanced* +(`advancedTask`) adds the hints that narrow the search for the worker — `numeric`, `phrase`, +`minLength`/`maxLength`, `comment`, and a `captcha_hint.png` passed as `imgInstructions`. *With +language pool* (`languagePoolTask`) demonstrates the one API detail that is easy to get wrong: +`languagePool` is the **second argument to `solve()`**, not a task field — +`captchaSolver.solve(languagePoolTask, 'en')`, accepting `'en'` or `'ru'`. + +Reads `./captcha.png` and `./captcha_hint.png` **relative to the working directory**, so run it from +where those files are. Solution: `text`. + +### coordinates.js + +[Source code](coordinates.js) · [API documentation](https://captcha-solver.com/en/docs/captcha-types#coordinates) + +Three chains, no proxy variant. *Basic* (`basicTask`) submits an image plus a `comment` telling the +worker what to click. *Advanced* (`advancedTask`) adds an `instruction.png` as `imgInstructions` and +constrains the answer with `minClicks`/`maxClicks`. *Yandex SmartCaptcha image mode* (`yandexTask`) +reuses the same `CoordinatesTask` with `imgType: 'smart_captcha'` (object selection) or +`'pazl_smart_captcha'` (puzzle), which is how the image variant of Yandex SmartCaptcha is solved. + +Reads `./captcha.png` and `./instruction.png` **relative to the working directory**. Solution: +`coordinates`, an array of `{ x, y }` points. From 0e9222b292ce5de6accff890bc8c176e2ee24d59 Mon Sep 17 00:00:00 2001 From: dzmitry-duboyski Date: Fri, 7 Aug 2026 21:10:07 +0300 Subject: [PATCH 14/21] Note the missing example images in the TODO image_to_text.js and coordinates.js read four PNGs that are not in the repository, so both fail with ENOENT before reaching the API -- the only examples that need more than an API key. Adding the files is half the fix: the paths are relative to the working directory, so the reads have to move to new URL(..., import.meta.url) as well, and examples/README.md currently documents the opposite. Co-Authored-By: Claude Opus 5 --- todo.md | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/todo.md b/todo.md index f2e0ecc..6547500 100644 --- a/todo.md +++ b/todo.md @@ -42,6 +42,47 @@ --- +## Изображения для примеров с картинками + +**Статус:** не сделано. Четыре примера читают с диска файлы, которых в репозитории нет. + +### В чём проблема + +`image_to_text.js` и `coordinates.js` (в обоих каталогах, `async/` и `sync/`) начинают работу с чтения картинки: + +| Файл | Что читает | +|---|---| +| `examples/async/image_to_text.js`, `examples/sync/image_to_text.js` | `./captcha.png`, `./captcha_hint.png` (в блоке *Advanced*) | +| `examples/async/coordinates.js`, `examples/sync/coordinates.js` | `./captcha.png`, `./instruction.png` | + +Ни одного из этих файлов в репозитории нет, поэтому оба примера падают с `ENOENT` на первой же строке — ещё до обращения к API. Это единственные примеры, которые нельзя запустить, просто подставив свой ключ. + +Хуже того, путь передан как `./captcha.png` и резолвится **относительно рабочего каталога** (`process.cwd()`), а не относительно файла скрипта. То есть положить картинки рядом со скриптами недостаточно: `node examples/async/coordinates.js` из корня репозитория всё равно будет искать их в корне. + +### Что сделать + +- [ ] **Добавить сами изображения.** Нужны четыре: текстовая капча (`captcha.png`), подсказка к ней (`captcha_hint.png`), кликовая капча и инструкция к ней (`instruction.png`). Класть в `examples/assets/` — рядом с тем, что их использует. Корневой `assets/` занят баннером репозитория, мешать одно с другим не стоит. +- [ ] **Поправить пути в четырёх файлах**, чтобы они не зависели от рабочего каталога: + + ```javascript + const body = fs.readFileSync(new URL('../assets/captcha.png', import.meta.url)).toString('base64'); + ``` + + `new URL(..., import.meta.url)` работает начиная с Node 14 и подходит под `engines.node: ">=18"`. Вариант с `import.meta.dirname` требует Node 20.11+ и планку по Node поднимет. +- [ ] **Обновить [examples/README.md](examples/README.md).** Сейчас там написано, что изображения нужно принести свои и что репозиторий их намеренно не содержит: раздел «Before you run» и подпись под таблицей. После добавления картинок эти оговорки станут неверными. + +### Альтернатива, если картинки решат не коммитить + +В `.env.example` уже лежит готовая base64-строка `IMAGE_TO_TEXT_BASE64` с картинкой капчи. Примеры могут брать `body` из неё, а к чтению файла оставить закомментированную строку с пояснением. Тогда `image_to_text.js` заработает из коробки без бинарников в репозитории. + +Для `coordinates.js` это не сработает: там нужны две картинки, и осмысленных значений для них в `.env.example` нет — пришлось бы добавлять ещё две длинные base64-строки, что читаемости файлу не добавит. + +### На что обратить внимание + +`examples/` и `assets/` целиком исключены из публикуемого пакета в [.npmignore](.npmignore), а `files: ["src/"]` в `package.json` и так работает как белый список. Так что вес картинок на размер npm-тарбола не повлияет — только на размер клона репозитория. + +--- + ## Идеи на будущее (не приоритет) - [ ] `coverageThreshold` в `jest.config.js` — чтобы CI падал при просадке покрытия ниже порога. Имеет смысл включать после того, как бейдж заработает и станет понятен реальный базовый уровень. From b8e6664239fd1ac9e0ca16a620efbc636b264cee Mon Sep 17 00:00:00 2001 From: dzmitry-duboyski Date: Fri, 7 Aug 2026 21:18:17 +0300 Subject: [PATCH 15/21] Split the integration tests into one file per check The suite is about to grow a test per captcha type, and a single client.test.js would have made every new one a paid test by default. Split along cost rather than captcha type, since that is what actually separates these tests: balance.test.js spends nothing and needs no target page, so it works as a smoke test that the key and the network path are fine. Everything calling solve() bills the account on every nightly run and now lives in its own file. helpers.js holds the pieces that would otherwise be copied into each new file: the key, a client factory, and the skip guard. The guard changes behaviour. It used to be `if (!apiKey) return` inside each test body, so a keyless run reported green "passed" for tests that never called the API. Skipping at the describe level reports "skipped" instead. The run still exits 0 having verified nothing, so the CI fail-early step stays necessary. Co-Authored-By: Claude Opus 5 --- tests/README.md | 45 ++++++++++++++++++++++---- tests/integration/balance.test.js | 23 +++++++++++++ tests/integration/client.test.js | 44 ------------------------- tests/integration/helpers.js | 37 +++++++++++++++++++++ tests/integration/recaptcha_v2.test.js | 32 ++++++++++++++++++ 5 files changed, 130 insertions(+), 51 deletions(-) create mode 100644 tests/integration/balance.test.js delete mode 100644 tests/integration/client.test.js create mode 100644 tests/integration/helpers.js create mode 100644 tests/integration/recaptcha_v2.test.js diff --git a/tests/README.md b/tests/README.md index 6adaa59..7f2acf1 100644 --- a/tests/README.md +++ b/tests/README.md @@ -13,6 +13,8 @@ - [Тесты по типам капч](#тесты-по-типам-капч) - [Контракт публичного API](#контракт-публичного-api) - [Integration-тесты](#integration-тесты) + - [Общий хелпер](#общий-хелпер) + - [Как включаются](#как-включаются) - [Ручной запуск](#ручной-запуск) - [Покрытие](#покрытие) - [CI / пайплайн](#ci--пайплайн) @@ -39,7 +41,9 @@ tests/ │ └── async/ # те же сценарии в стиле async/await │ └── (те же 10 файлов) └── integration/ # против реального API (нужен ключ, тратит баланс) - └── client.test.js + ├── helpers.js # общий гард и фабрика клиента (не тест) + ├── balance.test.js # бесплатная проверка: баланс + └── recaptcha_v2.test.js # платная проверка: реальный solve() ``` Раннер — Jest 29 в режиме ESM (`node --experimental-vm-modules`), настройки в [jest.config.js](../jest.config.js) в корне проекта. Используется дефолтный `testMatch`, то есть подхватывается любой файл `*.test.js`. Наборы отбираются по пути каталога (`jest tests/unit` / `jest tests/integration`), поэтому новый файл не требует ничего настраивать — он попадает в нужный набор по своему расположению. @@ -49,7 +53,7 @@ tests/ | Тип | Где | Сеть | Нужен API-ключ | Кол-во | |---|---|---|---|---| | Unit | `tests/unit/` | нет, `fetch` замокан | нет | 21 сьют / 81 тест | -| Integration | `tests/integration/` | да, реальный API | да | 1 сьют / 2 теста | +| Integration | `tests/integration/` | да, реальный API | да | 2 сьюта / 2 теста | Unit-тесты полностью изолированы: сетевой слой подменяется либо через `global.fetch = jest.fn(...)`, либо через `jest.spyOn(client, '_request')`. Никаких внешних запросов и никаких списаний с баланса. @@ -158,16 +162,43 @@ SDK предоставляет один и тот же промисный API, ## Integration-тесты -`tests/integration/client.test.js` — единственный файл, который ходит в реальный API `https://api.captcha-solver.com`. +Единственный набор, который ходит в реальный API `https://api.captcha-solver.com`. Один файл на проверку: -Два теста: +| Файл | Что делает | Таймаут | Тратит баланс | +|---|---|---|---| +| `balance.test.js` | запрашивает баланс аккаунта | 15 с | нет | +| `recaptcha_v2.test.js` | создаёт реальную задачу reCAPTCHA v2 и ждёт токен | 120 с | **да** | + +Разбиение здесь идёт **по цене, а не по типу капчи**. `balance.test.js` ничего не стоит и не зависит от внешней демо-страницы, поэтому его можно гонять как smoke-тест «ключ жив, сеть есть»: + +```bash +npm test -- tests/integration/balance.test.js +``` + +Всё, что вызывает `solve()`, списывает деньги с аккаунта при каждом прогоне. Поэтому новый тип капчи здесь — это осознанное решение платить за него в каждом ночном прогоне, а не «до кучи к unit-тестам». Контракты задач уже проверены в unit-тестах бесплатно; интеграционный тест нужен там, где важно увидеть реальный сквозной путь. + +### Общий хелпер -1. `getBalance returns a number` — запрашивает баланс аккаунта, таймаут 15 с. -2. `solve reCAPTCHA v2` — создаёт реальную задачу reCAPTCHA v2 и ждёт токен, таймаут 120 с. **Расходует баланс аккаунта.** +`tests/integration/helpers.js` — не тест: суффикса `.test.js` нет, поэтому дефолтный `testMatch` его не подхватывает. В нём три вещи, которые иначе копировались бы в каждый файл: + +| Экспорт | Зачем | +|---|---| +| `apiKey` | `process.env.CAPTCHA_API_KEY` в одном месте | +| `describeIntegration(name, fn)` | `describe` при наличии ключа, `describe.skip` без него | +| `createClient()` | клиент с ключом из окружения, свой на каждый тест | ### Как включаются -Тесты сами себя пропускают, если не задана переменная окружения `CAPTCHA_API_KEY`: тело каждого теста начинается с `if (!apiKey) return;`. Поэтому без ключа запуск проходит зелёным, но по факту ничего не проверяет — в выводе будет строка `CAPTCHA_API_KEY not set. Skipping integration tests.` +Без переменной окружения `CAPTCHA_API_KEY` набор пропускает сам себя — на уровне `describe`, через `describeIntegration`. В выводе это видно честно: + +``` +Test Suites: 2 skipped, 0 of 2 total +Tests: 2 skipped, 2 total +``` + +Раньше гард стоял внутри каждого теста (`if (!apiKey) return;`), и прогон без ключа показывал зелёные `passed` для тестов, которые не сделали ни одного запроса. Пропуск на уровне сьюта убирает это враньё: `skipped` — это `skipped`. + +Обратите внимание: прогон всё равно завершается **успешно**, просто ничего не проверив. Для CI этого мало, поэтому в workflow есть отдельный шаг-предохранитель — см. [CI / пайплайн](#ci--пайплайн). Переменные: diff --git a/tests/integration/balance.test.js b/tests/integration/balance.test.js new file mode 100644 index 0000000..5ee42d1 --- /dev/null +++ b/tests/integration/balance.test.js @@ -0,0 +1,23 @@ +/** + * Integration: account balance against the real API. + * + * The cheap half of the integration suite. getBalance() spends no balance and + * needs no target page, so this doubles as a smoke test that the key is valid + * and the network path to the API works. Run it alone when you want to check + * that much without paying for a solve: + * + * npm test -- tests/integration/balance.test.js + * + * Skipped unless CAPTCHA_API_KEY is set. See helpers.js. + */ + +import { describeIntegration, createClient } from './helpers.js'; + +describeIntegration('getBalance against the real API', () => { + test('returns the balance as a number', async () => { + const balance = await createClient().getBalance(); + + // The API returns the amount as a string; the SDK is expected to coerce it. + expect(typeof balance).toBe('number'); + }, 15000); +}); diff --git a/tests/integration/client.test.js b/tests/integration/client.test.js deleted file mode 100644 index 8001d69..0000000 --- a/tests/integration/client.test.js +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Real-API integration tests. Skipped automatically unless CAPTCHA_API_KEY - * is set in the environment. Run with `npm run test:integration`. - * - * Imports the SDK by package name on purpose, not by relative path into - * src/: that is what makes the suite exercise the package's real entry - * points. See tests/README.md for why. Do not "fix" it to ../../../src/. - */ - -import { CaptchaClient, Tasks } from 'captcha-sdk'; - -describe('CaptchaClient integration', () => { - let client; - const apiKey = process.env.CAPTCHA_API_KEY; - - beforeAll(() => { - if (!apiKey) { - console.log('CAPTCHA_API_KEY not set. Skipping integration tests.'); - } - }); - - beforeEach(() => { - if (apiKey) { - client = new CaptchaClient({ clientKey: apiKey }); - } - }); - - test('getBalance returns a number', async () => { - if (!apiKey) return; - const balance = await client.getBalance(); - expect(typeof balance).toBe('number'); - }, 15000); - - test('solve reCAPTCHA v2', async () => { - if (!apiKey) return; - const task = new Tasks.RecaptchaV2Proxyless({ - websiteURL: process.env.RECAPTCHA_V2_URL || 'https://recaptcha-demo.appspot.com/recaptcha-v2-checkbox.php', - websiteKey: process.env.RECAPTCHA_V2_SITE_KEY || '6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI' - }); - - const solution = await client.solve(task); - expect(solution.gRecaptchaResponse).toBeDefined(); - }, 120000); -}); diff --git a/tests/integration/helpers.js b/tests/integration/helpers.js new file mode 100644 index 0000000..f1fe06b --- /dev/null +++ b/tests/integration/helpers.js @@ -0,0 +1,37 @@ +/** + * Shared setup for the real-API integration tests. + * + * Imports the SDK by package name on purpose, not by relative path into + * src/: that is what makes the suite exercise the package's real entry + * points. See tests/README.md for why. Do not "fix" it to ../../src/. + * + * Note this file is NOT a test file -- it has no .test.js suffix, so Jest's + * default testMatch ignores it. Keep it that way when adding helpers. + */ + +import { CaptchaClient } from 'captcha-sdk'; + +export const apiKey = process.env.CAPTCHA_API_KEY; + +/** + * describe() when a key is available, describe.skip() when it is not. + * + * The suite used to guard every test body with `if (!apiKey) return`, which + * made a keyless run report green "passed" for tests that never touched the + * API -- a silent lie that is worse than a failure. Skipping at the describe + * level reports "skipped" instead, so the output matches reality. + * + * Resolved per call rather than captured at import time, so the module does + * not depend on when Jest installs its globals. + */ +export function describeIntegration(name, fn) { + (apiKey ? describe : describe.skip)(name, fn); +} + +/** + * A client bound to the environment's key. Built per test rather than shared, + * so nothing carries over between tests. + */ +export function createClient() { + return new CaptchaClient({ clientKey: apiKey }); +} diff --git a/tests/integration/recaptcha_v2.test.js b/tests/integration/recaptcha_v2.test.js new file mode 100644 index 0000000..4b01ade --- /dev/null +++ b/tests/integration/recaptcha_v2.test.js @@ -0,0 +1,32 @@ +/** + * Integration: solve a real reCAPTCHA v2 end to end. + * + * SPENDS REAL BALANCE on every run -- it creates an actual task and waits for + * a worker to solve it. This is the suite's proof that the whole path works: + * createTask, polling, and the solution shape the API really returns. + * + * Imports the SDK by package name on purpose, not by relative path into src/. + * See tests/README.md for why. Do not "fix" it to ../../src/. + * + * Skipped unless CAPTCHA_API_KEY is set. See helpers.js. + */ + +import { Tasks } from 'captcha-sdk'; +import { describeIntegration, createClient } from './helpers.js'; + +// Defaults point at Google's public demo page, so the test runs without any +// extra configuration. Override both together when targeting another page. +const websiteURL = process.env.RECAPTCHA_V2_URL + || 'https://recaptcha-demo.appspot.com/recaptcha-v2-checkbox.php'; +const websiteKey = process.env.RECAPTCHA_V2_SITE_KEY + || '6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI'; + +describeIntegration('reCAPTCHA v2 against the real API', () => { + test('solve returns a gRecaptchaResponse token', async () => { + const task = new Tasks.RecaptchaV2Proxyless({ websiteURL, websiteKey }); + + const solution = await createClient().solve(task); + + expect(solution.gRecaptchaResponse).toBeDefined(); + }, 120000); +}); From 106f7aae73030581c873ae5ad8479445fbef29ed Mon Sep 17 00:00:00 2001 From: dzmitry-duboyski Date: Fri, 7 Aug 2026 21:34:18 +0300 Subject: [PATCH 16/21] Cover six more captcha types in the integration tests Adds real-API tests for reCAPTCHA v3, Turnstile, GeeTest v4, Yandex SmartCaptcha, Tencent and Image to Text, one file per type. No target page or sitekey is committed. Every target comes from the environment and a suite whose variables are unset skips itself, so the repository carries no links to real sites and no keys of theirs. The Google demo page that recaptcha_v2.test.js used to fall back on is gone, and .env.example now ships variable names with empty values instead of working keys for reCAPTCHA, Turnstile and GeeTest. describeTarget() in helpers.js is describeIntegration() plus that check; it hands the collected values to the suite body. helpers.js also loads dotenv now, so a local run reads .env instead of exporting a dozen variables by hand. Values already in the environment still win, and unit tests do not import the file. Two consequences worth stating: - A fresh clone has no working integration check beyond the balance, by design. image_to_text.test.js is the exception: it needs an image rather than a page, and .env.example carries a usable one. - The nightly CI job passes only CAPTCHA_API_KEY, so it now verifies the balance and skips all seven solve suites. Running them there is not planned yet; tests/README.md records what to do when it is. GeeTest v3 stays uncovered on purpose -- its challenge is per-session and has to be scraped immediately before the task is created. Co-Authored-By: Claude Opus 5 --- .env.example | 65 ++++++++--- tests/README.md | 104 ++++++++++++++---- tests/integration/geetest_v4.test.js | 36 ++++++ tests/integration/helpers.js | 45 +++++++- tests/integration/image_to_text.test.js | 38 +++++++ tests/integration/recaptcha_v2.test.js | 26 ++--- tests/integration/recaptcha_v3.test.js | 37 +++++++ tests/integration/tencent.test.js | 30 +++++ tests/integration/turnstile.test.js | 29 +++++ tests/integration/yandex_smartcaptcha.test.js | 33 ++++++ 10 files changed, 392 insertions(+), 51 deletions(-) create mode 100644 tests/integration/geetest_v4.test.js create mode 100644 tests/integration/image_to_text.test.js create mode 100644 tests/integration/recaptcha_v3.test.js create mode 100644 tests/integration/tencent.test.js create mode 100644 tests/integration/turnstile.test.js create mode 100644 tests/integration/yandex_smartcaptcha.test.js diff --git a/.env.example b/.env.example index c4eb880..f6b5b4a 100644 --- a/.env.example +++ b/.env.example @@ -1,19 +1,58 @@ -CAPTCHA_API_KEY=your_api_key_here +# Copy to .env and fill in. .env is gitignored. +# +# Used by two things: +# - the scripts in examples/, which call `import 'dotenv/config'`; +# - the integration tests, via tests/integration/helpers.js. +# +# Target pages and their sitekeys are deliberately left empty. This repository +# does not carry links to real sites or their keys, so every target is yours to +# fill in. An integration suite whose variables are unset skips itself instead +# of failing -- see tests/README.md. -RECAPTCHA_V2_SITE_KEY=6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI -RECAPTCHA_V2_URL=https://recaptcha-demo.appspot.com/recaptcha-v2-checkbox.php +# Required for everything below. +CAPTCHA_API_KEY= -RECAPTCHA_V3_SITE_KEY=6LfD3wAVAAAAAKQHYQqhNNYh3zSqLU0YPM-iu0Wc -RECAPTCHA_V3_URL=https://recaptcha-demo.appspot.com/recaptcha-v3-request-scores.php +# --- Targets for the integration tests --- +# Each pair enables one suite in tests/integration/. Fill in only the types you +# want to check; the rest will skip. -TURNSTILE_SITE_KEY=0x4AAAAAAAJmU3oC9CbBXuBT -TURNSTILE_URL=https://peet.ws/turnstile-test/non-interactive.html +# tests/integration/recaptcha_v2.test.js +RECAPTCHA_V2_URL= +RECAPTCHA_V2_SITE_KEY= -GEETEST_V3_GT=f2ae6cadcf7886856696c46d84d109d1 -GEETEST_V3_CHALLENGE=12345678abc90123d45678e90123f45g6 -GEETEST_V3_URL=https://www.geetest.com/en/demo +# tests/integration/recaptcha_v3.test.js +# RECAPTCHA_V3_PAGE_ACTION is optional: set it when the site passes an action +# to grecaptcha.execute(). +RECAPTCHA_V3_URL= +RECAPTCHA_V3_SITE_KEY= +RECAPTCHA_V3_PAGE_ACTION= -GEETEST_V4_CAPTCHA_ID=e392e65f912c780f2c3ebac7702651de -GEETEST_V4_URL=https://www.geetest.com/en/demo +# tests/integration/turnstile.test.js +TURNSTILE_URL= +TURNSTILE_SITE_KEY= -IMAGE_TO_TEXT_BASE64=iVBORw0KGgoAAAANSUhEUgAAABoAAAAaCAYAAACpSkzOAAAACXBIWXMAAAsTAAALEwEAmpwYAAABaklEQVRIic2VPWtUQRjHfzPnnHv3bnb3ZneNuFkJKgkWVjY2NhZWFn4AEWwsLOwUbIVUtgERxCSFgiAGxEIiCOYDbPIVfBe5u3vuOTMHi7gkJP4Hnup5nv8z7/PMDP8ZMVIA3POcc0YIMUXkAVAVUcixpxpWCsgLWKu8AP4VJcSNMXUAHoJsh1DUlwLWao1i/DuI0CuUWwVa0sTGBB/+Q4qjQw5qZYp2B/UUESFPKK5ZAvSapFy5oAQnRw6CLPK95LmmAt0PDr9ZwnAI6t3nACdEtBniFJDPmHSm87mIdw4zVygIliYiTwH2K5IbhqINkDNjIl8adT3u5BIIHhO52B3Oc0FVT+4BEHuNWHfIDRFZX7vI/H9QIlIvB0R+AnvN6OtYRAVwZ80ICXTHaA+mIgtnmuNNzUGQtkgpYhA7z4goLskAJ0CaK2vIGa0FNv+L4hBZa4idDcAzz3aL8GmG5T8vsl/+zN6LFQAAAABJRU5ErkJggg== \ No newline at end of file +# tests/integration/geetest_v4.test.js +# v4 only. v3 needs a fresh per-session `challenge` scraped from the target, +# so it cannot be driven from static configuration. +GEETEST_V4_URL= +GEETEST_V4_CAPTCHA_ID= + +# tests/integration/yandex_smartcaptcha.test.js +YANDEX_SMARTCAPTCHA_URL= +YANDEX_SMARTCAPTCHA_SITE_KEY= + +# tests/integration/tencent.test.js +# TENCENT_CAPTCHA_SCRIPT is optional: set it only when the site loads the +# widget from a non-default script URL. +TENCENT_URL= +TENCENT_APP_ID= +TENCENT_CAPTCHA_SCRIPT= + +# tests/integration/image_to_text.test.js +# The one solve test that needs no page and no sitekey, just an image: a pure +# base64 string with no "data:image/png;base64," prefix. The value below is a +# sample captcha, so this suite runs as-is. +# IMAGE_TO_TEXT_EXPECTED is optional: set it to the text on your own image to +# assert the answer instead of merely asserting that something came back. +IMAGE_TO_TEXT_BASE64=iVBORw0KGgoAAAANSUhEUgAAABoAAAAaCAYAAACpSkzOAAAACXBIWXMAAAsTAAALEwEAmpwYAAABaklEQVRIic2VPWtUQRjHfzPnnHv3bnb3ZneNuFkJKgkWVjY2NhZWFn4AEWwsLOwUbIVUtgERxCSFgiAGxEIiCOYDbPIVfBe5u3vuOTMHi7gkJP4Hnup5nv8z7/PMDP8ZMVIA3POcc0YIMUXkAVAVUcixpxpWCsgLWKu8AP4VJcSNMXUAHoJsh1DUlwLWao1i/DuI0CuUWwVa0sTGBB/+Q4qjQw5qZYp2B/UUESFPKK5ZAvSapFy5oAQnRw6CLPK95LmmAt0PDr9ZwnAI6t3nACdEtBniFJDPmHSm87mIdw4zVygIliYiTwH2K5IbhqINkDNjIl8adT3u5BIIHhO52B3Oc0FVT+4BEHuNWHfIDRFZX7vI/H9QIlIvB0R+AnvN6OtYRAVwZ80ICXTHaA+mIgtnmuNNzUGQtkgpYhA7z4goLskAJ0CaK2vIGa0FNv+L4hBZa4idDcAzz3aL8GmG5T8vsl/+zN6LFQAAAABJRU5ErkJggg== +IMAGE_TO_TEXT_EXPECTED= diff --git a/tests/README.md b/tests/README.md index 7f2acf1..08fb878 100644 --- a/tests/README.md +++ b/tests/README.md @@ -13,6 +13,8 @@ - [Тесты по типам капч](#тесты-по-типам-капч) - [Контракт публичного API](#контракт-публичного-api) - [Integration-тесты](#integration-тесты) + - [Почему в репозитории нет ни одной цели](#почему-в-репозитории-нет-ни-одной-цели) + - [Чего здесь нет](#чего-здесь-нет) - [Общий хелпер](#общий-хелпер) - [Как включаются](#как-включаются) - [Ручной запуск](#ручной-запуск) @@ -43,7 +45,13 @@ tests/ └── integration/ # против реального API (нужен ключ, тратит баланс) ├── helpers.js # общий гард и фабрика клиента (не тест) ├── balance.test.js # бесплатная проверка: баланс - └── recaptcha_v2.test.js # платная проверка: реальный solve() + ├── image_to_text.test.js # платные проверки: реальный solve(), по файлу на тип + ├── recaptcha_v2.test.js + ├── recaptcha_v3.test.js + ├── turnstile.test.js + ├── geetest_v4.test.js + ├── yandex_smartcaptcha.test.js + └── tencent.test.js ``` Раннер — Jest 29 в режиме ESM (`node --experimental-vm-modules`), настройки в [jest.config.js](../jest.config.js) в корне проекта. Используется дефолтный `testMatch`, то есть подхватывается любой файл `*.test.js`. Наборы отбираются по пути каталога (`jest tests/unit` / `jest tests/integration`), поэтому новый файл не требует ничего настраивать — он попадает в нужный набор по своему расположению. @@ -53,7 +61,7 @@ tests/ | Тип | Где | Сеть | Нужен API-ключ | Кол-во | |---|---|---|---|---| | Unit | `tests/unit/` | нет, `fetch` замокан | нет | 21 сьют / 81 тест | -| Integration | `tests/integration/` | да, реальный API | да | 2 сьюта / 2 теста | +| Integration | `tests/integration/` | да, реальный API | да | 8 сьютов / 8 тестов | Unit-тесты полностью изолированы: сетевой слой подменяется либо через `global.fetch = jest.fn(...)`, либо через `jest.spyOn(client, '_request')`. Никаких внешних запросов и никаких списаний с баланса. @@ -164,51 +172,73 @@ SDK предоставляет один и тот же промисный API, Единственный набор, который ходит в реальный API `https://api.captcha-solver.com`. Один файл на проверку: -| Файл | Что делает | Таймаут | Тратит баланс | -|---|---|---|---| -| `balance.test.js` | запрашивает баланс аккаунта | 15 с | нет | -| `recaptcha_v2.test.js` | создаёт реальную задачу reCAPTCHA v2 и ждёт токен | 120 с | **да** | +| Файл | Что проверяет | Переменные цели | Таймаут теста | Тратит баланс | +|---|---|---|---|---| +| `balance.test.js` | баланс аккаунта | — | 15 с | нет | +| `image_to_text.test.js` | распознавание текста на картинке | `IMAGE_TO_TEXT_BASE64` | 130 с | **да** | +| `recaptcha_v2.test.js` | `gRecaptchaResponse` | `RECAPTCHA_V2_URL`, `RECAPTCHA_V2_SITE_KEY` | 130 с | **да** | +| `recaptcha_v3.test.js` | `gRecaptchaResponse` при `minScore: 0.3` | `RECAPTCHA_V3_URL`, `RECAPTCHA_V3_SITE_KEY` | 190 с | **да** | +| `turnstile.test.js` | `token` | `TURNSTILE_URL`, `TURNSTILE_SITE_KEY` | 130 с | **да** | +| `geetest_v4.test.js` | `captcha_output`, `lot_number`, `pass_token` | `GEETEST_V4_URL`, `GEETEST_V4_CAPTCHA_ID` | 310 с | **да** | +| `yandex_smartcaptcha.test.js` | `token` | `YANDEX_SMARTCAPTCHA_URL`, `YANDEX_SMARTCAPTCHA_SITE_KEY` | 130 с | **да** | +| `tencent.test.js` | `ticket`, `randstr` | `TENCENT_URL`, `TENCENT_APP_ID` | 130 с | **да** | -Разбиение здесь идёт **по цене, а не по типу капчи**. `balance.test.js` ничего не стоит и не зависит от внешней демо-страницы, поэтому его можно гонять как smoke-тест «ключ жив, сеть есть»: +Разбиение идёт **по цене, а не по типу капчи**. `balance.test.js` ничего не стоит и ни от чего не зависит, поэтому годится как smoke-тест «ключ жив, сеть есть»: ```bash npm test -- tests/integration/balance.test.js ``` -Всё, что вызывает `solve()`, списывает деньги с аккаунта при каждом прогоне. Поэтому новый тип капчи здесь — это осознанное решение платить за него в каждом ночном прогоне, а не «до кучи к unit-тестам». Контракты задач уже проверены в unit-тестах бесплатно; интеграционный тест нужен там, где важно увидеть реальный сквозной путь. +Всё остальное вызывает `solve()` и списывает деньги с аккаунта при каждом прогоне. Поэтому эти тесты и запускаются выборочно, по одному файлу, а не всем набором. + +### Почему в репозитории нет ни одной цели + +Ни одного URL реальной страницы и ни одного sitekey в коде нет — **намеренно**. Всё берётся из переменных окружения, дефолтов не предусмотрено. Раньше `recaptcha_v2.test.js` подставлял демо-страницу Google, а `.env.example` содержал рабочие ключи для reCAPTCHA, Turnstile и GeeTest — это убрано. + +Практическое следствие: **у свежего клона нет ни одной работающей интеграционной проверки, кроме баланса**. Это цена решения, а не недоработка. Свои цели пропишите в `.env` (он в `.gitignore`), шаблон с именами переменных — в [.env.example](../.env.example). + +Исключение — `image_to_text.test.js`: ему не нужны ни страница, ни sitekey, только картинка. В `.env.example` лежит готовая base64-строка, поэтому он работает из коробки. + +### Чего здесь нет + +- **GeeTest v3.** Ему нужен свежий `challenge`, привязанный к сессии и добываемый с целевой страницы непосредственно перед созданием задачи. Из статической конфигурации это не заводится — потребовался бы скрапер, как в [examples/async/geetest_v3.js](../examples/async/geetest_v3.js). +- **Coordinates.** Нужны две картинки — сама капча и инструкция; см. раздел про изображения в [todo.md](../todo.md). +- **Варианты с прокси и reCAPTCHA v2 Enterprise.** Требуют рабочего прокси и сайта с Enterprise-виджетом соответственно. +- **Cloudflare Challenge pages** в `turnstile.test.js` — покрыт только обычный виджет. Challenge-страницы требуют свежих `action`, `data` и `pageData`, вытащенных со страницы. ### Общий хелпер -`tests/integration/helpers.js` — не тест: суффикса `.test.js` нет, поэтому дефолтный `testMatch` его не подхватывает. В нём три вещи, которые иначе копировались бы в каждый файл: +`tests/integration/helpers.js` — не тест: суффикса `.test.js` нет, поэтому дефолтный `testMatch` его не подхватывает. В нём то, что иначе копировалось бы в каждый файл: | Экспорт | Зачем | |---|---| | `apiKey` | `process.env.CAPTCHA_API_KEY` в одном месте | | `describeIntegration(name, fn)` | `describe` при наличии ключа, `describe.skip` без него | -| `createClient()` | клиент с ключом из окружения, свой на каждый тест | +| `describeTarget(name, vars, fn)` | то же плюс проверка переменных цели; отдаёт их значения в колбэк | +| `createClient(options)` | клиент с ключом из окружения, свой на каждый тест; `options` — для медленных типов | + +Файл также подключает `dotenv/config`, поэтому `.env` читается автоматически — иначе для локального прогона пришлось бы экспортировать десяток переменных руками. Значения, уже заданные в окружении, dotenv не перезаписывает, так что переданное в командной строке или из CI по-прежнему имеет приоритет. Unit-тесты этот файл не импортируют и остаются без dotenv. ### Как включаются -Без переменной окружения `CAPTCHA_API_KEY` набор пропускает сам себя — на уровне `describe`, через `describeIntegration`. В выводе это видно честно: +Сьют пропускает сам себя, если нет `CAPTCHA_API_KEY` **или** не заданы переменные его цели. Пропуск идёт на уровне `describe`, поэтому в выводе он виден честно: ``` -Test Suites: 2 skipped, 0 of 2 total -Tests: 2 skipped, 2 total +Test Suites: 8 skipped, 0 of 8 total +Tests: 8 skipped, 8 total ``` Раньше гард стоял внутри каждого теста (`if (!apiKey) return;`), и прогон без ключа показывал зелёные `passed` для тестов, которые не сделали ни одного запроса. Пропуск на уровне сьюта убирает это враньё: `skipped` — это `skipped`. Обратите внимание: прогон всё равно завершается **успешно**, просто ничего не проверив. Для CI этого мало, поэтому в workflow есть отдельный шаг-предохранитель — см. [CI / пайплайн](#ci--пайплайн). -Переменные: - -| Переменная | Обязательна | По умолчанию | -|---|---|---| -| `CAPTCHA_API_KEY` | да | — (без неё тесты пропускаются) | -| `RECAPTCHA_V2_URL` | нет | `https://recaptcha-demo.appspot.com/recaptcha-v2-checkbox.php` | -| `RECAPTCHA_V2_SITE_KEY` | нет | `6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI` | +Необязательные переменные, уточняющие поведение: -> **Важно:** этот файл **не подключает dotenv** — `.env` автоматически не читается. `.env.example` рассчитан на скрипты из [examples/](../examples/), где есть `import 'dotenv/config'`. Для тестов переменные нужно передавать через окружение (см. ниже). +| Переменная | Для чего | +|---|---| +| `RECAPTCHA_V3_PAGE_ACTION` | action, который сайт передаёт в `grecaptcha.execute()` — без совпадения сайт занижает оценку токена | +| `TENCENT_CAPTCHA_SCRIPT` | если сайт грузит виджет с нестандартного URL скрипта | +| `IMAGE_TO_TEXT_EXPECTED` | текст на вашей картинке; без него тест проверяет лишь то, что ответ непустой | ## Ручной запуск @@ -236,6 +266,15 @@ npm run test:unit ### Только integration-тесты +Самый удобный способ — завести `.env` в корне проекта (он в `.gitignore`, шаблон — [.env.example](../.env.example)). Хелпер подключает dotenv, поэтому переменные подхватятся сами: + +```bash +cp .env.example .env # заполнить ключ и нужные цели +npm run test:integration +``` + +Можно и через окружение — оно имеет приоритет над `.env`. + PowerShell: ```powershell @@ -249,7 +288,17 @@ bash / cmd: CAPTCHA_API_KEY=ваш_ключ npm run test:integration ``` -Без установленной переменной команда отработает, но оба теста фактически будут пустыми. +**Запускать весь набор разом обычно не нужно** — это семь платных задач за прогон. Гоняйте по одному файлу: + +```bash +# бесплатно: ключ и сеть +npm test -- tests/integration/balance.test.js + +# одна платная проверка +npm test -- tests/integration/turnstile.test.js +``` + +Сьюты без настроенных переменных пропустятся, так что заполнять `.env` целиком не обязательно — только те типы, которые сейчас проверяете. ### Отдельный файл или отдельный тест @@ -327,6 +376,17 @@ All files | 97.53 | 83.63 | 100 | 97.5 | - **Unit-тесты — обязательный блокирующий шаг.** Детерминированные, без сети и секретов, ~7 секунд. Безопасны на любом PR, включая форки. Гоняются на матрице Node `18 / 20 / 22` — в соответствии с `engines.node: ">=18"`. - **Integration-тесты не запускаются на pull request намеренно.** Они тратят реальный баланс аккаунта и зависят от доступности внешнего API. Главное же — в PR из форка секреты недоступны, поэтому тесты пропустили бы сами себя и дали **ложно-зелёный** результат вместо честного «не проверено». +### Что ночной прогон проверяет на самом деле + +Джоб `integration` передаёт в тесты только `CAPTCHA_API_KEY`. Переменных цели у него нет, поэтому **фактически проверяется лишь `balance.test.js`**, а все семь solve-сьютов пропускаются. Раньше `recaptcha_v2.test.js` работал за счёт зашитой демо-страницы Google — после отказа от целей в репозитории (см. [выше](#почему-в-репозитории-нет-ни-одной-цели)) это не так. + +Это осознанное состояние, а не поломка: гонять платные solve-тесты в CI пока не планируется. Когда понадобится, порядок такой: + +1. Завести цели как секреты репозитория — например `RECAPTCHA_V2_URL` и `RECAPTCHA_V2_SITE_KEY`. +2. Пробросить их в шаге `Run integration tests` рядом с `CAPTCHA_API_KEY`. +3. Запускать выборочно — `npm test -- tests/integration/recaptcha_v2.test.js`, а не весь набор, иначе каждая ночь будет стоить семь задач. +4. Дополнить шаг-предохранитель проверкой этих переменных, иначе опечатка в имени секрета обернётся молчаливым пропуском и зелёным джобом. + ### Две детали, которые легко упустить **Покрытие выгружается только с одной версии Node** (`if: matrix.node-version == 20`). Цифра от версии Node не зависит, а три параллельные выгрузки одного и того же отчёта только зашумят историю в Coveralls. diff --git a/tests/integration/geetest_v4.test.js b/tests/integration/geetest_v4.test.js new file mode 100644 index 0000000..35752c2 --- /dev/null +++ b/tests/integration/geetest_v4.test.js @@ -0,0 +1,36 @@ +/** + * Integration: solve a real GeeTest v4 challenge end to end. + * + * SPENDS REAL BALANCE on every run. + * + * Needs a target page. Set GEETEST_V4_URL and GEETEST_V4_CAPTCHA_ID, or the + * suite skips. There are deliberately no defaults -- see tests/README.md. + * + * v4 is covered but v3 is not, and that is a deliberate gap: v3 needs a fresh, + * session-specific `challenge` fetched from the target immediately before the + * task is created. A hardcoded one is stale on arrival, so a v3 test would + * have to scrape the target first -- see examples/async/geetest_v3.js for what + * that looks like. v4 identifies the widget by captcha_id, which is stable. + */ + +import { Tasks } from 'captcha-sdk'; +import { describeTarget, createClient } from './helpers.js'; + +describeTarget('GeeTest v4 against the real API', ['GEETEST_V4_URL', 'GEETEST_V4_CAPTCHA_ID'], (env) => { + test('solve returns a captcha_output payload', async () => { + const task = new Tasks.GeeTestProxyless({ + websiteURL: env.GEETEST_V4_URL, + version: 4, + initParameters: { captcha_id: env.GEETEST_V4_CAPTCHA_ID } + }); + + // GeeTest is the slowest supported type, hence the raised client timeout + // and the matching polling interval from the examples. + const solution = await createClient({ timeout: 300000, pollingInterval: 10000 }).solve(task); + + expect(typeof solution.captcha_output).toBe('string'); + expect(solution.captcha_output.length).toBeGreaterThan(0); + expect(solution.lot_number).toBeDefined(); + expect(solution.pass_token).toBeDefined(); + }, 310000); +}); diff --git a/tests/integration/helpers.js b/tests/integration/helpers.js index f1fe06b..3bbeb26 100644 --- a/tests/integration/helpers.js +++ b/tests/integration/helpers.js @@ -9,6 +9,12 @@ * default testMatch ignores it. Keep it that way when adding helpers. */ +// Loads .env so a local run does not have to export a dozen variables by hand. +// dotenv never overwrites variables already present in the environment, so +// anything passed on the command line or by CI still wins. Missing .env is not +// an error. Unit tests do not import this file and stay unaffected. +import 'dotenv/config'; + import { CaptchaClient } from 'captcha-sdk'; export const apiKey = process.env.CAPTCHA_API_KEY; @@ -28,10 +34,45 @@ export function describeIntegration(name, fn) { (apiKey ? describe : describe.skip)(name, fn); } +/** + * Same as describeIntegration, but also requires a target to be configured. + * + * Solving a real captcha needs a real page and its sitekey, and those are + * deliberately absent from this repository -- see tests/README.md. Every + * value comes from the environment, so a suite whose variables are unset + * skips instead of failing on `undefined`: + * + * describeTarget('Turnstile', ['TURNSTILE_URL', 'TURNSTILE_SITE_KEY'], (env) => { + * test('...', async () => { + * new Tasks.TurnstileProxyless({ websiteURL: env.TURNSTILE_URL, ... }); + * }); + * }); + * + * The callback receives the collected values. When the suite is skipped they + * are undefined, which is harmless: describe.skip evaluates the block to + * register test names, but never runs the test bodies that read them. + * + * @param {string} name suite name + * @param {string[]} requiredVars environment variables the suite cannot run without + * @param {(env: Record) => void} fn suite body + */ +export function describeTarget(name, requiredVars, fn) { + const env = Object.fromEntries(requiredVars.map((key) => [key, process.env[key]])); + const missing = requiredVars.filter((key) => !env[key]); + const runnable = Boolean(apiKey) && missing.length === 0; + + (runnable ? describe : describe.skip)(name, () => fn(env)); +} + /** * A client bound to the environment's key. Built per test rather than shared, * so nothing carries over between tests. + * + * Pass overrides for slow captcha types, e.g. createClient({ timeout: 300000 }). + * Keep the Jest timeout of such a test above the client timeout, otherwise + * Jest kills the test before the SDK can raise its own TimeoutError and the + * failure says nothing useful. */ -export function createClient() { - return new CaptchaClient({ clientKey: apiKey }); +export function createClient(options = {}) { + return new CaptchaClient({ clientKey: apiKey, ...options }); } diff --git a/tests/integration/image_to_text.test.js b/tests/integration/image_to_text.test.js new file mode 100644 index 0000000..5cb1f24 --- /dev/null +++ b/tests/integration/image_to_text.test.js @@ -0,0 +1,38 @@ +/** + * Integration: recognise text on a real captcha image end to end. + * + * SPENDS REAL BALANCE on every run. + * + * The only solve test that needs no target page and no sitekey -- just an + * image. Set IMAGE_TO_TEXT_BASE64 to a pure base64 string (no + * "data:image/png;base64," prefix), or the suite skips. .env.example ships a + * usable one. + * + * IMAGE_TO_TEXT_EXPECTED is optional. Without it the test only asserts that + * some non-empty text came back, which proves the round-trip but not the + * answer. Set it to the text on your image to assert the result itself -- + * compared case-insensitively, since the worker's casing is not guaranteed + * unless the task sets case_. + */ + +import { Tasks } from 'captcha-sdk'; +import { describeTarget, createClient } from './helpers.js'; + +const expected = process.env.IMAGE_TO_TEXT_EXPECTED; + +describeTarget('Image to Text against the real API', ['IMAGE_TO_TEXT_BASE64'], (env) => { + test('solve returns the recognised text', async () => { + const task = new Tasks.ImageToText({ body: env.IMAGE_TO_TEXT_BASE64 }); + + // Image tasks are the fastest type, so the default 120 s client timeout + // is generous already. + const solution = await createClient().solve(task); + + expect(typeof solution.text).toBe('string'); + expect(solution.text.length).toBeGreaterThan(0); + + if (expected) { + expect(solution.text.toLowerCase()).toBe(expected.toLowerCase()); + } + }, 130000); +}); diff --git a/tests/integration/recaptcha_v2.test.js b/tests/integration/recaptcha_v2.test.js index 4b01ade..4780f14 100644 --- a/tests/integration/recaptcha_v2.test.js +++ b/tests/integration/recaptcha_v2.test.js @@ -5,28 +5,26 @@ * a worker to solve it. This is the suite's proof that the whole path works: * createTask, polling, and the solution shape the API really returns. * + * Needs a target page. Set RECAPTCHA_V2_URL and RECAPTCHA_V2_SITE_KEY, or the + * suite skips. There are deliberately no defaults -- see tests/README.md. + * * Imports the SDK by package name on purpose, not by relative path into src/. * See tests/README.md for why. Do not "fix" it to ../../src/. - * - * Skipped unless CAPTCHA_API_KEY is set. See helpers.js. */ import { Tasks } from 'captcha-sdk'; -import { describeIntegration, createClient } from './helpers.js'; - -// Defaults point at Google's public demo page, so the test runs without any -// extra configuration. Override both together when targeting another page. -const websiteURL = process.env.RECAPTCHA_V2_URL - || 'https://recaptcha-demo.appspot.com/recaptcha-v2-checkbox.php'; -const websiteKey = process.env.RECAPTCHA_V2_SITE_KEY - || '6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI'; +import { describeTarget, createClient } from './helpers.js'; -describeIntegration('reCAPTCHA v2 against the real API', () => { +describeTarget('reCAPTCHA v2 against the real API', ['RECAPTCHA_V2_URL', 'RECAPTCHA_V2_SITE_KEY'], (env) => { test('solve returns a gRecaptchaResponse token', async () => { - const task = new Tasks.RecaptchaV2Proxyless({ websiteURL, websiteKey }); + const task = new Tasks.RecaptchaV2Proxyless({ + websiteURL: env.RECAPTCHA_V2_URL, + websiteKey: env.RECAPTCHA_V2_SITE_KEY + }); const solution = await createClient().solve(task); - expect(solution.gRecaptchaResponse).toBeDefined(); - }, 120000); + expect(typeof solution.gRecaptchaResponse).toBe('string'); + expect(solution.gRecaptchaResponse.length).toBeGreaterThan(0); + }, 130000); }); diff --git a/tests/integration/recaptcha_v3.test.js b/tests/integration/recaptcha_v3.test.js new file mode 100644 index 0000000..3ad52fb --- /dev/null +++ b/tests/integration/recaptcha_v3.test.js @@ -0,0 +1,37 @@ +/** + * Integration: solve a real reCAPTCHA v3 end to end. + * + * SPENDS REAL BALANCE on every run. + * + * Needs a target page. Set RECAPTCHA_V3_URL and RECAPTCHA_V3_SITE_KEY, or the + * suite skips. There are deliberately no defaults -- see tests/README.md. + * + * RECAPTCHA_V3_PAGE_ACTION is optional: pass it when the target site sets an + * action in grecaptcha.execute(), since a mismatched action lowers the score + * the site assigns to the token. + */ + +import { Tasks } from 'captcha-sdk'; +import { describeTarget, createClient } from './helpers.js'; + +// The cheapest and fastest threshold the API accepts. Not target data -- this +// is a property of the test, so it stays in the file rather than the env. +const MIN_SCORE = 0.3; + +describeTarget('reCAPTCHA v3 against the real API', ['RECAPTCHA_V3_URL', 'RECAPTCHA_V3_SITE_KEY'], (env) => { + test('solve returns a gRecaptchaResponse token', async () => { + const task = new Tasks.RecaptchaV3Proxyless({ + websiteURL: env.RECAPTCHA_V3_URL, + websiteKey: env.RECAPTCHA_V3_SITE_KEY, + minScore: MIN_SCORE, + pageAction: process.env.RECAPTCHA_V3_PAGE_ACTION || null + }); + + // v3 runs longer than v2, so the client gets a longer timeout than the + // 120 s default and the test a longer one still. + const solution = await createClient({ timeout: 180000 }).solve(task); + + expect(typeof solution.gRecaptchaResponse).toBe('string'); + expect(solution.gRecaptchaResponse.length).toBeGreaterThan(0); + }, 190000); +}); diff --git a/tests/integration/tencent.test.js b/tests/integration/tencent.test.js new file mode 100644 index 0000000..2890207 --- /dev/null +++ b/tests/integration/tencent.test.js @@ -0,0 +1,30 @@ +/** + * Integration: solve a real Tencent captcha end to end. + * + * SPENDS REAL BALANCE on every run. + * + * Needs a target page. Set TENCENT_URL and TENCENT_APP_ID, or the suite skips. + * There are deliberately no defaults -- see tests/README.md. + * + * TENCENT_CAPTCHA_SCRIPT is optional: set it only when the target loads the + * widget from a non-default script URL. + */ + +import { Tasks } from 'captcha-sdk'; +import { describeTarget, createClient } from './helpers.js'; + +describeTarget('Tencent against the real API', ['TENCENT_URL', 'TENCENT_APP_ID'], (env) => { + test('solve returns a ticket', async () => { + const task = new Tasks.TencentTaskProxyless({ + websiteURL: env.TENCENT_URL, + appId: env.TENCENT_APP_ID, + captchaScript: process.env.TENCENT_CAPTCHA_SCRIPT || null + }); + + const solution = await createClient().solve(task); + + expect(typeof solution.ticket).toBe('string'); + expect(solution.ticket.length).toBeGreaterThan(0); + expect(solution.randstr).toBeDefined(); + }, 130000); +}); diff --git a/tests/integration/turnstile.test.js b/tests/integration/turnstile.test.js new file mode 100644 index 0000000..b379164 --- /dev/null +++ b/tests/integration/turnstile.test.js @@ -0,0 +1,29 @@ +/** + * Integration: solve a real Cloudflare Turnstile challenge end to end. + * + * SPENDS REAL BALANCE on every run. + * + * Needs a target page. Set TURNSTILE_URL and TURNSTILE_SITE_KEY, or the suite + * skips. There are deliberately no defaults -- see tests/README.md. + * + * Only the plain widget is covered here. Cloudflare Challenge pages also need + * action, data and pageData scraped fresh from the page, which is a different + * kind of test: it would need a scraper of its own to stay meaningful. + */ + +import { Tasks } from 'captcha-sdk'; +import { describeTarget, createClient } from './helpers.js'; + +describeTarget('Turnstile against the real API', ['TURNSTILE_URL', 'TURNSTILE_SITE_KEY'], (env) => { + test('solve returns a token', async () => { + const task = new Tasks.TurnstileProxyless({ + websiteURL: env.TURNSTILE_URL, + websiteKey: env.TURNSTILE_SITE_KEY + }); + + const solution = await createClient().solve(task); + + expect(typeof solution.token).toBe('string'); + expect(solution.token.length).toBeGreaterThan(0); + }, 130000); +}); diff --git a/tests/integration/yandex_smartcaptcha.test.js b/tests/integration/yandex_smartcaptcha.test.js new file mode 100644 index 0000000..462b80f --- /dev/null +++ b/tests/integration/yandex_smartcaptcha.test.js @@ -0,0 +1,33 @@ +/** + * Integration: solve a real Yandex SmartCaptcha (token challenge) end to end. + * + * SPENDS REAL BALANCE on every run. + * + * Needs a target page. Set YANDEX_SMARTCAPTCHA_URL and + * YANDEX_SMARTCAPTCHA_SITE_KEY, or the suite skips. There are deliberately no + * defaults -- see tests/README.md. + * + * This covers the token challenge only. Yandex's image challenge is solved + * through CoordinatesTask with imgType: 'smart_captcha' and needs a captcha + * image plus an instruction image, so it belongs with the image-based tests + * rather than here. + */ + +import { Tasks } from 'captcha-sdk'; +import { describeTarget, createClient } from './helpers.js'; + +const REQUIRED = ['YANDEX_SMARTCAPTCHA_URL', 'YANDEX_SMARTCAPTCHA_SITE_KEY']; + +describeTarget('Yandex SmartCaptcha against the real API', REQUIRED, (env) => { + test('solve returns a token', async () => { + const task = new Tasks.YandexSmartCaptchaTaskProxyless({ + websiteURL: env.YANDEX_SMARTCAPTCHA_URL, + websiteKey: env.YANDEX_SMARTCAPTCHA_SITE_KEY + }); + + const solution = await createClient().solve(task); + + expect(typeof solution.token).toBe('string'); + expect(solution.token.length).toBeGreaterThan(0); + }, 130000); +}); From a088f0d1b579ef238064a0d6e00bacf974f1e2c2 Mon Sep 17 00:00:00 2001 From: dzmitry-duboyski Date: Fri, 7 Aug 2026 21:41:58 +0300 Subject: [PATCH 17/21] Document how to run a single integration test The "one file or one test" section showed unit examples only, so the commands for the paid suites had to be inferred. Adds the integration equivalents and two caveats that apply only there: a bare -t filter also matches the paid suites, and Jest runs files in parallel, so a whole-directory run submits several billed tasks at once. Co-Authored-By: Claude Opus 5 --- tests/README.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/README.md b/tests/README.md index 08fb878..4ed5f01 100644 --- a/tests/README.md +++ b/tests/README.md @@ -324,6 +324,35 @@ npm run test:unit -- --watch npm run test:unit -- --coverage ``` +Всё то же работает и для integration — путь просто указывает на другой каталог: + +```bash +# один интеграционный файл: бесплатно, только ключ и сеть +npm test -- tests/integration/balance.test.js + +# один интеграционный файл: платная проверка +npm test -- tests/integration/turnstile.test.js + +# весь интеграционный каталог (то же, что npm run test:integration) +npm test -- tests/integration + +# один тест по имени внутри файла +npm test -- tests/integration/tencent.test.js -t "solve returns a ticket" + +# подробный вывод: видно, какие сьюты пропущены и почему их стоит перепроверить +npm run test:integration -- --verbose +``` + +Две оговорки, специфичные именно для integration: + +**Фильтр `-t` без пути пройдётся и по интеграционным тестам.** `npm test -- -t "solve"` подхватит все платные сьюты, у которых настроены цели. Указывайте путь к файлу вместе с `-t`, если не хотите неожиданных списаний. + +**Jest запускает файлы параллельно**, по воркеру на файл. Пока платных файлов немного, это не мешает, но прогон всего каталога отправляет несколько задач в API одновременно. Чтобы шли по очереди: + +```bash +npm run test:integration -- --runInBand +``` + Можно вызывать Jest и напрямую, но тогда флаг `--experimental-vm-modules` нужно указывать самому: ```bash From 741eaa77eea6f7b4898c1e13c484a2bfc484523f Mon Sep 17 00:00:00 2001 From: dzmitry-duboyski Date: Sat, 8 Aug 2026 17:06:09 +0300 Subject: [PATCH 18/21] Ship sample captcha images and run the image flows on them The two image-based flows -- image to text and coordinates -- needed a picture that the repository did not have. The examples read ./captcha.png and friends and died with ENOENT before reaching the API, and the integration test took its image from IMAGE_TO_TEXT_BASE64 in .env.example, whose sample string was a valid PNG of 26x26 pixels: nothing to recognise, so every run failed with ERROR_CAPTCHA_UNSOLVABLE and spent balance doing it. Both samples now live in examples/assets/, one copy shared by the examples and the tests so the two cannot drift apart. Paths resolve through new URL(..., import.meta.url), against the script rather than the working directory, so everything runs from the repository root. - Add tests/integration/coordinates.test.js. It asserts the points fall inside the picture, with the bounds read from the PNG's IHDR chunk so swapping the sample cannot silently invalidate the check. - image_to_text.test.js reads the file instead of the environment; IMAGE_TO_TEXT_BASE64 is gone. Both image suites now need only a key, which also means the nightly CI job solves two captchas for real. - Fix hints that contradicted the sample: numeric: 1 (digits only) on a letters captcha, and a comment about a green apple on a grid of street signs. - Comment out the three example blocks that need an instruction image the repository still does not ship, naming the file they expect. - Stop calling every target identifier a sitekey: Tencent has an appId and GeeTest v4 a captchaId. Co-Authored-By: Claude Opus 5 --- .env.example | 27 ++++++----- examples/README.md | 39 +++++++++------ examples/assets/coordinates-captcha.png | Bin 0 -> 137796 bytes examples/assets/text-captcha.png | Bin 0 -> 30891 bytes examples/async/README.md | 32 +++++++------ examples/async/coordinates.js | 34 +++++++++---- examples/async/image_to_text.js | 28 ++++++++--- examples/sync/README.md | 33 +++++++------ examples/sync/coordinates.js | 30 ++++++++---- examples/sync/image_to_text.js | 28 ++++++++--- tests/README.md | 26 +++++++--- tests/integration/coordinates.test.js | 61 ++++++++++++++++++++++++ tests/integration/helpers.js | 10 ++-- tests/integration/image_to_text.test.js | 26 ++++++---- todo.md | 49 ++++++++++++------- 15 files changed, 303 insertions(+), 120 deletions(-) create mode 100644 examples/assets/coordinates-captcha.png create mode 100644 examples/assets/text-captcha.png create mode 100644 tests/integration/coordinates.test.js diff --git a/.env.example b/.env.example index f6b5b4a..e219840 100644 --- a/.env.example +++ b/.env.example @@ -4,10 +4,14 @@ # - the scripts in examples/, which call `import 'dotenv/config'`; # - the integration tests, via tests/integration/helpers.js. # -# Target pages and their sitekeys are deliberately left empty. This repository -# does not carry links to real sites or their keys, so every target is yours to -# fill in. An integration suite whose variables are unset skips itself instead -# of failing -- see tests/README.md. +# Target pages and their widget identifiers are deliberately left empty. This +# repository does not carry links to real sites or their keys, so every target +# is yours to fill in. An integration suite whose variables are unset skips +# itself instead of failing -- see tests/README.md. +# +# What identifies the widget depends on the captcha: reCAPTCHA, Turnstile and +# Yandex use a sitekey, Tencent uses an appId, GeeTest v4 a captchaId. The +# variable names below follow whichever one the vendor actually uses. # Required for everything below. CAPTCHA_API_KEY= @@ -48,11 +52,12 @@ TENCENT_URL= TENCENT_APP_ID= TENCENT_CAPTCHA_SCRIPT= -# tests/integration/image_to_text.test.js -# The one solve test that needs no page and no sitekey, just an image: a pure -# base64 string with no "data:image/png;base64," prefix. The value below is a -# sample captcha, so this suite runs as-is. -# IMAGE_TO_TEXT_EXPECTED is optional: set it to the text on your own image to -# assert the answer instead of merely asserting that something came back. -IMAGE_TO_TEXT_BASE64=iVBORw0KGgoAAAANSUhEUgAAABoAAAAaCAYAAACpSkzOAAAACXBIWXMAAAsTAAALEwEAmpwYAAABaklEQVRIic2VPWtUQRjHfzPnnHv3bnb3ZneNuFkJKgkWVjY2NhZWFn4AEWwsLOwUbIVUtgERxCSFgiAGxEIiCOYDbPIVfBe5u3vuOTMHi7gkJP4Hnup5nv8z7/PMDP8ZMVIA3POcc0YIMUXkAVAVUcixpxpWCsgLWKu8AP4VJcSNMXUAHoJsh1DUlwLWao1i/DuI0CuUWwVa0sTGBB/+Q4qjQw5qZYp2B/UUESFPKK5ZAvSapFy5oAQnRw6CLPK95LmmAt0PDr9ZwnAI6t3nACdEtBniFJDPmHSm87mIdw4zVygIliYiTwH2K5IbhqINkDNjIl8adT3u5BIIHhO52B3Oc0FVT+4BEHuNWHfIDRFZX7vI/H9QIlIvB0R+AnvN6OtYRAVwZ80ICXTHaA+mIgtnmuNNzUGQtkgpYhA7z4goLskAJ0CaK2vIGa0FNv+L4hBZa4idDcAzz3aL8GmG5T8vsl/+zN6LFQAAAABJRU5ErkJggg== +# tests/integration/image_to_text.test.js and coordinates.test.js need nothing +# here at all: no page, no widget identifier, just an image, and both images +# ship with the repository (examples/assets/). Those two suites run as-is with +# nothing but the key above. +# +# IMAGE_TO_TEXT_EXPECTED is the one optional knob: set it to the text on +# examples/assets/text-captcha.png to assert the answer itself instead of +# merely asserting that something came back. IMAGE_TO_TEXT_EXPECTED= diff --git a/examples/README.md b/examples/README.md index 598d24a..04a88ef 100644 --- a/examples/README.md +++ b/examples/README.md @@ -143,19 +143,22 @@ the site loads the widget from a non-default script URL. ### Image to Text -Three blocks, no proxy variant. *Basic* submits a base64 image and nothing else. *Advanced* adds the -hints that speed up recognition — `numeric`, `phrase`, `minLength`/`maxLength`, `comment` and an -`imgInstructions` image. *With language pool* shows that `languagePool` is the **second argument to +Three blocks, no proxy variant. *Basic* submits the base64 image with the character-set hints that +match the shipped sample — letters, no digits. *Advanced* adds the rest of the hints that speed up +recognition — `phrase`, `math`, `comment` and an `imgInstructions` image — and is commented out for +want of that second image. *With language pool* shows that `languagePool` is the **second argument to `solve()`**, not a task field: `solve(task, 'en')` picks an English-speaking worker pool (`'en'` or `'ru'`). ### Coordinates (click captcha) -Three blocks, no proxy variant. *Basic* passes an image plus a `comment` telling the worker what to -click. *Advanced* adds an `imgInstructions` image and the `minClicks`/`maxClicks` limits. *Yandex -SmartCaptcha image mode* reuses `CoordinatesTask` with `imgType: 'smart_captcha'` (object selection) -or `'pazl_smart_captcha'` (puzzle) — this is how you solve Yandex's image challenge rather than its -token challenge. +Three blocks, no proxy variant. *Basic* passes the shipped grid captcha plus a `comment` telling the +worker what to click — it repeats the instruction printed on the image itself, since nothing +guarantees the worker reads that. *Advanced* adds an `imgInstructions` image and the +`minClicks`/`maxClicks` limits. *Yandex SmartCaptcha image mode* reuses `CoordinatesTask` with +`imgType: 'smart_captcha'` (object selection) or `'pazl_smart_captcha'` (puzzle) — this is how you +solve Yandex's image challenge rather than its token challenge. The last two are commented out: both +need an instruction image the repository does not ship. ### Account balance @@ -176,12 +179,20 @@ Every example except `balance.js` needs something replaced first. | `tencent.js` | Real `websiteURL` and `appId` | | `geetest_v3.js` | Real `websiteURL` and `gt`, **plus a real init endpoint** (see below) | | `geetest_v4.js` | Real `websiteURL` and `captcha_id` | -| `image_to_text.js` | `captcha.png` and `captcha_hint.png` in the working directory | -| `coordinates.js` | `captcha.png` and `instruction.png` in the working directory | - -**Image files are resolved against the working directory**, not the script location — `fs.readFileSync('./captcha.png')` -looks in wherever you launched `node` from. Run those two examples from the directory holding your -images, or edit the paths. The repository intentionally ships no sample images. +| `image_to_text.js` | Nothing — the sample image ships in [assets/](assets/) | +| `coordinates.js` | Nothing — the sample image ships in [assets/](assets/) | + +**The two image examples run as-is**, with a valid key and nothing else. They read +[assets/text-captcha.png](assets/text-captcha.png) and +[assets/coordinates-captcha.png](assets/coordinates-captcha.png) through +`new URL('../assets/…', import.meta.url)`, which resolves against the script rather than the working +directory — so `node examples/async/coordinates.js` works from the repository root just as well as +from anywhere else. Point the read at your own file to solve a different image. + +**One block in each is commented out**: *Advanced* in `image_to_text.js`, and both *Advanced* and +*Yandex SmartCaptcha image mode* in `coordinates.js`. All three need a second picture — the +instruction image passed as `imgInstructions` — and the repository does not ship one yet. Drop your +own into [assets/](assets/) under the name the block expects and uncomment it. **`geetest_v3.js` will not run end-to-end as-is.** It fetches `https://target-site.com/path/to/geetest/init`, which is a placeholder, not a live endpoint; the diff --git a/examples/assets/coordinates-captcha.png b/examples/assets/coordinates-captcha.png new file mode 100644 index 0000000000000000000000000000000000000000..7b014149fb1eb182022f9f4923c365a73bbb50c0 GIT binary patch literal 137796 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

)D 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 Sm98290000 { console.error(error); }); +*/ diff --git a/examples/sync/image_to_text.js b/examples/sync/image_to_text.js index 3c99294..e1ea121 100644 --- a/examples/sync/image_to_text.js +++ b/examples/sync/image_to_text.js @@ -2,8 +2,9 @@ * Example: Solve an Image to Text challenge. * * Prerequisites: - * Set the CAPTCHA_API_KEY environment variable. - * Provide a captcha image file as base64 in the body parameter. + * Set the CAPTCHA_API_KEY environment variable. That is all: the sample + * captcha ships with the repository, in examples/assets/. + * Point the read below at your own file to solve a different image. * Use optional fields to give hints to the worker for faster solving. */ @@ -18,13 +19,16 @@ const captchaSolver = new CaptchaClient({ clientKey: apiKey }); // Read and encode the captcha image to base64. // The body must be a pure base64 string without the data:image/...;base64, prefix. -const body = fs.readFileSync('./captcha.png').toString('base64'); +// The path is resolved against this file rather than the working directory, so +// the example runs from anywhere -- including from the repository root. +const body = fs.readFileSync(new URL('../assets/text-captcha.png', import.meta.url)).toString('base64'); // --- Basic example --- -// Solves a simple image captcha with character set hints. +// Solves a simple image captcha with character set hints. The hints below match +// the sample image, which is letters and no digits -- adjust them for your own. const basicTask = new Tasks.ImageToText({ body: body, // Base64-encoded image (required) - numeric: 1, // 1 = digits only + numeric: 2, // 2 = letters only minLength: 4, // Minimum expected answer length maxLength: 6 // Maximum expected answer length }); @@ -40,7 +44,16 @@ captchaSolver.solve(basicTask) // --- Advanced example --- // Solves a math captcha with comment and instruction image. -const imgInstructions = fs.readFileSync('./captcha_hint.png').toString('base64'); +// +// Commented out, because it needs a second picture -- the instruction image +// shown to the worker -- and the repository does not ship one yet. Put your own +// text-captcha-hint.png into examples/assets/ and uncomment the block. Note the +// sample image is not a math captcha, so `math: true` fits your own image, not +// this one. +/* +const imgInstructions = fs.readFileSync( + new URL('../assets/text-captcha-hint.png', import.meta.url) +).toString('base64'); const advancedTask = new Tasks.ImageToText({ body: body, // Base64-encoded captcha image @@ -62,6 +75,7 @@ captchaSolver.solve(advancedTask) .catch((error) => { console.error(error); }); +*/ // --- With language pool --- // The languagePool parameter selects the worker pool by language. @@ -69,7 +83,7 @@ captchaSolver.solve(advancedTask) // Accepted values: "en" (English) or "ru" (Russian). const languagePoolTask = new Tasks.ImageToText({ body: body, - numeric: 1, + numeric: 2, minLength: 4, maxLength: 6 }); diff --git a/tests/README.md b/tests/README.md index 4ed5f01..867b0c3 100644 --- a/tests/README.md +++ b/tests/README.md @@ -46,6 +46,7 @@ tests/ ├── helpers.js # общий гард и фабрика клиента (не тест) ├── balance.test.js # бесплатная проверка: баланс ├── image_to_text.test.js # платные проверки: реальный solve(), по файлу на тип + ├── coordinates.test.js ├── recaptcha_v2.test.js ├── recaptcha_v3.test.js ├── turnstile.test.js @@ -175,7 +176,8 @@ SDK предоставляет один и тот же промисный API, | Файл | Что проверяет | Переменные цели | Таймаут теста | Тратит баланс | |---|---|---|---|---| | `balance.test.js` | баланс аккаунта | — | 15 с | нет | -| `image_to_text.test.js` | распознавание текста на картинке | `IMAGE_TO_TEXT_BASE64` | 130 с | **да** | +| `image_to_text.test.js` | распознавание текста на картинке | — (картинка лежит в репозитории) | 130 с | **да** | +| `coordinates.test.js` | `coordinates` — точки клика на картинке | — (картинка лежит в репозитории) | 130 с | **да** | | `recaptcha_v2.test.js` | `gRecaptchaResponse` | `RECAPTCHA_V2_URL`, `RECAPTCHA_V2_SITE_KEY` | 130 с | **да** | | `recaptcha_v3.test.js` | `gRecaptchaResponse` при `minScore: 0.3` | `RECAPTCHA_V3_URL`, `RECAPTCHA_V3_SITE_KEY` | 190 с | **да** | | `turnstile.test.js` | `token` | `TURNSTILE_URL`, `TURNSTILE_SITE_KEY` | 130 с | **да** | @@ -193,16 +195,24 @@ npm test -- tests/integration/balance.test.js ### Почему в репозитории нет ни одной цели -Ни одного URL реальной страницы и ни одного sitekey в коде нет — **намеренно**. Всё берётся из переменных окружения, дефолтов не предусмотрено. Раньше `recaptcha_v2.test.js` подставлял демо-страницу Google, а `.env.example` содержал рабочие ключи для reCAPTCHA, Turnstile и GeeTest — это убрано. +Ни одного URL реальной страницы и ни одного идентификатора виджета в коде нет — **намеренно**. Всё берётся из переменных окружения, дефолтов не предусмотрено. Раньше `recaptcha_v2.test.js` подставлял демо-страницу Google, а `.env.example` содержал рабочие ключи для reCAPTCHA, Turnstile и GeeTest — это убрано. + +Идентификатор у каждого вендора свой: sitekey у reCAPTCHA, Turnstile и Yandex, `appId` у Tencent, `captchaId` у GeeTest v4. Имена переменных следуют вендорским — `TENCENT_APP_ID`, а не `TENCENT_SITE_KEY`. Практическое следствие: **у свежего клона нет ни одной работающей интеграционной проверки, кроме баланса**. Это цена решения, а не недоработка. Свои цели пропишите в `.env` (он в `.gitignore`), шаблон с именами переменных — в [.env.example](../.env.example). -Исключение — `image_to_text.test.js`: ему не нужны ни страница, ни sitekey, только картинка. В `.env.example` лежит готовая base64-строка, поэтому он работает из коробки. +Исключение — два теста на картинках: `image_to_text.test.js` и `coordinates.test.js`. Им не нужны ни страница, ни идентификатор виджета, только изображение, а изображения лежат в репозитории — [text-captcha.png](../examples/assets/text-captcha.png) и [coordinates-captcha.png](../examples/assets/coordinates-captcha.png). Оба работают из коробки, достаточно ключа. + +Своего каталога с фикстурами у тестов нет намеренно: те же картинки скармливают API примеры `image_to_text.js` и `coordinates.js`, и одна общая копия не может разойтись со второй. Путь в тесте разрешается через `new URL(..., import.meta.url)`, то есть относительно самого файла теста, а не рабочего каталога. + +В `coordinates.test.js` границы картинки не захардкожены: ширина и высота читаются из IHDR-чанка самого PNG, и проверка «точка внутри изображения» остаётся верной, даже если картинку заменят на другую. + +Раньше картинка передавалась через переменную `IMAGE_TO_TEXT_BASE64` из `.env.example`. Строка там лежала валидная, но кодировала картинку 26×26 пикселей — распознавать в ней было нечего, и тест стабильно падал с `ERROR_CAPTCHA_UNSOLVABLE`. Переменной больше нет. ### Чего здесь нет - **GeeTest v3.** Ему нужен свежий `challenge`, привязанный к сессии и добываемый с целевой страницы непосредственно перед созданием задачи. Из статической конфигурации это не заводится — потребовался бы скрапер, как в [examples/async/geetest_v3.js](../examples/async/geetest_v3.js). -- **Coordinates.** Нужны две картинки — сама капча и инструкция; см. раздел про изображения в [todo.md](../todo.md). +- **Coordinates с `imgInstructions` и режим Yandex (`imgType: 'smart_captcha'`).** Обоим нужна вторая картинка — инструкция, — которой в репозитории пока нет; см. раздел про изображения в [todo.md](../todo.md). Базовая форма с `comment` покрыта. - **Варианты с прокси и reCAPTCHA v2 Enterprise.** Требуют рабочего прокси и сайта с Enterprise-виджетом соответственно. - **Cloudflare Challenge pages** в `turnstile.test.js` — покрыт только обычный виджет. Challenge-страницы требуют свежих `action`, `data` и `pageData`, вытащенных со страницы. @@ -407,13 +417,15 @@ All files | 97.53 | 83.63 | 100 | 97.5 | ### Что ночной прогон проверяет на самом деле -Джоб `integration` передаёт в тесты только `CAPTCHA_API_KEY`. Переменных цели у него нет, поэтому **фактически проверяется лишь `balance.test.js`**, а все семь solve-сьютов пропускаются. Раньше `recaptcha_v2.test.js` работал за счёт зашитой демо-страницы Google — после отказа от целей в репозитории (см. [выше](#почему-в-репозитории-нет-ни-одной-цели)) это не так. +Джоб `integration` передаёт в тесты только `CAPTCHA_API_KEY`. Переменных цели у него нет, поэтому из платных сьютов пропускаются все, кроме двух на картинках — `image_to_text.test.js` и `coordinates.test.js`: им переменные цели не нужны, и при живом ключе они **реально сходят в API и потратят баланс**. Раньше `recaptcha_v2.test.js` работал за счёт зашитой демо-страницы Google — после отказа от целей в репозитории (см. [выше](#почему-в-репозитории-нет-ни-одной-цели)) это не так. + +То есть **каждая ночь стоит две задачи** — по одной на картинку. Это плата за то, что ночной прогон вообще проверяет `solve()`, а не только баланс; раньше он не проверял ничего, кроме баланса. Если такая трата не нужна, самый простой способ — сузить шаг до конкретных файлов: `npm test -- tests/integration/balance.test.js`. -Это осознанное состояние, а не поломка: гонять платные solve-тесты в CI пока не планируется. Когда понадобится, порядок такой: +Остальные, «целевые», solve-тесты в CI гонять пока не планируется. Когда понадобится, порядок такой: 1. Завести цели как секреты репозитория — например `RECAPTCHA_V2_URL` и `RECAPTCHA_V2_SITE_KEY`. 2. Пробросить их в шаге `Run integration tests` рядом с `CAPTCHA_API_KEY`. -3. Запускать выборочно — `npm test -- tests/integration/recaptcha_v2.test.js`, а не весь набор, иначе каждая ночь будет стоить семь задач. +3. Запускать выборочно — `npm test -- tests/integration/recaptcha_v2.test.js`, а не весь набор, иначе каждая ночь будет стоить по задаче на каждый настроенный тип. 4. Дополнить шаг-предохранитель проверкой этих переменных, иначе опечатка в имени секрета обернётся молчаливым пропуском и зелёным джобом. ### Две детали, которые легко упустить diff --git a/tests/integration/coordinates.test.js b/tests/integration/coordinates.test.js new file mode 100644 index 0000000..d427e6c --- /dev/null +++ b/tests/integration/coordinates.test.js @@ -0,0 +1,61 @@ +/** + * Integration: solve a click captcha end to end and get back coordinates. + * + * SPENDS REAL BALANCE on every run. + * + * Like image_to_text.test.js, this one needs no target page and no widget + * identifier -- only an image, and the image ships with the repository. Both + * suites read their sample from examples/assets/ rather than from a fixtures + * directory of our own: the same pictures are what the examples feed to the + * API, and one copy cannot drift from the other. Paths are resolved against + * this file, not the working directory, so the suite runs from anywhere. + * + * Only the basic form is covered. `imgInstructions` and the Yandex image mode + * (`imgType: 'smart_captcha'`) both need a second picture -- an instruction + * image -- which the repository does not have yet; see todo.md. + */ + +import fs from 'node:fs'; + +import { Tasks } from 'captcha-sdk'; +import { describeIntegration, createClient } from './helpers.js'; + +const imagePath = new URL('../../examples/assets/coordinates-captcha.png', import.meta.url); + +// The sample is a 4x4 grid captcha whose own header reads "Select all squares +// with street signs". The comment repeats that instruction for the worker, +// because nothing guarantees they read the text baked into the image. +const comment = 'click on all squares with street signs'; + +describeIntegration('Coordinates against the real API', () => { + test('solve returns the clicked points', async () => { + const image = fs.readFileSync(imagePath); + + // The API wants pure base64 with no "data:image/png;base64," prefix. + const task = new Tasks.CoordinatesTask({ body: image.toString('base64'), comment }); + + // Click tasks are image tasks: as fast as image-to-text, so the default + // 120 s client timeout is generous already. + const solution = await createClient().solve(task); + + expect(Array.isArray(solution.coordinates)).toBe(true); + expect(solution.coordinates.length).toBeGreaterThan(0); + + // A point outside the picture is not a valid answer, and asserting that + // catches a whole class of nonsense -- negatives, nulls, coordinates in a + // different unit -- that "is a number" would let through. Dimensions come + // from the PNG's IHDR chunk (bytes 16..24) rather than being hard-coded, + // so swapping the sample cannot silently invalidate the bounds. + const width = image.readUInt32BE(16); + const height = image.readUInt32BE(20); + + for (const point of solution.coordinates) { + expect(typeof point.x).toBe('number'); + expect(typeof point.y).toBe('number'); + expect(point.x).toBeGreaterThanOrEqual(0); + expect(point.y).toBeGreaterThanOrEqual(0); + expect(point.x).toBeLessThanOrEqual(width); + expect(point.y).toBeLessThanOrEqual(height); + } + }, 130000); +}); diff --git a/tests/integration/helpers.js b/tests/integration/helpers.js index 3bbeb26..6fbda48 100644 --- a/tests/integration/helpers.js +++ b/tests/integration/helpers.js @@ -37,10 +37,12 @@ export function describeIntegration(name, fn) { /** * Same as describeIntegration, but also requires a target to be configured. * - * Solving a real captcha needs a real page and its sitekey, and those are - * deliberately absent from this repository -- see tests/README.md. Every - * value comes from the environment, so a suite whose variables are unset - * skips instead of failing on `undefined`: + * Solving a real captcha needs a real page and whatever identifies the widget + * on it -- a sitekey for reCAPTCHA, Turnstile and Yandex, an appId for + * Tencent, a captchaId for GeeTest v4. Those are deliberately absent from this + * repository -- see tests/README.md. Every value comes from the environment, + * so a suite whose variables are unset skips instead of failing on + * `undefined`: * * describeTarget('Turnstile', ['TURNSTILE_URL', 'TURNSTILE_SITE_KEY'], (env) => { * test('...', async () => { diff --git a/tests/integration/image_to_text.test.js b/tests/integration/image_to_text.test.js index 5cb1f24..3eff491 100644 --- a/tests/integration/image_to_text.test.js +++ b/tests/integration/image_to_text.test.js @@ -3,26 +3,36 @@ * * SPENDS REAL BALANCE on every run. * - * The only solve test that needs no target page and no sitekey -- just an - * image. Set IMAGE_TO_TEXT_BASE64 to a pure base64 string (no - * "data:image/png;base64," prefix), or the suite skips. .env.example ships a - * usable one. + * The only solve test that needs no target page and no widget identifier -- + * just an image, and it ships with the repository. So unlike every other solve + * suite this one runs on a fresh clone with nothing but CAPTCHA_API_KEY set. + * + * The sample lives in examples/assets/ rather than in a fixtures directory of + * our own: the same picture is what the image_to_text examples feed to the + * API, and one copy that both use cannot drift from the other. Path is + * resolved against this file, not the working directory, so the suite runs + * from anywhere. * * IMAGE_TO_TEXT_EXPECTED is optional. Without it the test only asserts that * some non-empty text came back, which proves the round-trip but not the - * answer. Set it to the text on your image to assert the result itself -- + * answer. Set it to the text on the image to assert the result itself -- * compared case-insensitively, since the worker's casing is not guaranteed * unless the task sets case_. */ +import fs from 'node:fs'; + import { Tasks } from 'captcha-sdk'; -import { describeTarget, createClient } from './helpers.js'; +import { describeIntegration, createClient } from './helpers.js'; +const imagePath = new URL('../../examples/assets/text-captcha.png', import.meta.url); const expected = process.env.IMAGE_TO_TEXT_EXPECTED; -describeTarget('Image to Text against the real API', ['IMAGE_TO_TEXT_BASE64'], (env) => { +describeIntegration('Image to Text against the real API', () => { test('solve returns the recognised text', async () => { - const task = new Tasks.ImageToText({ body: env.IMAGE_TO_TEXT_BASE64 }); + // The API wants pure base64 with no "data:image/png;base64," prefix. + const body = fs.readFileSync(imagePath).toString('base64'); + const task = new Tasks.ImageToText({ body }); // Image tasks are the fastest type, so the default 120 s client timeout // is generous already. diff --git a/todo.md b/todo.md index 6547500..916278b 100644 --- a/todo.md +++ b/todo.md @@ -44,38 +44,44 @@ ## Изображения для примеров с картинками -**Статус:** не сделано. Четыре примера читают с диска файлы, которых в репозитории нет. +**Статус:** основное сделано. Все четыре примера запускаются из коробки — нужен только ключ. Осталось добавить две картинки-инструкции и раскомментировать три блока, которые их ждут. -### В чём проблема +### В чём была проблема -`image_to_text.js` и `coordinates.js` (в обоих каталогах, `async/` и `sync/`) начинают работу с чтения картинки: +`image_to_text.js` и `coordinates.js` (в обоих каталогах, `async/` и `sync/`) начинали работу с чтения картинки, которой в репозитории не было, и падали с `ENOENT` на первой же строке — ещё до обращения к API. Хуже того, путь был передан как `./captcha.png` и резолвился **относительно рабочего каталога** (`process.cwd()`), а не относительно файла скрипта: положить картинки рядом со скриптами было бы недостаточно, `node examples/async/coordinates.js` из корня репозитория всё равно искал бы их в корне. -| Файл | Что читает | -|---|---| -| `examples/async/image_to_text.js`, `examples/sync/image_to_text.js` | `./captcha.png`, `./captcha_hint.png` (в блоке *Advanced*) | -| `examples/async/coordinates.js`, `examples/sync/coordinates.js` | `./captcha.png`, `./instruction.png` | +Что читается сейчас: -Ни одного из этих файлов в репозитории нет, поэтому оба примера падают с `ENOENT` на первой же строке — ещё до обращения к API. Это единственные примеры, которые нельзя запустить, просто подставив свой ключ. - -Хуже того, путь передан как `./captcha.png` и резолвится **относительно рабочего каталога** (`process.cwd()`), а не относительно файла скрипта. То есть положить картинки рядом со скриптами недостаточно: `node examples/async/coordinates.js` из корня репозитория всё равно будет искать их в корне. +| Файл | Основная картинка | Картинка-инструкция (`imgInstructions`) | +|---|---|---| +| `examples/*/image_to_text.js` | `../assets/text-captcha.png` — **есть** | `../assets/text-captcha-hint.png` — нет, блок *Advanced* закомментирован | +| `examples/*/coordinates.js` | `../assets/coordinates-captcha.png` — **есть** | `../assets/coordinates-captcha-instruction.png` — нет, блоки *Advanced* и *Yandex* закомментированы | ### Что сделать -- [ ] **Добавить сами изображения.** Нужны четыре: текстовая капча (`captcha.png`), подсказка к ней (`captcha_hint.png`), кликовая капча и инструкция к ней (`instruction.png`). Класть в `examples/assets/` — рядом с тем, что их использует. Корневой `assets/` занят баннером репозитория, мешать одно с другим не стоит. -- [ ] **Поправить пути в четырёх файлах**, чтобы они не зависели от рабочего каталога: +- [x] **Завести `examples/assets/`** — рядом с тем, что картинки использует. Корневой `assets/` занят баннером репозитория, мешать одно с другим не стоит. Имена — kebab-case, как у `assets/repo-banner-javascript.png`, и по типу капчи, а не по имени примера. +- [x] **Текстовая капча** — `examples/assets/text-captcha.png`. Её же читает `tests/integration/image_to_text.test.js`: одна копия на примеры и тесты, дублировать бинарник в `tests/` не нужно. +- [x] **Кликовая капча** — `examples/assets/coordinates-captcha.png`, её читает `tests/integration/coordinates.test.js`. +- [x] **Поправить пути в четырёх файлах**, чтобы они не зависели от рабочего каталога: ```javascript - const body = fs.readFileSync(new URL('../assets/captcha.png', import.meta.url)).toString('base64'); + const body = fs.readFileSync(new URL('../assets/text-captcha.png', import.meta.url)).toString('base64'); ``` `new URL(..., import.meta.url)` работает начиная с Node 14 и подходит под `engines.node: ">=18"`. Вариант с `import.meta.dirname` требует Node 20.11+ и планку по Node поднимет. -- [ ] **Обновить [examples/README.md](examples/README.md).** Сейчас там написано, что изображения нужно принести свои и что репозиторий их намеренно не содержит: раздел «Before you run» и подпись под таблицей. После добавления картинок эти оговорки станут неверными. +- [x] **Подсказки в `image_to_text.js` подогнаны под картинку.** Было `numeric: 1` (только цифры) при буквенной капче — воркеру уходила заведомо ложная подсказка. Стало `numeric: 2` (буквы), в обоих блоках, где эти поля есть. +- [x] **`comment` в `coordinates.js`** — `'click on all squares with street signs'` вместо `'click on the green apple'`: повторяет инструкцию, напечатанную на самой картинке. +- [x] **Обновлены [examples/README.md](examples/README.md), [examples/sync/README.md](examples/sync/README.md), [examples/async/README.md](examples/async/README.md).** Раздел «Before you run», описания обоих примеров и оговорки про рабочий каталог. +- [ ] **Добавить две картинки-инструкции:** `text-captcha-hint.png` (к текстовой) и `coordinates-captcha-instruction.png` (к кликовой) — и раскомментировать три блока, которые их ждут: *Advanced* в `image_to_text.js`, *Advanced* и *Yandex SmartCaptcha image mode* в `coordinates.js`. Тогда же можно будет покрыть режим `imgType: 'smart_captcha'` в `tests/integration/coordinates.test.js`. +- [ ] **Заодно решить, что делать с блоком *Advanced* в `image_to_text.js`.** Он подписан как математическая капча (`math: true`), а `text-captcha.png` — буквенная. Либо третья картинка с примером-уравнением, либо переписать блок под ту же картинку. + +### Отвергнутая альтернатива: base64 в `.env.example` -### Альтернатива, если картинки решат не коммитить +Раньше картинка для теста передавалась строкой `IMAGE_TO_TEXT_BASE64` из `.env.example`, и примеры могли бы брать `body` оттуда же — без бинарников в репозитории. Не годится по двум причинам. -В `.env.example` уже лежит готовая base64-строка `IMAGE_TO_TEXT_BASE64` с картинкой капчи. Примеры могут брать `body` из неё, а к чтению файла оставить закомментированную строку с пояснением. Тогда `image_to_text.js` заработает из коробки без бинарников в репозитории. +Во-первых, лежавшая там строка была валидным PNG, но размером 26×26 пикселей: распознавать в ней нечего, и `image_to_text.test.js` стабильно падал с `ERROR_CAPTCHA_UNSOLVABLE`, тратя баланс. Переменная убрана вместе со строкой. -Для `coordinates.js` это не сработает: там нужны две картинки, и осмысленных значений для них в `.env.example` нет — пришлось бы добавлять ещё две длинные base64-строки, что читаемости файлу не добавит. +Во-вторых, для `coordinates.js` это не работает в принципе: там нужны две картинки, и пришлось бы добавлять ещё две длинные base64-строки, что читаемости файлу не добавит. ### На что обратить внимание @@ -89,3 +95,12 @@ - [ ] Линтер (ESLint) — сейчас в проекте не настроен, в CI отдельного шага линтинга нет. - [ ] Тест на содержимое публикуемого пакета (`npm pack`) — проверить, что в тарбол попадает всё нужное из `files: ["src/"]`. - [ ] agent.md + +--- +Добавление метаданных по идентификации библиотек и примеров + +--- +нет подсказок для методов решения капч и других методов +--- +а почему нету option примеров ? +--- \ No newline at end of file From b38efed88db69090413d33007f88310740ec7453 Mon Sep 17 00:00:00 2001 From: dzmitry-duboyski Date: Sat, 8 Aug 2026 17:19:22 +0300 Subject: [PATCH 19/21] Reset the package version to 0.0.1 package.json and the __version__ export are asserted to match by tests/unit/public-api.test.js, so move both together. Co-Authored-By: Claude Opus 5 --- package.json | 2 +- src/index.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index dea53f6..3f3f188 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "captcha-sdk", - "version": "1.1.0", + "version": "0.0.1", "description": "Official JavaScript SDK for the Captcha Solver API. Solve reCAPTCHA v2/v3, Cloudflare Turnstile, GeeTest, Yandex SmartCaptcha, Tencent, and image/click captchas.", "main": "src/index.js", "type": "module", diff --git a/src/index.js b/src/index.js index 53406cc..6c5a3e4 100644 --- a/src/index.js +++ b/src/index.js @@ -2,4 +2,4 @@ export { CaptchaClient } from './client.js'; export { ApiError, NetworkError, TimeoutError, ValidationError, CaptchaError } from './exceptions.js'; export * as Tasks from './tasks.js'; -export const __version__ = '1.1.0'; \ No newline at end of file +export const __version__ = '0.0.1'; \ No newline at end of file From 24a7439fbd279537b8b7474d921a9b231eaa9ea6 Mon Sep 17 00:00:00 2001 From: dzmitry-duboyski Date: Sat, 8 Aug 2026 20:01:12 +0300 Subject: [PATCH 20/21] update gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e7f433a..0143c10 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ node_modules/ dist/ coverage/ .claude/ +todo.md From 136aa95829c91c39764d6e672869b7b53a008b56 Mon Sep 17 00:00:00 2001 From: dzmitry-duboyski Date: Sat, 8 Aug 2026 20:07:32 +0300 Subject: [PATCH 21/21] Translate the test documentation to English and refresh its numbers Five claims had gone stale against the tree: the integration suite is nine files, not eight; the skip-mode output and the count of paid tasks per run followed from that; and the unit run is well under two seconds, not seven. Also moves the --experimental-vm-modules and Node 18+ paragraphs out of the coverage section, where they had ended up during an earlier restructure, into a Requirements subsection under the manual-run instructions. Co-Authored-By: Claude Opus 5 --- tests/README.md | 451 ++++++++++++++++++++++++------------------------ 1 file changed, 227 insertions(+), 224 deletions(-) diff --git a/tests/README.md b/tests/README.md index 867b0c3..64ea8a5 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,35 +1,36 @@ -# Тесты SDK - -Документация по тестам `captcha-sdk`: что покрыто, как запускать вручную и что должно выполняться в CI. - -## Оглавление - -- [Структура каталога](#структура-каталога) -- [Типы тестов](#типы-тестов) -- [Как тесты импортируют SDK](#как-тесты-импортируют-sdk) -- [Unit-тесты](#unit-тесты) - - [Как разделены sync и async](#как-разделены-sync-и-async) - - [Тесты клиента](#тесты-клиента-clienttestjs) - - [Тесты по типам капч](#тесты-по-типам-капч) - - [Контракт публичного API](#контракт-публичного-api) -- [Integration-тесты](#integration-тесты) - - [Почему в репозитории нет ни одной цели](#почему-в-репозитории-нет-ни-одной-цели) - - [Чего здесь нет](#чего-здесь-нет) - - [Общий хелпер](#общий-хелпер) - - [Как включаются](#как-включаются) -- [Ручной запуск](#ручной-запуск) -- [Покрытие](#покрытие) -- [CI / пайплайн](#ci--пайплайн) - -## Структура каталога - -Верхний уровень делит тесты по **типу**, вложенный — по **стилю вызова**: +# SDK tests + +Documentation for the `captcha-sdk` test suite: what is covered, how to run it by hand, and what runs in CI. + +## Contents + +- [Directory layout](#directory-layout) +- [Test types](#test-types) +- [How the tests import the SDK](#how-the-tests-import-the-sdk) +- [Unit tests](#unit-tests) + - [How sync and async are split](#how-sync-and-async-are-split) + - [Client tests](#client-tests-clienttestjs) + - [Tests per captcha type](#tests-per-captcha-type) + - [Public API contract](#public-api-contract) +- [Integration tests](#integration-tests) + - [Why the repository carries no targets](#why-the-repository-carries-no-targets) + - [What is not covered here](#what-is-not-covered-here) + - [The shared helper](#the-shared-helper) + - [How they switch on](#how-they-switch-on) +- [Running them by hand](#running-them-by-hand) + - [Requirements](#requirements) +- [Coverage](#coverage) +- [CI / pipeline](#ci--pipeline) + +## Directory layout + +The top level splits tests by **type**, the nested level by **calling style**: ``` tests/ -├── unit/ # без сети, fetch замокан, ключ не нужен -│ ├── public-api.test.js # контракт публичных точек входа пакета -│ ├── sync/ # стиль промис-цепочек (.then/.catch) +├── unit/ # no network, fetch is mocked, no key needed +│ ├── public-api.test.js # contract of the package's public entry points +│ ├── sync/ # promise-chain style (.then/.catch) │ │ ├── client.test.js │ │ ├── coordinates.test.js │ │ ├── geetest.test.js @@ -40,12 +41,12 @@ tests/ │ │ ├── tencent.test.js │ │ ├── turnstile.test.js │ │ └── yandex_smartcaptcha.test.js -│ └── async/ # те же сценарии в стиле async/await -│ └── (те же 10 файлов) -└── integration/ # против реального API (нужен ключ, тратит баланс) - ├── helpers.js # общий гард и фабрика клиента (не тест) - ├── balance.test.js # бесплатная проверка: баланс - ├── image_to_text.test.js # платные проверки: реальный solve(), по файлу на тип +│ └── async/ # the same scenarios in async/await style +│ └── (the same 10 files) +└── integration/ # against the real API (needs a key, spends balance) + ├── helpers.js # shared guard and client factory (not a test) + ├── balance.test.js # free check: balance + ├── image_to_text.test.js # paid checks: real solve(), one file per type ├── coordinates.test.js ├── recaptcha_v2.test.js ├── recaptcha_v3.test.js @@ -55,97 +56,97 @@ tests/ └── tencent.test.js ``` -Раннер — Jest 29 в режиме ESM (`node --experimental-vm-modules`), настройки в [jest.config.js](../jest.config.js) в корне проекта. Используется дефолтный `testMatch`, то есть подхватывается любой файл `*.test.js`. Наборы отбираются по пути каталога (`jest tests/unit` / `jest tests/integration`), поэтому новый файл не требует ничего настраивать — он попадает в нужный набор по своему расположению. +The runner is Jest 29 in ESM mode (`node --experimental-vm-modules`), configured in [jest.config.js](../jest.config.js) at the project root. The default `testMatch` is used, so any `*.test.js` file is picked up. Suites are selected by directory path (`jest tests/unit` / `jest tests/integration`), so a new file needs no configuration — its location alone puts it in the right set. -## Типы тестов +## Test types -| Тип | Где | Сеть | Нужен API-ключ | Кол-во | +| Type | Where | Network | API key needed | Count | |---|---|---|---|---| -| Unit | `tests/unit/` | нет, `fetch` замокан | нет | 21 сьют / 81 тест | -| Integration | `tests/integration/` | да, реальный API | да | 8 сьютов / 8 тестов | +| Unit | `tests/unit/` | no, `fetch` is mocked | no | 21 suites / 81 tests | +| Integration | `tests/integration/` | yes, the real API | yes | 9 suites / 9 tests | -Unit-тесты полностью изолированы: сетевой слой подменяется либо через `global.fetch = jest.fn(...)`, либо через `jest.spyOn(client, '_request')`. Никаких внешних запросов и никаких списаний с баланса. +Unit tests are fully isolated: the network layer is replaced either through `global.fetch = jest.fn(...)` or through `jest.spyOn(client, '_request')`. No outbound requests, no charges against the account. -## Как тесты импортируют SDK +## How the tests import the SDK -Все тесты подключают SDK **по имени пакета**, как это делает пользователь, а не по прямым путям к файлам: +Every test pulls in the SDK **by package name**, the way a user would, rather than by direct file paths: ```javascript import { CaptchaClient, Tasks } from 'captcha-sdk'; ``` -Это работает без дополнительной настройки: Node поддерживает self-reference — пакет может импортировать сам себя по имени, если в `package.json` есть `name` и `exports`. Резолвер Jest это уважает, включая запрет на незаявленные подпути. +This works with no extra setup: Node supports self-reference — a package can import itself by name as long as `package.json` has a `name` and an `exports` map. Jest's resolver honours that, including the ban on undeclared subpaths. -Почему так, а не `../../../src/client.js`: +Why this instead of `../../../src/client.js`: -- **Тесты проверяют то, что получает пользователь.** Прямые импорты обходят [src/index.js](../src/index.js) и карту `exports`. С ними можно удалить экспорт из `index.js` или сломать `exports` в `package.json` — и вся сюита останется зелёной, хотя пакет не заработает ни у кого. -- **Покрытие становится честным.** При прямых импортах `index.js` не загружался ни одним тестом и просто выпадал из отчёта Jest — покрытие показывало 97.5 %, умалчивая о непроверенной точке входа. -- **Нет хрупких `../../../`.** При переносе каталогов правится только `jest.config.js`, а не 21 файл. +- **The tests check what the user actually gets.** Direct imports bypass [src/index.js](../src/index.js) and the `exports` map. With them you could delete an export from `index.js` or break `exports` in `package.json` and the whole suite would stay green, even though the package would work for nobody. +- **Coverage becomes honest.** With direct imports, `index.js` was loaded by no test at all and simply dropped out of Jest's report — coverage read 97.5 % while saying nothing about an entirely unchecked entry point. +- **No brittle `../../../`.** Moving directories means editing `jest.config.js`, not 21 files. -Моки от способа импорта не зависят: `global.fetch` подменяется глобально, а `jest.spyOn(client, '_request')` работает на уровне экземпляра. +Mocking does not depend on the import style: `global.fetch` is replaced globally, and `jest.spyOn(client, '_request')` works at the instance level. -## Unit-тесты +## Unit tests -### Как разделены sync и async +### How sync and async are split -SDK предоставляет один и тот же промисный API, который можно использовать двумя стилями. Подкаталоги отражают именно стиль вызова, а не разные реализации: +The SDK exposes one and the same promise-based API, usable in two styles. The subdirectories reflect the calling style, not two different implementations: -- `tests/unit/sync/` — вызовы через `.then()/.catch()`, тест возвращает промис; -- `tests/unit/async/` — те же вызовы через `async/await`. +- `tests/unit/sync/` — calls via `.then()/.catch()`, the test returns a promise; +- `tests/unit/async/` — the same calls via `async/await`. -Чтобы не дублировать проверки, действует правило: +To avoid duplicating checks, the rule is: -- **Сериализация задач** (`task.toDict()`) проверяется **один раз** — в `tests/unit/sync/<тип>.test.js`. Она не зависит от стиля вызова. -- **`solve()`** проверяется **в обоих** каталогах — это и есть смысл разделения: убедиться, что промисы SDK корректно работают в обоих стилях. +- **Task serialization** (`task.toDict()`) is checked **once**, in `tests/unit/sync/.test.js`. It does not depend on the calling style. +- **`solve()`** is checked in **both** directories — that is the whole point of the split: confirming that the SDK's promises behave correctly in either style. -Поэтому файлы в `async/` заметно короче: там только `solve()`. +That is why the files under `async/` are noticeably shorter: they only hold `solve()`. -### Тесты клиента (`client.test.js`) +### Client tests (`client.test.js`) -`unit/sync/client.test.js` и `unit/async/client.test.js` — зеркальные, по 12 тестов каждый. Проверяют транспорт, обработку ошибок и polling, вне привязки к конкретному типу капчи: +`unit/sync/client.test.js` and `unit/async/client.test.js` mirror each other, 12 tests apiece. They cover transport, error handling and polling, independently of any particular captcha type: -| Тест | Что проверяет | +| Test | What it checks | |---|---| -| `throws ValidationError when clientKey is missing` | конструктор `CaptchaClient` без `clientKey` кидает `ValidationError` | -| `creates task and returns taskId` | `createTask()` возвращает `taskId` из ответа API | -| `sends languagePool when provided` | при передаче `languagePool` поле уходит в теле запроса | -| `does not send languagePool when not provided` | без `languagePool` поле в тело **не** попадает | -| `getTaskResult returns full API response` | `getTaskResult()` отдаёт весь ответ (`status`, `solution`), а не только решение | -| `getBalance returns balance as float` | строка `"10.50"` из API приводится к числу `10.5` | -| `solve polls until status is ready and returns solution` | `solve()` опрашивает API до `status: 'ready'` и возвращает `solution` | -| `throws TimeoutError when timeout exceeded` | при превышении `timeout` бросается `TimeoutError` | -| `throws ApiError when a task fails during polling` | ошибка, пришедшая **на этапе polling**, превращается в `ApiError` | -| `throws ApiError when errorId is not zero` | ненулевой `errorId` в ответе → `ApiError` | -| `ApiError carries the string errorCode, not the numeric errorId` | в `error.errorCode` лежит строковый код (`ERROR_KEY_DOES_NOT_EXIST`), а не число | -| `throws NetworkError on fetch failure` | падение `fetch` оборачивается в `NetworkError` | - -### Тесты по типам капч - -Для каждого типа капчи проверяется, что класс задачи сериализуется в тело запроса ровно по контракту API, и что `solve()` возвращает ожидаемую форму решения. - -Общее для всех типов сериализации: -- поле `type` соответствует типу задачи API (`RecaptchaV2TaskProxyless`, `TurnstileTask` и т.д.); -- обязательные поля попадают в `toDict()`; -- `null`/`undefined` поля **вырезаются** и не уходят в запрос; -- прокси-варианты классов добавляют `proxyType`/`proxyAddress`/`proxyPort`/`proxyLogin`/`proxyPassword`. - -| Файл | Тестов (sync / async) | Что специфичного проверяется | +| `throws ValidationError when clientKey is missing` | the `CaptchaClient` constructor without `clientKey` throws `ValidationError` | +| `creates task and returns taskId` | `createTask()` returns the `taskId` from the API response | +| `sends languagePool when provided` | when `languagePool` is passed, the field goes out in the request body | +| `does not send languagePool when not provided` | without `languagePool` the field is **absent** from the body | +| `getTaskResult returns full API response` | `getTaskResult()` hands back the whole response (`status`, `solution`), not just the solution | +| `getBalance returns balance as float` | the API's `"10.50"` string is coerced to the number `10.5` | +| `solve polls until status is ready and returns solution` | `solve()` polls the API until `status: 'ready'` and returns the `solution` | +| `throws TimeoutError when timeout exceeded` | exceeding `timeout` raises `TimeoutError` | +| `throws ApiError when a task fails during polling` | an error arriving **during polling** turns into `ApiError` | +| `throws ApiError when errorId is not zero` | a non-zero `errorId` in the response → `ApiError` | +| `ApiError carries the string errorCode, not the numeric errorId` | `error.errorCode` holds the string code (`ERROR_KEY_DOES_NOT_EXIST`), not a number | +| `throws NetworkError on fetch failure` | a `fetch` failure is wrapped in `NetworkError` | + +### Tests per captcha type + +For each captcha type the suite checks that the task class serializes into the request body exactly per the API contract, and that `solve()` returns the expected solution shape. + +Common to every type's serialization: +- the `type` field matches the API task type (`RecaptchaV2TaskProxyless`, `TurnstileTask` and so on); +- required fields make it into `toDict()`; +- `null`/`undefined` fields are **stripped** and never reach the request; +- the proxy variants of the classes add `proxyType`/`proxyAddress`/`proxyPort`/`proxyLogin`/`proxyPassword`. + +| File | Tests (sync / async) | What is specific to it | |---|---|---| -| `recaptcha_v2.test.js` | 6 / 1 | имена полей `recaptchaDataSValue` и `apiDomain` (а не `dataSValue`); `isInvisible`, `userAgent`; прокси-вариант. `solve()` проходит через промежуточный `status: 'processing'` — ровно 3 запроса | -| `recaptcha_v2_enterprise.test.js` | 4 / 1 | `enterprisePayload` как объект, `isInvisible`; точное совпадение `toDict()` для минимального набора полей | -| `recaptcha_v3.test.js` | 4 / 1 | `minScore` обязателен — без него конструктор кидает `ValidationError`; `pageAction`, `apiDomain` | -| `turnstile.test.js` | 4 / 1 | имена полей `data` и `pageData` (а не `cData`) | -| `geetest.test.js` | 6 / 2 | v3: `gt` + `challenge`, поле `version` отсутствует. v4: `version: 4` + `initParameters`. Плюс `geetestApiServerSubdomain`. `solve()` тестируется отдельно для v3 и v4 — у них разная форма решения | -| `yandex_smartcaptcha.test.js` | 4 / 1 | `userAgent`, `cookies`; прокси-вариант | -| `tencent.test.js` | 4 / 1 | `appId`, опциональный `captchaScript` | -| `image_to_text.test.js` | 3 / 1 | параметр конструктора `case_` сериализуется в поле `case` (обход зарезервированного слова); `numeric`, `phrase`, `minLength`/`maxLength`, `comment`, `imgInstructions` | -| `coordinates.test.js` | 5 / 1 | варианты `imgType`: `smart_captcha` (Yandex) и `pazl_smart_captcha`; `imgInstructions`; лимиты `minClicks`/`maxClicks` | +| `recaptcha_v2.test.js` | 6 / 1 | the field names `recaptchaDataSValue` and `apiDomain` (not `dataSValue`); `isInvisible`, `userAgent`; proxy variant. `solve()` goes through an intermediate `status: 'processing'` — exactly 3 requests | +| `recaptcha_v2_enterprise.test.js` | 4 / 1 | `enterprisePayload` as an object, `isInvisible`; an exact `toDict()` match for the minimal field set | +| `recaptcha_v3.test.js` | 4 / 1 | `minScore` is required — without it the constructor throws `ValidationError`; `pageAction`, `apiDomain` | +| `turnstile.test.js` | 4 / 1 | the field names `data` and `pageData` (not `cData`) | +| `geetest.test.js` | 6 / 2 | v3: `gt` + `challenge`, no `version` field. v4: `version: 4` + `initParameters`. Plus `geetestApiServerSubdomain`. `solve()` is tested separately for v3 and v4 — their solution shapes differ | +| `yandex_smartcaptcha.test.js` | 4 / 1 | `userAgent`, `cookies`; proxy variant | +| `tencent.test.js` | 4 / 1 | `appId`, optional `captchaScript` | +| `image_to_text.test.js` | 3 / 1 | the constructor parameter `case_` serializes to the field `case` (working around the reserved word); `numeric`, `phrase`, `minLength`/`maxLength`, `comment`, `imgInstructions` | +| `coordinates.test.js` | 5 / 1 | the `imgType` variants `smart_captcha` (Yandex) and `pazl_smart_captcha`; `imgInstructions`; the `minClicks`/`maxClicks` limits | -Форма решения, ожидаемая в `solve()`, отличается по типам: `gRecaptchaResponse` (reCAPTCHA), `token` (Turnstile, Yandex), `text` (ImageToText), `coordinates` (Coordinates), `challenge`/`validate`/`seccode` (GeeTest v3), `captcha_output` и др. (GeeTest v4), `ticket`/`randstr` (Tencent). +The solution shape expected in `solve()` differs by type: `gRecaptchaResponse` (reCAPTCHA), `token` (Turnstile, Yandex), `text` (ImageToText), `coordinates` (Coordinates), `challenge`/`validate`/`seccode` (GeeTest v3), `captcha_output` and others (GeeTest v4), `ticket`/`randstr` (Tencent). -### Контракт публичного API +### Public API contract -`tests/unit/public-api.test.js` — 7 тестов, охраняющих карту `exports` из [package.json](../package.json): +`tests/unit/public-api.test.js` — 7 tests guarding the `exports` map from [package.json](../package.json): ```json "exports": { @@ -155,227 +156,233 @@ SDK предоставляет один и тот же промисный API, } ``` -Остальные тесты уже импортируют пакет по имени, поэтому сломанный главный вход уронит всю сюиту сам по себе. Этот файл закрывает то, что иначе осталось бы непроверенным: +Every other test already imports the package by name, so a broken main entry would bring the whole suite down on its own. This file covers what would otherwise go unchecked: -| Тест | Что проверяет | +| Test | What it checks | |---|---| -| `exposes the client, the task namespace and every error class` | из `.` доступны `CaptchaClient`, `Tasks` и все 5 классов ошибок | -| `Tasks namespace exposes every task class` | в `Tasks` присутствуют все 16 классов задач | -| `__version__ matches the version in package.json` | `__version__` не разошёлся с `version` при релизе | -| `"captcha-sdk/tasks" exposes every task class` | подпуть `./tasks` резолвится и отдаёт все классы | -| `"captcha-sdk/exceptions" exposes every error class` | подпуть `./exceptions` резолвится и отдаёт все классы | -| `subpaths and the main entry expose the same classes` | это **те же самые** объекты, а не дубликаты модуля — иначе `instanceof` ломался бы у тех, кто смешивает способы импорта | -| `internal modules are not reachable as subpaths` | `captcha-sdk/client` отвергается: файл существует, но в `exports` не заявлен и должен остаться приватным | +| `exposes the client, the task namespace and every error class` | `.` exposes `CaptchaClient`, `Tasks` and all 5 error classes | +| `Tasks namespace exposes every task class` | `Tasks` holds all 16 task classes | +| `__version__ matches the version in package.json` | `__version__` has not drifted from `version` during a release | +| `"captcha-sdk/tasks" exposes every task class` | the `./tasks` subpath resolves and hands back every class | +| `"captcha-sdk/exceptions" exposes every error class` | the `./exceptions` subpath resolves and hands back every class | +| `subpaths and the main entry expose the same classes` | these are the **same** objects, not duplicate module instances — otherwise `instanceof` would break for anyone mixing import styles | +| `internal modules are not reachable as subpaths` | `captcha-sdk/client` is rejected: the file exists, but it is not declared in `exports` and must stay private | -Список классов в тесте задан явными массивами, а не выведен из самого модуля. Это намеренно: сравнение экспорта с самим собой всегда проходит и ничего не проверяет. При добавлении нового типа капчи массив нужно дополнить руками — это и есть точка, где решение «сделать класс публичным» фиксируется явно. +The class lists in the test are spelled out as explicit arrays rather than derived from the module itself. That is deliberate: comparing an export against itself always passes and checks nothing. Adding a new captcha type means extending the array by hand — and that is exactly the point where the decision to make a class public gets recorded explicitly. -## Integration-тесты +## Integration tests -Единственный набор, который ходит в реальный API `https://api.captcha-solver.com`. Один файл на проверку: +The only set that talks to the real API at `https://api.captcha-solver.com`. One file per check: -| Файл | Что проверяет | Переменные цели | Таймаут теста | Тратит баланс | +| File | What it checks | Target variables | Test timeout | Spends balance | |---|---|---|---|---| -| `balance.test.js` | баланс аккаунта | — | 15 с | нет | -| `image_to_text.test.js` | распознавание текста на картинке | — (картинка лежит в репозитории) | 130 с | **да** | -| `coordinates.test.js` | `coordinates` — точки клика на картинке | — (картинка лежит в репозитории) | 130 с | **да** | -| `recaptcha_v2.test.js` | `gRecaptchaResponse` | `RECAPTCHA_V2_URL`, `RECAPTCHA_V2_SITE_KEY` | 130 с | **да** | -| `recaptcha_v3.test.js` | `gRecaptchaResponse` при `minScore: 0.3` | `RECAPTCHA_V3_URL`, `RECAPTCHA_V3_SITE_KEY` | 190 с | **да** | -| `turnstile.test.js` | `token` | `TURNSTILE_URL`, `TURNSTILE_SITE_KEY` | 130 с | **да** | -| `geetest_v4.test.js` | `captcha_output`, `lot_number`, `pass_token` | `GEETEST_V4_URL`, `GEETEST_V4_CAPTCHA_ID` | 310 с | **да** | -| `yandex_smartcaptcha.test.js` | `token` | `YANDEX_SMARTCAPTCHA_URL`, `YANDEX_SMARTCAPTCHA_SITE_KEY` | 130 с | **да** | -| `tencent.test.js` | `ticket`, `randstr` | `TENCENT_URL`, `TENCENT_APP_ID` | 130 с | **да** | - -Разбиение идёт **по цене, а не по типу капчи**. `balance.test.js` ничего не стоит и ни от чего не зависит, поэтому годится как smoke-тест «ключ жив, сеть есть»: +| `balance.test.js` | account balance | — | 15 s | no | +| `image_to_text.test.js` | text recognition on an image | — (image ships with the repo) | 130 s | **yes** | +| `coordinates.test.js` | `coordinates` — click points on an image | — (image ships with the repo) | 130 s | **yes** | +| `recaptcha_v2.test.js` | `gRecaptchaResponse` | `RECAPTCHA_V2_URL`, `RECAPTCHA_V2_SITE_KEY` | 130 s | **yes** | +| `recaptcha_v3.test.js` | `gRecaptchaResponse` with `minScore: 0.3` | `RECAPTCHA_V3_URL`, `RECAPTCHA_V3_SITE_KEY` | 190 s | **yes** | +| `turnstile.test.js` | `token` | `TURNSTILE_URL`, `TURNSTILE_SITE_KEY` | 130 s | **yes** | +| `geetest_v4.test.js` | `captcha_output`, `lot_number`, `pass_token` | `GEETEST_V4_URL`, `GEETEST_V4_CAPTCHA_ID` | 310 s | **yes** | +| `yandex_smartcaptcha.test.js` | `token` | `YANDEX_SMARTCAPTCHA_URL`, `YANDEX_SMARTCAPTCHA_SITE_KEY` | 130 s | **yes** | +| `tencent.test.js` | `ticket`, `randstr` | `TENCENT_URL`, `TENCENT_APP_ID` | 130 s | **yes** | + +The split follows **cost, not captcha type**. `balance.test.js` costs nothing and depends on nothing, which makes it a fine smoke test for "the key is alive, the network is up": ```bash npm test -- tests/integration/balance.test.js ``` -Всё остальное вызывает `solve()` и списывает деньги с аккаунта при каждом прогоне. Поэтому эти тесты и запускаются выборочно, по одному файлу, а не всем набором. +Everything else calls `solve()` and takes money off the account on every run. That is why these are run selectively, one file at a time, rather than as a whole set. -### Почему в репозитории нет ни одной цели +### Why the repository carries no targets -Ни одного URL реальной страницы и ни одного идентификатора виджета в коде нет — **намеренно**. Всё берётся из переменных окружения, дефолтов не предусмотрено. Раньше `recaptcha_v2.test.js` подставлял демо-страницу Google, а `.env.example` содержал рабочие ключи для reCAPTCHA, Turnstile и GeeTest — это убрано. +There is not a single real page URL and not a single widget identifier in the code — **on purpose**. Everything comes from environment variables, with no defaults. `recaptcha_v2.test.js` used to fall back to Google's demo page, and `.env.example` used to carry working keys for reCAPTCHA, Turnstile and GeeTest; that has been removed. -Идентификатор у каждого вендора свой: sitekey у reCAPTCHA, Turnstile и Yandex, `appId` у Tencent, `captchaId` у GeeTest v4. Имена переменных следуют вендорским — `TENCENT_APP_ID`, а не `TENCENT_SITE_KEY`. +The identifier differs per vendor: a sitekey for reCAPTCHA, Turnstile and Yandex, an `appId` for Tencent, a `captchaId` for GeeTest v4. Variable names follow the vendor's own term — `TENCENT_APP_ID`, not `TENCENT_SITE_KEY`. -Практическое следствие: **у свежего клона нет ни одной работающей интеграционной проверки, кроме баланса**. Это цена решения, а не недоработка. Свои цели пропишите в `.env` (он в `.gitignore`), шаблон с именами переменных — в [.env.example](../.env.example). +The practical consequence: **a fresh clone has no working integration check other than the balance one**. That is the price of the decision, not an oversight. Put your own targets in `.env` (it is gitignored); the template with the variable names is [.env.example](../.env.example). -Исключение — два теста на картинках: `image_to_text.test.js` и `coordinates.test.js`. Им не нужны ни страница, ни идентификатор виджета, только изображение, а изображения лежат в репозитории — [text-captcha.png](../examples/assets/text-captcha.png) и [coordinates-captcha.png](../examples/assets/coordinates-captcha.png). Оба работают из коробки, достаточно ключа. +The exception is the two image-based tests, `image_to_text.test.js` and `coordinates.test.js`. They need neither a page nor a widget identifier, only an image, and the images ship with the repository — [text-captcha.png](../examples/assets/text-captcha.png) and [coordinates-captcha.png](../examples/assets/coordinates-captcha.png). Both work out of the box; a key is all it takes. -Своего каталога с фикстурами у тестов нет намеренно: те же картинки скармливают API примеры `image_to_text.js` и `coordinates.js`, и одна общая копия не может разойтись со второй. Путь в тесте разрешается через `new URL(..., import.meta.url)`, то есть относительно самого файла теста, а не рабочего каталога. +The tests deliberately have no fixtures directory of their own: the very same images are what the `image_to_text.js` and `coordinates.js` examples feed to the API, and one shared copy cannot drift from a second one. The path in the test is resolved through `new URL(..., import.meta.url)`, i.e. relative to the test file itself rather than the working directory. -В `coordinates.test.js` границы картинки не захардкожены: ширина и высота читаются из IHDR-чанка самого PNG, и проверка «точка внутри изображения» остаётся верной, даже если картинку заменят на другую. +In `coordinates.test.js` the image bounds are not hard-coded: width and height are read from the PNG's own IHDR chunk, so the "point lies inside the image" assertion stays correct even if the sample is swapped out. -Раньше картинка передавалась через переменную `IMAGE_TO_TEXT_BASE64` из `.env.example`. Строка там лежала валидная, но кодировала картинку 26×26 пикселей — распознавать в ней было нечего, и тест стабильно падал с `ERROR_CAPTCHA_UNSOLVABLE`. Переменной больше нет. +The image used to be passed through an `IMAGE_TO_TEXT_BASE64` variable in `.env.example`. The string there was valid, but it encoded a 26×26 pixel image — there was nothing in it to recognise, and the test failed reliably with `ERROR_CAPTCHA_UNSOLVABLE`. The variable is gone. -### Чего здесь нет +### What is not covered here -- **GeeTest v3.** Ему нужен свежий `challenge`, привязанный к сессии и добываемый с целевой страницы непосредственно перед созданием задачи. Из статической конфигурации это не заводится — потребовался бы скрапер, как в [examples/async/geetest_v3.js](../examples/async/geetest_v3.js). -- **Coordinates с `imgInstructions` и режим Yandex (`imgType: 'smart_captcha'`).** Обоим нужна вторая картинка — инструкция, — которой в репозитории пока нет; см. раздел про изображения в [todo.md](../todo.md). Базовая форма с `comment` покрыта. -- **Варианты с прокси и reCAPTCHA v2 Enterprise.** Требуют рабочего прокси и сайта с Enterprise-виджетом соответственно. -- **Cloudflare Challenge pages** в `turnstile.test.js` — покрыт только обычный виджет. Challenge-страницы требуют свежих `action`, `data` и `pageData`, вытащенных со страницы. +- **GeeTest v3.** It needs a fresh session-bound `challenge` scraped from the target page immediately before the task is created. That cannot be driven from static configuration; it would take a scraper, as in [examples/async/geetest_v3.js](../examples/async/geetest_v3.js). +- **Coordinates with `imgInstructions`, and the Yandex mode (`imgType: 'smart_captcha'`).** Both need a second picture — an instruction image — which the repository does not have yet; see the images section in [todo.md](../todo.md). The basic form with `comment` is covered. +- **The proxy variants and reCAPTCHA v2 Enterprise.** They need a working proxy and a site with an Enterprise widget respectively. +- **Cloudflare Challenge pages** in `turnstile.test.js` — only the ordinary widget is covered. Challenge pages need fresh `action`, `data` and `pageData` pulled off the page. -### Общий хелпер +### The shared helper -`tests/integration/helpers.js` — не тест: суффикса `.test.js` нет, поэтому дефолтный `testMatch` его не подхватывает. В нём то, что иначе копировалось бы в каждый файл: +`tests/integration/helpers.js` is not a test: it has no `.test.js` suffix, so the default `testMatch` does not pick it up. It holds what would otherwise be copied into every file: -| Экспорт | Зачем | +| Export | Purpose | |---|---| -| `apiKey` | `process.env.CAPTCHA_API_KEY` в одном месте | -| `describeIntegration(name, fn)` | `describe` при наличии ключа, `describe.skip` без него | -| `describeTarget(name, vars, fn)` | то же плюс проверка переменных цели; отдаёт их значения в колбэк | -| `createClient(options)` | клиент с ключом из окружения, свой на каждый тест; `options` — для медленных типов | +| `apiKey` | `process.env.CAPTCHA_API_KEY` in one place | +| `describeIntegration(name, fn)` | `describe` when a key is present, `describe.skip` when it is not | +| `describeTarget(name, vars, fn)` | the same plus a check on the target variables; passes their values to the callback | +| `createClient(options)` | a client with the key from the environment, built per test; `options` is there for the slow types | -Файл также подключает `dotenv/config`, поэтому `.env` читается автоматически — иначе для локального прогона пришлось бы экспортировать десяток переменных руками. Значения, уже заданные в окружении, dotenv не перезаписывает, так что переданное в командной строке или из CI по-прежнему имеет приоритет. Unit-тесты этот файл не импортируют и остаются без dotenv. +The file also pulls in `dotenv/config`, so `.env` is read automatically — otherwise a local run would mean exporting a dozen variables by hand. dotenv never overwrites values already present in the environment, so anything passed on the command line or by CI still wins. Unit tests do not import this file and stay free of dotenv. -### Как включаются +### How they switch on -Сьют пропускает сам себя, если нет `CAPTCHA_API_KEY` **или** не заданы переменные его цели. Пропуск идёт на уровне `describe`, поэтому в выводе он виден честно: +A suite skips itself when `CAPTCHA_API_KEY` is missing **or** its target variables are unset. The skip happens at the `describe` level, so the output reports it honestly: ``` -Test Suites: 8 skipped, 0 of 8 total -Tests: 8 skipped, 8 total +Test Suites: 9 skipped, 0 of 9 total +Tests: 9 skipped, 9 total ``` -Раньше гард стоял внутри каждого теста (`if (!apiKey) return;`), и прогон без ключа показывал зелёные `passed` для тестов, которые не сделали ни одного запроса. Пропуск на уровне сьюта убирает это враньё: `skipped` — это `skipped`. +The guard used to sit inside each test (`if (!apiKey) return;`), and a keyless run showed green `passed` for tests that had made no request at all. Skipping at the suite level removes that lie: `skipped` means `skipped`. -Обратите внимание: прогон всё равно завершается **успешно**, просто ничего не проверив. Для CI этого мало, поэтому в workflow есть отдельный шаг-предохранитель — см. [CI / пайплайн](#ci--пайплайн). +Note that the run still finishes **successfully**, having simply checked nothing. That is not enough for CI, which is why the workflow has a dedicated fail-early step — see [CI / pipeline](#ci--pipeline). -Необязательные переменные, уточняющие поведение: +Optional variables that refine the behaviour: -| Переменная | Для чего | +| Variable | Purpose | |---|---| -| `RECAPTCHA_V3_PAGE_ACTION` | action, который сайт передаёт в `grecaptcha.execute()` — без совпадения сайт занижает оценку токена | -| `TENCENT_CAPTCHA_SCRIPT` | если сайт грузит виджет с нестандартного URL скрипта | -| `IMAGE_TO_TEXT_EXPECTED` | текст на вашей картинке; без него тест проверяет лишь то, что ответ непустой | +| `RECAPTCHA_V3_PAGE_ACTION` | the action the site passes to `grecaptcha.execute()` — without a match the site scores the token lower | +| `TENCENT_CAPTCHA_SCRIPT` | for a site that loads the widget from a non-default script URL | +| `IMAGE_TO_TEXT_EXPECTED` | the text on your image; without it the test only asserts that the answer is non-empty | -## Ручной запуск +## Running them by hand -Один раз перед всем: +Once, before anything else: ```bash npm install ``` -### Все тесты +### Requirements + +Node.js 18+ (see `engines` in `package.json`): the SDK relies on the global `fetch`. + +The `--experimental-vm-modules` flag is mandatory — the SDK is pure ESM (`"type": "module"`), and without it Jest cannot load the modules. The npm scripts already pass it; you only need it yourself when invoking Jest directly. The `ExperimentalWarning: VM Modules` line in the output is expected and not an error. + +### All tests ```bash npm test ``` -Запускает и unit-, и integration-тесты (последние — в режиме пропуска, если нет ключа). +Runs both the unit and the integration tests (the latter in skip mode when there is no key). -### Только unit-тесты +### Unit tests only ```bash npm run test:unit ``` -Ничего не требует: ни сети, ни ключа. Прогон занимает ~7 секунд, ожидаемый результат — `21 passed, 81 tests`. +Requires nothing: no network, no key. The run takes about a second and a half; the expected result is `21 passed, 81 tests`. -### Только integration-тесты +### Integration tests only -Самый удобный способ — завести `.env` в корне проекта (он в `.gitignore`, шаблон — [.env.example](../.env.example)). Хелпер подключает dotenv, поэтому переменные подхватятся сами: +The most convenient way is to keep a `.env` in the project root (it is gitignored; the template is [.env.example](../.env.example)). The helper pulls in dotenv, so the variables are picked up automatically: ```bash -cp .env.example .env # заполнить ключ и нужные цели +cp .env.example .env # fill in the key and the targets you need npm run test:integration ``` -Можно и через окружение — оно имеет приоритет над `.env`. +The environment works too, and it takes priority over `.env`. PowerShell: ```powershell -$env:CAPTCHA_API_KEY = "ваш_ключ" +$env:CAPTCHA_API_KEY = "your_key" npm run test:integration ``` bash / cmd: ```bash -CAPTCHA_API_KEY=ваш_ключ npm run test:integration +CAPTCHA_API_KEY=your_key npm run test:integration ``` -**Запускать весь набор разом обычно не нужно** — это семь платных задач за прогон. Гоняйте по одному файлу: +**Running the whole set at once is usually unnecessary** — that is eight paid tasks per run. Go file by file: ```bash -# бесплатно: ключ и сеть +# free: key and network npm test -- tests/integration/balance.test.js -# одна платная проверка +# one paid check npm test -- tests/integration/turnstile.test.js ``` -Сьюты без настроенных переменных пропустятся, так что заполнять `.env` целиком не обязательно — только те типы, которые сейчас проверяете. +Suites without their variables configured will skip, so there is no need to fill in `.env` completely — only the types you are checking right now. -### Отдельный файл или отдельный тест +### A single file or a single test -Аргументы после `--` пробрасываются в Jest: +Arguments after `--` are forwarded to Jest: ```bash -# один файл +# one file npm test -- tests/unit/sync/geetest.test.js -# все тесты одного каталога +# every test in one directory npm test -- tests/unit/async -# один тест по имени +# one test by name npm test -- -t "getBalance returns balance as float" -# подробный вывод по каждому тесту +# verbose per-test output npm run test:unit -- --verbose -# режим наблюдения при разработке +# watch mode during development npm run test:unit -- --watch -# покрытие +# coverage npm run test:unit -- --coverage ``` -Всё то же работает и для integration — путь просто указывает на другой каталог: +All of the above works for integration too — the path just points at a different directory: ```bash -# один интеграционный файл: бесплатно, только ключ и сеть +# one integration file: free, key and network only npm test -- tests/integration/balance.test.js -# один интеграционный файл: платная проверка +# one integration file: a paid check npm test -- tests/integration/turnstile.test.js -# весь интеграционный каталог (то же, что npm run test:integration) +# the whole integration directory (same as npm run test:integration) npm test -- tests/integration -# один тест по имени внутри файла +# one test by name inside a file npm test -- tests/integration/tencent.test.js -t "solve returns a ticket" -# подробный вывод: видно, какие сьюты пропущены и почему их стоит перепроверить +# verbose output: shows which suites were skipped and are worth a second look npm run test:integration -- --verbose ``` -Две оговорки, специфичные именно для integration: +Two caveats specific to integration: -**Фильтр `-t` без пути пройдётся и по интеграционным тестам.** `npm test -- -t "solve"` подхватит все платные сьюты, у которых настроены цели. Указывайте путь к файлу вместе с `-t`, если не хотите неожиданных списаний. +**A `-t` filter without a path will sweep the integration tests too.** `npm test -- -t "solve"` picks up every paid suite that has its targets configured. Pass the file path alongside `-t` unless you want surprise charges. -**Jest запускает файлы параллельно**, по воркеру на файл. Пока платных файлов немного, это не мешает, но прогон всего каталога отправляет несколько задач в API одновременно. Чтобы шли по очереди: +**Jest runs files in parallel**, one worker per file. With few paid files this is harmless, but running the whole directory fires several tasks at the API simultaneously. To make them go one at a time: ```bash npm run test:integration -- --runInBand ``` -Можно вызывать Jest и напрямую, но тогда флаг `--experimental-vm-modules` нужно указывать самому: +Jest can also be invoked directly, but then the flag is yours to pass: ```bash node --experimental-vm-modules node_modules/jest/bin/jest.js tests/unit/sync/geetest.test.js ``` -## Покрытие +## Coverage ```bash npm run test:unit -- --coverage ``` -Текущее состояние: +Current state: ``` File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s @@ -386,53 +393,49 @@ All files | 97.53 | 83.63 | 100 | 97.5 | tasks.js | 100 | 84.52 | 100 | 100 | 26,38,71-77,89,107 ``` -Непокрытое — ветка HTTP-ошибки (`!response.ok`) и обработка `AbortError` в `_request()`, плюс часть дефолтных значений параметров в конструкторах задач. - -Считается **только по unit-тестам**. Integration в подсчёт не входят намеренно: без ключа они пропускаются, и цифра скакала бы в зависимости от того, был ли доступен `CAPTCHA_API_KEY`. - -Два параметра в [jest.config.js](../jest.config.js) важны именно для покрытия: +What is uncovered: the HTTP error branch (`!response.ok`) and the `AbortError` handling in `_request()`, plus some default parameter values in the task constructors. -- `collectCoverageFrom: ['src/**/*.js']` — считать по всем файлам `src/`, а не только по импортированным из тестов. Без этого новый файл, который никто не подключил, молча выпал бы из отчёта вместо того, чтобы показать 0 %. Ровно так до перехода на импорт по имени пакета из отчёта выпадал `src/index.js`, и покрытие выглядело как 97.5 % при полностью непроверенной точке входа. -- `coverageReporters: ['text', 'lcov']` — `text` для вывода в консоль, `lcov` для выгрузки в Coveralls из CI. +The figure counts **unit tests only**. Integration is deliberately left out: without a key those suites skip, and the number would swing depending on whether `CAPTCHA_API_KEY` happened to be available. -Артефакты пишутся в `coverage/` (в `.gitignore`). +Two settings in [jest.config.js](../jest.config.js) matter specifically for coverage: -Флаг обязателен: SDK — чистый ESM (`"type": "module"`), без него Jest не сможет загрузить модули. Предупреждение `ExperimentalWarning: VM Modules` в выводе — норма. +- `collectCoverageFrom: ['src/**/*.js']` — count every file under `src/`, not just the ones imported by tests. Without it a new file that nobody wired up would drop out of the report silently instead of showing 0 %. That is exactly how `src/index.js` used to fall out of the report before the switch to package-name imports, leaving coverage at a comfortable-looking 97.5 % with a completely unchecked entry point. +- `coverageReporters: ['text', 'lcov']` — `text` for the console, `lcov` for the Coveralls upload from CI. -Требуется Node.js 18+ (см. `engines` в `package.json`): SDK опирается на глобальный `fetch`. +Artifacts are written to `coverage/` (gitignored). -## CI / пайплайн +## CI / pipeline -Конфигурация — [.github/workflows/tests.yml](../.github/workflows/tests.yml). Два джоба: +The configuration is [.github/workflows/tests.yml](../.github/workflows/tests.yml). Two jobs: -| Джоб | Команда | Когда запускается | Секреты | +| Job | Command | When it runs | Secrets | |---|---|---|---| -| `unit` | `npm run test:unit -- --coverage` | push в `main`, любой pull request, расписание, ручной запуск | не нужны | -| `integration` | `npm run test:integration` | **только** по расписанию (ежедневно в 03:00 UTC) и вручную через workflow_dispatch | `CAPTCHA_API_KEY` | +| `unit` | `npm run test:unit -- --coverage` | push to `main`, any pull request, schedule, manual dispatch | none | +| `integration` | `npm run test:integration` | **only** on schedule (daily at 03:00 UTC) and manually via workflow_dispatch | `CAPTCHA_API_KEY` | -### Почему джобы разделены +### Why the jobs are split -- **Unit-тесты — обязательный блокирующий шаг.** Детерминированные, без сети и секретов, ~7 секунд. Безопасны на любом PR, включая форки. Гоняются на матрице Node `18 / 20 / 22` — в соответствии с `engines.node: ">=18"`. -- **Integration-тесты не запускаются на pull request намеренно.** Они тратят реальный баланс аккаунта и зависят от доступности внешнего API. Главное же — в PR из форка секреты недоступны, поэтому тесты пропустили бы сами себя и дали **ложно-зелёный** результат вместо честного «не проверено». +- **Unit tests are the mandatory blocking step.** Deterministic, no network, no secrets, a couple of seconds. Safe on any PR, forks included. They run on a Node `18 / 20 / 22` matrix, matching `engines.node: ">=18"`. +- **Integration tests deliberately do not run on pull requests.** They spend real account balance and depend on an external API being up. More importantly, secrets are unavailable in a PR from a fork, so the tests would skip themselves and produce a **false green** instead of an honest "not checked". -### Что ночной прогон проверяет на самом деле +### What the nightly run actually checks -Джоб `integration` передаёт в тесты только `CAPTCHA_API_KEY`. Переменных цели у него нет, поэтому из платных сьютов пропускаются все, кроме двух на картинках — `image_to_text.test.js` и `coordinates.test.js`: им переменные цели не нужны, и при живом ключе они **реально сходят в API и потратят баланс**. Раньше `recaptcha_v2.test.js` работал за счёт зашитой демо-страницы Google — после отказа от целей в репозитории (см. [выше](#почему-в-репозитории-нет-ни-одной-цели)) это не так. +The `integration` job passes only `CAPTCHA_API_KEY` to the tests. It has no target variables, so among the paid suites everything skips except the two image-based ones — `image_to_text.test.js` and `coordinates.test.js`: they need no targets, and with a live key they **really do hit the API and spend balance**. `recaptcha_v2.test.js` used to work off a hard-coded Google demo page; after targets were dropped from the repository (see [above](#why-the-repository-carries-no-targets)) that is no longer the case. -То есть **каждая ночь стоит две задачи** — по одной на картинку. Это плата за то, что ночной прогон вообще проверяет `solve()`, а не только баланс; раньше он не проверял ничего, кроме баланса. Если такая трата не нужна, самый простой способ — сузить шаг до конкретных файлов: `npm test -- tests/integration/balance.test.js`. +So **every night costs two tasks**, one per image. That is the price of the nightly run checking `solve()` at all, rather than the balance alone, which is all it used to check. If that spend is unwanted, the simplest fix is to narrow the step to specific files: `npm test -- tests/integration/balance.test.js`. -Остальные, «целевые», solve-тесты в CI гонять пока не планируется. Когда понадобится, порядок такой: +The remaining target-driven solve tests are not planned for CI yet. When they are wanted, the order is: -1. Завести цели как секреты репозитория — например `RECAPTCHA_V2_URL` и `RECAPTCHA_V2_SITE_KEY`. -2. Пробросить их в шаге `Run integration tests` рядом с `CAPTCHA_API_KEY`. -3. Запускать выборочно — `npm test -- tests/integration/recaptcha_v2.test.js`, а не весь набор, иначе каждая ночь будет стоить по задаче на каждый настроенный тип. -4. Дополнить шаг-предохранитель проверкой этих переменных, иначе опечатка в имени секрета обернётся молчаливым пропуском и зелёным джобом. +1. Add the targets as repository secrets — e.g. `RECAPTCHA_V2_URL` and `RECAPTCHA_V2_SITE_KEY`. +2. Pass them through in the `Run integration tests` step alongside `CAPTCHA_API_KEY`. +3. Run selectively — `npm test -- tests/integration/recaptcha_v2.test.js`, not the whole set, or every night will cost one task per configured type. +4. Extend the fail-early step to check those variables too, otherwise a typo in a secret name turns into a silent skip and a green job. -### Две детали, которые легко упустить +### Two details that are easy to miss -**Покрытие выгружается только с одной версии Node** (`if: matrix.node-version == 20`). Цифра от версии Node не зависит, а три параллельные выгрузки одного и того же отчёта только зашумят историю в Coveralls. +**Coverage is uploaded from one Node version only** (`if: matrix.node-version == 20`). The figure does not depend on the Node version, and three parallel uploads of the same report would only clutter the history in Coveralls. -**Джоб `integration` падает явно, если секрет не подставился.** Перед запуском тестов есть шаг-предохранитель: +**The `integration` job fails loudly when the secret did not come through.** There is a fail-early step before the tests: ```yaml - name: Fail early if the API key is missing @@ -443,10 +446,10 @@ All files | 97.53 | 83.63 | 100 | 97.5 | fi ``` -Без него отсутствие ключа выглядело бы как успешный прогон: тесты пропускают себя сами и джоб зеленеет, ничего не проверив. Это ровно тот случай, когда молчаливый пропуск опаснее падения. +Without it a missing key would look like a successful run: the tests skip themselves and the job goes green having checked nothing. This is precisely the case where a silent skip is more dangerous than a failure. -### Чего в пайплайне пока нет +### What the pipeline does not have yet -- Шага линтинга — линтер в проекте не настроен. -- Публикации в npm. -- Бейджей в README — вынесено в [todo.md](../todo.md) вместе с подключением Coveralls (требует действий в веб-интерфейсе). +- A lint step — no linter is configured in the project. +- Publishing to npm. +- Badges in the README — tracked in [todo.md](../todo.md) together with wiring up Coveralls (which needs action in a web UI).