Files
livewire-material/tests/Feature/ErrorPagesTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 3854f4321c Build the Workbench's package CSS apart from Tailwind
Plan step 37 review. @tailwindcss/vite inlines the imports of an entry
that uses Tailwind without Vite's skipDuplicates, so with all.css beside
@import 'tailwindcss' every shared component stylesheet repeated later in
the cascade (231 repeated rules; button.css's hover and disabled rules
thirteen times), and every browser test ran against an order no
application gets. workbench/resources/css/package.css now holds all.css
and the scheme in an entry Tailwind never touches, and app.css keeps
Tailwind for the showcase's classes. The built package CSS repeats no
rule, and the Workbench's CSS shrinks from 559 to 406 KB.

Both entries open with the same layer statement, properties first,
because Tailwind hoists that layer to the top of its output; the order
is the one the single entry had, whichever the page links first. The
showcase's Vite config, vite.config.js and the error page test's probe
manifest name the new entry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 03:12:44 +02:00

292 lines
13 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/package.css' => ['file' => 'assets/package-probe.css', 'src' => 'workbench/resources/css/package.css', 'isEntry' => true],
'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/package-probe.css', false)
->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 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('@media (prefers-reduced-motion: no-preference) { [data-md-error-shape] { animation: material-error-turn 60s linear infinite; } }');
});