fix(replay): port the engine to browser-use 0.13's CDP actor API (deterministic replay was 100% dead) - #168
Conversation
…rface The replay engine was written against Playwright APIs (page.locator, wait_for_selector, page.check, query_selector_all, ...) that do not exist on browser-use 0.13's CDP actor surface. Every such call raises AttributeError at runtime, usually swallowed by broad excepts, so entire subsystems (deterministic selector path, validation detection, radio/ checkbox handling, XPath fallback) fail silently. Two-directional import-time contract test, no browser launch: - surface tests: every Page/Element/Browser attribute the engine relies on must exist with a compatible signature - static scan: no Playwright-only API calls outside the (upcoming) workflow_use/compat/cdp.py compatibility layer The scan currently fails listing 35 real call sites - it is the worklist for the CDP port and goes green when the port lands. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The deterministic replay path was written against Playwright's API and died on the first cssSelector step: page.locator / locator.wait_for do not exist on browser-use 0.13's CDP actor Page, so get_best_element_handle raised AttributeError for every selector attempt, the per-selector except swallowed it, and every recorded workflow failed with 'Failed to find element' on its first interaction. - new workflow_use/compat/cdp.py: the single module that knows the Playwright<->CDP differences (wait_for_element polling, visibility via geometry+computed style, XPath resolution through a temporary marker attribute, JSON-decoding of evaluate()'s string returns, readyState-based wait_for_load_state, base64 screenshot-to-file, checkbox state setter, select-by-visible-text with input/change events) - controller/utils.py: get_best_element_handle now returns a browser-use Element; drop the Playwright-only :has-text() fallback (invalid CSS that throws in querySelectorAll) in favor of a real tag+text scan - controller/service.py: element.click() instead of click(force=), select-by-label via compat (Element.select_option only matches values, option label text is invisible to it), focus+page.press instead of the non-existent Element.press, tagName check via JSON-decoded evaluate (previously compared a raw 'false' string, which is always truthy) - controller/service.py: exclude ALL builtins by real 0.13 names (the old DISABLED_DEFAULT_ACTIONS listed pre-0.2 names, so only 4 of 27 matched); register go_back/go_forward as real CDP navigations - previously go_back was excluded while the step built an EMPTY ActionModel(), so Tools.act no-opped and reported success without navigating - workflow/service.py: guard against empty action models, use the compat load-state wait (wait_for_load_state doesn't exist on CDP), write debug screenshots via the compat helper (screenshot(path=) doesn't exist) Contract-test scan is now clean for controller/ and workflow/service.py. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… CDP
Completes the CDP port - the contract test's static scan is now clean
across the whole engine. What was silently dead and now works:
- validation_utils: query_selector_all/is_visible/text_content don't
exist on CDP, so EVERY selector attempt raised and both helpers always
returned 'no errors' - form submits rejected by validation were being
verified as SUCCESS. Rewritten as one page-evaluate pass that returns
all visible error texts in a single round-trip.
- element_finder: strategies read node.text/.aria_label/.placeholder/
.title/.alt via getattr with '' defaults - none exist on the slotted
EnhancedDOMTreeNode, so no semantic strategy could ever match. New
accessors read the real sources (attributes dict, ax_node name/role,
get_all_children_text). XPath results were discarded because
Page.evaluate returns a JSON string that was compared against dict -
now decoded via the compat layer. is_visible now only rejects explicit
False (the field is Optional).
- semantic_executor:
* page.check/page.uncheck (11 sites) -> _set_checked_by_selector using
CDP property reads + user-like clicks; radio/checkbox steps could
previously only 'succeed' when the state already matched
* page.wait_for_selector (5 sites) -> compat wait_for_element polling
* select_option(step.selectedText) matched option VALUES only (label
text is invisible to Element.select_option) -> select by visible
text in page JS with proper input/change events
* AI extraction called ainvoke(str) (needs a message list) and read
.content (field is .completion) - extract steps always silently
degraded to dumping raw markdown; now uses UserMessage + .completion
* error context read page.url/page.title() (don't exist) and
screenshot(path=) - error reports never captured page state
* dropped Playwright-only :visible/:has-text() selectors (invalid CSS
that throws in querySelectorAll)
* container-scoped element search (Element.query_selector_all/
text_content don't exist) rewritten as a single JS pass
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
5 issues found across 11 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="workflows/workflow_use/compat/cdp.py">
<violation number="1" location="workflows/workflow_use/compat/cdp.py:47">
P3: `evaluate` returns the wrong type for legitimate JavaScript strings equal to `True` or `False`; callers expecting text can receive booleans instead. Preserving the actor's result type (or exposing a typed boolean/result envelope instead of guessing from strings) would avoid this ambiguity.</violation>
<violation number="2" location="workflows/workflow_use/compat/cdp.py:150">
P2: XPath resolution can mutate page state and select a stale element because the fixed marker is not collision-safe and cleanup does not preserve a pre-existing attribute. A per-lookup marker/token with restoration of the original attribute would keep the temporary lookup side effect isolated.</violation>
<violation number="3" location="workflows/workflow_use/compat/cdp.py:204">
P3: The new CDP layer ships three helpers that nothing in the engine actually calls: `is_checked`, `set_checkbox_state`, and `element_inner_text`. Checkbox handling in the engine is done by `semantic_executor._set_checked_by_selector`/`_element_is_checked`, and text is read via `element_text_content` or inline `cdp.evaluate` JS, so these three are dead code. Consider removing them (the contract test's mention of `set_checkbox_state` in a comment doesn't exercise them), so the compat surface stays minimal and future readers aren't led to use untested paths.</violation>
</file>
<file name="workflows/workflow_use/controller/service.py">
<violation number="1" location="workflows/workflow_use/controller/service.py:57">
P2: A navigation that remains in a non-complete `readyState` for 10 seconds is still reported as successful because the compatibility wait's `False` result is discarded. Checking the return value and failing the action on timeout would prevent later deterministic steps from running against an incompletely loaded page; the same handling is needed for the history-navigation waits.</violation>
</file>
<file name="workflows/workflow_use/workflow/element_finder.py">
<violation number="1" location="workflows/workflow_use/workflow/element_finder.py:528">
P1: XPath strategies are reported as found, but the subsequent click fails because the CDP JSON result is still treated as a dict in `semantic_executor.py`. The click path should use the same decoded-evaluate compatibility helper (or decode the result) before calling `.get(...)`.</violation>
</file>
Tip: instead of fixing issues one by one fix them all with cubic
Re-trigger cubic
|
|
||
| result = await page.evaluate(js_code) | ||
| # Page.evaluate returns a JSON string; decode it before inspecting | ||
| result = await cdp.evaluate(page, js_code) |
There was a problem hiding this comment.
P1: XPath strategies are reported as found, but the subsequent click fails because the CDP JSON result is still treated as a dict in semantic_executor.py. The click path should use the same decoded-evaluate compatibility helper (or decode the result) before calling .get(...).
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At workflows/workflow_use/workflow/element_finder.py, line 528:
<comment>XPath strategies are reported as found, but the subsequent click fails because the CDP JSON result is still treated as a dict in `semantic_executor.py`. The click path should use the same decoded-evaluate compatibility helper (or decode the result) before calling `.get(...)`.</comment>
<file context>
@@ -501,7 +524,8 @@ async def _find_with_xpath(
- result = await page.evaluate(js_code)
+ # Page.evaluate returns a JSON string; decode it before inspecting
+ result = await cdp.evaluate(page, js_code)
if not result:
</file context>
| if elements: | ||
| element = elements[0] | ||
| try: | ||
| await element.evaluate(f"() => this.removeAttribute('{_XPATH_MARKER_ATTR}')") |
There was a problem hiding this comment.
P2: XPath resolution can mutate page state and select a stale element because the fixed marker is not collision-safe and cleanup does not preserve a pre-existing attribute. A per-lookup marker/token with restoration of the original attribute would keep the temporary lookup side effect isolated.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At workflows/workflow_use/compat/cdp.py, line 150:
<comment>XPath resolution can mutate page state and select a stale element because the fixed marker is not collision-safe and cleanup does not preserve a pre-existing attribute. A per-lookup marker/token with restoration of the original attribute would keep the temporary lookup side effect isolated.</comment>
<file context>
@@ -0,0 +1,254 @@
+ if elements:
+ element = elements[0]
+ try:
+ await element.evaluate(f"() => this.removeAttribute('{_XPATH_MARKER_ATTR}')")
+ except Exception:
+ pass
</file context>
|
|
||
| await asyncio.sleep(2) | ||
| # CDP navigate doesn't wait automatically; wait for the document to load. | ||
| await cdp.wait_for_load_state(page, 'load', timeout_ms=10000) |
There was a problem hiding this comment.
P2: A navigation that remains in a non-complete readyState for 10 seconds is still reported as successful because the compatibility wait's False result is discarded. Checking the return value and failing the action on timeout would prevent later deterministic steps from running against an incompletely loaded page; the same handling is needed for the history-navigation waits.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At workflows/workflow_use/controller/service.py, line 57:
<comment>A navigation that remains in a non-complete `readyState` for 10 seconds is still reported as successful because the compatibility wait's `False` result is discarded. Checking the return value and failing the action on timeout would prevent later deterministic steps from running against an incompletely loaded page; the same handling is needed for the history-navigation waits.</comment>
<file context>
@@ -65,15 +53,32 @@ async def navigation(params: NavigationAction, browser_session: Browser) -> Acti
-
- await asyncio.sleep(2)
+ # CDP navigate doesn't wait automatically; wait for the document to load.
+ await cdp.wait_for_load_state(page, 'load', timeout_ms=10000)
msg = f'🔗 Navigated to URL: {params.url}'
</file context>
| await cdp.wait_for_load_state(page, 'load', timeout_ms=10000) | |
| if not await cdp.wait_for_load_state(page, 'load', timeout_ms=10000): | |
| raise TimeoutError(f' timed out waiting for page load after navigating to {params.url}') |
| """ | ||
| if raw is None or raw == '': | ||
| return None | ||
| if raw == 'True': |
There was a problem hiding this comment.
P3: evaluate returns the wrong type for legitimate JavaScript strings equal to True or False; callers expecting text can receive booleans instead. Preserving the actor's result type (or exposing a typed boolean/result envelope instead of guessing from strings) would avoid this ambiguity.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At workflows/workflow_use/compat/cdp.py, line 47:
<comment>`evaluate` returns the wrong type for legitimate JavaScript strings equal to `True` or `False`; callers expecting text can receive booleans instead. Preserving the actor's result type (or exposing a typed boolean/result envelope instead of guessing from strings) would avoid this ambiguity.</comment>
<file context>
@@ -0,0 +1,254 @@
+ """
+ if raw is None or raw == '':
+ return None
+ if raw == 'True':
+ return True
+ if raw == 'False':
</file context>
| return await evaluate(element, '() => !!this.checked') is True | ||
|
|
||
|
|
||
| async def set_checkbox_state(element: 'Element', checked: bool) -> bool: |
There was a problem hiding this comment.
P3: The new CDP layer ships three helpers that nothing in the engine actually calls: is_checked, set_checkbox_state, and element_inner_text. Checkbox handling in the engine is done by semantic_executor._set_checked_by_selector/_element_is_checked, and text is read via element_text_content or inline cdp.evaluate JS, so these three are dead code. Consider removing them (the contract test's mention of set_checkbox_state in a comment doesn't exercise them), so the compat surface stays minimal and future readers aren't led to use untested paths.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At workflows/workflow_use/compat/cdp.py, line 204:
<comment>The new CDP layer ships three helpers that nothing in the engine actually calls: `is_checked`, `set_checkbox_state`, and `element_inner_text`. Checkbox handling in the engine is done by `semantic_executor._set_checked_by_selector`/`_element_is_checked`, and text is read via `element_text_content` or inline `cdp.evaluate` JS, so these three are dead code. Consider removing them (the contract test's mention of `set_checkbox_state` in a comment doesn't exercise them), so the compat surface stays minimal and future readers aren't led to use untested paths.</comment>
<file context>
@@ -0,0 +1,254 @@
+ return await evaluate(element, '() => !!this.checked') is True
+
+
+async def set_checkbox_state(element: 'Element', checked: bool) -> bool:
+ """Playwright ``check()``/``uncheck()`` equivalent via user-like clicks.
+
</file context>
…se#169 (16 issues) Replay engine (PR browser-use#168 feedback): - text fallback (_find_by_tag_and_text) rewritten as one page-side pass in the compat layer: scans ALL candidates (no 40-element cap), matches rendered innerText instead of hidden textContent plus aria-label/ title/placeholder/value, and POLLS until timeout_ms (the old version ignored its timeout and queried once) - XPath/text marker attributes are now per-lookup tokens (secrets.token_hex) so overlapping lookups can't fetch each other's element; marked-element fetch/cleanup extracted to one helper - XPath click path decoded: page.evaluate returns a JSON string, and .get() on it raised AFTER the click already fired in the page (click-then-report-failure trap) - element_finder._node_attr also reads browser-use's nested {'attributes': {...}} dict shape for dict-form nodes - removed unused compat element_inner_text; semantic_executor._set_checked_by_selector now reuses cdp.set_checkbox_state instead of duplicating it Security (PR browser-use#169 feedback): - extension: type=tel and phone/telefon/gsm naming count as sensitive (a real phone number leaked into a saved workflow exactly this way); rrweb now masks ALL inputs (nothing downstream consumes rrweb input data, so defense-in-depth is free) - redaction: hints now include elementText/elementTag/inputType, and the pattern covers cc-number/cc-csc/cc-exp autocomplete tokens plus tel/phone terminology (EN+TR) - variable identifier: default omission is type-based (password/card/ SSN/email/phone), not confidence-based - a context-detected password at 0.85 confidence was still persisted as a plaintext default; only an already-masked capture may surface as a default 13 new tests (tests/test_redaction.py); full suite 140 passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- text fallback (_find_by_tag_and_text) rewritten as one page-side pass
in the compat layer: scans ALL candidates (no 40-element cap), matches
rendered innerText instead of hidden textContent plus aria-label/
title/placeholder/value, and POLLS until timeout_ms (previously the
timeout was ignored and the DOM was queried once)
- XPath/text marker attributes use per-lookup tokens so overlapping
lookups cannot fetch each other's element; fetch/cleanup extracted to
a shared helper
- XPath click path decodes the evaluate JSON before .get() - it raised
AttributeError AFTER the click had already fired in the page
- element_finder._node_attr also reads the nested {'attributes': {...}}
dict shape for dict-form nodes
- removed unused compat element_inner_text; checkbox handling reuses
cdp.set_checkbox_state instead of duplicating it
- pytest config: asyncio_mode=auto so pytest-asyncio 1.2 strict mode
doesn't skip the repo's unmarked async tests
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks for the thorough review @cubic-dev-ai — addressed in a0fda9c:
Two items deliberately unchanged, happy to revisit:
|
@Sangaibisi Looks good — I re-checked each fix in the diff and they match what I flagged:
On the two you left unchanged, your reasoning holds and I'm fine deferring both:
One thing worth keeping visible for that follow-up: The contract test is the real win here — it would have caught this whole class at CI time instead of at replay time. No further changes from me. Tip: get faster answers by chatting with cubic’s review copilot in the review UI |
There was a problem hiding this comment.
All reported issues were addressed across 5 files (changes from recent commits).
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
…t discovery Actions from cubic-dev-ai re-review of PRs browser-use#168/browser-use#169, applied to develop (same defects exist here), plus real bugs the widened test net caught: Security (PR browser-use#169 findings, all confirmed valid): - variable_identifier: sensitivity is now decided by TYPE or NAME/CONTEXT hints (shared redaction matcher). A field named "password"/"iban" that pattern-matching classified as plain STRING was still getting its raw recorded value written to input_schema.default. - variable_identifier: a masked capture no longer becomes a '********' default - defaults are typed verbatim on replay, so it would literally enter eight asterisks into the login field. Sensitive inputs get no default at all and are forced required. - redaction + extension isSensitiveField: phone vocabulary now covers mobile/mobil/cell(ular)/msisdn/cep naming (fields that are not type="tel"); \bcep\b bounded so "Recep" doesn't mask. Extension also learns secret/token. New is_sensitive_hint() shared helper. Real bugs caught by un-hiding the nested suites: - healing/variable_extractor: VAR:name:value markers embedded in larger text (e.g. a URL query ?q=VAR:term:laptop) clobbered the ENTIRE field with {term}, destroying the URL; multiple markers kept only the last. Markers now replace their own span; a marker owning the whole field still consumes it (value may contain spaces: VAR:user_name:John Doe). - semantic_extractor safeGetLabelText: the previous-sibling fallback accepted ANY element with textContent as a "label", so a Cancel button adopted the neighboring "Submit Form" text and links adopted the whole form's text as their key. Non-LABEL siblings now only label true form fields, and only with short text. Verified against a live browser (8/8 with RUN_BROWSER_TESTS=1). Test infrastructure (PR browser-use#168 findings): - pyproject testpaths widened to tests + workflow_use + the top-level regression file: workflow_use/{mcp,workflow,healing,builder}/tests were silently undiscovered (and hid the two bugs above). - Live-LLM suites (test_extract, test_exploration_agent, test_workflow_creation, test_generate_workflow) gated behind RUN_LLM_TESTS - they crashed collection without API keys. - test_semantic_extractor rewritten for the CDP surface: set_content does not exist; markup loads via data: URLs. Browser classes gated behind RUN_BROWSER_TESTS; _normalize_text contract test fixed to the real behavior (whitespace collapse, case preserved - lookup folds). - Password-looking fixture strings replaced with inert values so secret scanners don't flag the test file itself. Suite: 191 passed, 13 skipped (was 159/8 before widening). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… is sync Review follow-up: the contract test needs no async plugin (import-time hasattr assertions + a static scan). Removing pytest-asyncio and the [tool.pytest.ini_options] block restores the repo's default collection semantics exactly: - testpaths=["tests"] no longer forces a bare `uv run pytest` to execute the pre-existing live-browser/LLM scripts in tests/ (asyncio_mode=auto made those async tests actually run - hanging without chromium, network and API keys). - The nested workflow_use/*/tests suites are no longer hidden from default discovery. Run the gate with: uv run pytest tests/test_browser_use_contract.py Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Round-2 review response (c25a7ef): the contract test is fully synchronous, so |
Problem
The replay engine is written against Playwright's API, but runs on browser-use 0.13's CDP actor surface, which has none of it. Verified with the pinned version (
browser-use==0.13.4):page.locator/locator.wait_forpage.wait_for_selector/wait_for_load_statepage.check/page.uncheckpage.query_selector_all/el.is_visible()/el.text_content()locator.click(force=True)force=kwarglocator.select_option(label=...)valuesonly — option label text is invisible to itlocator.presspresspage.screenshot(path=...)path=evaluatetreated as returning dict/boolBecause nearly every call site wraps these in broad
exceptblocks, the failures are silent:get_best_element_handleexhausts all fallbacks and raises for every selector, so any recorded workflow with acssSelectorstep fails on its first element interaction. Radio/checkbox steps only 'succeed' when the state already matches; form-validation detection always returns 'no errors' (rejected submits are reported SUCCESS); XPath fallback never fires; the multi-strategy finder readsnode.text/node.aria_labeletc. that don't exist on the slottedEnhancedDOMTreeNode, so no semantic strategy can ever match.Fix (reviewable commit-by-commit)
test(contract)— an import-time API-contract test (workflows/tests/test_browser_use_contract.py, no browser launch):fix(replay)deterministic path — newworkflow_use/compat/cdp.py(single module that knows the Playwright↔CDP differences: pollingwait_for_element, visibility via geometry+computed style, XPath resolution via a temporary marker attribute, JSON-decoding ofevaluatestring returns, readyState-based load waits, base64 screenshot-to-file, checkbox state setter, select-by-visible-text with properinput/changeevents) + portscontroller/utils.py,controller/service.py,workflow/service.py. Also:DISABLED_DEFAULT_ACTIONSlisted pre-0.2 action names, so only 4 of 27 matched and 16 agent-only builtins stayed registered while customclick/input/scrollsilently overwrote builtins — replaced with excluding all builtins by their real names + explicit registrationsgo_back/go_forwardsteps built an emptyActionModel(), whichTools.actno-ops while reporting success — now real CDP navigations:has-text()/:visibleselectors (invalid CSS that throws inquerySelectorAll)fix(replay)semantic executor / validation / element finder — same treatment forsemantic_executor.py(11page.checksites, 5wait_for_selectorsites, select-by-visible-text, AI extraction calledainvoke(str)and read.contentwhile the API takes a message list and exposes.completion),validation_utils.py(rewritten as one page-evaluate pass), andelement_finder.py(reads the real node sources:attributesdict,ax_node,get_all_children_text()).Verification
Related: #165 (recording-side findings), #166 (recording pipeline fixes — independent; both PRs apply cleanly in either order aside from trivial context overlap).
🤖 Generated with Claude Code
Summary by cubic
Ports the replay engine from Playwright APIs to the
browser-use0.13 CDP actor surface, restoring deterministic replay. Adds a CDP compat layer and contract tests so API breaks fail fast instead of silently at runtime.Bug Fixes
EnhancedDOMTreeNodesources (attributes/AX/text, including nested{'attributes': {...}}), uses CDP-safe text/XPath strategies with per-lookup marker tokens, and a page-side text finder that scans renderedinnerTextand polls until timeout; removed:has-text()/:visible.get_url/get_titleand writes base64 screenshots; XPath click path now decodes evaluate JSON before inspecting; checkbox state handling is centralized viacdp.set_checkbox_state.New Features
workflow_use/compat/cdp.pyshims:wait_for_element, visibility via geometry+style, XPath resolution with tokenized markers, readyState-based load waits, base64 screenshot-to-file, checkbox state setter, select-by-visible-text, and focused key press.Page/Element/BrowserAPI surface and statically block Playwright-only calls to guardbrowser-useupgrades.pytestfor the contract test; removepytest-asyncioand pytest config. Run the gate with:uv run pytest tests/test_browser_use_contract.py.Written for commit c25a7ef. Summary will update on new commits.