@@ -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