An over-engineering audit of the whole tree, applied in five reviewed batches. Behaviour stays the same except where UPGRADE.md says otherwise. PHP: the showcase and error-page stylesheets are prebuilt into resources/dist by bin/stylesheets.mjs, through Vite's own postcss-import (first occurrence kept, the order an application's build gives), instead of Stylesheets::bundle() inlining imports on every request; only the import walk DesignGuard needs stays. SchemeStylesheet::withProfiles() replaces three copies of the scheme-plus-profiles loop, material:scheme leaves spec and contrast checks to the node script that already made them, and the error page's scheme cache, the hashed view namespace, the translations path with no lang/ folder and DesignGuard's 1.x-name hints are gone. JS: the androidx shape port progress.js and both bin scripts each carried lives once in resources/js/shapes.js (the generated SVGs are unchanged); util.js holds ringIndex(), ms(), reopenGuard() and remember(), which were written out several times; listeners are released through AbortController; tooltip.js's hoverPopover() serves the rich tooltip too. CSS: every rule for an element inside the navigation rail queries `--md-navigation-rail-value` instead of repeating the seven collapsed conditions under five media branches; badge, alert, progress, slider and button read one non-inheriting colour-role table (components/color.css); the dialog chrome, the submenu's popover chrome, the chip's state layer and touch target, and the visually-hidden inputs use the shared rules they copied; foundation/tokens.css is folded into foundation.css. Views: Support\Field and Support\Link replace the error-key, bound-value and link-attribute blocks copied into the fields and link components; the timepicker period group, the menu filter and the showcase head are partials; the datepicker's steppers and entry fields are loops; component docblocks no longer restate SKILL.md. Tests and tooling: one dataset-driven ComponentStylesheetsTest replaces four per-group files, DesignGuardTest and the layout-component tests use datasets, browser tests share one ready() helper, CSS parsing lives in ComponentStylesheet alone. docs/audits and the finding IDs citing it are removed, as are pestphp/pest-plugin-laravel, the unused composer scripts and check:font; the lint job runs in the feature job, which now installs node packages so the prebuilt-stylesheet staleness test runs in CI. Feature suite 1177 passed, Chrome browser suite 299 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
372 lines
17 KiB
PHP
372 lines
17 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\Support\Stylesheets;
|
||
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);
|
||
});
|
||
|
||
/**
|
||
* Plan step 46: an application's entry imports the stylesheets its own views render, and none of
|
||
* them renders this layout, so with a build the page inlines its own layout's rules beside the
|
||
* application's tags rather than hoping the entry imported `error-page.css` — SealShare's didn't,
|
||
* and its 403/404/500 pages drew unstyled under its real build.
|
||
*/
|
||
it('loads the application\'s Vite entries and inlines the error layout\'s own rules beside them', 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');
|
||
|
||
$html = $this->get('/abort/404')
|
||
->assertNotFound()
|
||
->assertSee('build/assets/app-probe.css', false)
|
||
->assertSee('build/assets/app-probe.js', false)
|
||
->assertSee('<style data-md-error-styles>'.ErrorPage::layoutStyles().'</style>', false)
|
||
->assertDontSee('data-md-error-fallback', false)
|
||
->getContent();
|
||
|
||
preg_match('~<style data-md-error-styles>(.*?)</style>~s', $html, $inlined);
|
||
$plain = (string) preg_replace('~/\*.*?\*/~s', '', $inlined[1] ?? '');
|
||
|
||
// After the application's stylesheet, so its layer statement has already set the order.
|
||
expect(strpos($html, '<style data-md-error-styles>'))->toBeGreaterThan(strpos($html, 'build/assets/app-probe.css'))
|
||
// The layout's rules, and those of the button and shape stylesheets it imports.
|
||
->and($plain)->toContain('body:has(> [data-md-error-page])')
|
||
->toContain('[data-md-error-shape] svg')
|
||
->toContain('[data-md-button]')
|
||
->toContain('[data-md-shape]')
|
||
// Not the foundation or a scheme: the application's build brings those.
|
||
->not->toContain('box-sizing: border-box')
|
||
->not->toMatch('/--md-sys-color-surface\s*:/');
|
||
});
|
||
|
||
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('keeps the fallback as the only stylesheet when the Vite manifest is missing', function () {
|
||
app()->usePublicPath($this->temporary);
|
||
Vite::useHotFile($this->temporary.'/hot');
|
||
|
||
$html = $this->get('/abort/404')
|
||
->assertNotFound()
|
||
->assertSee('<style data-md-error-fallback>'.ErrorPage::fallbackStyles().'</style>', false)
|
||
->assertDontSee('data-md-error-styles', false)
|
||
->getContent();
|
||
|
||
expect(substr_count($html, '<style'))->toBe(1)
|
||
->and($html)->not->toContain('<link rel="stylesheet"');
|
||
});
|
||
|
||
it('draws the standard, medium and high contrast levels when the Vite manifest is missing', function () {
|
||
File::put($this->temporary.'/material-scheme.json', json_encode([
|
||
'light' => ['surface' => '#fafaf0', 'primary' => '#123456'],
|
||
'dark' => ['surface' => '#101010'],
|
||
'contrast' => [
|
||
'standard' => 0,
|
||
'medium' => ['light' => ['primary' => '#334455']],
|
||
'high' => ['light' => ['primary' => '#000000'], 'dark' => ['primary' => '#ffffff']],
|
||
],
|
||
]));
|
||
|
||
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("[data-contrast='medium']", false)
|
||
->assertSee("[data-contrast='high']", false)
|
||
->assertSee('--md-sys-color-primary: #334455;', false)
|
||
->assertSee('--md-sys-color-primary: #000000;', false)
|
||
->assertSee('--md-sys-color-primary: #ffffff;', false);
|
||
});
|
||
|
||
/**
|
||
* Plan step 40: the fallback no longer resolves the active profile in PHP — `<x-theme-script>`
|
||
* has already embedded it (`Scheme::profile()`, the resolver included) and writes it to
|
||
* `<html data-scheme>` before this stylesheet is read — so the fallback draws every profile, in
|
||
* `material-scheme.css`'s own `[data-scheme]` shape, and lets the attribute already on the page
|
||
* pick between them.
|
||
*/
|
||
it('draws every colour profile when the Vite manifest is missing, keyed on data-scheme', 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("[data-scheme='indigo']", false)
|
||
->assertSee("[data-scheme='teal']", false)
|
||
->assertSee('--md-sys-color-primary: #4f46e5;', false)
|
||
->assertSee('--md-sys-color-primary: #00897b;', false)
|
||
// The theme script resolved and embedded the active profile server-side; the CSS above
|
||
// only has to let [data-scheme] (which it sets on <html>) pick between the two blocks.
|
||
->assertSee('"scheme":"teal"', 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 ComponentStylesheetsTest's view-scoped datasets
|
||
* (whose "imports what its view renders" and "no class list" checks read a fixed
|
||
* resources/views/components/<name>.blade.php path) — those two stay here, reading its real view
|
||
* path, while the stylesheet-only checks (shape, tokens, block import) run there instead, on the
|
||
* `error-page` entry of its dataset.
|
||
*/
|
||
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('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('animation: material-error-turn 60s linear infinite;');
|
||
});
|
||
|
||
/**
|
||
* Plan step 40: the fallback is the prebuilt bundle of the foundation and the error layout
|
||
* (which pulls in button.css and shape.css) rather than a hand-built stylesheet, so it carries
|
||
* their rules verbatim, with no `@import` (bundled away), no relative `url()` (the only one in the
|
||
* bundle, `tokens/font.css`'s, leaves with the `@font-face` block it lives in) and no `@font-face`
|
||
* itself — the fallback has no build to serve the font file from.
|
||
*/
|
||
it('bundles the foundation and the error layout into the fallback, without an import, a relative url, or a font face', function () {
|
||
withoutSkeletonErrorViews();
|
||
|
||
$this->withoutVite();
|
||
$css = (string) ErrorPage::fallbackStyles();
|
||
$plain = (string) preg_replace('~/\*.*?\*/~s', '', $css);
|
||
|
||
expect($plain)->not->toContain('@import')
|
||
->not->toContain('@font-face')
|
||
->and(preg_match('/url\(\s*(?!["\']?(?:data:|https?:|\/))/i', $plain))->toBe(0, 'a relative url() remains in the fallback')
|
||
// The error layout's own rules, and the button and shape stylesheets it imports.
|
||
->and($plain)->toContain('[data-md-error-page]')
|
||
->toContain('[data-md-error-shape]')
|
||
->toContain('[data-md-button]')
|
||
->toContain('[data-md-shape]')
|
||
->toContain('[data-md-icon]')
|
||
// The foundation's reset, tokens and page travelled in too, not only the error layout.
|
||
->toContain('box-sizing: border-box')
|
||
->toContain('--md-sys-color-surface')
|
||
->toMatch('/html \{\s*background-color/');
|
||
});
|
||
|
||
/**
|
||
* Plan step 46: what the page inlines beside a build is the prebuilt bundle of the error layout
|
||
* alone. It reaches no `@font-face` — the package's only one is tokens/font.css's, which only the
|
||
* foundation imports, and the application's build serves it — and no `url()` a request from the
|
||
* page would have to resolve, so nothing needs dropping from it the way the fallback drops the face.
|
||
*/
|
||
it('inlines beside a build the error layout\'s bundle, which holds no font face, url or import', function () {
|
||
$layout = realpath(__DIR__.'/../../resources/css/components/error-page.css');
|
||
$css = (string) ErrorPage::layoutStyles();
|
||
$plain = (string) preg_replace('~/\*.*?\*/~s', '', $css);
|
||
|
||
expect($css)->toBe(File::get(__DIR__.'/../../resources/dist/error-page.css'))
|
||
->and(array_map(basename(...), Stylesheets::resolvedFiles([$layout])))
|
||
->toEqualCanonicalizing(['error-page.css', 'button.css', 'icon.css', 'loading.css', 'tooltip.css', 'shape.css', 'color.css'])
|
||
->and($plain)->not->toContain('@font-face')
|
||
->not->toContain('@import')
|
||
->not->toMatch('/\burl\(/i')
|
||
->toContain('@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;');
|
||
});
|