Add M3 error pages and the Markdown mail theme
tests / feature (8.5) (push) Successful in 1m8s
tests / browser (safari, webkit) (push) Has been cancelled
tests / browser (firefox, firefox) (push) Has been cancelled
tests / lint (push) Successful in 1m5s
tests / feature (8.4) (push) Successful in 1m11s
tests / browser (chrome, chromium) (push) Failing after 6m9s

Error pages for 401 to 503 in an error-view root appended to view.paths,
with an inline fallback when the Vite build is missing, and a mail theme
rendered from the application's scheme JSON, with opt-in header and
message components. Completes Phase 9.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
This commit is contained in:
Andreas Reinhold / reini
2026-09-13 08:55:58 +02:00
co-authored by Claude Opus 5
parent ab692b66bb
commit 8f174520eb
29 changed files with 1595 additions and 2 deletions
+54
View File
@@ -0,0 +1,54 @@
<?php
/**
* The 404 page, reached the way a visitor reaches it: an address nothing answers.
*/
function missingPage(string $colorScheme = 'light')
{
$page = visit('/nothing-lives-here');
$page = $colorScheme === 'dark' ? $page->inDarkMode() : $page->inLightMode();
return $page->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && document.querySelector('[data-error-page]') !== null");
}
/**
* Whether the element's computed background is the colour a role resolves to on this page.
*/
function paintedIn(string $selector, string $role): string
{
return <<<JS
(() => {
const probe = document.createElement('div');
probe.style.backgroundColor = 'var(--md-sys-color-{$role})';
document.body.append(probe);
const expected = getComputedStyle(probe).backgroundColor;
probe.remove();
return expected !== 'rgba(0, 0, 0, 0)' && getComputedStyle(document.querySelector('{$selector}')).backgroundColor === expected;
})()
JS;
}
it('draws the 404 page in the scheme, in light and dark', function () {
missingPage('light')
->assertSee('Page not found')
->assertVisible('[data-error-headline]')
->assertScript("document.documentElement.getAttribute('data-theme') === 'light'")
->assertScript(paintedIn('body', 'surface'))
->assertScript("getComputedStyle(document.querySelector('[data-error-shape] svg')).color !== getComputedStyle(document.body).color")
->assertNoJavaScriptErrors();
missingPage('dark')
->assertScript("document.documentElement.getAttribute('data-theme') === 'dark'")
->assertScript(paintedIn('body', 'surface'))
->assertScript("getComputedStyle(document.body).backgroundColor !== 'rgb(253, 247, 254)'");
});
it('fits a 400px screen without scrolling sideways', function () {
missingPage()
->resize(400, 800)
->assertVisible('[data-error-headline]')
->assertScript('document.documentElement.scrollWidth <= window.innerWidth')
->assertScript("document.querySelector('[data-error-actions] a').getBoundingClientRect().right <= 400");
});
+183
View File
@@ -0,0 +1,183 @@
<?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;
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-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-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-error-page', false);
$this->get('/abort/403')->assertSee('data-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-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-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('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-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');
});
+205
View File
@@ -0,0 +1,205 @@
<?php
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Markdown;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\View;
use Illuminate\Support\Str;
use NoNameWeb\LivewireMaterial\LivewireMaterialServiceProvider;
class ThemedProbeMail extends Mailable
{
public function content(): Content
{
return new Content(markdown: 'mail-theme-probe');
}
}
beforeEach(function () {
$this->temporary = sys_get_temp_dir().'/livewire-material-mail-'.Str::random(8);
File::ensureDirectoryExists($this->temporary);
File::put($this->temporary.'/mail-theme-probe.blade.php', <<<'BLADE'
<x-mail::message>
# Your export is ready
The report has **finished**.
<x-mail::button :url="'https://example.com/export'">
Download
</x-mail::button>
<x-mail::panel>
Files expire after seven days.
</x-mail::panel>
<x-mail::table>
| File | Size |
|:-----|-----:|
| orders.csv | 1.2 MB |
</x-mail::table>
</x-mail::message>
BLADE);
File::put($this->temporary.'/mail-footer-probe.blade.php', <<<'BLADE'
<x-mail::message>
Hello.
<x-slot:footer>
Sent by the probe · [Unsubscribe](https://example.com/unsubscribe)
</x-slot:footer>
</x-mail::message>
BLADE);
View::addLocation($this->temporary);
config(['mail.markdown.theme' => 'livewire-material::mail.theme']);
});
afterEach(function () {
File::deleteDirectory($this->temporary);
});
/**
* The inline style of the first element carrying the class.
*/
function inlineStyle(string $html, string $class): string
{
preg_match('/<[^>]+class="[^"]*\b'.preg_quote($class, '/').'\b[^"]*"[^>]*style="([^"]*)"/', $html, $match);
return $match[1] ?? '';
}
/**
* Put the package's mail components on `mail.markdown.paths`, as the provider does when
* `livewire-material.mail.components` is on at boot, and make the renderer again.
*/
function withPackageMailComponents(): void
{
config(['livewire-material.mail.components' => true]);
$provider = app()->getProvider(LivewireMaterialServiceProvider::class);
(fn () => $this->registerMailComponents())->call($provider);
app()->forgetInstance(Markdown::class);
}
it('inlines the application\'s scheme onto the mail', function () {
File::put($this->temporary.'/material-scheme.json', json_encode([
'light' => ['primary' => '#123456', 'on-primary' => '#fefefe', 'surface-container' => '#eeeeee', 'surface-container-lowest' => '#fdfdfd'],
'dark' => ['primary' => '#abcdef'],
]));
config(['livewire-material.scheme' => $this->temporary.'/material-scheme.json']);
$html = (new ThemedProbeMail)->render();
expect(inlineStyle($html, 'button-primary'))
->toContain('background-color: #123456')
->toContain('border-top: 16px solid #123456')
->toContain('color: #fefefe')
->toContain('border-radius: 9999px')
->and(inlineStyle($html, 'inner-body'))->toContain('background-color: #fdfdfd')
->and(inlineStyle($html, 'wrapper'))->toContain('background-color: #eeeeee')
->and(inlineStyle($html, 'panel-content'))->toContain('background-color: #eeeeee')
->and($html)
->toMatch('/<h1 style="[^"]*font-size: 24px[^"]*line-height: 32px/')
->toMatch('/<p style="[^"]*font-size: 16px[^"]*letter-spacing: 0.5px/')
->toMatch('/<th [^>]*style="[^"]*font-weight: 500/')
->toMatch('/<th align="right" style="[^"]*text-align: right/')
->not->toContain('#abcdef')
->not->toContain('var(--')
->not->toContain('color-mix');
});
it('takes the theme from a mailable too', function () {
config(['mail.markdown.theme' => 'default']);
$mail = new ThemedProbeMail;
$mail->theme = 'livewire-material::mail.theme';
expect(inlineStyle($mail->render(), 'button-primary'))->toContain('border-radius: 9999px');
});
it('falls back to the package\'s default scheme', function (?string $contents) {
$path = $this->temporary.'/material-scheme.json';
if ($contents !== null) {
File::put($path, $contents);
}
config(['livewire-material.scheme' => $path]);
$default = json_decode(File::get(__DIR__.'/../../resources/css/tokens/scheme.json'), true);
expect(inlineStyle((new ThemedProbeMail)->render(), 'button-primary'))
->toContain("background-color: {$default['light']['primary']}");
})->with([
'no scheme file' => [null],
'not JSON' => ['{ nope'],
'not a colour' => [json_encode(['light' => ['primary' => 'red; background: url(x)']])],
]);
it('styles every button colour Laravel and M3 name', function () {
$css = view('livewire-material::mail.theme')->render();
foreach (['primary', 'secondary', 'tertiary', 'error', 'success', 'warning', 'info', 'blue', 'green', 'red'] as $color) {
expect($css)->toContain(".button-{$color} {");
}
expect($css)->not->toContain('@media');
});
it('leaves the application\'s mail components alone unless asked', function () {
expect(config('mail.markdown.paths'))->not->toContain(LivewireMaterialServiceProvider::mailComponentPath());
withPackageMailComponents();
withPackageMailComponents();
expect(config('mail.markdown.paths'))->toBe([
resource_path('views/vendor/mail'),
LivewireMaterialServiceProvider::mailComponentPath(),
]);
});
it('renders the package header: the app name, never Laravel\'s logo', function () {
config(['app.name' => 'Laravel']);
$framework = (new ThemedProbeMail)->render();
withPackageMailComponents();
$html = (new ThemedProbeMail)->render();
expect($framework)->toContain('notification-logo')
->and($html)->not->toContain('notification-logo')
->toMatch('/<td class="header"[^>]*>\s*<a [^>]*>\s*Laravel\s*<\/a>/');
});
it('renders a configured logo in the header, sized by attributes', function () {
config(['app.name' => 'Probe', 'livewire-material.mail.logo' => ['src' => 'https://example.com/logo.png', 'width' => 160, 'height' => 40]]);
withPackageMailComponents();
expect((new ThemedProbeMail)->render())
->toMatch('/<img src="https:\/\/example.com\/logo.png" class="logo" alt="Probe" width="160" height="40"/');
});
it('lets a mail replace the footer, in HTML and in text', function () {
config(['app.name' => 'Probe']);
withPackageMailComponents();
$markdown = app(Markdown::class);
expect((string) $markdown->render('mail-footer-probe'))
->toContain('Sent by the probe')
->toContain('href="https://example.com/unsubscribe"')
->not->toContain('All rights reserved.')
->and((string) $markdown->renderText('mail-footer-probe'))
->toContain('Sent by the probe')
->not->toContain('All rights reserved.')
->and((string) $markdown->render('mail-theme-probe'))
->toContain('© '.date('Y').' Probe. All rights reserved.');
});
+18
View File
@@ -2,6 +2,8 @@
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\File;
use Illuminate\Support\ServiceProvider;
use NoNameWeb\LivewireMaterial\LivewireMaterialServiceProvider;
/**
@@ -35,3 +37,19 @@ it('registers the components under a configured prefix', function () {
it('keeps blade-icons from registering an <x-icon> that would shadow ours', function () {
expect(Blade::getClassComponentAliases())->not->toHaveKey('icon');
});
it('publishes the error pages into the application\'s errors folder', function () {
expect(ServiceProvider::pathsToPublish(LivewireMaterialServiceProvider::class, 'livewire-material-errors'))->toBe([
LivewireMaterialServiceProvider::errorViewPath().'/errors' => resource_path('views/errors'),
]);
});
it('publishes the mail components where Laravel looks for them', function () {
expect(ServiceProvider::pathsToPublish(LivewireMaterialServiceProvider::class, 'livewire-material-mail'))->toBe([
LivewireMaterialServiceProvider::mailComponentPath().'/html' => resource_path('views/vendor/mail/html'),
LivewireMaterialServiceProvider::mailComponentPath().'/text' => resource_path('views/vendor/mail/text'),
])->and(config('mail.markdown.paths'))->toContain(resource_path('views/vendor/mail'));
expect(File::files(LivewireMaterialServiceProvider::mailComponentPath().'/html'))->not->toBeEmpty()
->and(File::files(LivewireMaterialServiceProvider::mailComponentPath().'/text'))->not->toBeEmpty();
});
+17
View File
@@ -51,3 +51,20 @@ it('lists the symbol names for the icon search', function () {
->assertJsonFragment(['calendar_month'])
->assertJsonCount(count(SvgFile::symbolNames()));
});
it('previews an error page through the exception handler', function () {
$this->withoutVite()
->get('/material/errors/404')
->assertNotFound()
->assertSee('Page not found');
$this->get('/material/errors/418')->assertNotFound();
});
it('previews a sample mail in the package theme', function () {
$this->get('/material/mail')
->assertOk()
->assertSee('class="button button-primary"', false)
->assertSee('border-radius: 9999px', false)
->assertSee('Unsubscribe');
});