Plan step 37: all.css replaces the two interim import lists with one entry for an application that wants everything — the foundation, every layout stylesheet and every component stylesheet, grouped under the same block comments components.css used, plus a Layout block. It also directly imports the three files nothing imported by name before (layout/spacing.css, layout/visibility.css, components/selection.css), so every file under components/ and layout/ is now one @import away. Every test that read a block of components.css or layout.css now reads the matching block of all.css through one shared helper (allCssBlock(), in tests/Pest.php so it loads for any test run) instead of repeating the same substr() search in each file. StylesheetsTest.php's shape, Tailwind-free and breakpoint checks, previously run twice (once for the foundation, once for the layout tree), now run once over the whole tree all.css reaches, since every component and layout stylesheet is plain CSS after step 36; a new test asserts all.css imports everything under components/ and layout/ exactly once. The Workbench imports all.css in place of layout.css and components.css. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
290 lines
12 KiB
PHP
290 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\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 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; } }');
|
||
});
|