mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-10-18 23:07:13 -05:00
* fix(humanize): scroll the x axis in scroll-into-view (#521) humanScrollIntoView only measured and scrolled the y axis. A target past the right edge of a horizontally overflowing page counted as in view, so no wheel events were sent, the click point landed offscreen and the pointer check failed with "element is covered by <none>" after the full timeout. After the vertical pass, a horizontal pass now checks that the box fits inside the viewport width and, if not, centres it with the same humanized wheel burst on the x axis. It skips the scroll when the page is already pinned at the needed horizontal boundary, mirroring the y pinned check. Applied to Python (sync and async), JS Playwright, Puppeteer and .NET. .NET: IRawScrollPage.GetScrollStateAsync now also returns X and MaxX. Fixes #521 * fix(humanize): handle RTL scrollX in the horizontal pinned check (#521) In right-to-left documents Chrome's window.scrollX runs from 0 down to -(scrollWidth - clientWidth). The x pass treated "scrollX <= 0" as pinned at the left edge, so on an RTL page at its start a target in the left overflow was never scrolled into view and the click failed with "covered by <none>". The scroll-state script now also reads the viewport direction (taken from <body> when present, as Chrome does) and returns an RTL-aware minX and maxX. Python, JS Playwright and .NET compare against those. The .NET adapter shifts X and MaxX by minX so IRawScrollPage keeps its tuple shape and X still runs 0..MaxX. Puppeteer has no pinned check on the x axis and is unchanged. * fix(humanize): skip the x pass for sub-pixel distances A box wider than the viewport never passes the containment check, so every action on it ran the x pass even when it was already centred: a mouse move to the scroll area and a 0 or 1px wheel. Skip the pass when the distance to centre is under 1px. Applied to Python (sync and async), JS Playwright, Puppeteer and .NET. * test(humanize): reword the set_content note on the #521 browser tests The comment stated as fact that set_content never settles in CloakBrowser. What I observed is narrower: it timed out in local runs of these tests, so say that instead.
This commit is contained in:
1 parent
9bc5e374d7
commit
65b23cac51
11 files changed
+801
-35
No files matched your search
@@ -32,6 +32,10 @@ function isInViewport(
|
||||
return topEdge >= zoneTop && bottomEdge <= zoneBottom;
|
||||
}
|
||||
|
||||
function isInViewportX(bounds: ElementBounds, viewportWidth: number): boolean {
|
||||
return bounds.x >= 0 && bounds.x + bounds.width <= viewportWidth;
|
||||
}
|
||||
|
||||
export async function smoothWheel(
|
||||
raw: RawMouse,
|
||||
delta: number,
|
||||
@@ -104,6 +108,19 @@ export async function humanScrollIntoView(
|
||||
}
|
||||
if (!viewport || !viewport.height) throw new Error('Viewport size not available');
|
||||
|
||||
const yPass = await scrollYIntoView(raw, getBox, viewport, cursorX, cursorY, cfg);
|
||||
return scrollXIntoView(raw, getBox, yPass.box, viewport, yPass.cursorX, yPass.cursorY, cfg);
|
||||
}
|
||||
|
||||
/** Vertical pass: bring the box into ``scroll_target_zone``. */
|
||||
async function scrollYIntoView(
|
||||
raw: RawMouse,
|
||||
getBox: () => Promise<ElementBounds | null>,
|
||||
viewport: { width: number; height: number },
|
||||
cursorX: number,
|
||||
cursorY: number,
|
||||
cfg: HumanConfig,
|
||||
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number }> {
|
||||
let box = await getBox();
|
||||
if (!box) throw new Error('Element not found while scrolling into view');
|
||||
|
||||
@@ -187,6 +204,46 @@ export async function humanScrollIntoView(
|
||||
return { box, cursorX, cursorY };
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal pass: bring the box inside the viewport width (#521).
|
||||
*
|
||||
* Only containment is checked here, not ``scroll_target_zone``: the zone is a
|
||||
* vertical reading position, and horizontal overflow is the exception.
|
||||
*/
|
||||
async function scrollXIntoView(
|
||||
raw: RawMouse,
|
||||
getBox: () => Promise<ElementBounds | null>,
|
||||
box: ElementBounds,
|
||||
viewport: { width: number; height: number },
|
||||
cursorX: number,
|
||||
cursorY: number,
|
||||
cfg: HumanConfig,
|
||||
): Promise<{ box: ElementBounds; cursorX: number; cursorY: number }> {
|
||||
if (isInViewportX(box, viewport.width)) {
|
||||
return { box, cursorX, cursorY };
|
||||
}
|
||||
|
||||
const distanceToScroll = box.x + box.width / 2 - viewport.width / 2;
|
||||
// A box wider than the viewport is never contained; once centred, skip the near-zero wheel.
|
||||
if (Math.abs(distanceToScroll) < 1) {
|
||||
return { box, cursorX, cursorY };
|
||||
}
|
||||
|
||||
const scrollAreaX = Math.round(viewport.width * rand(0.3, 0.7));
|
||||
const scrollAreaY = Math.round(viewport.height * rand(0.3, 0.7));
|
||||
await humanMove(raw, cursorX, cursorY, scrollAreaX, scrollAreaY, cfg);
|
||||
cursorX = scrollAreaX;
|
||||
cursorY = scrollAreaY;
|
||||
await sleep(randRange(cfg.scroll_pre_move_delay));
|
||||
|
||||
await smoothWheel(raw, Math.round(distanceToScroll), cfg, 'x');
|
||||
await sleep(randRange(cfg.scroll_settle_delay));
|
||||
|
||||
const finalBox = await getBox();
|
||||
if (!finalBox) throw new Error('Element lost after scrolling into view');
|
||||
return { box: finalBox, cursorX, cursorY };
|
||||
}
|
||||
|
||||
/**
|
||||
* Selector-based humanized scroll (Puppeteer).
|
||||
*
|
||||
|
||||
+92
-5
@@ -39,18 +39,31 @@ function isInViewport(
|
||||
return topEdge >= zoneTop && bottomEdge <= zoneBottom;
|
||||
}
|
||||
|
||||
function isInViewportX(bounds: ElementBounds, viewportWidth: number): boolean {
|
||||
return bounds.x >= 0 && bounds.x + bounds.width <= viewportWidth;
|
||||
}
|
||||
|
||||
// In RTL documents Chrome's scrollX runs from 0 down to -range, and the
|
||||
// viewport takes its direction from <body> when there is one.
|
||||
const SCROLL_JS =
|
||||
'(() => { const e = document.scrollingElement || document.documentElement;' +
|
||||
' return { y: window.scrollY, maxY: Math.max(0, e.scrollHeight - e.clientHeight) }; })()';
|
||||
' const rangeX = Math.max(0, e.scrollWidth - e.clientWidth);' +
|
||||
" const rtl = getComputedStyle(document.body || e).direction === 'rtl';" +
|
||||
' return { y: window.scrollY, maxY: Math.max(0, e.scrollHeight - e.clientHeight),' +
|
||||
' x: window.scrollX, minX: rtl ? -rangeX : 0, maxX: rtl ? 0 : rangeX }; })()';
|
||||
|
||||
async function readScrollState(page: Page): Promise<{ y: number; maxY: number }> {
|
||||
async function readScrollState(
|
||||
page: Page,
|
||||
): Promise<{ y: number; maxY: number; x: number; minX: number; maxX: number }> {
|
||||
const world = getWorld(page);
|
||||
if (!world) throw new StealthWorldUnavailableError();
|
||||
try {
|
||||
const state = await world.evaluate(SCROLL_JS);
|
||||
if (
|
||||
!state || typeof state !== 'object' ||
|
||||
typeof state.y !== 'number' || typeof state.maxY !== 'number'
|
||||
typeof state.y !== 'number' || typeof state.maxY !== 'number' ||
|
||||
typeof state.x !== 'number' || typeof state.minX !== 'number' ||
|
||||
typeof state.maxX !== 'number'
|
||||
) {
|
||||
throw new StealthEvaluationError('<scroll-state>');
|
||||
}
|
||||
@@ -61,14 +74,24 @@ async function readScrollState(page: Page): Promise<{ y: number; maxY: number }>
|
||||
}
|
||||
}
|
||||
|
||||
async function smoothWheel(raw: RawMouse, delta: number, cfg: HumanConfig): Promise<void> {
|
||||
async function smoothWheel(
|
||||
raw: RawMouse,
|
||||
delta: number,
|
||||
cfg: HumanConfig,
|
||||
axis: 'x' | 'y' = 'y',
|
||||
): Promise<void> {
|
||||
const absD = Math.abs(delta);
|
||||
const sign = delta > 0 ? 1 : -1;
|
||||
let sent = 0;
|
||||
while (sent < absD) {
|
||||
const stepSize = rand(20, 40);
|
||||
const chunk = Math.min(stepSize, absD - sent);
|
||||
await raw.wheel(0, Math.round(chunk) * sign);
|
||||
const d = Math.round(chunk) * sign;
|
||||
if (axis === 'x') {
|
||||
await raw.wheel(d, 0);
|
||||
} else {
|
||||
await raw.wheel(0, d);
|
||||
}
|
||||
sent += chunk;
|
||||
await sleep(rand(8, 20));
|
||||
}
|
||||
@@ -94,6 +117,23 @@ export async function humanScrollIntoView<T extends ElementBounds>(
|
||||
}
|
||||
if (!viewport || !viewport.height) throw new Error('Viewport size not available');
|
||||
|
||||
const yPass = await scrollYIntoView(page, raw, getBox, viewport, cursorX, cursorY, cfg);
|
||||
const xPass = await scrollXIntoView(
|
||||
page, raw, getBox, yPass.box, viewport, yPass.cursorX, yPass.cursorY, cfg,
|
||||
);
|
||||
return { ...xPass, didScroll: yPass.didScroll || xPass.didScroll };
|
||||
}
|
||||
|
||||
/** Vertical pass: bring the box into ``scroll_target_zone``. */
|
||||
async function scrollYIntoView<T extends ElementBounds>(
|
||||
page: Page,
|
||||
raw: RawMouse,
|
||||
getBox: () => Promise<T | null>,
|
||||
viewport: { width: number; height: number },
|
||||
cursorX: number,
|
||||
cursorY: number,
|
||||
cfg: HumanConfig,
|
||||
): Promise<{ box: T; cursorX: number; cursorY: number; didScroll: boolean }> {
|
||||
let box = await getBox();
|
||||
if (!box) throw new Error('Element not found while scrolling into view');
|
||||
|
||||
@@ -180,6 +220,53 @@ export async function humanScrollIntoView<T extends ElementBounds>(
|
||||
return { box: finalBox, cursorX, cursorY, didScroll: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal pass: bring the box inside the viewport width (#521).
|
||||
*
|
||||
* Only containment is checked here, not ``scroll_target_zone``: the zone is a
|
||||
* vertical reading position, and horizontal overflow is the exception.
|
||||
*/
|
||||
async function scrollXIntoView<T extends ElementBounds>(
|
||||
page: Page,
|
||||
raw: RawMouse,
|
||||
getBox: () => Promise<T | null>,
|
||||
box: T,
|
||||
viewport: { width: number; height: number },
|
||||
cursorX: number,
|
||||
cursorY: number,
|
||||
cfg: HumanConfig,
|
||||
): Promise<{ box: T; cursorX: number; cursorY: number; didScroll: boolean }> {
|
||||
if (isInViewportX(box, viewport.width)) {
|
||||
return { box, cursorX, cursorY, didScroll: false };
|
||||
}
|
||||
|
||||
const distanceToScroll = box.x + box.width / 2 - viewport.width / 2;
|
||||
// A box wider than the viewport is never contained; once centred, skip the near-zero wheel.
|
||||
if (Math.abs(distanceToScroll) < 1) {
|
||||
return { box, cursorX, cursorY, didScroll: false };
|
||||
}
|
||||
|
||||
// Page pinned at the boundary in the needed direction: scrolling can't help.
|
||||
const { x, minX, maxX } = await readScrollState(page);
|
||||
if (distanceToScroll < 0 ? x <= minX : x >= maxX) {
|
||||
return { box, cursorX, cursorY, didScroll: false };
|
||||
}
|
||||
|
||||
const scrollAreaX = Math.round(viewport.width * rand(0.3, 0.7));
|
||||
const scrollAreaY = Math.round(viewport.height * rand(0.3, 0.7));
|
||||
await humanMove(raw, cursorX, cursorY, scrollAreaX, scrollAreaY, cfg);
|
||||
cursorX = scrollAreaX;
|
||||
cursorY = scrollAreaY;
|
||||
await sleep(randRange(cfg.scroll_pre_move_delay));
|
||||
|
||||
await smoothWheel(raw, Math.round(distanceToScroll), cfg, 'x');
|
||||
await sleep(randRange(cfg.scroll_settle_delay));
|
||||
|
||||
const finalBox = await getBox();
|
||||
if (!finalBox) throw new Error('Element lost after scrolling into view');
|
||||
return { box: finalBox, cursorX, cursorY, didScroll: true };
|
||||
}
|
||||
|
||||
export async function scrollToElement(
|
||||
page: Page,
|
||||
raw: RawMouse,
|
||||
|
||||
@@ -1619,7 +1619,7 @@ describe("humanScrollIntoView", () => {
|
||||
const page: any = {
|
||||
viewportSize: () => ({ width: 1280, height: 720 }),
|
||||
evaluate: vi.fn(),
|
||||
_stealth: { evaluate: vi.fn(async () => ({ y: 0, maxY: 2000 })) },
|
||||
_stealth: { evaluate: vi.fn(async () => ({ y: 0, maxY: 2000, x: 0, minX: 0, maxX: 0 })) },
|
||||
};
|
||||
const raw = {
|
||||
move: vi.fn(async () => { }), down: vi.fn(async () => { }),
|
||||
@@ -1645,7 +1645,7 @@ describe("humanScrollIntoView", () => {
|
||||
const page: any = {
|
||||
viewportSize: () => ({ width: 1280, height: 720 }),
|
||||
evaluate: vi.fn(),
|
||||
_stealth: { evaluate: vi.fn(async () => ({ y: 500, maxY: 2000 })) },
|
||||
_stealth: { evaluate: vi.fn(async () => ({ y: 500, maxY: 2000, x: 0, minX: 0, maxX: 0 })) },
|
||||
};
|
||||
const raw = {
|
||||
move: vi.fn(async () => { }), down: vi.fn(async () => { }),
|
||||
@@ -1658,6 +1658,96 @@ describe("humanScrollIntoView", () => {
|
||||
expect(raw.wheel).toHaveBeenCalled();
|
||||
expect(page.evaluate).not.toHaveBeenCalled();
|
||||
}, 15000);
|
||||
|
||||
it("scrolls the x axis for an element past the right edge (#521)", async () => {
|
||||
const { humanScrollIntoView } = await import("../src/human/scroll.js");
|
||||
const cfg = resolveConfig("default", {
|
||||
scroll_pre_move_delay: [0, 1], scroll_settle_delay: [0, 1],
|
||||
});
|
||||
|
||||
const page: any = {
|
||||
viewportSize: () => ({ width: 1000, height: 700 }),
|
||||
evaluate: vi.fn(),
|
||||
_stealth: { evaluate: vi.fn(async () => ({ y: 0, maxY: 0, x: 0, minX: 0, maxX: 600 })) },
|
||||
};
|
||||
const raw = {
|
||||
move: vi.fn(async () => { }), down: vi.fn(async () => { }),
|
||||
up: vi.fn(async () => { }), wheel: vi.fn(async (_dx: number, _dy: number) => { }),
|
||||
};
|
||||
const boxes = [
|
||||
{ x: 800, y: 300, width: 800, height: 30 },
|
||||
{ x: 200, y: 300, width: 800, height: 30 },
|
||||
];
|
||||
let i = 0;
|
||||
const getBox = async () => boxes[i++];
|
||||
|
||||
const result = await humanScrollIntoView(page, raw, getBox, 0, 0, cfg);
|
||||
|
||||
expect(result.didScroll).toBe(true);
|
||||
expect(result.box.x).toBe(200);
|
||||
expect(raw.wheel).toHaveBeenCalled();
|
||||
for (const [dx, dy] of raw.wheel.mock.calls) {
|
||||
expect(dx).toBeGreaterThan(0);
|
||||
expect(dy).toBe(0);
|
||||
}
|
||||
expect(page.evaluate).not.toHaveBeenCalled();
|
||||
}, 15000);
|
||||
|
||||
it("skips the x axis when the page is already scrolled fully right", async () => {
|
||||
const { humanScrollIntoView } = await import("../src/human/scroll.js");
|
||||
const cfg = resolveConfig("default");
|
||||
|
||||
const page: any = {
|
||||
viewportSize: () => ({ width: 1000, height: 700 }),
|
||||
evaluate: vi.fn(),
|
||||
_stealth: { evaluate: vi.fn(async () => ({ y: 0, maxY: 0, x: 600, minX: 0, maxX: 600 })) },
|
||||
};
|
||||
const raw = {
|
||||
move: vi.fn(async () => { }), down: vi.fn(async () => { }),
|
||||
up: vi.fn(async () => { }), wheel: vi.fn(async () => { }),
|
||||
};
|
||||
const clippedBox = { x: 900, y: 300, width: 200, height: 30 };
|
||||
|
||||
const result = await humanScrollIntoView(page, raw, async () => clippedBox, 0, 0, cfg);
|
||||
|
||||
expect(result.didScroll).toBe(false);
|
||||
expect(raw.wheel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("scrolls the x axis left on an RTL page at its start", async () => {
|
||||
const { humanScrollIntoView } = await import("../src/human/scroll.js");
|
||||
const cfg = resolveConfig("default", {
|
||||
scroll_pre_move_delay: [0, 1], scroll_settle_delay: [0, 1],
|
||||
});
|
||||
|
||||
const page: any = {
|
||||
viewportSize: () => ({ width: 1000, height: 700 }),
|
||||
evaluate: vi.fn(),
|
||||
_stealth: {
|
||||
evaluate: vi.fn(async () => ({ y: 0, maxY: 0, x: 0, minX: -600, maxX: 0 })),
|
||||
},
|
||||
};
|
||||
const raw = {
|
||||
move: vi.fn(async () => { }), down: vi.fn(async () => { }),
|
||||
up: vi.fn(async () => { }), wheel: vi.fn(async (_dx: number, _dy: number) => { }),
|
||||
};
|
||||
const boxes = [
|
||||
{ x: -600, y: 300, width: 200, height: 30 },
|
||||
{ x: 400, y: 300, width: 200, height: 30 },
|
||||
];
|
||||
let i = 0;
|
||||
const getBox = async () => boxes[i++];
|
||||
|
||||
const result = await humanScrollIntoView(page, raw, getBox, 0, 0, cfg);
|
||||
|
||||
expect(result.didScroll).toBe(true);
|
||||
expect(result.box.x).toBe(400);
|
||||
expect(raw.wheel).toHaveBeenCalled();
|
||||
for (const [dx, dy] of raw.wheel.mock.calls) {
|
||||
expect(dx).toBeLessThan(0);
|
||||
expect(dy).toBe(0);
|
||||
}
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
describe("el.scrollIntoViewIfNeeded humanization", () => {
|
||||
|
||||
@@ -954,6 +954,36 @@ describe("Puppeteer: mouse.wheel() smooth scroll", () => {
|
||||
// =========================================================================
|
||||
// mouse.dragAndDrop() — Bézier drag between coordinates
|
||||
// =========================================================================
|
||||
describe("Puppeteer: humanScrollIntoView x axis (#521)", () => {
|
||||
it("scrolls the x axis for an element past the right edge", async () => {
|
||||
const { humanScrollIntoView } = await import("../src/human-puppeteer/scroll.js");
|
||||
const cfg = resolveConfig("default", {
|
||||
scroll_pre_move_delay: [0, 1], scroll_settle_delay: [0, 1],
|
||||
});
|
||||
|
||||
const page: any = { viewport: () => ({ width: 1000, height: 700 }) };
|
||||
const raw = {
|
||||
move: vi.fn(async () => {}), down: vi.fn(async () => {}),
|
||||
up: vi.fn(async () => {}), wheel: vi.fn(async (_dx: number, _dy: number) => {}),
|
||||
};
|
||||
const boxes = [
|
||||
{ x: 800, y: 300, width: 800, height: 30 },
|
||||
{ x: 200, y: 300, width: 800, height: 30 },
|
||||
];
|
||||
let i = 0;
|
||||
const getBox = async () => boxes[i++];
|
||||
|
||||
const result = await humanScrollIntoView(page, raw as any, getBox, 0, 0, cfg);
|
||||
|
||||
expect(result.box.x).toBe(200);
|
||||
expect(raw.wheel).toHaveBeenCalled();
|
||||
for (const [dx, dy] of raw.wheel.mock.calls) {
|
||||
expect(dx).toBeGreaterThan(0);
|
||||
expect(dy).toBe(0);
|
||||
}
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
describe("Puppeteer: mouse.dragAndDrop() humanization", () => {
|
||||
it("mouse.dragAndDrop is patched after patchPage", async () => {
|
||||
const { patchPage } = await import("../src/human-puppeteer/index.js");
|
||||
@@ -2193,6 +2223,32 @@ describeIfSlow("Puppeteer stealth browser: focus() uses click", () => {
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
const WIDE_ROW_HTML = `
|
||||
<body style="margin:0">
|
||||
<div style="display:flex">
|
||||
<div style="min-width:800px">left</div>
|
||||
<button id="target" style="min-width:800px"
|
||||
onclick="this.textContent = 'CLICKED'">right</button>
|
||||
</div>
|
||||
</body>`;
|
||||
|
||||
describeIfSlow("Puppeteer stealth browser: horizontal scroll into view (#521)", () => {
|
||||
it("clicks a target past the right edge of a horizontally overflowing page", async () => {
|
||||
const { launch } = await import("../src/puppeteer.js");
|
||||
const browser = await launch({ humanize: true, headless: true });
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1000, height: 700 });
|
||||
await page.setContent(WIDE_ROW_HTML);
|
||||
await page.click("#target");
|
||||
await sleep(500);
|
||||
expect(await page.$eval("#target", (el) => el.textContent)).toBe("CLICKED");
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
describeIfSlow("Puppeteer stealth browser: mouse.wheel smooth scroll", () => {
|
||||
it("mouse.wheel generates multiple small scroll events", async () => {
|
||||
const { launch } = await import("../src/puppeteer.js");
|
||||
|
||||
@@ -1305,6 +1305,31 @@ describeIfSlow("stealth browser: selector parity and target identity", () => {
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
// page.setContent timed out in local runs of this test, so the page is loaded as a data: URL.
|
||||
const WIDE_ROW_HTML = `
|
||||
<body style="margin:0">
|
||||
<div style="display:flex">
|
||||
<div style="min-width:800px">left</div>
|
||||
<button id="target" style="min-width:800px"
|
||||
onclick="this.textContent = 'CLICKED'">right</button>
|
||||
</div>
|
||||
</body>`;
|
||||
|
||||
describeIfSlow("stealth browser: horizontal scroll into view (#521)", () => {
|
||||
it("clicks a target past the right edge of a horizontally overflowing page", async () => {
|
||||
const { launch } = await import("../src/index.js");
|
||||
const browser = await launch({ headless: true, humanize: true });
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1000, height: 700 } });
|
||||
await page.goto("data:text/html," + encodeURIComponent(WIDE_ROW_HTML));
|
||||
await page.click("#target", { timeout: 5000 });
|
||||
expect(await page.locator("#target").textContent()).toBe("CLICKED");
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
describeIfSlow("stealth browser: full form no evaluate leak", () => {
|
||||
it("form with shift symbols has zero evaluate leaks and zero untrusted events", async () => {
|
||||
const { launch } = await import("../src/index.js");
|
||||
|
||||
Reference in new issue
Block a user