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
282 lines
12 KiB
PHP
282 lines
12 KiB
PHP
<?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 don’t have access', 'Your account isn’t allowed to open this page.'],
|
||
[404, 'Page not found', 'The page you’re looking for doesn’t 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, 'We’ll be right back', 'We’re 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('We’re 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('We’ll 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';");
|
||
});
|