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
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:
co-authored by
Claude Opus 5
parent
ab692b66bb
commit
8f174520eb
@@ -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");
|
||||
});
|
||||
@@ -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 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-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('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-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');
|
||||
});
|
||||
@@ -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.');
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user