Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions js/src/empty-answer-submits.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* An empty answer is an answer, and it still has to be sent.
*
* Regression: the submit-control lookup lived INSIDE the loop over the model's
* actions, while the decision to press it lived outside. A plan with no actions
* never entered that loop, so `verifyButton` stayed null, and
*
* const shouldClickSubmit = !slid && (answered || !performedAction);
* if (shouldClickSubmit && verifyButton) { ... }
*
* computed `shouldClickSubmit === true` — the branch that exists precisely for
* "we had nothing to do and want the round to advance" — and then pressed
* nothing, because the control it needed had never been resolved.
* `performedAction` stayed false and the caller aborted the round on
* "Captcha still detected but solver performed no interactions".
*
* WHERE IT BIT, AND WHY IT LOOKED LIKE A MODEL REGRESSION
*
* reCAPTCHA's 3x3 has a `none_present` variation: the prompt names a class, no
* tile contains it, and the widget's control reads SKIP rather than VERIFY. The
* correct answer is to select nothing and press it. Fixture seed 20260730 is
* exactly that — target `traffic light`, `target_ids: []`, `submit_label: SKIP`.
*
* It surfaced when CaptchaKrakenFinetune fixed font resolution on the macOS
* Tier 3 runner. Before, the prompt rendered in a fallback bitmap face reading
* "Selectall images with / traffic lights"; after, real reCAPTCHA chrome with
* the target term bolded and a legible "If there are none, click skip." This
* client runs at temperature 0, so the model is a function of the picture: a
* correct picture got a correct EMPTY answer, and the empty answer was the one
* shape the driver could not send. `recaptcha_grid_3x3` js went 2/3 -> 1/3 and
* read as the font fix causing a regression.
*
* `getVerifyButton` was never the problem — 'Skip' has always been in its list
* (see geetest-submit-button.test.ts, which pins the finder itself). The finder
* was simply never called.
*
* This is a STRUCTURAL test. What is wrong is where a call sits relative to a
* loop, and `solveSingle` is 200 lines around a screenshot, a planner
* round-trip and a live page — mocking all of that observes the nesting far
* less directly than reading it. The Python half is pinned by
* `python/tests/test_empty_answer_still_submits.py`; per CLAUDE.md 1c the two
* ports must behave the same.
*/

import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as fs from 'fs';
import * as path from 'path';

/**
* `npm test` compiles into `.test-build/`, so `__dirname` is not the source
* tree. Walk up for `src/solver.ts` and this works under both tsx (run from
* `src/`) and the compiled runner.
*/
function findSolverSource(): string {
let dir = __dirname;
for (let i = 0; i < 6; i++) {
for (const rel of ['solver.ts', path.join('src', 'solver.ts')]) {
const candidate = path.join(dir, rel);
if (fs.existsSync(candidate)) return candidate;
}
dir = path.dirname(dir);
}
throw new Error(`could not locate solver.ts upward from ${__dirname}`);
}

const SOLVER = findSolverSource();
const LOOKUP = 'getVerifyButton';

/** Brace depth at the START of each line, ignoring braces inside strings. */
function depths(src: string): number[] {
const out: number[] = [];
let depth = 0;
for (const line of src.split('\n')) {
out.push(depth);
// Strip line comments and string/template literals before counting, so a
// brace inside an xpath template does not shift the depth.
const bare = line
.replace(/\/\/.*$/, '')
.replace(/'(?:[^'\\]|\\.)*'/g, "''")
.replace(/"(?:[^"\\]|\\.)*"/g, '""')
.replace(/`(?:[^`\\]|\\.)*`/g, '``');
for (const ch of bare) {
if (ch === '{') depth++;
else if (ch === '}') depth--;
}
}
return out;
}

test('the submit control is resolved outside the loop over the model actions', () => {
const src = fs.readFileSync(SOLVER, 'utf-8');
const lines = src.split('\n');
const depth = depths(src);

const loopIdx = lines.findIndex((l) => /for\s*\(\s*const\s+action\s+of\s+actionList/.test(l));
assert.notEqual(loopIdx, -1,
'no `for (const action of actionList)` in solver.ts — re-point this test at '
+ 'the loop that executes the model plan');

const loopDepth = depth[loopIdx];

// Where the finder is CALLED (not declared, not referenced in a comment).
const callIdxs = lines
.map((l, i) => ({ l, i }))
.filter(({ l }) => new RegExp(`this\\.${LOOKUP}\\s*\\(`).test(l))
.map(({ i }) => i);

assert.ok(callIdxs.length > 0,
`this.${LOOKUP}(...) is never called in solver.ts — the widget's own submit `
+ 'control would never be pressed by any path');

// A call belongs to the loop if it sits deeper than the loop's own line and
// before the loop closes (the first line back at loopDepth).
let loopEnd = lines.length;
for (let i = loopIdx + 1; i < lines.length; i++) {
if (depth[i] <= loopDepth) { loopEnd = i; break; }
}

const nested = callIdxs.filter((i) => i > loopIdx && i < loopEnd);
assert.deepEqual(nested.map((i) => i + 1), [],
`this.${LOOKUP}() is called INSIDE the action loop `
+ `(line${nested.length > 1 ? 's' : ''} ${nested.map((i) => i + 1).join(', ')}, `
+ `loop spans ${loopIdx + 1}..${loopEnd}).\n\n`
+ 'A plan with NO actions never enters that loop, so no control is resolved, '
+ 'shouldClickSubmit finds verifyButton null, nothing is pressed and the '
+ "round aborts on 'performed no interactions'. That is reCAPTCHA 3x3's "
+ '`none_present` variation, whose correct answer is to select nothing and '
+ 'press SKIP.\n\n'
+ 'Resolve the control after the loop, on the same level as the submit '
+ 'decision that consumes it.');
});
45 changes: 27 additions & 18 deletions js/src/solver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1023,26 +1023,35 @@ export class CaptchaKrakenSolver {
await this.emitStep(captchaElement, 'wait', `waited ${(action as any).duration_ms}ms`, puzzleSource, frameRole, attempt, { action });
}
}
// `scope` when there is no vendor iframe. Eight vendors render into the
// HOST PAGE — GeeTest, Yidun, Tencent, Yandex, Lemin, Prosopo,
// MTCaptcha, BotDetect — so `contentFrame()` is null for all of them
// and the button was never even SEARCHED FOR, while the text box and
// the slider handle it sits beside were both found through `scope`
// above. Two containers for two halves of one interaction.
//
// `scope` is the widget container and getVerifyButton's xpaths are
// RELATIVE, so the submit of the FORM the captcha guards is out of
// reach by construction. The press itself is bounded by
// `shouldClickSubmit` below, which is where that hazard belongs.
const lookup = frame ?? (slid ? null : scope);
if (lookup) {
verifyButton = await this.getVerifyButton(lookup);
if (verifyButton) {
await this.move(page, verifyButton);
}
}

// Resolve the widget's submit control AFTER the action loop, not inside
// it. An empty plan — the correct answer to reCAPTCHA 3x3's
// `none_present` variation, where nothing matches and the control reads
// SKIP — never enters that loop, so the lookup never ran, the press below
// found `verifyButton` null, and the round aborted on 'performed no
// interactions'. The finder was never the problem: 'Skip' has always been
// in its list. It was simply never called for the one answer shape that
// performs no other action.
// `scope` when there is no vendor iframe. Eight vendors render into the
// HOST PAGE — GeeTest, Yidun, Tencent, Yandex, Lemin, Prosopo,
// MTCaptcha, BotDetect — so `contentFrame()` is null for all of them
// and the button was never even SEARCHED FOR, while the text box and
// the slider handle it sits beside were both found through `scope`
// above. Two containers for two halves of one interaction.
//
// `scope` is the widget container and getVerifyButton's xpaths are
// RELATIVE, so the submit of the FORM the captcha guards is out of
// reach by construction. The press itself is bounded by
// `shouldClickSubmit` below, which is where that hazard belongs.
const lookup = frame ?? (slid ? null : scope);
if (lookup) {
verifyButton = await this.getVerifyButton(lookup);
if (verifyButton) {
await this.move(page, verifyButton);
}
// 'done' actions intentionally fall through to the Verify-button block below.
}
// 'done' actions fall through to the submit block below, same as before.

// Submit policy: press the widget's own submit control whenever we have
// put an ANSWER into it — a selection, a placed piece, a typed code — or
Expand Down
53 changes: 31 additions & 22 deletions python/src/captchakraken/page_solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -2528,28 +2528,37 @@ def _solve_single(
_delay(duration)
performed_action = True

# `scope` when there is no vendor iframe. Eight vendors render
# into the HOST PAGE — GeeTest, Yidun, Tencent, Yandex, Lemin,
# Prosopo, MTCaptcha, BotDetect — so `content_frame()` is None
# for all of them and the button was never even SEARCHED FOR,
# while the text box and the slider handle it sits beside were
# both found through `scope` a few lines above. Two containers
# for two halves of one interaction.
#
# This used to be gated on `typed`, for fear of turning up the
# submit of the FORM the captcha guards. `scope` is the widget
# container and the xpaths are RELATIVE, so that button is out
# of reach by construction; what the gate actually did was make
# every non-typed inline puzzle unsubmittable. Measured on the
# Tier 3 fixtures: 4 pairs aborting outright and 11 more types
# burning all ten solve loops on a puzzle they had answered on
# the first one. The press itself is still bounded by
# `should_submit` below, which is where the hazard belongs.
lookup = frame or (scope if not slid else None)
if lookup is not None:
verify_button = self._get_verify_button(lookup)
if verify_button:
self._move_to_element(page, verify_button)

# Resolve the widget's submit control AFTER the action loop, not
# inside it. An empty plan — the correct answer to reCAPTCHA 3x3's
# `none_present` variation, where nothing matches and the control
# reads SKIP — never enters that loop, so the lookup never ran, the
# press below found `verify_button` None, and the round aborted on
# 'performed no interactions'. The finder was never the problem:
# 'Skip' has always been in its list. It was simply never called for
# the one answer shape that performs no other action.
# `scope` when there is no vendor iframe. Eight vendors render
# into the HOST PAGE — GeeTest, Yidun, Tencent, Yandex, Lemin,
# Prosopo, MTCaptcha, BotDetect — so `content_frame()` is None
# for all of them and the button was never even SEARCHED FOR,
# while the text box and the slider handle it sits beside were
# both found through `scope` a few lines above. Two containers
# for two halves of one interaction.
#
# This used to be gated on `typed`, for fear of turning up the
# submit of the FORM the captcha guards. `scope` is the widget
# container and the xpaths are RELATIVE, so that button is out
# of reach by construction; what the gate actually did was make
# every non-typed inline puzzle unsubmittable. Measured on the
# Tier 3 fixtures: 4 pairs aborting outright and 11 more types
# burning all ten solve loops on a puzzle they had answered on
# the first one. The press itself is still bounded by
# `should_submit` below, which is where the hazard belongs.
lookup = frame or (scope if not slid else None)
if lookup is not None:
verify_button = self._get_verify_button(lookup)
if verify_button:
self._move_to_element(page, verify_button)

# Submit policy: press the widget's own submit control whenever we
# have put an ANSWER into it — a selection, a placed piece, a typed
Expand Down
52 changes: 49 additions & 3 deletions python/tests/test_browser_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,18 @@
real `sync_playwright` page actually provides every member that duck-typing
assumes — and that the watcher drives one end to end.

SKIPPED WHEN PLAYWRIGHT IS ABSENT, and deliberately not a dependency: this
package ships with no browser dependency at all. To run these:
RUNS WHEREVER A BROWSER EXISTS. Playwright pins one exact Chromium build and
refuses to launch any other, so `chromium.launch()` with no path fails on a box
that HAS Chromium — just not the pinned one — and this file used to report that
as four ERRORS. Four broken tests, in the one place that checks the
compatibility claim against something real. So the launch walks every installed
build and passes `executable_path`, the same resolution
CaptchaKrakenFinetune's `tests/test_fixtures_are_solvable.py` uses.

Skipping is reserved for a box with NO browser at all, because the package
ships with no browser dependency and an end user is not required to have one.
Skipping because the pinned BUILD NUMBER moved is not that, and hid a browser
that launches fine. To install one:

pip install playwright && playwright install chromium
"""
Expand Down Expand Up @@ -46,10 +56,46 @@
"""


def _installed_chromiums() -> List[str]:
"""Every Chromium build on the box, newest first.

Playwright resolves ONE pinned build and errors if it is missing, which is
a version check dressed as an availability check: `playwright install`
fetching build N+1 does not make build N stop working.
"""
cache = Path.home() / ".cache" / "ms-playwright"
rels = ("chrome-linux64/chrome", "chrome-linux/chrome",
"chrome-mac/Chromium.app/Contents/MacOS/Chromium",
"chrome-win/chrome.exe")
found = []
for d in sorted(cache.glob("chromium-*"), reverse=True):
for rel in rels:
if (d / rel).exists():
found.append(str(d / rel))
break
return found


@pytest.fixture(scope="module")
def page():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, args=LAUNCH_ARGS)
# The pinned build first — on a correctly provisioned box that is the
# right answer and needs no path. Then every build actually present.
# Only when none of them launches is there genuinely no browser here.
attempts: List[dict] = [{}]
attempts += [{"executable_path": exe} for exe in _installed_chromiums()]
browser = None
failures = []
for kwargs in attempts:
try:
browser = p.chromium.launch(headless=True, args=LAUNCH_ARGS, **kwargs)
break
except Exception as exc: # noqa: BLE001
failures.append(f"{kwargs.get('executable_path', 'pinned build')}: "
f"{str(exc).splitlines()[0]}")
if browser is None:
pytest.skip("no launchable Chromium on this box; tried "
+ "; ".join(failures))
try:
ctx = browser.new_context(viewport={"width": 1280, "height": 720})
yield ctx.new_page()
Expand Down
Loading
Loading