Plan step 36 review of a8c2d88f. error-page.css drew a bare `body`, which
components.css already bundles into every Workbench page and step 37's
all.css would put in every application page: each would take the error
page's background, margin and system font. The rule now matches
body:has(> [data-md-error-page]); the fallback stylesheet, inlined into
the error page alone, keeps its own. A test in the containment harness
and in ErrorPagesTest rejects an unscoped html, body, dialog or :root.
The body's system font stack is gone: the old `font-sans` was the brand
typeface (tokens/theme.css), which the page inherits from the
foundation's html rule, and the header's claim otherwise was wrong. The
shape's slow turn, which moved from the view's inline style into the
stylesheet, is back in the fallback so the no-build page still turns.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
294 lines
13 KiB
PHP
294 lines
13 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\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 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';");
|
||
});
|
||
|
||
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; } }');
|
||
});
|