Catch an exit half-way on a slow machine, not only on a fast one
tests / feature (8.4) (push) Successful in 1m45s
tests / feature (8.5) (push) Successful in 2m0s
tests / browser (chrome, chromium) (push) Successful in 8m6s
tests / browser (firefox, firefox) (push) Failing after 12m52s
tests / browser (safari, webkit) (push) Failing after 13m1s

The browser suite passes on every engine here and fails on the runner,
which takes three times as long: each failing test triggers a close and
then samples for an in-between state — a rail part-way out, a scrim
part-way faded, a menu's exit copy part-way sunk — and a starved runner
takes its one sample after the 150-650ms exit has already finished.

So the tests that assert *that* something animates now stretch every
motion duration token to three seconds first (`slowMotion()`, beside
`ready()` in tests/Pest.php; ActionsTest's own copy of it goes). The two
polling helpers grew their budget to match: the rail panel and the sheet
slide on emphasized-accelerate, which is under 1% of its travel at a
quarter of the way through, so a 300ms window no longer reached the
threshold once the exit itself was three seconds long.

The two full-screen date picker tests waited for a resize through
click()'s own retry, which ate the whole 15s budget on Firefox; they now
wait for the new width and a settled document first. The bottom sheet's
preset test waits for its entry to finish before pressing the grip.

`hold()` in search.js reads the view's animations a frame after the
closed state, as the rail's settle() does, but one frame is not always
enough: an engine that starts them on its next tick shows none, and the
full-screen layout would end at once, mid-exit. An empty list is now
asked again on the following frame.

Feature 1159 passed. Browser 299 passed on Chrome, Firefox and WebKit,
and again on WebKit under ten spinning cores.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andreas Reinhold / reini
2026-09-17 23:57:43 +02:00
co-authored by Claude Opus 5
parent 145f60aa11
commit 94d6e31432
7 changed files with 82 additions and 21 deletions
+14 -1
View File
@@ -162,15 +162,28 @@ document.addEventListener('alpine:init', () => {
this.leaving = true
requestAnimationFrame(() => {
// A frame on, the closed state has met the style and the exit's transitions exist, as
// the rail's own settle() reads them. One frame is not always enough: an engine that
// starts them on its next refresh tick would show none here, and holding for nothing
// would end the full-screen layout at once, mid-exit. So an empty list is asked again
// on the following frame before it counts as "nothing to wait for".
const hold = (frame) => requestAnimationFrame(() => {
const animations = this.$refs.view?.getAnimations() ?? []
if (animations.length === 0 && frame === 0) {
hold(1)
return
}
Promise.allSettled(animations.map((animation) => animation.finished)).then(() => {
if (leaving === this.leavings) {
this.leaving = false
}
})
})
hold(0)
},
clear() {
+1 -15
View File
@@ -993,20 +993,6 @@ it('scrolls the FAB menu\'s items on a short window, behind the close button, wh
$page->assertScript("{$trigger}.getBoundingClientRect().top === window.__fabTop");
});
/**
* Slows the page's motion tokens, so a popover's exit copy (resources/js/popover-exit.js) is
* still on screen after a separate round trip to the browser: a key press or a click already
* costs about as long as a 150350ms exit, and the copy moves on the tokens as the popover would.
*/
function slowMotion(mixed $page, string $duration = '1500ms'): mixed
{
$page->script(<<<JS
document.head.insertAdjacentHTML('beforeend', '<style>:root { --md-sys-motion-effects-fast-duration: {$duration}; --md-sys-motion-effects-default-duration: {$duration}; --md-sys-motion-spatial-fast-duration: {$duration}; --md-sys-motion-spatial-default-duration: {$duration}; }</style>')
JS);
return $page;
}
/** The exit copy on screen: shown, inert, hidden from assistive technology, with no ids of its own. */
const EXIT_COPY = "(() => { const copy = document.querySelector('[data-md-popover-ghost]'); return copy !== null && copy.matches(':popover-open') && copy.inert && copy.getAttribute('aria-hidden') === 'true' && ! copy.hasAttribute('id') && copy.querySelector('[id], [popover]:not([data-md-popover-ghost])') === null; })()";
@@ -1030,7 +1016,7 @@ it('fades a menu out after the browser has closed it, on Escape or a press outsi
// The copy is decoration: Alpine starts nothing inside it.
->assertScript("window.eval(\"[...document.querySelectorAll('[data-md-popover-ghost], [data-md-popover-ghost] *')].every((element) => element._x_dataStack === undefined)\")")
// Gone once the slowest of its transitions has run.
->assertScript('new Promise((resolve) => setTimeout(() => resolve(document.querySelector("[data-md-popover-ghost]") === null), 1800))');
->assertScript('new Promise((resolve) => setTimeout(() => resolve(document.querySelector("[data-md-popover-ghost]") === null), 3300))');
// A press outside it.
$page->click(MORE)
+13 -3
View File
@@ -435,7 +435,10 @@ it('cycles a bottom sheet\'s preset heights from its handle, announcing each, an
$announce = "{$sheet}.querySelector('[data-md-bottom-sheet-announce]')";
$page->assertScript("getComputedStyle({$sheet}).display !== 'none'")
->assertScript("{$sheet}.style.getPropertyValue('--sheet-stop').trim() === '50dvh'");
->assertScript("{$sheet}.style.getPropertyValue('--sheet-stop').trim() === '50dvh'")
// Settled, not still sliding in: a grip pressed while the entry is still under way would
// read as unstable on a loaded runner, the way opening it does elsewhere in this file.
->assertScript("{$sheet}.getAnimations({ subtree: true }).length === 0");
$gripSelector = '#containment [data-md-bottom-sheet-panel][data-md-preset] [data-md-bottom-sheet-grip]';
@@ -992,6 +995,8 @@ it('fades the sheet\'s scrim out on close, rather than making it vanish', functi
->assertScript("getComputedStyle({$sheet}).display !== 'none'")
->assertScript("getComputedStyle({$scrim}).opacity === '1'");
slowMotion($page);
// Triggering the close and sampling for a mid-fade opacity in the same round trip: a round
// trip apiece for a separate `script` and `assertScript` already costs real time, easily as
// much as the 200ms fade itself, so a click, then a later separate read, can just as easily
@@ -1026,16 +1031,21 @@ it('slides the sheet out on close, rather than making it vanish', function (stri
->assertScript("getComputedStyle({$sheet}).display !== 'none'")
->assertScript("Math.abs({$offset}) < 0.5");
slowMotion($page);
// Sampled in the page, in the same round trip as the close, as the scrim's fade is above: the
// sheet must be caught still displayed and part of the way to its closed offset (one sheet's
// own size past its edge), not gone at once — which is what Firefox showed while the exit
// leaned on `allow-discrete` holding `display`.
// leaned on `allow-discrete` holding `display`. 400 iterations (2000ms): the sheet closes on
// the emphasized-accelerate easing, which stays close to its start for a good part of its run,
// so slowMotion()'s stretched exit needs longer than a scrim's fade (a spring, front-loaded)
// before the offset has moved past a pixel.
$midSlide = $page->script(<<<JS
(async () => {
const size = {$sheet}.getBoundingClientRect().{$dimension}
{$scrim}.click()
for (let i = 0; i < 60; i++) {
for (let i = 0; i < 400; i++) {
const style = getComputedStyle({$sheet})
const offset = {$sample}
if (style.display !== 'none' && offset > 1 && offset < size - 1) return true
+5
View File
@@ -506,6 +506,10 @@ it('opens the range picker full screen below 600px and grows its month list both
$page = dateProbe()
->resize(390, 800)
// Explicit, not implicit: the resize's own reflow can still be under way when the click
// that follows fires, which is what made this time out on a loaded runner — the toggle's
// box kept moving under the click rather than the click itself being slow.
->assertScript('window.innerWidth === 390 && document.documentElement.getAnimations({ subtree: true }).length === 0')
->click('[aria-controls="trip-field-picker"][data-md-datepicker-toggle]')
->assertScript("{$picker}.dataset.mdPresentation === 'full'")
->assertScript("{$picker}.matches(':modal')")
@@ -552,6 +556,7 @@ it('closes the full-screen range picker from its app bar close button without ke
dateProbe()
->resize(390, 800)
->assertScript('window.innerWidth === 390 && document.documentElement.getAnimations({ subtree: true }).length === 0')
->click('[aria-controls="trip-field-picker"][data-md-datepicker-toggle]')
->click(day('trip-field', '2026-09-20'))
->click('#trip-field-picker [data-md-datepicker-close]')
+11 -2
View File
@@ -647,7 +647,10 @@ it('reads expanded while a modal rail is open over the page, and collapsed once
* Closes a rail with the given script and reports whether, sampling every few ms in the page
* itself, it was ever caught part-way: the whole close in one round trip, since a separate
* `script` and `assertScript` apiece already take as long as the 200ms exit (see ContainmentTest's
* scrim fades).
* scrim fades). Called after slowMotion(), so the panel's own close — on the emphasized-accelerate
* easing, which stays close to its start for a good part of its run — still needs a couple of
* seconds of that stretched exit before its position has moved the couple of pixels `$partWay`
* looks for; 2000ms (400 × 5ms) covers that with room to spare, short of the full exit ending.
*/
function caughtMidClose(mixed $page, string $close, string $partWay): bool
{
@@ -655,7 +658,7 @@ function caughtMidClose(mixed $page, string $close, string $partWay): bool
(async () => {
{$close}
for (let i = 0; i < 60; i++) {
for (let i = 0; i < 400; i++) {
if ({$partWay}) return true
await new Promise((resolve) => setTimeout(resolve, 5))
}
@@ -686,6 +689,8 @@ it('slides the compact rail out on close, rather than making it vanish', functio
->assertScript(RAIL.".hasAttribute('data-md-open')")
->assertScript(settled(RAIL_PANEL).' && Math.round('.RAIL_PANEL.'.getBoundingClientRect().left) === 0');
slowMotion($page);
expect(caughtMidClose($page, "document.querySelector('[data-md-navigation-rail-scrim]').click()", slidingOut(RAIL_PANEL)))->toBeTrue();
$page->assertScript('getComputedStyle('.RAIL_PANEL.").display === 'none'")
@@ -704,6 +709,8 @@ it('slides a rail that hides when collapsed out on close, rather than making it
->assertScript("{$rail}.hasAttribute('data-md-open')")
->assertScript(settled($panel)." && Math.round({$panel}.getBoundingClientRect().left) === 0 && Math.round({$panel}.getBoundingClientRect().width) === 256");
slowMotion($page);
expect(caughtMidClose($page, "{$rail}.querySelector(':scope > [data-md-navigation-rail-scrim]').click()", slidingOut($panel)))->toBeTrue();
$page->assertScript("getComputedStyle({$panel}).display === 'none'")
@@ -721,6 +728,8 @@ it('fades a modal rail\'s scrim out on close while the rail stands collapsed in
$fading = "(getComputedStyle({$scrim}).display !== 'none' && parseFloat(getComputedStyle({$scrim}).opacity) > 0.02 && parseFloat(getComputedStyle({$scrim}).opacity) < 0.98)";
slowMotion($page);
expect(caughtMidClose($page, "{$scrim}.click()", $fading))->toBeTrue();
// The panel goes straight back into the layout: only the scrim was over the page.
+4
View File
@@ -246,6 +246,8 @@ it('fades the docked search\'s scrim out on close, rather than making it vanish'
// `display: none` at the open state's full opacity.
->assertScript("(({ display, opacity }) => display !== 'none' && opacity === '1')(getComputedStyle({$root}.querySelector('[data-md-search-scrim]')))");
slowMotion($page);
expect($page->script(searchCloseSample('[data-md-search-scrim]', 'parseFloat(getComputedStyle(element).opacity)')))->toBeTrue();
$page->assertScript("getComputedStyle({$root}.querySelector('[data-md-search-scrim]')).display === 'none'");
@@ -258,6 +260,8 @@ it('keeps the docked search above the page while its view closes', function () {
->click('#find')
->assertScript("getComputedStyle({$view}).display !== 'none' && getComputedStyle({$view}).opacity === '1'");
slowMotion($page);
// The view caught part-way through its fade only counts while the root is still at z-index 50.
expect($page->script(searchCloseSample('[data-md-search-view]', "getComputedStyle(root).zIndex === '50' ? parseFloat(getComputedStyle(element).opacity) : 0")))->toBeTrue();
+34
View File
@@ -33,6 +33,40 @@ function layoutRoot(string $html, ?string $tag = null): array
return $root + collect($pairs)->mapWithKeys(fn (array $pair): array => [$pair[1] => $pair[2] ?? ''])->all();
}
/**
* Stretches every motion duration token (resources/css/tokens/motion.css) on :root to $duration,
* so a test that has to catch an exit half-way is not racing the machine: a round trip for a
* separate script() and assertScript() already costs real time, easily as much as a real exit
* (150650ms), so a single sample taken after triggering one can just as easily land before it
* starts or after it has already finished on a loaded runner. Chain it, after the open has
* settled, immediately before the action that triggers the exit being sampled.
*
* Use this only in a test asserting *that* something animates — a part-way opacity, offset or
* transform. Never in a test asserting a duration value, which this would make wrong.
*/
function slowMotion(mixed $page, string $duration = '3s'): mixed
{
$tokens = [
'--md-sys-motion-spatial-fast-duration',
'--md-sys-motion-spatial-default-duration',
'--md-sys-motion-spatial-slow-duration',
'--md-sys-motion-effects-fast-duration',
'--md-sys-motion-effects-default-duration',
'--md-sys-motion-effects-slow-duration',
'--md-sys-motion-duration-short',
'--md-sys-motion-duration-medium',
'--md-sys-motion-duration-long',
];
$css = collect($tokens)->map(fn (string $token): string => "{$token}: {$duration} !important;")->implode(' ');
$page->script(<<<JS
document.head.insertAdjacentHTML('beforeend', '<style>:root { {$css} }</style>')
JS);
return $page;
}
/**
* The page is done loading, and Alpine and/or Livewire (whichever the page renders) have booted —
* chained after visit(), before a Browser test probe reads anything either one wires up. Most