diff --git a/js/src/empty-answer-submits.test.ts b/js/src/empty-answer-submits.test.ts new file mode 100644 index 0000000..7271acc --- /dev/null +++ b/js/src/empty-answer-submits.test.ts @@ -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.'); +}); diff --git a/js/src/solver.ts b/js/src/solver.ts index 61bb76c..db810c9 100644 --- a/js/src/solver.ts +++ b/js/src/solver.ts @@ -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 diff --git a/python/src/captchakraken/page_solver.py b/python/src/captchakraken/page_solver.py index 651edd6..b131be8 100644 --- a/python/src/captchakraken/page_solver.py +++ b/python/src/captchakraken/page_solver.py @@ -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 diff --git a/python/tests/test_browser_compat.py b/python/tests/test_browser_compat.py index da9b514..fc03f3f 100644 --- a/python/tests/test_browser_compat.py +++ b/python/tests/test_browser_compat.py @@ -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 """ @@ -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() diff --git a/python/tests/test_empty_answer_still_submits.py b/python/tests/test_empty_answer_still_submits.py new file mode 100644 index 0000000..740bb04 --- /dev/null +++ b/python/tests/test_empty_answer_still_submits.py @@ -0,0 +1,105 @@ +"""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. So a plan with no actions +never resolved a control, and `should_submit` — which explicitly wants to press +"when we had nothing to do and want the round to advance" — found `verify_button` +still None and pressed nothing. `performed_action` stayed False, and the caller's +"still detected but the solver performed no interactions" guard aborted the +solve. + +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_class: "traffic light"`, `target_ids: []`, +`submit_label: "SKIP"`. + +It surfaced when CaptchaKrakenFinetune fixed font resolution on the macOS Tier 3 +runner. Before that fix the prompt rendered in a fallback bitmap face and read +"Selectall images with / traffic lights"; after it, the widget draws real +reCAPTCHA chrome with the target term bolded and a legible "If there are none, +click skip." The 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 is +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. +The finder was simply never called. + +This is a STRUCTURAL test. What is wrong is where a call sits relative to a +loop, and no amount of mocking `_execute_plan` (200 lines, a screenshot, a +planner round-trip and a live page) observes that as directly as reading the +tree. The JS half is pinned by `js/src/empty-answer-submits.test.ts`; per +CLAUDE.md 1c the two ports must behave the same. +""" +from __future__ import annotations + +import ast +from pathlib import Path + +SOLVER = (Path(__file__).resolve().parents[1] + / "src" / "captchakraken" / "page_solver.py") + +LOOKUP_FN = "_get_verify_button" + + +def _module() -> ast.Module: + return ast.parse(SOLVER.read_text()) + + +def _calls_to(node: ast.AST, name: str) -> list: + return [n for n in ast.walk(node) + if isinstance(n, ast.Call) + and isinstance(n.func, ast.Attribute) + and n.func.attr == name] + + +def _action_loops(tree: ast.Module) -> list: + """Every `for ... in ` in the module. + + Matched by the iterated name rather than by line number, so the test keeps + pointing at the right loop when the file moves. + """ + out = [] + for node in ast.walk(tree): + if not isinstance(node, ast.For): + continue + it = node.iter + name = (getattr(it, "id", None) + or getattr(getattr(it, "attr", None), "__str__", lambda: None)() + or getattr(it, "attr", None)) + if isinstance(name, str) and "action" in name.lower(): + out.append(node) + return out + + +def test_the_submit_control_is_resolved_outside_the_action_loop(): + tree = _module() + loops = _action_loops(tree) + assert loops, ( + "no `for ... in ` loop found in page_solver.py — this test " + "cannot pin what it was written to pin; re-point it at the loop that " + "executes the model's plan") + + nested = [c for loop in loops for c in _calls_to(loop, LOOKUP_FN)] + assert not nested, ( + f"{LOOKUP_FN} is called INSIDE the loop over the model's actions " + f"(line{'s' if len(nested) > 1 else ''} " + f"{', '.join(str(c.lineno) for c in nested)}).\n\n" + "A plan with NO actions never enters that loop, so no submit control is " + "resolved, `should_submit` finds verify_button None, nothing is pressed " + "and the solve aborts on 'performed no interactions'. That is the exact " + "shape of 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." + ) + + +def test_the_lookup_still_happens_at_all(): + """Guard the obvious over-correction: deleting the call also passes above.""" + assert _calls_to(_module(), LOOKUP_FN), ( + f"{LOOKUP_FN} is never called in page_solver.py — the widget's own " + "submit control would never be pressed by any path")