Skip to content

Commit 42d79f9

Browse files
committed
test(initializr): add stress interactions, liveness probe, stuck-state detection
The original Test 2 ran 9 mostly-friendly interactions and a single visual check at the end, so silent stuck states (e.g. a Dialog modal that starves the worker) could pass vacuously: blackFrac/transparentFrac deltas stay 0 because the canvas can't change at all. Add 11 new aggressive interactions that target the seams where the PR #4795 dropped-release race lived -- alternating cross-form clicks, triple-tap bursts, long-press, drag-with-distant-release, click-during- relayout, type-then-backspace bursts, keyboard-tab walk, wheel jitter, out-of-canvas clicks, right-click->left-click, sub-threshold jitter, and resize-during-drag. Each is designed to overlap press/release with transitions, paints, or focus changes. Also add three explicit guards: - Test 2 precondition liveness probe: click a known-good target and fail fast if the canvas doesn't change within 2s. Without this, a worker stuck behind an undismissable Dialog let Test 2 pass clean. - Test 3 post-stress liveness check: after the full interaction loop, click the Generate-Project banner and verify the canvas changes within 5s. Catches stuck states that only manifest after a stress cycle. - Test 4 collapsible-section rapid-toggle stress: 6 fast clicks on the IDE expander with a final transparent-pixel sanity check, to surface canvas-cleared-but-not-repainted regressions on the layout-animation path.
1 parent b5a012c commit 42d79f9

1 file changed

Lines changed: 218 additions & 0 deletions

File tree

scripts/test-initializr-interaction.mjs

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
495495
console.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+
496522
const baseSig = sigBefore;
497523
let darkenedFrames = 0;
498524
let 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+
622798
const blackFractions = [];
623799
let transparentFrames = 0;
624800
let maxTransparentDelta = 0;
@@ -649,6 +825,48 @@ expect(transparentFrames < 2, `Test 2: ${transparentFrames}/${interactions.lengt
649825

650826
await 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 ===
653871
const trace = await page.evaluate(() => {
654872
// We need to ask the worker for its trace because hooks live in the

0 commit comments

Comments
 (0)