tests / feature (8.4) (push) Successful in 1m49s
tests / feature (8.5) (push) Successful in 1m51s
tests / browser (chrome, chromium) (push) Successful in 7m34s
tests / browser (firefox, firefox) (push) Failing after 14m19s
tests / browser (safari, webkit) (push) Failing after 13m16s
On a slow machine Escape could close the full-screen search and have it open again for good. A close hands focus back to the input — Escape does, and so does the view's focus trap as it lets go — and `focused()` told that return from someone coming to search by a 250ms wall-clock window. Both returns run on frames, and a runner painting a few frames a second took longer than that, so the returning focus opened the view again. A `returning` flag now covers the close until the hand-back has actually run, on the same frame, and the constant is gone. `hold()` times the full-screen layout from the exit's own duration token, the one Alpine's x-transition holds `display` for, instead of waiting a frame or two for the exit's transitions to appear: `getAnimations()` is empty both before an engine creates them and after they end, and a loaded engine can leave a second between frames. The browser tests were racing the same slow machine, reproduced in a Linux container like the runner, with its Playwright Firefox and WebKitGTK and two cores kept busy: - The browser plugin retries every script and action with a one-second attempt until the budget runs out. An in-page sleep longer than that only ever passed on the last attempt, which is where the 47-50s tests came from, and a script that clicks was run again against a page that had moved on. `onceInPage()` runs such a script once however often it is retried; the long sleeps are plain retried conditions now. - Under load WebKitGTK paints no frame while a tight setTimeout loop runs, so a sample loop saw the start value and then nothing. `caughtMidExit()` samples on animation frames, for 2.5s. - "No animations running" is also true before an opening transition exists, so a close could be sampled from a scrim at 4% opacity. `settled()` waits two frames before it asks. The workflow no longer uploads failure screenshots: Gitea's artifact service timed out on every attempt, two minutes per red run, and the job logs are readable without it. Feature 1159 passed. Browser 299 passed on Chrome, Firefox and WebKit on macOS, and on Firefox and WebKitGTK in the Linux container under load, twice each. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
208 lines
9.2 KiB
PHP
208 lines
9.2 KiB
PHP
<?php
|
||
|
||
use Illuminate\Support\Facades\File;
|
||
use NoNameWeb\LivewireMaterial\Tests\TestCase;
|
||
|
||
pest()->extend(TestCase::class)->in('Feature', 'Browser');
|
||
|
||
/**
|
||
* The attributes of a rendered component's tag, by name; a boolean attribute Blade renders as
|
||
* `name="name"`. Without `$tag`, it is the first tag anywhere in the rendered Blade (anchored to
|
||
* the start), and the element name comes back too, under `<` — the layout components' render
|
||
* tests, which assert what their root carries, read it this way. With `$tag` (`|`-separated for
|
||
* more than one, e.g. `'button|a'`), it is the first `<$tag>` anywhere in the rendered Blade
|
||
* instead, with no `<` key — for a component whose root is not the tag Blade renders first (a
|
||
* button or link inside a wrapper, a span), where the anchored match would read the wrong element.
|
||
*
|
||
* @return array<string, string>
|
||
*/
|
||
function layoutRoot(string $html, ?string $tag = null): array
|
||
{
|
||
if ($tag === null) {
|
||
preg_match('/^\s*<([a-z]+)\b([^>]*)>/s', $html, $match);
|
||
$root = ['<' => $match[1] ?? ''];
|
||
$attributes = $match[2] ?? '';
|
||
} else {
|
||
preg_match('/<(?:'.$tag.')\b([^>]*)>/', $html, $match);
|
||
$root = [];
|
||
$attributes = $match[1] ?? '';
|
||
}
|
||
|
||
preg_match_all('/([\w:.@-]+)(?:="([^"]*)")?/', $attributes, $pairs, PREG_SET_ORDER);
|
||
|
||
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
|
||
* (150–650ms), 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.
|
||
*
|
||
* Three seconds, not one: a loaded runner paints only a handful of frames a second, and a one-second
|
||
* exit can begin and end between two of them with no frame in between showing it part-way. It costs
|
||
* nothing in runtime — what made those tests take 45 seconds apiece was a script sleeping through
|
||
* the exit in the page, past the plugin's budget for one attempt (onceInPage()), not the stretch.
|
||
*
|
||
* 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;
|
||
}
|
||
|
||
/**
|
||
* A JS expression answering whether `$element` has finished whatever it animates — chained as an
|
||
* assertScript() after an open, before a test stretches motion and samples the exit, or before it
|
||
* presses something whose box is still moving. `$subtree` reads its descendants' animations too.
|
||
*
|
||
* Two animation frames first, because an empty `getAnimations()` means two different things: an
|
||
* entry that has run, and one the engine has not created yet. Until the first style update after
|
||
* `x-show` reveals an element its `@starting-style` entry does not exist, and the computed style
|
||
* already reads the open value, so a bare "no animations, opacity 1" passes on a page that has
|
||
* drawn nothing since the open — and the exit sampled from there turns out to be an entry barely
|
||
* begun, with nothing left to catch. A runner painting a handful of frames a second sits in that
|
||
* window for hundreds of milliseconds at a time.
|
||
*/
|
||
function settled(string $element, bool $subtree = false): string
|
||
{
|
||
$options = $subtree ? '{ subtree: true }' : '';
|
||
|
||
return <<<JS
|
||
(async () => {
|
||
await new Promise((resolve) => requestAnimationFrame(resolve))
|
||
await new Promise((resolve) => requestAnimationFrame(resolve))
|
||
|
||
return {$element}.getAnimations({$options}).length === 0
|
||
})()
|
||
JS;
|
||
}
|
||
|
||
/**
|
||
* Runs `$expression` — an async IIFE — in the page once, however often the plugin asks for it, and
|
||
* answers with what that one run returned.
|
||
*
|
||
* The plugin gives each script() and assertScript() attempt 1000ms and then runs the whole
|
||
* expression again, for 45 seconds, before one last attempt with the full timeout (AwaitableWebpage,
|
||
* Execution::waitForExpectation). Anything that takes longer than a second in the page therefore
|
||
* runs some fifty times and only succeeds on that last attempt, which costs 45 seconds a test —
|
||
* and where the expression presses something, every run after the first presses it again, on a page
|
||
* the first run has already left somewhere else, so what comes back describes none of them.
|
||
*/
|
||
function onceInPage(string $expression): string
|
||
{
|
||
$memo = '__onceInPage'.crc32($expression);
|
||
|
||
return "(() => (window.{$memo} ??= {$expression}))()";
|
||
}
|
||
|
||
/**
|
||
* A JS expression that runs `$trigger` in the page and then samples `$condition` there once per
|
||
* animation frame, answering whether it ever held: the whole exit and its sample in one round trip,
|
||
* since a separate script() and assertScript() apiece already cost as much as a real exit.
|
||
*
|
||
* On animation frames rather than a timer, which is what the sampling loops used to do: a loaded
|
||
* engine — WebKitGTK on the runner — stops painting while a tight `setTimeout` loop holds the main
|
||
* thread, so the CSS transition never advances and every sample reads the opening value, then
|
||
* `display: none`. The exit was reported as a vanishing when nothing had gone wrong but the frames.
|
||
* Asking for a frame both paces the loop to the renderer and makes it run, so each sample is a
|
||
* frame someone would have seen.
|
||
*
|
||
* The budget covers most of a slowMotion() exit, because a loaded runner can leave a second between
|
||
* two frames of a heavy page and the one frame that shows the exit half-way may be the third.
|
||
*
|
||
* Sampled once through onceInPage(), because triggering the exit a second time on a page whose
|
||
* first one is long finished would report a miss whatever the page did the first time.
|
||
*/
|
||
function caughtMidExit(string $trigger, string $condition, int $budget = 2500): string
|
||
{
|
||
return onceInPage(<<<JS
|
||
(async () => {
|
||
{$trigger}
|
||
|
||
const deadline = performance.now() + {$budget}
|
||
|
||
while (performance.now() < deadline) {
|
||
if ({$condition}) {
|
||
return true
|
||
}
|
||
|
||
await new Promise((resolve) => requestAnimationFrame(resolve))
|
||
}
|
||
|
||
return false
|
||
})()
|
||
JS);
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
* pages need both; one that renders no Livewire component needs Alpine alone, and vice versa.
|
||
*/
|
||
function ready(mixed $page, bool $alpine = true, bool $livewire = true): mixed
|
||
{
|
||
$checks = ["document.readyState === 'complete'"];
|
||
|
||
if ($alpine) {
|
||
$checks[] = "typeof window.Alpine !== 'undefined'";
|
||
}
|
||
|
||
if ($livewire) {
|
||
$checks[] = "typeof window.Livewire !== 'undefined'";
|
||
}
|
||
|
||
return $page->waitForEvent('networkidle')->assertScript(implode(' && ', $checks));
|
||
}
|
||
|
||
// A shared CI runner is slower than a workstation: an animation or a smooth scroll can take longer
|
||
// than the default five seconds to settle there. BROWSER_TIMEOUT (milliseconds) raises the limit.
|
||
if (($timeout = (int) getenv('BROWSER_TIMEOUT')) > 0) {
|
||
pest()->browser()->timeout($timeout);
|
||
}
|
||
|
||
/**
|
||
* The named block of resources/css/all.css — the content between its `/* Heading *\/` comment and
|
||
* the next one, or the end of the file for the last block ("Navigation"). components.css and
|
||
* layout.css grouped their imports the same way before all.css folded both together; this
|
||
* is the one place that block lookup lives now, so a stylesheet test asks "is this imported from
|
||
* the Containment block" without repeating the search in every file that needs it. It is here,
|
||
* rather than in tests/Feature/StylesheetsTest.php, because Pest.php is always loaded, whichever
|
||
* test file or path is run.
|
||
*/
|
||
function allCssBlock(string $heading): string
|
||
{
|
||
$all = File::get(__DIR__.'/../resources/css/all.css');
|
||
$marker = "/* {$heading} */";
|
||
$start = strpos($all, $marker);
|
||
|
||
if ($start === false) {
|
||
throw new RuntimeException("all.css has no `{$marker}` block.");
|
||
}
|
||
|
||
$next = strpos($all, '/*', $start + strlen($marker));
|
||
|
||
return $next === false ? substr($all, $start) : substr($all, $start, $next - $start);
|
||
}
|