Files
livewire-material/tests/Feature/ErrorPagesTest.php
T
Andreas Reinhold / reiniandClaude Sonnet 5 a8c2d88fbb Rewrite the error pages without Tailwind
Plan step 36 (containment group, error pages): errors::minimal's class
lists move into resources/css/components/error-page.css, keyed on
data-md-error-page/-art/-shape/-code/-headline/-message/-actions;
body carries no hook of its own and is styled by a bare `body`
selector, matching how ErrorPage::fallbackStyles() already styles it
(no other rewritten view is a full HTML document). The section
contract (title, code, headline, message, shape, actions) and the
framework's 401/402 compatibility are untouched, since neither depends
on a class or a hook. The shape's <x-shape> (a component outside this
batch, and one with no "fill the parent" prop) is coloured and sized
to its container by a plain `svg` descendant selector rather than a
class or a new prop. The page's font stays a literal system stack, not
var(--md-ref-typeface-brand): Tailwind's own `font-sans` utility this
replaces was never the brand font either
(resources/css/tokens/theme.css's --font-sans is the only utility
that is), and an error page must read before any webfont has loaded.

ErrorPage::fallbackStyles() draws onto the same hooks, renamed the
same way (data-error-* -> data-md-error-*, data-error-fallback ->
data-md-error-fallback) so the no-build path keeps working; how it is
built (a hand-written heredoc, not Stylesheets::bundle()) is
unchanged, per step 40. Updated in the same commit:
tests/Feature/ErrorPagesTest.php and tests/Browser/ErrorPagesTest.php.

The error pages' views live outside resources/views/components/, so
they are not in ContainmentStylesheetsTest's dataset (its "imports
what its view renders" check reads a fixed
resources/views/components/<name>.blade.php path); adapted
tests/Feature/ErrorPagesTest.php instead, with the same stylesheet-
shape, import, ViewClasses, token/px-breakpoint and containment-block
checks the dataset gives every other component.

Imported from the Containment block of components.css.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 21:44:55 +02:00

282 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
use Illuminate\Foundation\Exceptions\RegisterErrorViewPaths;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Vite;
use Illuminate\Support\Str;
use NoNameWeb\LivewireMaterial\LivewireMaterialServiceProvider;
use NoNameWeb\LivewireMaterial\Support\Scheme;
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
use NoNameWeb\LivewireMaterial\Tests\Support\ViewClasses;
beforeEach(function () {
$this->temporary = sys_get_temp_dir().'/livewire-material-errors-'.Str::random(8);
File::ensureDirectoryExists($this->temporary);
Route::middleware('web')->get('/abort/{code}', fn (string $code) => abort((int) $code, request()->query('message', '')));
});
afterEach(function () {
File::deleteDirectory($this->temporary);
});
/**
* Testbench's skeleton application ships an errors/503.blade.php of its own — an application
* view, which rightly wins over the package's. A test of the package's 503 stands in an
* application without one.
*/
function withoutSkeletonErrorViews(): void
{
$views = test()->temporary.'/views';
File::ensureDirectoryExists($views);
config(['view.paths' => [$views, LivewireMaterialServiceProvider::errorViewPath()]]);
}
it('renders the package page for each status', function (int $code, string $headline, string $sentence) {
withoutSkeletonErrorViews();
$this->withoutVite()
->get("/abort/{$code}")
->assertStatus($code)
->assertSee('data-md-error-page', false)
->assertSee("{$code}</p>", false)
->assertSee($headline)
->assertSee($sentence);
})->with([
[403, 'You dont have access', 'Your account isnt allowed to open this page.'],
[404, 'Page not found', 'The page youre looking for doesnt exist or has moved.'],
[419, 'This page has expired', 'Refresh the page'],
[429, 'Slow down a little', 'Wait a moment, then try again.'],
[500, 'Something went wrong', 'An error on our side'],
[503, 'Well be right back', 'Were making some improvements.'],
]);
it('draws the framework\'s own error pages in the package layout', function () {
$this->withoutVite()
->get('/abort/401')
->assertStatus(401)
->assertSee('data-md-error-page', false)
->assertSee('Unauthorized')
->assertSee('Go home');
});
it('appends the error-view root to view.paths after the application\'s, before the view finder is built', function () {
$root = LivewireMaterialServiceProvider::errorViewPath();
expect(realpath($root))->toBe(realpath(__DIR__.'/../../resources/views/error-pages'))
->and(File::directories($root))->toBe([$root.'/errors'])
->and(config('view.paths'))->toBe([resource_path('views'), $root])
->and(app()->viewPath())->toBe(resource_path('views'))
->and(app('view')->getFinder()->getPaths())->toContain($root);
$provider = app()->getProvider(LivewireMaterialServiceProvider::class);
(fn () => $this->registerErrorViews())->call($provider);
expect(config('view.paths'))->toBe([resource_path('views'), $root]);
});
it('lets the application\'s own error view win', function () {
File::ensureDirectoryExists($this->temporary.'/errors');
File::put($this->temporary.'/errors/404.blade.php', 'The application view');
config(['view.paths' => [$this->temporary, ...config('view.paths')]]);
$this->withoutVite()
->get('/abort/404')
->assertNotFound()
->assertSee('The application view')
->assertDontSee('data-md-error-page', false);
$this->get('/abort/403')->assertSee('data-md-error-page', false);
});
it('loads the application\'s Vite entries', function () {
File::ensureDirectoryExists($this->temporary.'/build');
File::put($this->temporary.'/build/manifest.json', json_encode([
'workbench/resources/css/app.css' => ['file' => 'assets/app-probe.css', 'src' => 'workbench/resources/css/app.css', 'isEntry' => true],
'workbench/resources/js/app.js' => ['file' => 'assets/app-probe.js', 'src' => 'workbench/resources/js/app.js', 'isEntry' => true],
]));
app()->usePublicPath($this->temporary);
Vite::useHotFile($this->temporary.'/hot');
$this->get('/abort/404')
->assertNotFound()
->assertSee('build/assets/app-probe.css', false)
->assertSee('build/assets/app-probe.js', false)
->assertDontSee('data-md-error-fallback', false);
});
it('still renders, in the application\'s scheme, when the Vite manifest is missing', function () {
File::put($this->temporary.'/material-scheme.json', json_encode([
'light' => ['surface' => '#fafaf0', 'primary' => '#123456'],
'dark' => ['surface' => '#101010'],
]));
config(['livewire-material.scheme' => $this->temporary.'/material-scheme.json']);
app()->usePublicPath($this->temporary);
Vite::useHotFile($this->temporary.'/hot');
$this->get('/abort/500')
->assertStatus(500)
->assertSee('Something went wrong')
->assertSee('<style data-md-error-fallback>', false)
->assertSee('--md-sys-color-surface: #fafaf0;', false)
->assertSee('--md-sys-color-primary: #123456;', false)
->assertSee('--md-sys-color-surface: #101010;', false)
->assertSee('--md-sys-color-on-surface: #34313a;', false)
->assertDontSee('/build/', false);
});
it('draws the active colour profile when the Vite manifest is missing', function () {
File::put($this->temporary.'/material-scheme.json', json_encode([
'light' => ['primary' => '#4f46e5'],
'dark' => ['primary' => '#aaaaff'],
'default' => 'indigo',
'profiles' => [
'indigo' => ['label' => 'Indigo', 'light' => ['primary' => '#4f46e5'], 'dark' => ['primary' => '#aaaaff']],
'teal' => ['label' => 'Teal', 'light' => ['primary' => '#00897b'], 'dark' => ['primary' => '#80cbc4']],
],
]));
config(['livewire-material.scheme' => $this->temporary.'/material-scheme.json']);
app()->usePublicPath($this->temporary);
Vite::useHotFile($this->temporary.'/hot');
Scheme::resolveProfileUsing(fn (): string => 'teal');
try {
$this->get('/abort/500')
->assertStatus(500)
->assertSee('--md-sys-color-primary: #00897b;', false)
->assertSee('--md-sys-color-primary: #80cbc4;', false)
->assertDontSee('#4f46e5', false);
} finally {
Scheme::resolveProfileUsing(null);
}
});
it('shows the message an application passed for 403 and 503', function (int $code, string $message) {
withoutSkeletonErrorViews();
$this->withoutVite()
->get("/abort/{$code}?message=".urlencode($message))
->assertStatus($code)
->assertSee($message);
})->with([
[403, 'Only the owner can open this share.'],
[503, 'Back at 14:00 after the upgrade.'],
]);
it('does not take maintenance mode\'s "Service Unavailable" for a message', function () {
withoutSkeletonErrorViews();
$this->withoutVite()
->get('/abort/503?message=Service+Unavailable')
->assertStatus(503)
->assertSee('Were making some improvements.')
->assertDontSee('>Service Unavailable<', false);
});
it('prerenders the 503 page for maintenance mode, without an exception', function () {
withoutSkeletonErrorViews();
$this->withoutVite();
(new RegisterErrorViewPaths)();
expect(view('errors::503', ['retryAfter' => 60])->render())
->toContain('data-md-error-page')
->toContain('Well be right back')
->toContain('location.reload()');
});
it('offers Back only when the visitor came from another page', function () {
$this->withoutVite()
->get('/abort/404')
->assertSee('Go home')
->assertDontSee('Go back');
$this->withoutVite()
->get('/abort/404', ['Referer' => url('/shares')])
->assertSee('Go back')
->assertSee('href="'.url('/shares').'"', false);
});
it('sends an expired form back to its page to refresh', function () {
$this->withoutVite()
->get('/abort/419', ['Referer' => url('/settings')])
->assertStatus(419)
->assertSee('href="'.url('/settings').'"', false)
->assertSee('Refresh the page');
});
/**
* Plan step 36: `errors::minimal`'s class lists moved into
* resources/css/components/error-page.css, keyed on `data-md-error-*`. Its view lives outside
* resources/views/components/, so it is not in ContainmentStylesheetsTest's dataset (whose
* "imports what its view renders" check reads a fixed resources/views/components/<name>.blade.php
* path); this is its own small stylesheet-shape test instead, checking the same things.
*/
it('draws the error layout from a stylesheet shaped like every package stylesheet', function () {
$css = ComponentStylesheet::read('error-page');
expect($css->css)->toStartWith('/*')
->and($css->statements()[0] ?? null)->toBe('@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;')
->and(array_slice($css->statements(), 1))->each->toMatch('/^@import \'\.\/[a-z-]+\.css\';$/')
->and($css->blocks())->each->toBe('@layer material.components')
->and($css->css)->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b|--(?:theme|spacing|alpha)\(|\btheme\(/');
foreach ($css->imports() as $import) {
expect(is_file(dirname(ComponentStylesheet::path('error-page')).'/'.$import))->toBeTrue("error-page.css imports {$import}, which does not exist");
}
});
it('imports the stylesheet of every component the error layout renders', function () {
preg_match_all('/<x-livewire-material::([a-z-]+)/', File::get(__DIR__.'/../../resources/views/error-pages/errors/minimal.blade.php'), $tags);
$rendered = collect($tags[1])->unique()
->filter(fn (string $tag): bool => is_file(ComponentStylesheet::path($tag)) && str_contains(File::get(ComponentStylesheet::path($tag)), '@layer material.components'))
->map(fn (string $tag): string => "./{$tag}.css")
->values()
->all();
expect(array_values(array_diff($rendered, ComponentStylesheet::read('error-page')->imports())))->toBe([]);
});
it('writes no class list into the error layout but the interaction and text classes', function () {
expect(ViewClasses::violations(File::get(__DIR__.'/../../resources/views/error-pages/errors/minimal.blade.php')))->toBe([]);
});
it('takes its values from the tokens and its breakpoints in px', function () {
$source = (string) preg_replace('~/\*.*?\*/~s', '', ComponentStylesheet::read('error-page')->css);
expect($source)->not->toMatch('/#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|oklch|oklab|lab|lch)\(/i')
->not->toMatch('/\bfont:(?!\s*var\(--md-sys-typescale-)/')
->not->toMatch('/\btransition[a-z-]*:[^;]*(?:\d+m?s\b|\bease\b|ease-in|ease-out|cubic-bezier)/');
preg_match_all('/@media\s*([^{]+)\{/', $source, $queries);
foreach ($queries[1] as $query) {
preg_match_all('/\(([^()]*)\)/', $query, $features);
foreach ($features[1] as $feature) {
preg_match_all('/(\d*\.?\d+)(px|rem|em)\b/', $feature, $lengths, PREG_SET_ORDER);
foreach ($lengths as [, $number, $unit]) {
expect($unit)->toBe('px', $query);
expect(in_array($number, ['600', '840', '1200', '1600'], true))->toBeTrue("{$query} is not at an M3 breakpoint");
}
}
}
});
it('is imported from the containment block of components.css', function () {
$components = File::get(__DIR__.'/../../resources/css/components.css');
$block = substr($components, (int) strpos($components, '/* Containment */'));
$block = substr($block, 0, (int) strpos($block, '/* Navigation */'));
expect($block)->toContain("@import './components/error-page.css';");
});