mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-10-18 23:07:13 -05:00
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:
10 files changed
+263
-6
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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 () => {}),
|
||||
|
||||
@@ -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)
|
||||
# =========================================================================
|
||||
|
||||
Reference in new issue
Block a user