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 */ 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(<<:root { {$css} }') 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 << { 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(<< { {$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); }