Skip to content
Open
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi

---

## [Unreleased]

- **[wrapper]** Fix `humanize=True` reporting a fully rendered, on-screen element as not visible when it (or an ancestor list item) uses `display: contents` — a pattern used by some dropdown/menu widgets to strip default list-item box styling. `getBoundingClientRect()` on such an element is always empty even though its children/text render normally; the isolated-world actionability read now recurses into children for this case, mirroring Playwright's own actionability engine, so `click`/`fill`/etc. no longer fail after the full timeout on an element that is genuinely visible. Also cross-checks a "not visible" in-world verdict against Playwright's own `is_visible()` before failing whenever geometry is present, as a safety net for other such divergences. Regression from 0.5.6. Python, JavaScript, and .NET.

---

## [0.5.8] — 2026-08-18

- **[wrapper]** Fix `humanize=True` actions (`fill`, `click`, `type`) failing with an element-not-attached error after a navigation driven by a click or form submission instead of `goto`. The pre-action element checks could stay bound to the previous document and never recover; they now refresh on every navigation. Regression from 0.5.6. Python, JavaScript Playwright/Puppeteer, and .NET.
Expand Down
19 changes: 18 additions & 1 deletion cloakbrowser/human/actionability.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,19 @@ def _backoff_sleep(attempt: int) -> None:
# Pre-scroll actionability: attached, visible, enabled, editable
# ---------------------------------------------------------------------------

def _box_has_area(box: Optional[dict]) -> bool:
return bool(box) and (box.get("width", 0) > 0 or box.get("height", 0) > 0)


def _playwright_says_visible(page: Any, selector: str) -> bool:
"""Cheap, non-waiting cross-check used before trusting an in-world 'not visible'
verdict that disagrees with a non-empty box (defense-in-depth for #560)."""
try:
return page.locator(selector).first.is_visible()
except Exception:
return False


def _stealth_actionable(page: Any, selector: str, checks: FrozenSet[str]) -> bool:
"""Run the actionability checks through the isolated world.

Expand All @@ -106,7 +119,11 @@ def _stealth_actionable(page: Any, selector: str, checks: FrozenSet[str]) -> boo
# retry loop backs off and re-reads in-world (mirrors wait_for(attached)).
raise ElementNotAttachedError(selector)
if "visible" in checks and not data.get("visible"):
raise ElementNotVisibleError(selector)
# A non-empty box despite an in-world "not visible" verdict means the two
# reads disagree; cross-check with Playwright's own is_visible() rather
# than trusting a possible false negative (#560) before raising.
if not (_box_has_area(data.get("box")) and _playwright_says_visible(page, selector)):
raise ElementNotVisibleError(selector)
if "enabled" in checks and not data.get("enabled"):
raise ElementNotEnabledError(selector)
if "editable" in checks and not data.get("editable"):
Expand Down
12 changes: 11 additions & 1 deletion cloakbrowser/human/actionability_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
ElementNotReceivingEventsError,
_BACKOFF_MS,
_boxes_differ,
_box_has_area,
_POINTER_EVENTS_LOCATOR_JS,
_POINTER_EVENTS_HANDLE_JS,
)
Expand All @@ -40,6 +41,14 @@ async def _async_backoff_sleep(attempt: int) -> None:
# Pre-scroll actionability
# ---------------------------------------------------------------------------

async def _async_playwright_says_visible(page: Any, selector: str) -> bool:
"""Async mirror of ``_playwright_says_visible``."""
try:
return await page.locator(selector).first.is_visible()
except Exception:
return False


async def _async_stealth_actionable(page: Any, selector: str, checks: FrozenSet[str]) -> bool:
"""Async mirror of ``_stealth_actionable`` — isolated-world actionability read.

Expand All @@ -55,7 +64,8 @@ async def _async_stealth_actionable(page: Any, selector: str, checks: FrozenSet[
if status == NOT_FOUND:
raise ElementNotAttachedError(selector)
if "visible" in checks and not data.get("visible"):
raise ElementNotVisibleError(selector)
if not (_box_has_area(data.get("box")) and await _async_playwright_says_visible(page, selector)):
raise ElementNotVisibleError(selector)
if "enabled" in checks and not data.get("enabled"):
raise ElementNotEnabledError(selector)
if "editable" in checks and not data.get("editable"):
Expand Down
47 changes: 40 additions & 7 deletions cloakbrowser/human/stealth_dom.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,41 @@
if (idx < 0 || idx >= list.length) return null;
return list[idx];
}
// display:contents elements have no box of their own (getBoundingClientRect is
// always all-zero) even though their children/text render normally -- Playwright's
// own actionability engine recurses into children for this case, so we mirror it
// here rather than reporting a false negative for e.g. reset "li { display: contents }"
// patterns used by custom dropdown/menu widgets (#560).
function __visBox(el){
const st = getComputedStyle(el);
if (st.display === 'contents') {
let ux1 = Infinity, uy1 = Infinity, ux2 = -Infinity, uy2 = -Infinity, found = false;
for (let child = el.firstChild; child; child = child.nextSibling) {
let r = null;
if (child.nodeType === 1) {
const cv = __visBox(child);
if (cv.visible) r = cv.box;
} else if (child.nodeType === 3 && __normWS(child.textContent)) {
const rng = document.createRange();
rng.selectNode(child);
const rr = rng.getBoundingClientRect();
if (rr.width > 0 || rr.height > 0) r = { x: rr.x, y: rr.y, width: rr.width, height: rr.height };
}
if (r) {
found = true;
ux1 = Math.min(ux1, r.x); uy1 = Math.min(uy1, r.y);
ux2 = Math.max(ux2, r.x + r.width); uy2 = Math.max(uy2, r.y + r.height);
}
}
if (!found) return { visible: false, box: null };
return { visible: true, box: { x: ux1, y: uy1, width: ux2 - ux1, height: uy2 - uy1 } };
}
const rc = el.getBoundingClientRect();
const hasBox = rc.width > 0 || rc.height > 0;
const box = hasBox ? { x: rc.x, y: rc.y, width: rc.width, height: rc.height } : null;
const visible = hasBox && st.visibility !== 'hidden' && st.display !== 'none';
return { visible, box };
}
"""

# ---------------------------------------------------------------------------
Expand All @@ -120,24 +155,22 @@
const __el = __resolve(__SEL);
if (__el === 'UNSUPPORTED') return { r: 'unsupported' };
if (!__el) return { r: 'not_found' };
const __rc = __el.getBoundingClientRect();
const __vb = __visBox(__el);
// Playwright's bounding_box returns null for elements with no box (display:none).
if (__rc.width === 0 && __rc.height === 0 && __rc.x === 0 && __rc.y === 0) return { r: 'not_found' };
return { r: 'ok', box: { x: __rc.x, y: __rc.y, width: __rc.width, height: __rc.height } };
if (!__vb.box) return { r: 'not_found' };
return { r: 'ok', box: __vb.box };
"""

_ACTIONABLE_OP = r"""
const __el = __resolve(__SEL);
if (__el === 'UNSUPPORTED') return { r: 'unsupported' };
if (!__el) return { r: 'not_found' };
const __st = getComputedStyle(__el);
const __rc = __el.getBoundingClientRect();
const __visible = __st.visibility !== 'hidden' && __st.display !== 'none' && (__rc.width > 0 || __rc.height > 0);
const __vb = __visBox(__el);
const __tag = __el.tagName.toLowerCase();
const __enabled = !(__el.disabled === true || __el.getAttribute('aria-disabled') === 'true');
const __editable = __enabled && !__el.readOnly &&
(__tag === 'input' || __tag === 'textarea' || __tag === 'select' || __el.isContentEditable === true);
return { r: 'ok', visible: __visible, enabled: __enabled, editable: __editable };
return { r: 'ok', visible: __vb.visible, enabled: __enabled, editable: __editable, box: __vb.box };
"""

_VIEWPORT_JS = "(() => ({ width: window.innerWidth, height: window.innerHeight }))()"
Expand Down
20 changes: 18 additions & 2 deletions dotnet/src/CloakBrowser/Human/Actionability.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,14 @@ private static Task BackoffSleepAsync(int attempt)
return Task.Delay(BackoffMs[idx]);
}

/// <summary>Cheap, non-waiting cross-check used before trusting an in-world "not visible"
/// verdict that disagrees with a non-empty box (defense-in-depth for #560).</summary>
private static async Task<bool> PlaywrightSaysVisibleAsync(IPage page, string selector)
{
try { return await page.Locator(selector).First.IsVisibleAsync().ConfigureAwait(false); }
catch { return false; }
}

private static double NowMs() => Environment.TickCount64;

/// <summary>
Expand Down Expand Up @@ -155,10 +163,18 @@ public static async Task EnsureActionableAsync(
// predicates only for selector grammar the isolated world can't resolve.
if (stealth != null)
{
var (st, vis, en, ed) = await StealthDom.ActionableAsync(stealth, selector).ConfigureAwait(false);
var (st, vis, en, ed, box) = await StealthDom.ActionableAsync(stealth, selector).ConfigureAwait(false);
if (st == StealthStatus.Ok)
{
if (checks.Contains("visible") && !vis) throw new ElementNotVisibleError(selector);
if (checks.Contains("visible") && !vis)
{
// A non-empty box despite an in-world "not visible" verdict means the
// two reads disagree; cross-check with Playwright's own IsVisibleAsync()
// rather than trusting a possible false negative (#560) before throwing.
bool boxHasArea = box is { Width: > 0 } or { Height: > 0 };
if (!(boxHasArea && await PlaywrightSaysVisibleAsync(page, selector).ConfigureAwait(false)))
throw new ElementNotVisibleError(selector);
}
if (checks.Contains("enabled") && !en) throw new ElementNotEnabledError(selector);
if (checks.Contains("editable") && !ed) throw new ElementNotEditableError(selector);
return;
Expand Down
62 changes: 52 additions & 10 deletions dotnet/src/CloakBrowser/Human/StealthDom.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,29 +95,62 @@ function __resolve(sel){
if (idx < 0 || idx >= list.length) return null;
return list[idx];
}
// display:contents elements have no box of their own (getBoundingClientRect is
// always all-zero) even though their children/text render normally -- Playwright's
// own actionability engine recurses into children for this case, so we mirror it
// here rather than reporting a false negative for e.g. reset ""li { display: contents }""
// patterns used by custom dropdown/menu widgets (#560).
function __visBox(el){
const st = getComputedStyle(el);
if (st.display === 'contents') {
let ux1 = Infinity, uy1 = Infinity, ux2 = -Infinity, uy2 = -Infinity, found = false;
for (let child = el.firstChild; child; child = child.nextSibling) {
let r = null;
if (child.nodeType === 1) {
const cv = __visBox(child);
if (cv.visible) r = cv.box;
} else if (child.nodeType === 3 && __normWS(child.textContent)) {
const rng = document.createRange();
rng.selectNode(child);
const rr = rng.getBoundingClientRect();
if (rr.width > 0 || rr.height > 0) r = { x: rr.x, y: rr.y, width: rr.width, height: rr.height };
}
if (r) {
found = true;
ux1 = Math.min(ux1, r.x); uy1 = Math.min(uy1, r.y);
ux2 = Math.max(ux2, r.x + r.width); uy2 = Math.max(uy2, r.y + r.height);
}
}
if (!found) return { visible: false, box: null };
return { visible: true, box: { x: ux1, y: uy1, width: ux2 - ux1, height: uy2 - uy1 } };
}
const rc = el.getBoundingClientRect();
const hasBox = rc.width > 0 || rc.height > 0;
const box = hasBox ? { x: rc.x, y: rc.y, width: rc.width, height: rc.height } : null;
const visible = hasBox && st.visibility !== 'hidden' && st.display !== 'none';
return { visible, box };
}
";

private const string BoxOp = @"
const __el = __resolve(__SEL);
if (__el === 'UNSUPPORTED') return { r: 'unsupported' };
if (!__el) return { r: 'not_found' };
const __rc = __el.getBoundingClientRect();
if (__rc.width === 0 && __rc.height === 0 && __rc.x === 0 && __rc.y === 0) return { r: 'not_found' };
return { r: 'ok', box: { x: __rc.x, y: __rc.y, width: __rc.width, height: __rc.height } };
const __vb = __visBox(__el);
if (!__vb.box) return { r: 'not_found' };
return { r: 'ok', box: __vb.box };
";

private const string ActionableOp = @"
const __el = __resolve(__SEL);
if (__el === 'UNSUPPORTED') return { r: 'unsupported' };
if (!__el) return { r: 'not_found' };
const __st = getComputedStyle(__el);
const __rc = __el.getBoundingClientRect();
const __visible = __st.visibility !== 'hidden' && __st.display !== 'none' && (__rc.width > 0 || __rc.height > 0);
const __vb = __visBox(__el);
const __tag = __el.tagName.toLowerCase();
const __enabled = !(__el.disabled === true || __el.getAttribute('aria-disabled') === 'true');
const __editable = __enabled && !__el.readOnly &&
(__tag === 'input' || __tag === 'textarea' || __tag === 'select' || __el.isContentEditable === true);
return { r: 'ok', visible: __visible, enabled: __enabled, editable: __editable };
return { r: 'ok', visible: __vb.visible, enabled: __enabled, editable: __editable, box: __vb.box };
";

/// <summary>Live window dimensions, read in the isolated world (no_viewport headed mode).</summary>
Expand Down Expand Up @@ -179,15 +212,24 @@ private static (StealthStatus Status, JsonElement? Data) Classify(JsonElement? r
b.GetProperty("width").GetDouble(), b.GetProperty("height").GetDouble()));
}

public static async Task<(StealthStatus Status, bool Visible, bool Enabled, bool Editable)> ActionableAsync(
private static BoundingBox? ParseBox(JsonElement data)
{
if (!data.TryGetProperty("box", out var b) || b.ValueKind != JsonValueKind.Object) return null;
return new BoundingBox(
b.GetProperty("x").GetDouble(), b.GetProperty("y").GetDouble(),
b.GetProperty("width").GetDouble(), b.GetProperty("height").GetDouble());
}

public static async Task<(StealthStatus Status, bool Visible, bool Enabled, bool Editable, BoundingBox? Box)> ActionableAsync(
IsolatedWorld world, string selector)
{
var (status, data) = await EvalAsync(world, BuildActionableJs(selector)).ConfigureAwait(false);
if (status != StealthStatus.Ok) return (status, false, false, false);
if (status != StealthStatus.Ok) return (status, false, false, false, null);
return (status,
data!.Value.GetProperty("visible").GetBoolean(),
data.Value.GetProperty("enabled").GetBoolean(),
data.Value.GetProperty("editable").GetBoolean());
data.Value.GetProperty("editable").GetBoolean(),
ParseBox(data.Value));
}

private const string IsInputOp = @"
Expand Down
50 changes: 50 additions & 0 deletions dotnet/tests/CloakBrowser.Tests/Human/StealthDomTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,56 @@ function run(js, matches, point){

private static string JsStr(string s) => JsonSerializer.Serialize(s);

// Regression #560: a display:contents element (e.g. a reset "li { display: contents }"
// used by custom dropdown/menu widgets) has no box of its own, but its children/text
// still render. Verifies the shipped resolver JS recurses into children instead of
// reporting a false "not visible"/not_found, mirroring Playwright's own actionability
// engine (packages/injected/src/domUtils.ts::computeBox).
[Fact]
public void ResolverSemantics_DisplayContents_RunUnderNode()
{
var node = FindNode();
if (node == null) return; // skip: node unavailable

string actionableJs = StealthDom.BuildActionableJs("li");
string boxJs = StealthDom.BuildBoxJs("li");

string script = @"
function contentsEl(tag, display){
const e = { tagName: tag, nodeType: 1, disabled: false, readOnly: false, isContentEditable: false,
firstChild: null, nextSibling: null, __display: display || 'block', __rect: { x: 0, y: 0, width: 0, height: 0 } };
e.getAttribute = () => null;
e.getBoundingClientRect = () => e.__rect;
return e;
}
function textNode(text, rect){ return { nodeType: 3, textContent: text, nextSibling: null, __rect: rect }; }
function append(parent, child){
if (!parent.firstChild) { parent.firstChild = child; return; }
let last = parent.firstChild;
while (last.nextSibling) last = last.nextSibling;
last.nextSibling = child;
}
function run(js, root){
const document = {
querySelectorAll: () => [root],
createRange: () => ({ _n: null, selectNode(n){ this._n = n; }, getBoundingClientRect(){ return this._n.__rect; } }),
};
const getComputedStyle = (el) => ({ visibility: 'visible', display: el.__display || 'block' });
return new Function('document', 'getComputedStyle', 'return ' + js)(document, getComputedStyle);
}
const li = contentsEl('LI', 'contents');
append(li, textNode('Hoy', { x: 10, y: 20, width: 30, height: 12 }));
const actionableJs = " + JsStr(actionableJs) + @";
const boxJs = " + JsStr(boxJs) + @";
console.log('ACTIONABLE', JSON.stringify(run(actionableJs, li)));
console.log('BOX', JSON.stringify(run(boxJs, li)));
";

string outp = RunNode(node, script);
Assert.Contains("ACTIONABLE {\"r\":\"ok\",\"visible\":true,\"enabled\":true,\"editable\":false,\"box\":{\"x\":10,\"y\":20,\"width\":30,\"height\":12}}", outp);
Assert.Contains("BOX {\"r\":\"ok\",\"box\":{\"x\":10,\"y\":20,\"width\":30,\"height\":12}}", outp);
}

private static string? FindNode()
{
foreach (var p in new[] { "node", "/usr/local/bin/node", "/opt/homebrew/bin/node", "/usr/bin/node" })
Expand Down
23 changes: 22 additions & 1 deletion js/src/human/actionability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,20 @@ function backoffSleep(attempt: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, BACKOFF_MS[idx]));
}

function boxHasArea(box: { width?: number; height?: number } | null | undefined): boolean {
return !!box && ((box.width ?? 0) > 0 || (box.height ?? 0) > 0);
}

/** Cheap, non-waiting cross-check used before trusting an in-world 'not visible'
* verdict that disagrees with a non-empty box (defense-in-depth for #560). */
async function playwrightSaysVisible(pageOrFrame: Page | Frame, selector: string): Promise<boolean> {
try {
return await pageOrFrame.locator(selector).first().isVisible();
} catch {
return false;
}
}

// ---------------------------------------------------------------------------
// Pre-scroll actionability
// ---------------------------------------------------------------------------
Expand All @@ -115,7 +129,14 @@ async function stealthActionable(
// loop backs off and re-reads in-world (mirrors waitFor({ state: 'attached' })).
throw new ElementNotAttachedError(selector);
}
if (checks.has('visible') && !data.visible) throw new ElementNotVisibleError(selector);
if (checks.has('visible') && !data.visible) {
// A non-empty box despite an in-world 'not visible' verdict means the two
// reads disagree; cross-check with Playwright's own isVisible() rather than
// trusting a possible false negative (#560) before throwing.
if (!(boxHasArea(data.box) && await playwrightSaysVisible(pageOrFrame, selector))) {
throw new ElementNotVisibleError(selector);
}
}
if (checks.has('enabled') && !data.enabled) throw new ElementNotEnabledError(selector);
if (checks.has('editable') && !data.editable) throw new ElementNotEditableError(selector);
return true;
Expand Down
Loading