Skip to content

Commit c64c2ff

Browse files
committed
fix: revalidate hover before dispatch and keep it out of flows
Share one post-scroll hit test for the synthetic and CDP paths, reject a covered target before any pointer event, and pause recording when a hover handler navigates. Record why hover is not replayed in a flow.
1 parent 7678c80 commit c64c2ff

6 files changed

Lines changed: 90 additions & 40 deletions

File tree

‎apps/headless/LinuxHost/BrowserProcess.swift‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -778,6 +778,9 @@ final class LinuxBrowserSession: @unchecked Sendable {
778778
_ = try command("Input.dispatchMouseEvent", parameters: [
779779
"type": "mouseMoved", "x": target.x, "y": target.y,
780780
])
781+
// A hover handler can start a cross-document navigation before the next
782+
// command. Pause capture so a recording does not composite that frame.
783+
pauseRecordingCapture()
781784
return .object([
782785
"hovered": .string(target.reference),
783786
"role": .string(target.role),

‎apps/headless/Sources/HeadlessProtocol/Resources/AgentRuntime.js‎

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -707,9 +707,8 @@ if (!globalThis.__headlessAgent) {
707707
element.click();
708708
return {clicked: refFor(element), role: role(element), name: name(element)};
709709
};
710-
const hover = args => {
711-
const element = target(args);
712-
element.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'});
710+
// Hit-test the layout after scroll, before any pointer event.
711+
const pointerHit = element => {
713712
if (!visible(element)) fail('ELEMENT_NOT_FOUND', 'ELEMENT_NOT_VISIBLE');
714713
const rect = element.getBoundingClientRect();
715714
const left = Math.max(0, rect.left);
@@ -723,10 +722,16 @@ if (!globalThis.__headlessAgent) {
723722
if (!hit || (hit !== element && !element.contains(hit))) {
724723
fail('ELEMENT_NOT_FOUND', 'ELEMENT_OBSCURED');
725724
}
725+
return {x, y};
726+
};
727+
const hover = args => {
728+
const element = target(args);
729+
element.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'});
730+
const point = pointerHit(element);
726731
const Pointer = typeof PointerEvent === 'function' ? PointerEvent : MouseEvent;
727-
const pointer = {bubbles: true, clientX: x, clientY: y, pointerId: 1,
732+
const pointer = {bubbles: true, clientX: point.x, clientY: point.y, pointerId: 1,
728733
pointerType: 'mouse', isPrimary: true};
729-
const mouse = {bubbles: true, clientX: x, clientY: y};
734+
const mouse = {bubbles: true, clientX: point.x, clientY: point.y};
730735
const previous = hoveredElement instanceof Element && hoveredElement.isConnected
731736
? hoveredElement : null;
732737
hoveredElement = element;
@@ -765,26 +770,24 @@ if (!globalThis.__headlessAgent) {
765770
if (!editable || element.disabled || element.readOnly) throw new Error('NOT_EDITABLE');
766771
}
767772
element.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'});
768-
if (action !== 'hover') element.focus({preventScroll: true});
769-
if (action === 'hover' && !visible(element)) {
770-
fail('ELEMENT_NOT_FOUND', 'ELEMENT_NOT_VISIBLE');
773+
if (action === 'hover') {
774+
const point = pointerHit(element);
775+
return {
776+
ref: refFor(element), role: role(element), name: name(element),
777+
x: point.x, y: point.y,
778+
};
771779
}
780+
element.focus({preventScroll: true});
772781
const rect = element.getBoundingClientRect();
773782
const left = Math.max(0, rect.left);
774783
const right = Math.min(innerWidth, rect.right);
775784
const top = Math.max(0, rect.top);
776785
const bottom = Math.min(innerHeight, rect.bottom);
777-
if (right <= left || bottom <= top) {
778-
if (action === 'hover') fail('ELEMENT_NOT_FOUND', 'ELEMENT_NOT_VISIBLE');
779-
throw new Error('ELEMENT_NOT_VISIBLE');
780-
}
786+
if (right <= left || bottom <= top) throw new Error('ELEMENT_NOT_VISIBLE');
781787
const x = left + (right - left) / 2;
782788
const y = top + (bottom - top) / 2;
783789
const hit = document.elementFromPoint(x, y);
784-
if (!hit || (hit !== element && !element.contains(hit))) {
785-
if (action === 'hover') fail('ELEMENT_NOT_FOUND', 'ELEMENT_OBSCURED');
786-
throw new Error('ELEMENT_OBSCURED');
787-
}
790+
if (!hit || (hit !== element && !element.contains(hit))) throw new Error('ELEMENT_OBSCURED');
788791
return {ref: refFor(element), role: role(element), name: name(element), x, y};
789792
};
790793
const checkedFileInput = element => {

‎apps/headless/Tests/agent-runtime.test.mjs‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,29 @@ assert.throws(
289289
error => error.headlessCode === 'ELEMENT_NOT_FOUND' && error.message === 'ELEMENT_NOT_VISIBLE',
290290
);
291291
secondButton.style.opacity = '1';
292+
window.document.elementFromPoint = () => window.document.body;
293+
const coveredEvents = secondHoverEvents.length;
294+
assert.throws(
295+
() => agent.hover({target: hiddenHoverRef}),
296+
error => error.headlessCode === 'ELEMENT_NOT_FOUND' && error.message === 'ELEMENT_OBSCURED',
297+
);
298+
assert.throws(
299+
() => agent.inputTarget({target: hiddenHoverRef}, 'hover'),
300+
error => error.headlessCode === 'ELEMENT_NOT_FOUND' && error.message === 'ELEMENT_OBSCURED',
301+
);
302+
assert.equal(secondHoverEvents.length, coveredEvents, 'obscured hover must not dispatch');
303+
secondButton.remove();
304+
assert.throws(
305+
() => agent.hover({target: hiddenHoverRef}),
306+
error => error.headlessCode === 'ELEMENT_NOT_FOUND' && /detached/.test(error.message),
307+
);
308+
const staleHoverRef = agent.snapshot(false, false, {context: 'actions', limit: 20})
309+
.elements.find(element => element.name === 'Runtime action').ref;
310+
agent.snapshot(false, false, {context: 'text', limit: 1});
311+
assert.throws(
312+
() => agent.hover({target: staleHoverRef}),
313+
error => error.headlessCode === 'ELEMENT_NOT_FOUND' && /expired/.test(error.message),
314+
);
292315
window.document.elementFromPoint = () => button;
293316

294317
const clicked = agent.click({role: 'button', name: 'Runtime action'});

‎apps/headless/docs/COMMANDS.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -244,8 +244,11 @@ back | reload
244244
semantic target (`--role`/`--name`). `fill` accepts a reference; `press` acts
245245
on the focused control. On Linux click, fill, and key input dispatch trusted
246246
CDP events; WebKit uses synthetic input. Hover does not focus or click its
247-
target and returns no coordinates. Native select and hover dispatch are
248-
declared separately by capabilities.
247+
target and returns no coordinates. Before dispatch it resolves a ref from the
248+
latest inspection only, then rejects a stale, detached, hidden, or
249+
center-covered target. Hover is not a flow step: a flow replays commands, and
250+
a later replay would not reproduce a transient pointer state. Native select
251+
and hover dispatch are declared separately by capabilities.
249252
- `fill REF -- value` keeps leading dashes in the value. Flow recordings never
250253
record fill values.
251254
- `select` supports native single-selection HTML controls only. Choose exactly

‎apps/headless/docs/P2.md‎

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -91,15 +91,21 @@ operation on both engines. The capability matrix reports this separately as
9191
`selectDispatch: synthetic-dom`, so Chromium's `inputDispatch: trusted-cdp`
9292
claim remains limited to click, fill, and key input.
9393

94-
`hover` accepts the same ref or role/name target grammar as `click`. Chromium
95-
resolves and hit-tests the target in the isolated world, then dispatches one
96-
trusted CDP mouse move. WebKit emits one fixed synthetic pointer/mouse hover
97-
transition sequence but cannot activate CSS `:hover`. `hoverDispatch` declares
98-
the difference. Neither path focuses, clicks, returns coordinates, accepts
99-
selectors, or exposes caller JavaScript. Page hover handlers may navigate or
100-
cause other page side effects under the normal browser safety policies. Hover
101-
is not replayed in flows and is not advertised in action hints because
102-
meaningful hover behavior cannot be inferred from markup.
94+
`hover` accepts the same ref or role/name target grammar as `click`. A ref
95+
resolves only from the latest inspection, so a later inspect or document
96+
refresh expires it. After scrolling the target into view, both engines reject
97+
a detached, hidden, or center-covered target before any pointer event.
98+
Chromium then dispatches one trusted CDP mouse move. WebKit emits one fixed
99+
synthetic pointer/mouse hover transition sequence but cannot activate CSS
100+
`:hover`. `hoverDispatch` declares the difference. Neither path focuses,
101+
clicks, returns coordinates, accepts selectors, or exposes caller JavaScript.
102+
Page hover handlers may navigate or cause other page side effects under the
103+
normal browser safety policies. Hover is not a flow step. A flow replays
104+
commands later, and a replayed hover would not restore the pointer or, on
105+
WebKit, CSS `:hover`. Screenshot series store images from the capture that
106+
just ran. Those images are not commands, so a series is not replayed either.
107+
Inspection does not advertise hover because markup cannot prove that hovering
108+
has meaningful behavior.
103109

104110
The Linux host accepts only a qualified non-Snap runtime for this CDP pipe.
105111
Automatic selection prefers an installed bundled runtime and native browser

‎docs/roadmap/architecture-decisions.md‎

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1171,21 +1171,33 @@ a narrower region instead of yielding partial or misleading evidence.
11711171
## 37. Hover is semantic, bounded, and dispatch-accurate
11721172

11731173
**Decision:** protocol `0.5` gains one compatible portable `hover` command with
1174-
the same fresh element-ref or exact role/name target grammar as `click`. The
1175-
isolated runtime scrolls the target to the viewport and rejects missing, stale, hidden,
1176-
detached, or center-point-obscured targets before dispatch. The public response
1177-
contains only the bounded target ref, role, and name. It never returns the
1178-
internal hit-test coordinates.
1179-
1180-
Chromium performs one trusted `Input.dispatchMouseEvent` mouse move after the
1181-
isolated-world target check. WebKit tracks the previous synthetic target and
1182-
emits fixed pointer/mouse leave, enter, and move transitions. The capability
1183-
matrix reports `hoverDispatch` as `trusted-cdp` or `synthetic-dom`. The command
1184-
never focuses or clicks directly, accepts selectors or coordinates, or exposes
1174+
the same fresh element-ref or exact role/name target grammar as `click`. A ref
1175+
resolves only against the latest inspection. A later inspect or a document
1176+
refresh expires it, and a detached node fails closed. After scrolling the
1177+
target into view, both engines reject a hidden target and a target whose
1178+
viewport center is covered by another element, including a dropdown or other
1179+
overlay, before any pointer event. The public response contains only the
1180+
bounded target ref, role, and name. It never returns the internal hit-test
1181+
coordinates.
1182+
1183+
Chromium performs one trusted `Input.dispatchMouseEvent` mouse move after that
1184+
check. WebKit tracks the previous synthetic target and emits fixed
1185+
pointer/mouse leave, enter, and move transitions. The capability matrix
1186+
reports `hoverDispatch` as `trusted-cdp` or `synthetic-dom`. The command never
1187+
focuses or clicks directly, accepts selectors or coordinates, or exposes
11851188
caller JavaScript. Page-controlled hover handlers can still navigate or cause
11861189
other page side effects, subject to the existing navigation and download
1187-
policies. Hover is not a replayable flow step. Inspection does not advertise
1188-
hover because markup cannot prove that hovering has meaningful behavior.
1190+
policies.
1191+
1192+
Hover is not a flow step. A flow replays commands later. Hover only describes
1193+
where the pointer is now, and WebKit's synthetic sequence does not activate
1194+
CSS `:hover`, so a replay would not reproduce the revealed surface. Screenshot
1195+
series are saved images from one capture. They are not replayed commands, so
1196+
they are not flow steps either. Hover follows that rule. A hover that reveals
1197+
a login form still returns `AUTH_REQUIRED` through the existing challenge
1198+
path, and the hover itself is not stored for replay. Inspection does not
1199+
advertise hover because markup cannot prove that hovering has meaningful
1200+
behavior.
11891201

11901202
Generic drag remains deferred to
11911203
[#208](https://github.com/LockInTime/headless/issues/208). That contract must

0 commit comments

Comments
 (0)