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}

", 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('', false) ->assertDontSee('data-md-error-fallback', false) ->getContent(); preg_match('~~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, '', false) ->assertDontSee('data-md-error-styles', false) ->getContent(); expect(substr_count($html, 'toBe(1) ->and($html)->not->toContain('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 — `` * has already embedded it (`Scheme::profile()`, the resolver included) and writes it to * `` 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 ) 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 ContainmentStylesheetsTest's dataset (whose * "imports what its view renders" check reads a fixed resources/views/components/.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('/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('animation: material-error-turn 60s linear infinite;'); }); /** * Plan step 40: the fallback is `Stylesheets::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]') // The foundation's reset and tokens travelled in too, not only the error layout. ->toContain('box-sizing: border-box') ->toContain('--md-sys-color-surface'); }); /** * Plan step 46: what the page inlines beside a build is `Stylesheets::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(Stylesheets::bundle([$layout])) ->and(array_map(basename(...), Stylesheets::resolvedFiles([$layout]))) ->toEqualCanonicalizing(['error-page.css', 'button.css', 'icon.css', 'loading.css', 'tooltip.css', 'shape.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;'); });