Skip to content

Commit c566339

Browse files
authored
fix: scope frame actions and navigation outcomes to their owning frame (#228)
Skyvern-AI/rustwright-cloud#216 --------- Co-authored-by: suchintan <3853670+suchintan@users.noreply.github.com>
1 parent 6eb07e0 commit c566339

3 files changed

Lines changed: 4421 additions & 698 deletions

File tree

mcp/src/actor.rs

Lines changed: 109 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9663,7 +9663,7 @@ mod tests {
96639663
#moving { width: 140px; height: 44px; }
96649664
#moving.run { animation: move 320ms linear; }
96659665
#partially-offscreen { position: fixed; left: -1000px; top: 120px; width: 1100px; height: 44px; }
9666-
#detach { display: block; margin-top: 1800px; width: 140px; height: 44px; }
9666+
#detach { position: absolute; left: 0; top: 100000px; width: 140px; height: 44px; }
96679667
@keyframes move { from { transform: translateX(0); } to { transform: translateX(240px); } }
96689668
</style>
96699669
<button id="hidden">Hidden</button>
@@ -9701,14 +9701,58 @@ mod tests {
97019701
});
97029702
}
97039703
const detached = document.querySelector('#detach');
9704-
// Deliberately rely on the engine calling this property: synchronous removal makes
9705-
// its next snapshot see isConnected === false; async observer delivery races dispatch
9706-
// and the winner has changed between Chromium builds.
9707-
const nativeScrollIntoView = detached.scrollIntoView;
9708-
detached.scrollIntoView = function(options) {
9709-
nativeScrollIntoView.call(this, options);
9710-
this.remove();
9704+
globalThis.detachedPointerEvents = [];
9705+
globalThis.detachedScroll = null;
9706+
globalThis.detachArmed = false;
9707+
globalThis.detachStartScrollY = 0;
9708+
globalThis.detachMotionTick = 0;
9709+
for (const name of ['mousedown', 'mouseup', 'click']) {
9710+
detached.addEventListener(name, event => {
9711+
globalThis.detachedPointerEvents.push({
9712+
type: event.type,
9713+
trusted: event.isTrusted,
9714+
});
9715+
});
9716+
}
9717+
// DOM geometry is frame-discrete, so two samples in the same frame are
9718+
// identical for any fixture. While the element exists, this fixture guarantees
9719+
// any two samples at least one frame (~16ms) apart differ: motion ticks run
9720+
// under both rAF and timer scheduling, and every tick moves the target by one
9721+
// CSS pixel (above the engine's 0.5px tolerance). A stability sampler that
9722+
// spaces samples closer than one frame measures sub-frame stability, which
9723+
// cannot observe motion by construction. Removal is scheduling-independent:
9724+
// the synchronous scroll listener and the interval and rAF monitors all call
9725+
// detachAfterScroll.
9726+
function advanceDetachMotion() {
9727+
if (!globalThis.detachArmed || !detached.isConnected) return;
9728+
globalThis.detachMotionTick += 1;
9729+
detached.style.transform = `translateY(${globalThis.detachMotionTick}px)`;
9730+
}
9731+
const detachAfterScroll = () => {
9732+
if (!globalThis.detachArmed || !detached.isConnected) return;
9733+
if (scrollY === globalThis.detachStartScrollY) return;
9734+
globalThis.detachedScroll = {
9735+
from: globalThis.detachStartScrollY,
9736+
to: scrollY,
9737+
};
9738+
detached.remove();
9739+
};
9740+
addEventListener('scroll', detachAfterScroll, { passive: true });
9741+
// This harness owns a --headless=new browser with no user-visible window, so
9742+
// another application cannot occlude it and requestAnimationFrame stays live.
9743+
// The interval monitor still covers throttled frames, while the scroll listener
9744+
// removes synchronously during event dispatch regardless of either scheduler.
9745+
const monitorDetach = () => {
9746+
advanceDetachMotion();
9747+
detachAfterScroll();
9748+
if (detached.isConnected) requestAnimationFrame(monitorDetach);
97119749
};
9750+
requestAnimationFrame(monitorDetach);
9751+
const detachMonitorTimer = setInterval(() => {
9752+
advanceDetachMotion();
9753+
detachAfterScroll();
9754+
if (!detached.isConnected) clearInterval(detachMonitorTimer);
9755+
}, 16);
97129756
fetch('/capture?events=actionability-ready');
97139757
</script>"#
97149758
.to_owned()
@@ -10603,10 +10647,63 @@ mod tests {
1060310647
&& event["clientX"] == json!(0))
1060410648
);
1060510649

10606-
assert_actionability(
10607-
page.click("#detach", ActionOptions::timeout(3_000.0))
10608-
.expect_err("detached target must not click"),
10609-
ActionabilityError::Detached,
10650+
let detached_precondition = page
10651+
.evaluate(
10652+
"(() => {
10653+
const target = document.querySelector('#detach');
10654+
const rect = target.getBoundingClientRect();
10655+
globalThis.detachStartScrollY = scrollY;
10656+
globalThis.detachArmed = true;
10657+
advanceDetachMotion();
10658+
return {
10659+
top: rect.top,
10660+
bottom: rect.bottom,
10661+
viewportHeight: innerHeight,
10662+
scrollY,
10663+
};
10664+
})()",
10665+
None,
10666+
ActionOptions::timeout(1_000.0),
10667+
)
10668+
.expect("arm detached target fixture");
10669+
let detached_top = detached_precondition["top"]
10670+
.as_f64()
10671+
.expect("detached fixture top");
10672+
let viewport_height = detached_precondition["viewportHeight"]
10673+
.as_f64()
10674+
.expect("detached fixture viewport height");
10675+
assert!(
10676+
detached_top > viewport_height * 2.0,
10677+
"detached fixture precondition failed: target must start far below the viewport: \
10678+
{detached_precondition}"
10679+
);
10680+
10681+
let detached_click = page.click("#detach", ActionOptions::timeout(3_000.0));
10682+
let detached_evidence = page
10683+
.evaluate(
10684+
"({
10685+
scroll: globalThis.detachedScroll,
10686+
pointerEvents: globalThis.detachedPointerEvents,
10687+
connected: document.querySelector('#detach')?.isConnected ?? false,
10688+
})",
10689+
None,
10690+
ActionOptions::timeout(1_000.0),
10691+
)
10692+
.expect("read detached target evidence");
10693+
assert!(
10694+
detached_evidence["scroll"].is_object(),
10695+
"detached fixture precondition failed: actionability never scrolled the target: \
10696+
{detached_evidence}"
10697+
);
10698+
let detached_error = match detached_click {
10699+
Err(error) => error,
10700+
Ok(()) => panic!("detached target must not click: {detached_evidence}"),
10701+
};
10702+
assert_actionability(detached_error, ActionabilityError::Detached);
10703+
assert_eq!(
10704+
detached_evidence["pointerEvents"],
10705+
json!([]),
10706+
"detached target must not receive pointer events"
1061010707
);
1061110708

1061210709
page.goto(

0 commit comments

Comments
 (0)