Plan step 39 (parts 1-3, folded into one commit: PHP tests read the deleted files directly, so they cannot land apart from it). Tailwind leaves the whole stack: - Delete resources/css/tailwind.css, tokens/theme.css and tokens/utilities.css. Neither token file declared an --md-sys-* custom property of its own (both only referenced tokens declared elsewhere), so nothing loses a value; the md-* interaction and text classes already mirror utilities.css's declarations exactly (foundation/interaction.css, text.css). - npm uninstall tailwindcss @tailwindcss/vite; vite.config.js drops the plugin and its import; composer.json drops the tailwindcss keyword (no lock change — keywords are outside Composer's content hash). - The Workbench now builds one CSS entry, workbench/resources/css/app.css (all.css, showcase.css and the scheme; package.css is folded in and removed) instead of two, used by ErrorPage::assets() and every browser-test probe page's raw @vite() call; the showcase's own pages still take their CSS from the bundle route. - TokensTest and StylesheetsTest: the two facts theme.css and utilities.css carried (every scheme role becomes a colour, resolved on the element; md-type-* matches the type-* utilities) are asserted directly against the scheme and text.css now that there is no second copy to cross-check; StylesheetsTest gained a full-tree scan (every .css file under resources/css/ is reached from all.css or showcase.css, no exclusions left for Tailwind); the Workbench-entry test and every "moved out of tailwind.css" assertion updated for the single entry and its removal. - DesignGuard.php's comments and the development skill's setup section no longer name the deleted files or a second Tailwind entry. 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; } }');
|
||
});
|