@@ -493,6 +493,32 @@ expect(okToBefore < dialogToOk,
493493// canvas (not just 5px nudges), keyboard input on focused fields, and
494494// rapid-fire clicks designed to overlap with the EDT paint cadence.
495495console . log ( '\n=== Test 2: Repeated interactions (black-square detection) ===' ) ;
496+
497+ // Liveness probe: if Test 1 left the worker in a stuck state (Hello
498+ // dialog modal blocks subsequent input), Test 2's interactions all
499+ // silently no-op and the blackFrac/transparentFrac deltas stay 0,
500+ // passing vacuously. Confirm the canvas is responsive to a known-good
501+ // action before running the stress loop -- otherwise tag the run as
502+ // stuck up front instead of pretending the stress passed clean.
503+ const sigBeforeTest2Probe = await canvasSignature ( ) ;
504+ await page . mouse . click ( 593 , 905 ) ; // IDE expander row -- toggles visible
505+ let test2Live = false ;
506+ for ( let i = 0 ; i < 8 ; i ++ ) {
507+ await new Promise ( r => setTimeout ( r , 250 ) ) ;
508+ const sigNow = await canvasSignature ( ) ;
509+ if ( sigBeforeTest2Probe && sigHamming ( sigBeforeTest2Probe , sigNow ) > 1 ) {
510+ test2Live = true ;
511+ break ;
512+ }
513+ }
514+ console . log ( `Test 2 liveness probe: canvas responsive=${ test2Live } ` ) ;
515+ if ( ! test2Live ) {
516+ // Snap a screenshot for the artifact bundle so a CI failure has
517+ // something visible to point at.
518+ await snapshotCanvas ( 'test2-stuck-canvas' ) ;
519+ }
520+ expect ( test2Live , `Test 2 precondition: worker is stuck (canvas unresponsive 2s after a known-good click) — likely Test 1 left an open Dialog whose modal starves subsequent input` ) ;
521+
496522const baseSig = sigBefore ;
497523let darkenedFrames = 0 ;
498524let maxDelta = 0 ;
@@ -619,6 +645,156 @@ addInteraction('quick-clicks-on-preview', async () => {
619645 }
620646} ) ;
621647
648+ // ----------------------------------------------------------------------
649+ // Stress interactions designed to trigger sticking / paint artifacts.
650+ // Each one tries a different way of overlapping press/release with
651+ // transitions, paints, or focus changes -- exactly the seams where the
652+ // PR #4795 dropped-release race lived.
653+ // ----------------------------------------------------------------------
654+
655+ // Rapid alternating presses across distant widgets. Stresses the
656+ // generator-yield interleaving between simultaneously-running onMouseDown
657+ // invocations -- the same race that dropped Hello's pointerReleased before
658+ // the deferredRelease fix.
659+ addInteraction ( 'alternating-cross-form-clicks' , async ( ) => {
660+ const pts = [ [ 180 , 525 ] , [ 936 , 141 ] , [ 180 , 580 ] , [ 936 , 250 ] , [ 640 , 870 ] ] ;
661+ for ( let i = 0 ; i < 12 ; i ++ ) {
662+ const [ x , y ] = pts [ i % pts . length ] ;
663+ await page . mouse . click ( x , y , { delay : 5 } ) ;
664+ await new Promise ( r => setTimeout ( r , 25 ) ) ;
665+ }
666+ } ) ;
667+
668+ // Triple-tap rapid burst on a single element. Each tap is a full
669+ // down-up cycle with no inter-event delay -- the browser fires
670+ // pointerdown/up back-to-back, exercising the onMouseDown JSO yield
671+ // window onMouseUp can interleave on.
672+ addInteraction ( 'triple-tap-burst' , async ( ) => {
673+ for ( let i = 0 ; i < 3 ; i ++ ) {
674+ await page . mouse . click ( 348 , 690 , { delay : 0 } ) ;
675+ }
676+ } ) ;
677+
678+ // Long-press: hold down for 1.5s with no movement. Browsers fire
679+ // pointerdown immediately and pointerup only on the eventual release;
680+ // in the meantime the worker should stay responsive (animation frames,
681+ // other clicks). Stuck-state check below verifies that.
682+ addInteraction ( 'long-press-hold' , async ( ) => {
683+ await page . mouse . move ( 348 , 690 ) ;
684+ await page . mouse . down ( ) ;
685+ await new Promise ( r => setTimeout ( r , 1500 ) ) ;
686+ await page . mouse . up ( ) ;
687+ } ) ;
688+
689+ // Press at A, drag to B, release at B (different visual element). On
690+ // the JS port the press's pointerPressed targets the form at (A); the
691+ // release lands on the form at (B). Mismatched press/release form
692+ // matching is a known dropped-release case and surfaced the deferred-
693+ // release fix.
694+ addInteraction ( 'drag-with-distant-release' , async ( ) => {
695+ await page . mouse . move ( 180 , 525 ) ;
696+ await page . mouse . down ( ) ;
697+ for ( let t = 0 ; t <= 10 ; t ++ ) {
698+ await page . mouse . move ( 180 + t * 50 , 525 + t * 30 , { steps : 1 } ) ;
699+ await new Promise ( r => setTimeout ( r , 12 ) ) ;
700+ }
701+ await page . mouse . up ( ) ;
702+ } ) ;
703+
704+ // Clicks during in-flight Form transition: trigger the IDE expander
705+ // (which runs a transition-style relayout) and immediately click again
706+ // before the relayout settles. If the worker drops events while paint
707+ // frames stack up, the second click goes nowhere.
708+ addInteraction ( 'click-during-relayout' , async ( ) => {
709+ await page . mouse . click ( 640 , 870 ) ; // "Generate Project" wide bar
710+ // Don't await the layout settle -- click again immediately
711+ await page . mouse . click ( 180 , 525 , { delay : 0 } ) ;
712+ await page . mouse . click ( 348 , 690 , { delay : 0 } ) ;
713+ await new Promise ( r => setTimeout ( r , 400 ) ) ;
714+ } ) ;
715+
716+ // Type-then-burst-backspace: rapid edits on a focused field. Stresses
717+ // the keydown/keypress/keyup pipeline and any pending-text-changes
718+ // flush the JS port does on each event.
719+ addInteraction ( 'type-burst-then-backspace-burst' , async ( ) => {
720+ await page . mouse . click ( 300 , 300 ) ;
721+ await new Promise ( r => setTimeout ( r , 200 ) ) ;
722+ await page . keyboard . type ( 'abcdefghij' , { delay : 5 } ) ;
723+ for ( let i = 0 ; i < 10 ; i ++ ) {
724+ await page . keyboard . press ( 'Backspace' , { delay : 5 } ) ;
725+ }
726+ } ) ;
727+
728+ // Tab navigation: keyboard-only walk through the form. Hits any
729+ // focus-change paint paths that mouse interaction skips.
730+ addInteraction ( 'keyboard-tab-walk' , async ( ) => {
731+ for ( let i = 0 ; i < 8 ; i ++ ) {
732+ await page . keyboard . press ( 'Tab' ) ;
733+ await new Promise ( r => setTimeout ( r , 50 ) ) ;
734+ }
735+ } ) ;
736+
737+ // Rapid wheel scrolling in alternating directions: stresses the
738+ // drag-event/wheel-event cooperative cadence.
739+ addInteraction ( 'wheel-jitter' , async ( ) => {
740+ await page . mouse . move ( 640 , 500 ) ;
741+ for ( let i = 0 ; i < 20 ; i ++ ) {
742+ await page . mouse . wheel ( 0 , i % 2 === 0 ? 80 : - 80 ) ;
743+ await new Promise ( r => setTimeout ( r , 15 ) ) ;
744+ }
745+ } ) ;
746+
747+ // Click outside the canvas (in the surrounding page chrome). Should be
748+ // a complete no-op for the worker -- but the host's window-level
749+ // listener still serialises and posts. If the worker treats out-of-
750+ // bounds events differently, the resulting bookkeeping mismatch could
751+ // stick mouseDown.
752+ addInteraction ( 'click-outside-canvas' , async ( ) => {
753+ await page . mouse . click ( 10 , 10 ) ;
754+ await new Promise ( r => setTimeout ( r , 50 ) ) ;
755+ await page . mouse . click ( 1270 , 10 ) ;
756+ await new Promise ( r => setTimeout ( r , 50 ) ) ;
757+ await page . mouse . click ( 640 , 890 ) ;
758+ } ) ;
759+
760+ // Right-click (button=2) on the preview canvas. The JS port has a
761+ // dedicated context-menu hook (``contextListener.handleEvent`` in
762+ // onMouseDown when ``me.getButton() == 2``); a stuck pointer state in
763+ // that path would block subsequent left clicks.
764+ addInteraction ( 'right-click-then-left-click' , async ( ) => {
765+ await page . mouse . click ( 936 , 250 , { button : 'right' } ) ;
766+ await new Promise ( r => setTimeout ( r , 100 ) ) ;
767+ // dismiss any context menu, then hit a normal target
768+ await page . keyboard . press ( 'Escape' ) ;
769+ await new Promise ( r => setTimeout ( r , 100 ) ) ;
770+ await page . mouse . click ( 180 , 525 ) ;
771+ } ) ;
772+
773+ // Tiny drag (under drag-threshold). The user-perceived event is a
774+ // click, but the JS port emits pointerdown/move/up with nontrivial
775+ // movement deltas. Easy to mis-classify and drop.
776+ addInteraction ( 'sub-threshold-jitter-click' , async ( ) => {
777+ await page . mouse . move ( 348 , 690 ) ;
778+ await page . mouse . down ( ) ;
779+ await page . mouse . move ( 350 , 691 ) ;
780+ await page . mouse . move ( 348 , 690 ) ;
781+ await page . mouse . up ( ) ;
782+ } ) ;
783+
784+ // Resize-during-drag: viewport changes while a drag is held. The
785+ // canvas re-allocates; if the in-flight pointer state isn't reset,
786+ // the next click lands in stale coords.
787+ addInteraction ( 'resize-during-drag' , async ( ) => {
788+ await page . mouse . move ( 640 , 400 ) ;
789+ await page . mouse . down ( ) ;
790+ await page . setViewportSize ( { width : 1100 , height : 800 } ) ;
791+ await new Promise ( r => setTimeout ( r , 300 ) ) ;
792+ await page . mouse . move ( 500 , 350 ) ;
793+ await page . mouse . up ( ) ;
794+ await page . setViewportSize ( { width : 1280 , height : 900 } ) ;
795+ await new Promise ( r => setTimeout ( r , 300 ) ) ;
796+ } ) ;
797+
622798const blackFractions = [ ] ;
623799let transparentFrames = 0 ;
624800let maxTransparentDelta = 0 ;
@@ -649,6 +825,48 @@ expect(transparentFrames < 2, `Test 2: ${transparentFrames}/${interactions.lengt
649825
650826await snapshotCanvas ( 'after-many-interactions' ) ;
651827
828+ // === Test 3: Liveness after stress ===
829+ // After all the rapid clicks/drags/keys above, verify the worker is
830+ // still responsive. The check: pick a known-actionable target (the
831+ // Generate Project banner toggles a download URL but visibly highlights
832+ // on press); click it and confirm the canvas changes within a generous
833+ // timeout. If the canvas freezes here, some interaction above wedged
834+ // the EDT or worker-listener path.
835+ console . log ( '\n=== Test 3: Liveness after Test 2 stress ===' ) ;
836+ const sigBeforeLiveness = await canvasSignature ( ) ;
837+ await page . mouse . click ( 640 , 870 ) ; // Generate Project button
838+ let sigChanged = false ;
839+ let livenessIters = 0 ;
840+ for ( let i = 0 ; i < 20 ; i ++ ) {
841+ await new Promise ( r => setTimeout ( r , 250 ) ) ;
842+ const sigNow = await canvasSignature ( ) ;
843+ livenessIters = i + 1 ;
844+ if ( sigBeforeLiveness && sigHamming ( sigBeforeLiveness , sigNow ) > 1 ) {
845+ sigChanged = true ;
846+ break ;
847+ }
848+ }
849+ console . log ( `liveness: canvas changed=${ sigChanged } after ${ livenessIters * 250 } ms` ) ;
850+ expect ( sigChanged , `Test 3: UI appears stuck — canvas unchanged 5s after Generate-Project click (worker likely starved)` ) ;
851+
852+ // === Test 4: Rapid open/close cycles on collapsible sections ===
853+ // The form has IDE / Theme Customization / Localization / Java Version /
854+ // Current Settings sections that expand on click. Rapidly expand and
855+ // collapse one to stress the layout/animation pipeline. After the
856+ // burst, the canvas signature should have stabilized (no transparent
857+ // pixels left over from a half-finished animation frame).
858+ console . log ( '\n=== Test 4: Rapid expand/collapse on collapsible section ===' ) ;
859+ for ( let i = 0 ; i < 6 ; i ++ ) {
860+ await page . mouse . click ( 593 , 905 ) ; // IDE expander row
861+ await new Promise ( r => setTimeout ( r , 80 ) ) ;
862+ }
863+ await new Promise ( r => setTimeout ( r , 800 ) ) ;
864+ const sigAfterCollapseStress = await canvasSignature ( ) ;
865+ const transparentDelta4 = sigAfterCollapseStress . transparentFrac - baseSig . transparentFrac ;
866+ console . log ( `collapse-stress final transparentFrac=${ sigAfterCollapseStress . transparentFrac . toFixed ( 3 ) } (delta=${ transparentDelta4 >= 0 ? '+' : '' } ${ transparentDelta4 . toFixed ( 3 ) } )` ) ;
867+ expect ( transparentDelta4 < 0.02 , `Test 4: rapid expand/collapse left ${ ( transparentDelta4 * 100 ) . toFixed ( 1 ) } % transparent pixels — canvas-cleared-but-not-repainted` ) ;
868+ await snapshotCanvas ( 'after-collapse-stress' ) ;
869+
652870// === Diagnostic: dump the worker-side trace events ===
653871const trace = await page . evaluate ( ( ) => {
654872 // We need to ask the worker for its trace because hooks live in the
0 commit comments