Files
livewire-material/tests/Feature/ErrorPagesTest.php
T
Andreas Reinhold / reiniandClaude Sonnet 5 e1fe573182 Bundle the error page's fallback instead of hand-writing it
Plan step 40. ErrorPage::fallbackStyles() now inlines
Stylesheets::bundle() of the foundation and error-page.css (which
imports button.css and shape.css) rather than a hand-copied
stylesheet, so the fallback can never drift from the built version.
Every @font-face block is dropped structurally (withoutFontFace(),
brace-balanced, not a text search) since there is no build to serve
the font file; --md-ref-typeface-brand already lists ui-sans-serif,
system-ui and sans-serif after the brand name, so the page still gets
a sensible system stack. The scheme half comes from
Scheme::forStylesheet() drawn through SchemeStylesheet::levels(), in
material-scheme.css's own selector shape: standard, the medium and
high contrast levels, and a block per colour profile keyed on
[data-scheme] (the theme script has already resolved and written the
active one to <html> before this stylesheet is read, so nothing here
picks one in PHP). Both halves are cached per worker, the scheme half
by the scheme file's path and mtime.

Tests: both render paths, the inlined CSS's shape (no @import, no
relative url(), no @font-face, the button/shape/error-page rules, the
scheme's roles, [data-contrast='high'] and a profile block), and a new
browser test that forces the fallback and checks the button, the
shape and a dark-mode repaint.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 07:30:05 +02:00

352 lines
16 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\ErrorPage;
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 standard, medium and high contrast levels when the Vite manifest is missing', function () {
File::put($this->temporary.'/material-scheme.json', json_encode([
'light' => ['surface' => '#fafaf0', 'primary' => '#123456'],
'dark' => ['surface' => '#101010'],
'contrast' => [
'standard' => 0,
'medium' => ['light' => ['primary' => '#334455']],
'high' => ['light' => ['primary' => '#000000'], 'dark' => ['primary' => '#ffffff']],
],
]));
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("[data-contrast='medium']", false)
->assertSee("[data-contrast='high']", false)
->assertSee('--md-sys-color-primary: #334455;', false)
->assertSee('--md-sys-color-primary: #000000;', false)
->assertSee('--md-sys-color-primary: #ffffff;', false);
});
/**
* Plan step 40: the fallback no longer resolves the active profile in PHP — `<x-theme-script>`
* has already embedded it (`Scheme::profile()`, the resolver included) and writes it to
* `<html data-scheme>` before this stylesheet is read — so the fallback draws every profile, in
* `material-scheme.css`'s own `[data-scheme]` shape, and lets the attribute already on the page
* pick between them.
*/
it('draws every colour profile when the Vite manifest is missing, keyed on data-scheme', 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("[data-scheme='indigo']", false)
->assertSee("[data-scheme='teal']", false)
->assertSee('--md-sys-color-primary: #4f46e5;', false)
->assertSee('--md-sys-color-primary: #00897b;', false)
// The theme script resolved and embedded the active profile server-side; the CSS above
// only has to let [data-scheme] (which it sets on <html>) pick between the two blocks.
->assertSee('"scheme":"teal"', 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 all.css', function () {
expect(allCssBlock('Containment'))->toContain("@import './components/error-page.css';");
});
it('styles the body only when it holds the error layout, and turns the shape on both paths', function () {
$source = (string) preg_replace('~/\*.*?\*/~s', '', ComponentStylesheet::read('error-page')->css);
// The stylesheet travels in the application's bundle, so a bare `body` would restyle every page.
expect($source)->not->toMatch('/(?:^|[,{};]\s*)(?:html|body|dialog|:root)(?![\w-])(?!\[data-md-|:has\(> \[data-md-)/m')
->and(ComponentStylesheet::read('error-page')->declarations('body:has(> [data-md-error-page])'))->not->toHaveKey('font-family')
->and((string) ErrorPage::fallbackStyles())
->toContain('@keyframes material-error-turn')
->toContain('animation: material-error-turn 60s linear infinite;');
});
/**
* Plan step 40: the fallback is `Stylesheets::bundle()` of the foundation and the error layout
* (which pulls in button.css and shape.css) rather than a hand-built stylesheet, so it carries
* their rules verbatim, with no `@import` (bundled away), no relative `url()` (the only one in the
* bundle, `tokens/font.css`'s, leaves with the `@font-face` block it lives in) and no `@font-face`
* itself — the fallback has no build to serve the font file from.
*/
it('bundles the foundation and the error layout into the fallback, without an import, a relative url, or a font face', function () {
withoutSkeletonErrorViews();
$this->withoutVite();
$css = (string) ErrorPage::fallbackStyles();
$plain = (string) preg_replace('~/\*.*?\*/~s', '', $css);
expect($plain)->not->toContain('@import')
->not->toContain('@font-face')
->and(preg_match('/url\(\s*(?!["\']?(?:data:|https?:|\/))/i', $plain))->toBe(0, 'a relative url() remains in the fallback')
// The error layout's own rules, and the button and shape stylesheets it imports.
->and($plain)->toContain('[data-md-error-page]')
->toContain('[data-md-error-shape]')
->toContain('[data-md-button]')
->toContain('[data-md-shape]')
// The foundation's reset and tokens travelled in too, not only the error layout.
->toContain('box-sizing: border-box')
->toContain('--md-sys-color-surface');
});