fix(humanize): refresh pre-action element checks after click/form navigation (#507)

Humanized actions could fail with an element-not-attached error after a
navigation driven by a click or form submit, since the checks only refreshed
on goto. Now refresh on any main-frame navigation. Python, JS
Playwright/Puppeteer, and .NET. Regression from 0.5.6.
This commit is contained in:
CloakHQ committed 2026-08-17 21:00:02 +02:00
1 parent cf194eba26
commit a91d105d4d
10 files changed
+263 -6

No files matched your search

+1
View File
@@ -8,6 +8,7 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
## [Unreleased]
- **[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.
- **[binary]** Chromium **150.0.7871.114.6** (Pro Stable, Linux x64 + arm64 and Windows x64) — 71 source-level patches. Linux advances from `150.0.7871.114.4`; Windows advances from `150.0.7871.114.3`. macOS remains on Stable `150.0.7871.114.3`.
- **Identity and hardware:** expanded the Windows hardware-profile pool and tightened alignment between graphics, CPU, memory, and display characteristics. Fixed seeds may resolve to a different complete hardware identity than on the previous Stable build.
- **Windows fidelity:** completed text measurements and font availability across a broader real-world font set, refined platform-specific path handling, improved headed window geometry, and expanded locale coverage including Greek.
+21
View File
@@ -1025,6 +1025,17 @@ def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
cdp_session = None
logger.debug("Could not create CDP session — stealth features disabled")
if stealth is not None:
# Invalidate the isolated world on any main-frame nav, not just goto,
# so click/form navigations don't leave it bound to a stale doc (#507).
try:
page.on(
"framenavigated",
lambda frame: stealth.invalidate() if frame == page.main_frame else None,
)
except Exception:
logger.debug("Could not wire framenavigated invalidation")
raw_mouse: RawMouse = type("_RawMouse", (), {
"move": originals.mouse_move,
"down": originals.mouse_down,
@@ -2095,6 +2106,16 @@ def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None:
cdp_session_holder: list[Any] = [None] # mutable container for closure
page._cdp_session_holder = cdp_session_holder # expose for frame-level patching
# Invalidate the isolated world on any main-frame nav, not just goto, so
# click/form navigations don't leave it bound to a stale doc (#507).
try:
page.on(
"framenavigated",
lambda frame: stealth.invalidate() if frame == page.main_frame else None,
)
except Exception:
logger.debug("Could not wire framenavigated invalidation")
async def _ensure_cdp() -> Any:
if cdp_session_holder[0] is None:
try:
@@ -70,6 +70,13 @@ public sealed class HumanPage
_rawKeyboard = new PlaywrightRawKeyboard(page.Keyboard);
_scrollPage = new PlaywrightScrollPage(page);
_evaluator = new PlaywrightEvaluator(page);
// Invalidate the isolated world on any main-frame nav, not just goto, so
// click/form navigations don't leave it bound to a stale doc (#507).
_page.FrameNavigated += (_, frame) =>
{
if (frame == _page.MainFrame) _stealth?.Invalidate();
};
}
/// <summary>
@@ -35,6 +35,13 @@ public sealed partial class HumanizedPage : IPage
_human = new HumanPage(inner, cfg);
_mouse = new HumanizedMouse(inner.Mouse, cursor, cfg);
_keyboard = new HumanizedKeyboard(inner.Keyboard, cursor, cfg);
// Invalidate the isolated world on any main-frame nav, not just goto, so
// click/form navigations don't leave it bound to a stale doc (#507).
_inner.FrameNavigated += (_, frame) =>
{
if (frame == _inner.MainFrame) _cursor.InvalidateStealth();
};
}
/// <summary>The original, un-humanized Playwright page (escape hatch for raw speed).</summary>
@@ -0,0 +1,101 @@
using System.Reflection;
using CloakBrowser.Human;
using CloakBrowser.Wrappers;
using Microsoft.Playwright;
using Xunit;
namespace CloakBrowser.Tests.Wrappers;
/// <summary>
/// Regression #507: a click/form/history navigation (not just page.goto) must
/// invalidate the isolated world. bfcache keeps the previous document's context
/// alive, so a stale eval keeps succeeding against the old document and
/// fill()/click() actionability reads fail. Both page wrappers must subscribe to
/// <see cref="IPage.FrameNavigated"/> and invalidate on MAIN-frame navigations only.
/// </summary>
public class FrameNavigatedInvalidationTests
{
private static HumanConfig FastConfig() => new()
{
IdleBetweenActions = false,
InitialCursorX = (100, 100),
InitialCursorY = (100, 100),
};
/// <summary>Give the world a non-null cached context id so Invalidate() is observable.</summary>
private static void PrimeContextId(IsolatedWorld world, int id) =>
typeof(IsolatedWorld).GetField("_contextId", BindingFlags.NonPublic | BindingFlags.Instance)!
.SetValue(world, id);
private static int? ReadContextId(IsolatedWorld world) =>
(int?)typeof(IsolatedWorld).GetField("_contextId", BindingFlags.NonPublic | BindingFlags.Instance)!
.GetValue(world);
[Fact]
public void HumanizedPage_invalidates_world_on_main_frame_nav_only()
{
var (mouse, _) = Fake.Of<IMouse>();
var (keyboard, _) = Fake.Of<IKeyboard>();
var (page, pageRec) = Fake.Of<IPage>();
var (mainFrame, _) = Fake.Of<IFrame>();
var (otherFrame, _) = Fake.Of<IFrame>();
pageRec.On("Mouse", mouse);
pageRec.On("Keyboard", keyboard);
pageRec.On("MainFrame", mainFrame);
EventHandler<IFrame>? handler = null;
pageRec.On("add_FrameNavigated", args => { handler = (EventHandler<IFrame>)args[0]!; return null; });
var cursor = new HumanCursor(page);
// Inject a real isolated world with a primed context id so invalidation is visible.
var world = new IsolatedWorld(page);
PrimeContextId(world, 999);
typeof(HumanCursor).GetField("_stealth", BindingFlags.NonPublic | BindingFlags.Instance)!
.SetValue(cursor, world);
_ = new HumanizedPage(page, cursor, FastConfig());
Assert.NotNull(handler); // construction subscribed to FrameNavigated
// subframe navigation -> world stays bound
handler!(page, otherFrame);
Assert.Equal(999, ReadContextId(world));
// main-frame navigation -> world invalidated
handler!(page, mainFrame);
Assert.Null(ReadContextId(world));
}
[Fact]
public void HumanPage_invalidates_world_on_main_frame_nav_only()
{
var (mouse, _) = Fake.Of<IMouse>();
var (keyboard, _) = Fake.Of<IKeyboard>();
var (page, pageRec) = Fake.Of<IPage>();
var (mainFrame, _) = Fake.Of<IFrame>();
var (otherFrame, _) = Fake.Of<IFrame>();
pageRec.On("Mouse", mouse);
pageRec.On("Keyboard", keyboard);
pageRec.On("MainFrame", mainFrame);
EventHandler<IFrame>? handler = null;
pageRec.On("add_FrameNavigated", args => { handler = (EventHandler<IFrame>)args[0]!; return null; });
var human = new HumanPage(page, FastConfig());
Assert.NotNull(handler);
var world = new IsolatedWorld(page);
PrimeContextId(world, 777);
typeof(HumanPage).GetField("_stealth", BindingFlags.NonPublic | BindingFlags.Instance)!
.SetValue(human, world);
handler!(page, otherFrame);
Assert.Equal(777, ReadContextId(world));
handler!(page, mainFrame);
Assert.Null(ReadContextId(world));
}
}
+5
View File
@@ -861,6 +861,11 @@ function patchFrames(
throw error;
}
});
// Invalidate the isolated world on any main-frame nav, not just goto, so
// click/form navigations don't leave it bound to a stale doc (#507).
page.on('framenavigated', (frame: Frame) => {
if (stealth && frame === page.mainFrame()) stealth.invalidate();
});
(page as any)._humanFrameListenerAttached = true;
}
+5
View File
@@ -676,6 +676,11 @@ function patchFrames(
throw error;
}
});
// Invalidate the isolated world on any main-frame nav, not just goto, so
// click/form navigations don't leave it bound to a stale doc (#507).
page.on('framenavigated', (frame: Frame) => {
if (frame === page.mainFrame()) stealth.invalidate();
});
(page as any)._humanFrameListenerAttached = true;
}
+29 -3
View File
@@ -465,9 +465,12 @@ describe("patchPage frame patching", () => {
patchPage(page as any, cfg, cursor as any);
await page.goto("https://example.com");
expect(page.on).toHaveBeenCalledTimes(1);
const [eventName, handler] = page.on.mock.calls[0];
expect(eventName).toBe("frameattached");
// Two listeners are wired once: frameattached (patch dynamic frames) and
// framenavigated (invalidate the isolated world — #507).
expect(page.on).toHaveBeenCalledTimes(2);
const attachedCall = page.on.mock.calls.find((c: any[]) => c[0] === "frameattached");
expect(attachedCall).toBeDefined();
const handler = attachedCall[1];
const attachedFrame = buildMockFrame();
const originalClick = attachedFrame.click;
@@ -480,6 +483,29 @@ describe("patchPage frame patching", () => {
expect(attachedFrame.click).toBe(patchedClick);
});
it("invalidates the isolated world on main-frame navigation, not subframes (#507)", async () => {
const { patchPage } = await import("../src/human/index.js");
const mainFrame = { ...buildMockFrame(), childFrames: vi.fn(() => []) };
const page = buildMockPage({ mainFrameReturn: mainFrame });
const cfg = resolveConfig("default");
const cursor = { x: 0, y: 0, initialized: false };
patchPage(page as any, cfg, cursor as any);
const invalidateSpy = vi.spyOn((page as any)._stealth, "invalidate");
const navCall = page.on.mock.calls.find((c: any[]) => c[0] === "framenavigated");
expect(navCall).toBeDefined();
const handler = navCall[1];
// subframe navigation -> no invalidation
handler(buildMockFrame());
expect(invalidateSpy).not.toHaveBeenCalled();
// main-frame navigation -> invalidate
handler(page.mainFrame());
expect(invalidateSpy).toHaveBeenCalledTimes(1);
});
it("uses frame.locator for frame.click instead of page.click", async () => {
const { patchPage } = await import("../src/human/index.js");
+6 -3
View File
@@ -1528,9 +1528,12 @@ describe("Puppeteer: frame-level patching", () => {
patchPage(page as any, cfg, cursor as any);
await page.goto("https://example.com");
expect(page.on).toHaveBeenCalledTimes(1);
const [eventName, handler] = page.on.mock.calls[0];
expect(eventName).toBe("frameattached");
// Two listeners are wired once: frameattached (patch dynamic frames) and
// framenavigated (invalidate the isolated world — #507).
expect(page.on).toHaveBeenCalledTimes(2);
const attachedCall = page.on.mock.calls.find((c: any[]) => c[0] === "frameattached");
expect(attachedCall).toBeDefined();
const handler = attachedCall[1];
const attachedFrame: any = {
click: vi.fn(async () => {}),
+81
View File
@@ -2702,6 +2702,87 @@ class TestStealthAsync:
assert asyncio.run(_get_element_box_async(page, "#x")) == box
# =========================================================================
# framenavigated -> isolated-world invalidation (#507)
# =========================================================================
class TestFrameNavigatedInvalidation:
"""Regression #507: click/form/history navigation must invalidate the
isolated world, not just page.goto. Without this the world stays bound to
a bfcached old document and fill()/click() actionability reads fail."""
@staticmethod
def _build_page(is_async):
from unittest.mock import MagicMock, AsyncMock
Mock = AsyncMock if is_async else MagicMock
page = MagicMock()
for name in ("click", "dblclick", "hover", "type", "fill", "goto",
"check", "uncheck", "select_option", "press"):
setattr(page, name, Mock())
page.is_checked = Mock(return_value=False)
page.viewport_size = {"width": 1280, "height": 720}
page.evaluate = Mock(return_value={"hit": True})
# new_cdp_session must SUCCEED so the stealth world is created
page.context.new_cdp_session = Mock(return_value=MagicMock())
for surface in ("mouse", "keyboard"):
setattr(page, surface, MagicMock())
for m in ("move", "click", "wheel", "down", "up"):
setattr(page.mouse, m, Mock())
for m in ("type", "down", "up", "press", "insert_text"):
setattr(page.keyboard, m, Mock())
page.query_selector = Mock(return_value=None)
page.query_selector_all = Mock(return_value=[])
page.wait_for_selector = Mock(return_value=None)
# main_frame is a property in the Playwright API (sync + async)
main_frame = MagicMock()
main_frame.child_frames = []
page.main_frame = main_frame
return page
@staticmethod
def _framenavigated_handler(page):
# capture the handler registered via page.on("framenavigated", cb)
for call in page.on.call_args_list:
if call.args and call.args[0] == "framenavigated":
return call.args[1]
return None
def _run(self, is_async):
import cloakbrowser.human as h
from cloakbrowser.human import _CursorState
from cloakbrowser.human.config import resolve_config
cfg = resolve_config("default", {"idle_between_actions": False})
cursor = _CursorState()
cursor.initialized = True
cursor.x = cursor.y = 100
page = self._build_page(is_async)
(h.patch_page_async if is_async else h.patch_page)(page, cfg, cursor)
handler = self._framenavigated_handler(page)
assert handler is not None, "framenavigated listener was not registered"
world = page._stealth_world
assert world is not None
# main-frame navigation invalidates the world
world._context_id = 123
handler(page.main_frame)
assert world._context_id is None, "main-frame nav did not invalidate the world"
# a subframe navigation must NOT invalidate
world._context_id = 456
handler(MagicMock()) # some other frame
assert world._context_id == 456, "subframe nav wrongly invalidated the world"
def test_sync_invalidates_on_main_frame_nav(self):
self._run(is_async=False)
def test_async_invalidates_on_main_frame_nav(self):
self._run(is_async=True)
# =========================================================================
# Direct runner (backwards compat)
# =========================================================================