Skip to content

Fix humanize=True false-negative visible check for display:contents elements - #514

Open
thesob wants to merge 1 commit into
CloakHQ:mainfrom
thesob:fix/display-contents-visible-check
Open

Fix humanize=True false-negative visible check for display:contents elements#514
thesob wants to merge 1 commit into
CloakHQ:mainfrom
thesob:fix/display-contents-visible-check

Conversation

@thesob

@thesob thesob commented Aug 21, 2026

Copy link
Copy Markdown

Bug

Reported: humanize=True page.click() (and other actions) fail with ElementNotVisibleError on an element that is demonstrably visible and rendered on screen — specifically reproduced on a Bootstrap-style toggled dropdown-menu <li> option. Regression from 0.5.6, when the isolated-world (CDP) actionability re-implementation replaced the previous real-Playwright is_visible()/bounding_box() calls.

Root cause

The isolated-world visibility/geometry reads (_ACTIONABLE_OP/_BOX_OP in stealth_dom.py, mirrored in the JS and .NET ports) computed visibility and geometry from an element's own getComputedStyle() + getBoundingClientRect() only.

An element with display: contents (e.g. a reset <li> used by some custom dropdown/menu widgets to strip default list box styling while keeping DOM semantics) is never itself boxedgetBoundingClientRect() on it is always {0,0,0,0} — even though its children/text render normally. This exactly matches Playwright's own actionability engine (packages/injected/src/domUtils.ts::computeBox), which special-cases display: contents by recursing into children/text nodes instead of trusting the element's own empty rect. CloakBrowser's isolated-world reimplementation never added this special case, so it reported visible: false / no box indefinitely for such elements — a real, static CSS property, not a transient race — explaining why the retry loop never recovered.

Fix

  • Add a shared __visBox() helper to the isolated-world resolver JS (identical across Python, JS, and .NET) that recurses into child elements/text nodes for display: contents, unioning their rects. Used by both the box read and the actionable (visible/enabled/editable) read.
  • _ACTIONABLE_OP now also returns the box alongside visible/enabled/editable.
  • As defense-in-depth, ensure_actionable/stealthActionable/EnsureActionableAsync cross-check a visible: false in-world verdict against Playwright's own is_visible() before failing, whenever a non-empty box is present — matching the "only trust not visible when geometry is also absent/zero" pattern.

Tests

  • Python: new real-browser regression test (TestDisplayContentsActionability in tests/test_humanize_unit.py) reproducing a toggled display:contents dropdown; confirmed it fails with the pre-fix code (via git stash) and passes with the fix.
  • JS: new display:contents behavioral tests in js/tests/stealthDom.test.ts executing the actual shipped resolver JS against a DOM stub, plus cross-check tests for ensureActionable.
  • .NET: new Node-executed resolver test in dotnet/tests/CloakBrowser.Tests/Human/StealthDomTests.cs mirroring the JS test.

All existing unit tests pass unmodified (verified for Python and JS; .NET mirrored byte-for-byte from the tested JS/Python logic — could not compile locally, no dotnet SDK available in this environment).

Changelog

Added an [Unreleased] entry in CHANGELOG.md.

…lements

The isolated-world actionability read (used by ensure_actionable/scroll_to_element since 0.5.6) computed visibility and geometry from an element's own getComputedStyle()/getBoundingClientRect() only. An element with display: contents (e.g. a reset li in a custom dropdown/menu widget) always has an empty box for itself even though its children/text render normally, causing click/fill/etc. to fail with ElementNotVisibleError on a genuinely visible element for the full timeout.

Mirrors Playwright's own actionability engine (packages/injected/src/domUtils.ts::computeBox), which recurses into children/text nodes for display:contents elements instead of trusting the element's own empty rect.

- Add a shared __visBox helper to the isolated-world resolver JS (Python/JS/.NET) that recurses into children/text nodes for display:contents, used by both the box and actionable reads.
- Cross-check a not-visible in-world verdict against Playwright's own is_visible() before failing whenever a non-empty box is present, as defense-in-depth against other such divergences.
- Add regression tests across Python (real browser), JS (Node-executed resolver JS), and .NET (Node-executed resolver JS).

Regression from 0.5.6.
@Cloak-HQ

Copy link
Copy Markdown
Member

Thanks for the report, detailed investigation, and reproduction. We confirmed the display: contents geometry issue and implemented it as part of a broader selector and actionability fix across Python, JavaScript, and .NET, with unit and real-browser regression coverage.

We intentionally excluded the proposed Playwright visibility fallback to preserve consistent selector handling. The replacement is available on the fix/humanized-selector-parity branch and is currently undergoing final validation before being merged.

If you’re able to test your original reproduction against that branch, we’d appreciate confirmation that it resolves the issue.

Since the underlying fix is now implemented separately, we won’t merge this PR as-is. Thanks again—your investigation directly helped identify the missing behavior.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants