Files
livewire-material/tests/Feature/DesignGuardTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 fb7007c976
tests / feature (8.4) (push) Successful in 2m0s
tests / feature (8.5) (push) Successful in 2m0s
tests / browser (chrome, chromium) (push) Failing after 8m3s
tests / browser (firefox, firefox) (push) Failing after 12m58s
tests / browser (safari, webkit) (push) Failing after 13m8s
Take Tailwind out of the package, and its detection out of the guard
Tailwind left the stack in 2.0.0, but the package still carried about 330
mentions of it. What the guard's Tailwind detection protected — a class
that compiles to nothing — is now protected by a check that does not care
where a dead class came from.

DesignGuard: about 500 lines of Tailwind tables, scales, palettes and
"2.0.0 replacement" hints give way to one check — a class a view or PHP
file writes that neither the application's stylesheets nor the package's
own declare. It catches a utility of any framework, a typo and a class
whose rules were deleted alike, so it also found two classes ReStride
draws nothing with. A stylesheet has to be in reach for it: the `.css`
files among the scanned paths, or what the `missingStylesheets()` entry
imports. The class reader no longer mistakes an array index for a class
list (`$block['base']`), and it reads the array a class helper is given,
where it read nothing before.

The package's own three Tailwind self-guards go with it. Only their one
unique check stays, as a test of its own: every `matchMedia` width in
resources/js is an M3 breakpoint.

The pagination views are `material.blade.php` and
`simple-material.blade.php`; only Laravel's and Livewire's default theme
names ever made them `tailwind`. The provider sets `Paginator`'s default
views and switches `livewire.pagination_theme` to `material` when it is
still Livewire's own default, so no application can forget the config; a
theme an application chose, and a component's own `$paginationTheme` or
`paginationView()`, still win.

The rest is prose: the layer-order guidance for an application that still
builds Tailwind, the Tailwind wording in the README, the Boost guidelines
and the development skill, and about 25 "this used to be a Tailwind
utility" comments, along with every "plan step NN" pointer into a
gitignored folder. The reset keeps its credit, and NOTICE now carries it
too.

Feature suite 1159 passed, Chrome browser suite 299 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 21:07:39 +02:00

415 lines
26 KiB
PHP

<?php
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\View;
use NoNameWeb\LivewireMaterial\Testing\DesignGuard;
const GUARD_FIXTURES = __DIR__.'/../Fixtures/design-guard';
/**
* @param list<string> $violations
* @return list<string>
*/
function fixtureRelative(array $violations): array
{
return array_map(fn (string $violation): string => str_replace(realpath(GUARD_FIXTURES).'/', '', $violation), $violations);
}
/**
* Check (i)'s message for one class: it names no replacement, since a class no stylesheet declares
* is dead whatever it was once meant to do.
*/
function undeclared(string $class): string
{
return "class `{$class}` is declared in no stylesheet — the package declares `md-*`, the rest is your own CSS";
}
/**
* Every check that only scans a fixture, optionally names the CSS entry `missingStylesheets()`
* should check against, and compares the result to an exact violations list — most of this file,
* kept as one `it()` over a `[fixture, entry, expected]` dataset instead of one near-identical test
* per check. A test that does more (chains `forbidColours()`/`forbid()`/`unusedStylesheets()`,
* compares two scans against each other, mutates config or the view finder, or asserts more than
* once) stays its own test, further down.
*/
dataset('design guard scans', [
// app/Status.php returns class names from a PHP `match`, and views/page.blade.php writes one in
// running text and in a Blade comment: none of the three is a class list, so none is read.
'finds what compiles to nothing' => [
[realpath(GUARD_FIXTURES.'/app'), realpath(GUARD_FIXTURES.'/views')],
null,
[
'views/page.blade.php:2 '.undeclared('p-4'),
'views/page.blade.php:3 unknown Material Symbol `o-home`',
'views/page.blade.php:4 unknown Material Symbol `not_a_symbol`',
'views/page.blade.php:6 '.undeclared('focus-ring'),
'views/page.blade.php:6 '.undeclared('text-tertiary'),
'views/page.blade.php:8 Blade directive `@class` inside a component tag, where it does not compile — use `:class="\\Illuminate\\Support\\Arr::toCssClasses([…])"`',
'views/page.blade.php:8 '.undeclared('size-4'),
],
],
'passes a class the application\'s own stylesheet declares, and reports the one it does not' => [
[realpath(GUARD_FIXTURES.'/declared/view.blade.php'), realpath(GUARD_FIXTURES.'/declared/app.css')],
null,
['declared/view.blade.php:1 '.undeclared('mt-2')],
],
'reads a class list in an attribute, an Alpine binding and a PHP one, but not a string a condition compares or a call takes' => [
[realpath(GUARD_FIXTURES.'/declared/bindings.blade.php'), realpath(GUARD_FIXTURES.'/declared/app.css')],
null,
[
'declared/bindings.blade.php:1 '.undeclared('collapsed'),
'declared/bindings.blade.php:1 '.undeclared('dashboard-busy'),
'declared/bindings.blade.php:1 '.undeclared('is-open'),
'declared/bindings.blade.php:2 '.undeclared('dashboard-enter-start'),
'declared/bindings.blade.php:2 '.undeclared('dashboard-enter'),
'declared/bindings.blade.php:3 '.undeclared('share-list'),
'declared/bindings.blade.php:4 '.undeclared('dashboard-lead'),
'declared/bindings.blade.php:6 '.undeclared('share-list'),
],
],
'reads the keys of a class list, the array a class helper is given and every branch of a ternary, never a string that indexes an array' => [
[realpath(GUARD_FIXTURES.'/declared/lookups.blade.php'), realpath(GUARD_FIXTURES.'/declared/app.css')],
null,
[
'declared/lookups.blade.php:4 '.undeclared('product-shot-base'),
'declared/lookups.blade.php:8 '.undeclared('routes-show-run-chosen'),
'declared/lookups.blade.php:9 '.undeclared('shot-helper-one'),
'declared/lookups.blade.php:9 '.undeclared('shot-helper-two'),
'declared/lookups.blade.php:10 '.undeclared('shot-tern-a'),
'declared/lookups.blade.php:10 '.undeclared('shot-tern-b'),
'declared/lookups.blade.php:10 '.undeclared('shot-tern-c'),
],
],
'passes every class the package itself declares, showcase included' => [
realpath(GUARD_FIXTURES.'/declared/package-classes.blade.php'),
null,
[],
],
'names what a component tag takes in place of each directive' => [
realpath(GUARD_FIXTURES.'/directives'),
null,
[
'directives/tags.blade.php:1 Blade directive `@js` inside a component tag, where it does not compile — use `{{ \\Illuminate\\Support\\Js::from(…) }}`',
'directives/tags.blade.php:2 Blade directive `@json` inside a component tag, where it does not compile — use `{{ json_encode(…) }}`',
'directives/tags.blade.php:3 Blade directive `@class` inside a component tag, where it does not compile — use `:class="\\Illuminate\\Support\\Arr::toCssClasses([…])"`',
'directives/tags.blade.php:3 '.undeclared('is-open'),
'directives/tags.blade.php:4 Blade directive `@style` inside a component tag, where it does not compile — use `:style="\\Illuminate\\Support\\Arr::toCssStyles([…])"`',
"directives/tags.blade.php:5 Blade directive `@entangle` inside a component tag, where it does not compile — use `\$wire.entangle('…')` in the Alpine expression",
'directives/tags.blade.php:6 Blade directive `@disabled` inside a component tag, where it does not compile — use `:disabled="…"`',
'directives/tags.blade.php:7 Blade directive `@if` inside a component tag, where it does not compile — use a `:prop` binding or `{{ }}`, or move it to a plain element inside the slot',
],
],
'leaves every role alone until an application forbids one' => [
realpath(GUARD_FIXTURES.'/forbidden-colours'),
null,
[
'forbidden-colours/page.blade.php:5 '.undeclared('bg-tertiary'),
],
],
'names the missing package stylesheet and the exact @import line to add' => [
realpath(GUARD_FIXTURES.'/stylesheets/views/split-button.blade.php'),
realpath(GUARD_FIXTURES.'/stylesheets/foundation-only.css'),
[
"stylesheets/views/split-button.blade.php:1 `<x-split-button>` needs `components/split-button.css`, missing from stylesheets/foundation-only.css — add `@import '../../../../resources/css/components/split-button.css';`",
],
],
'counts what an imported package stylesheet already brings in, through its own imports' => [
realpath(GUARD_FIXTURES.'/stylesheets/views/dependencies.blade.php'),
realpath(GUARD_FIXTURES.'/stylesheets/split-button.css'),
[],
],
'needs nothing more once the entry imports all.css' => [
realpath(GUARD_FIXTURES.'/stylesheets/views'),
realpath(GUARD_FIXTURES.'/stylesheets/all.css'),
[],
],
'says so when the CSS entry does not exist' => [
realpath(GUARD_FIXTURES.'/stylesheets/views/plain.blade.php'),
'/nowhere/app.css',
['/nowhere/app.css:1 the CSS entry `missingStylesheets()` names does not exist'],
],
'requires foundation.css whatever the views hold' => [
realpath(GUARD_FIXTURES.'/stylesheets/views/plain.blade.php'),
realpath(GUARD_FIXTURES.'/stylesheets/missing-foundation.css'),
[
"stylesheets/missing-foundation.css:1 the CSS entry never imports `foundation.css`, required by every package stylesheet — add `@import '../../../../resources/css/foundation.css';`",
],
],
'recognises a package tag written fully qualified' => [
realpath(GUARD_FIXTURES.'/stylesheets/views/namespaced.blade.php'),
realpath(GUARD_FIXTURES.'/stylesheets/foundation-only.css'),
[
"stylesheets/views/namespaced.blade.php:1 `<x-button>` needs `components/button.css`, missing from stylesheets/foundation-only.css — add `@import '../../../../resources/css/components/button.css';`",
],
],
'needs pagination.css when a view calls ->links()' => [
realpath(GUARD_FIXTURES.'/stylesheets/views/pagination.blade.php'),
realpath(GUARD_FIXTURES.'/stylesheets/foundation-only.css'),
[
"stylesheets/views/pagination.blade.php:1 `->links()` needs `components/pagination.css`, missing from stylesheets/foundation-only.css — add `@import '../../../../resources/css/components/pagination.css';`",
],
],
"finds every literal design value and off-scale media query in the application's own CSS" => [
realpath(GUARD_FIXTURES.'/app-css/app.css'),
null,
[
'app-css/app.css:3 literal colour `#ff0000` — use `var(--md-sys-color-*)`',
'app-css/app.css:4 literal colour `rgb(0, 0, 0)` — use `var(--md-sys-color-*)`',
'app-css/app.css:5 literal radius `border-radius: 12px` — use `var(--md-sys-shape-corner-*)`',
'app-css/app.css:6 literal colour `rgba(0, 0, 0, 0.2)` — use `var(--md-sys-color-*)`',
'app-css/app.css:6 literal shadow `box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2)` — use `var(--md-sys-elevation-*)`',
'app-css/app.css:7 literal font size `font-size: 14px` — set the whole style with `font: var(--md-sys-typescale-*)` and its `-tracking`, or an `md-type-*` class',
'app-css/app.css:8 literal font weight `font-weight: 600` — use an `md-type-emphasized-*` class, or `var(--md-ref-typeface-weight-regular|medium|bold)`',
'app-css/app.css:9 literal line height `line-height: 1.4` — set the whole style with `font: var(--md-sys-typescale-*)`, or an `md-type-*` class',
'app-css/app.css:10 literal letter spacing `letter-spacing: 0.02em` — use `var(--md-sys-typescale-*-tracking)`',
'app-css/app.css:11 literal duration in `transition: opacity 200ms ease-in-out` — use `var(--md-sys-motion-…-duration)`, paired with its easing',
'app-css/app.css:11 literal easing in `transition: opacity 200ms ease-in-out` — use `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)`, paired with its `-duration`',
'app-css/app.css:24 literal colour `white` — use `var(--md-sys-color-*)`',
'app-css/app.css:25 literal colour `#fff` — use `var(--md-sys-color-*)`',
"app-css/app.css:34 media query width `700px` is not one of M3's breakpoints — use 600, 840, 1200 or 1600px (medium, expanded, large, extra-large) with `>=` or `<`",
'app-css/app.css:43 literal radius `border-top-left-radius: calc(8px + 4px)` — use `var(--md-sys-shape-corner-*)`',
'app-css/app.css:46 literal font `font: 600 14px/20px sans-serif` — use `font: var(--md-sys-typescale-*)` with its `-tracking`, or an `md-type-*` class',
'app-css/app.css:47 literal font size `font-size: clamp(1rem, 2vw, 2rem)` — set the whole style with `font: var(--md-sys-typescale-*)` and its `-tracking`, or an `md-type-*` class',
'app-css/app.css:48 literal line height `line-height: 1.5` — set the whole style with `font: var(--md-sys-typescale-*)`, or an `md-type-*` class',
"app-css/app.css:51 media query width `839px` is not one of M3's breakpoints — use 600, 840, 1200 or 1600px (medium, expanded, large, extra-large) with `>=` or `<`",
"app-css/app.css:53 media query width `40rem` is not one of M3's breakpoints — use 600, 840, 1200 or 1600px (medium, expanded, large, extra-large) with `>=` or `<`",
],
],
// SealShare's drop zone pairs a spatial spring for scale with an effects spring for opacity in
// one transition-duration list; blanking both var()s used to leave a bare comma the guard called
// literal.
'accepts a list of values when every item is a token, and quotes a mixed one as written' => [
realpath(GUARD_FIXTURES.'/css-lists/lists.css'),
null,
[
'css-lists/lists.css:8 literal duration `transition-duration: var(--md-sys-motion-spatial-slow-duration), 200ms` — use `var(--md-sys-motion-…-duration)`, paired with its easing',
'css-lists/lists.css:9 literal easing `transition-timing-function: var(--md-sys-motion-spatial-slow), ease-out` — use `var(--md-sys-motion-spatial-*)`/`var(--md-sys-motion-effects-*)`, paired with its `-duration`',
],
],
'leaves a box-shadow ring in a colour role alone, but still reports a blurred or literal-coloured one' => [
realpath(GUARD_FIXTURES.'/shadows/rings.css'),
null,
[
'shadows/rings.css:16 literal colour `rgba(0, 0, 0, 0.2)` — use `var(--md-sys-color-*)`',
'shadows/rings.css:16 literal shadow `box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2)` — use `var(--md-sys-elevation-*)`',
'shadows/rings.css:20 literal colour `#000` — use `var(--md-sys-color-*)`',
'shadows/rings.css:20 literal shadow `box-shadow: inset 0 0 4px 1px #000` — use `var(--md-sys-elevation-*)`',
],
],
]);
it('scans a fixture and reports exactly the expected violations', function (string|array $fixture, ?string $entry, array $expected) {
$guard = DesignGuard::scan($fixture);
expect(fixtureRelative(($entry === null ? $guard : $guard->missingStylesheets($entry))->violations()))->toBe($expected);
})->with('design guard scans');
it('leaves a Markdown mail component\'s classes and its theme to the mail theme, but still reads its icons and directives', function () {
$mail = realpath(GUARD_FIXTURES.'/mail/views');
$icon = 'mail/views/vendor/mail/html/sessions.blade.php:7 unknown Material Symbol `not_a_symbol`';
$directive = 'mail/views/vendor/mail/html/sessions.blade.php:8 Blade directive `@class` inside a component tag, where it does not compile — use `:class="\\Illuminate\\Support\\Arr::toCssClasses([…])"`';
config(['mail.markdown.paths' => [$mail.'/vendor/mail']]);
// The theme's `.table` declares nothing outside the mail components, and its literals are its own.
expect(fixtureRelative(DesignGuard::scan($mail)->violations()))->toBe([
'mail/views/page.blade.php:1 '.undeclared('table'),
$icon,
$directive,
]);
// Outside a mail component path the same files read as the application's own: the theme's
// classes against its own rules, which declare `table` and nothing else, and its literals as
// check (iii)'s.
config(['mail.markdown.paths' => []]);
expect(fixtureRelative(DesignGuard::scan($mail)->violations()))->toBe([
'mail/views/vendor/mail/html/sessions.blade.php:3 '.undeclared('sessions'),
'mail/views/vendor/mail/html/sessions.blade.php:3 '.undeclared('text-sm'),
'mail/views/vendor/mail/html/sessions.blade.php:4 '.undeclared('break-all'),
$icon,
$directive,
'mail/views/vendor/mail/html/sessions.blade.php:8 '.undeclared('wide'),
'mail/views/vendor/mail/html/themes/default.css:2 literal colour `#3d4852` — use `var(--md-sys-color-*)`',
'mail/views/vendor/mail/html/themes/default.css:3 literal font size `font-size: 14px` — set the whole style with `font: var(--md-sys-typescale-*)` and its `-tracking`, or an `md-type-*` class',
]);
});
it('bans the roles an application leaves out, wherever 2.0.0 writes one', function () {
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/forbidden-colours'))
->forbidColours(['tertiary', 'primary-container', 'error'])
->violations());
expect($violations)->toBe([
"forbidden-colours/app.css:3 `--md-sys-color-tertiary-container`: role `tertiary` is not part of this application's palette",
"forbidden-colours/app.css:4 `--md-sys-color-on-primary-container`: role `primary-container` is not part of this application's palette",
"forbidden-colours/page.blade.php:1 `color=\"tertiary\"`: role `tertiary` is not part of this application's palette",
"forbidden-colours/page.blade.php:2 `:tone=\"'tertiary'\"`: role `tertiary` is not part of this application's palette",
"forbidden-colours/page.blade.php:3 `:color=\"\$failed ? 'error' : 'info'\"`: role `error` is not part of this application's palette",
"forbidden-colours/page.blade.php:4 `--md-sys-color-on-tertiary-container`: role `tertiary` is not part of this application's palette",
"forbidden-colours/page.blade.php:4 `md-ink-error`: role `error` is not part of this application's palette",
'forbidden-colours/page.blade.php:5 '.undeclared('bg-tertiary'),
"forbidden-colours/page.blade.php:7 `--md-sys-color-primary-container`: role `primary-container` is not part of this application's palette",
]);
});
it('bans any further pattern', function () {
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/views'))
->forbid('/\bfocus-ring\b/', 'a retired utility')
->violations());
expect($violations)->toContain('views/page.blade.php:6 a retired utility');
});
it('names each stylesheet the entry imports that no scanned view needs, and leaves all.css alone', function () {
// dependencies.blade.php renders <x-button>, <x-menu> and <x-split-button>: menu.css is needed
// (and reached through split-button.css too); card.css and stack.css serve nothing on the page.
$entry = realpath(GUARD_FIXTURES.'/stylesheets/unused.css');
expect(fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/stylesheets/views/dependencies.blade.php'))->unusedStylesheets($entry)->violations()))->toBe([
"stylesheets/unused.css:4 `components/card.css` is imported, but no scanned view renders a component that needs it — remove `@import '../../../../resources/css/components/card.css';`",
"stylesheets/unused.css:5 `layout/stack.css` is imported, but no scanned view renders a component that needs it — remove `@import '../../../../resources/css/layout/stack.css';`",
])
// missingStylesheets() alone never reports an unused import.
->and(DesignGuard::scan(realpath(GUARD_FIXTURES.'/stylesheets/views/dependencies.blade.php'))->missingStylesheets($entry)->violations())->toBe([])
->and(DesignGuard::scan(realpath(GUARD_FIXTURES.'/stylesheets/views/dependencies.blade.php'))->unusedStylesheets(realpath(GUARD_FIXTURES.'/stylesheets/all.css'))->violations())->toBe([]);
});
it('follows an entry that imports what only a Vite build resolves, reports each missing stylesheet once, and leaves the entry\'s literals to scan()', function () {
$entry = realpath(GUARD_FIXTURES.'/stylesheets/foreign-imports.css');
$mt2 = 'stylesheets/views-foreign/page.blade.php:1 '.undeclared('mt-2');
$button = "stylesheets/views-foreign/page.blade.php:2 `<x-button>` needs `components/button.css`, missing from stylesheets/foreign-imports.css — add `@import '../../../../resources/css/components/button.css';`";
expect(fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/stylesheets/views-foreign'))->missingStylesheets($entry)->violations()))
->toBe([$mt2, $button])
->and(fixtureRelative(DesignGuard::scan([realpath(GUARD_FIXTURES.'/stylesheets/views-foreign'), $entry])->missingStylesheets($entry)->violations()))
->toBe(['stylesheets/foreign-imports.css:7 literal colour `#ff0000` — use `var(--md-sys-color-*)`', $mt2, $button]);
});
it('recognises a package tag written under the configured prefix', function () {
config(['livewire-material.prefix' => 'm']);
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/stylesheets/views/prefixed.blade.php'))
->missingStylesheets(realpath(GUARD_FIXTURES.'/stylesheets/foundation-only.css'))
->violations());
expect($violations)->toBe([
"stylesheets/views/prefixed.blade.php:1 `<x-button>` needs `components/button.css`, missing from stylesheets/foundation-only.css — add `@import '../../../../resources/css/components/button.css';`",
]);
});
it('needs list-item.css for a row written by hand, and leaves a card row to card.css', function () {
// rows.blade.php writes `data-md-list-row` on an <li> and a <tr> and renders no package tag;
// card-rows.blade.php writes it only on <x-card>, whose row card.css draws, and in a selector.
$rows = realpath(GUARD_FIXTURES.'/stylesheets/views/rows.blade.php');
$cardRows = realpath(GUARD_FIXTURES.'/stylesheets/views/card-rows.blade.php');
$foundationOnly = realpath(GUARD_FIXTURES.'/stylesheets/foundation-only.css');
$entry = realpath(GUARD_FIXTURES.'/stylesheets/rows.css');
expect(fixtureRelative(DesignGuard::scan($rows)->missingStylesheets($foundationOnly)->violations()))->toBe([
"stylesheets/views/rows.blade.php:3 `data-md-list-row` needs `components/list-item.css`, missing from stylesheets/foundation-only.css — add `@import '../../../../resources/css/components/list-item.css';`",
])
->and(fixtureRelative(DesignGuard::scan($rows)->unusedStylesheets($entry)->violations()))->toBe([
"stylesheets/rows.css:2 `components/card.css` is imported, but no scanned view renders a component that needs it — remove `@import '../../../../resources/css/components/card.css';`",
])
->and(fixtureRelative(DesignGuard::scan($cardRows)->missingStylesheets($foundationOnly)->violations()))->toBe([
"stylesheets/views/card-rows.blade.php:1 `<x-card>` needs `components/card.css`, missing from stylesheets/foundation-only.css — add `@import '../../../../resources/css/components/card.css';`",
])
->and(fixtureRelative(DesignGuard::scan($cardRows)->unusedStylesheets($entry)->violations()))->toBe([
"stylesheets/rows.css:3 `components/list-item.css` is imported, but no scanned view renders a component that needs it — remove `@import '../../../../resources/css/components/list-item.css';`",
]);
});
it('reads a row written by hand in the application\'s views only, not in the package\'s own', function () {
// list-item.blade.php and card.blade.php write `data-md-list-row` for the tags that render them.
$components = realpath(__DIR__.'/../../resources/views/components');
$violations = DesignGuard::scan([$components.'/list-item.blade.php', $components.'/card.blade.php'])
->missingStylesheets(realpath(GUARD_FIXTURES.'/stylesheets/foundation-only.css'))
->violations();
expect(array_values(array_filter($violations, fn (string $violation): bool => str_contains($violation, '`data-md-list-row`'))))->toBe([]);
});
it('reports a package tag the application shadows with its own anonymous component', function () {
$views = sys_get_temp_dir().'/livewire-material-guard-shadow-'.uniqid();
File::ensureDirectoryExists($views.'/components');
File::put($views.'/components/button.blade.php', '<span>the application\'s button</span>');
View::getFinder()->prependLocation($views);
try {
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/stylesheets/views/shadow.blade.php'))
->missingStylesheets(realpath(GUARD_FIXTURES.'/stylesheets/all.css'))
->violations());
} finally {
File::deleteDirectory($views);
}
expect($violations)->toBe([
"stylesheets/views/shadow.blade.php:1 `<x-button>` is shadowed by the application's own component of the same name — the package's `<x-button>` never renders here",
]);
});
it('reports a package tag the application shadows with its own component class', function () {
// Under a namespace of its own, which Blade's class guess reads from the application, so the
// class cannot shadow `<x-card>` for any later test in this process.
(fn () => $this->namespace = 'DesignGuardShadow\\')->call(app());
if (! class_exists('DesignGuardShadow\View\Components\Card', false)) {
eval('namespace DesignGuardShadow\View\Components; class Card {}');
}
$violations = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/stylesheets/views/shadow-class.blade.php'))
->missingStylesheets(realpath(GUARD_FIXTURES.'/stylesheets/all.css'))
->violations());
expect($violations)->toBe([
"stylesheets/views/shadow-class.blade.php:1 `<x-card>` is shadowed by the application's own component of the same name — the package's `<x-card>` never renders here",
]);
});
it('ignores the generated material-scheme.css, by name and by its generated header, wherever it sits among the scanned paths', function () {
$withScheme = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/app-css'))->violations());
$withoutScheme = fixtureRelative(DesignGuard::scan(realpath(GUARD_FIXTURES.'/app-css/app.css'))->violations());
expect($withScheme)->toBe($withoutScheme);
});
it('sorts every finding by path, then line, then message, whichever check produced it', function () {
$dir = sys_get_temp_dir().'/livewire-material-guard-sort-'.uniqid();
File::ensureDirectoryExists($dir);
$dir = realpath($dir);
// check (iii)'s application-CSS pass always runs last, after every view's own violations —
// an unsorted result would put a.css's finding after z.blade.php's, even though "a.css"
// sorts before "z.blade.php" (Finder itself already walks views alphabetically, so this is
// the one place natural order and sorted order actually diverge).
File::put($dir.'/a.css', "body {\n color: #ff0000;\n}\n");
File::put($dir.'/z.blade.php', '<div class="bg-red-500 p-4"></div>');
try {
$violations = DesignGuard::scan($dir)->violations();
} finally {
File::deleteDirectory($dir);
}
expect($violations)->toBe([
"{$dir}/a.css:2 literal colour `#ff0000` — use `var(--md-sys-color-*)`",
"{$dir}/z.blade.php:1 ".undeclared('bg-red-500'),
"{$dir}/z.blade.php:1 ".undeclared('p-4'),
]);
});
it('passes the package\'s own views, showcase and Workbench, every stylesheet imported through all.css', function () {
$root = realpath(__DIR__.'/../..');
$paths = [$root.'/resources/views', $root.'/resources/js', $root.'/src', $root.'/workbench/resources'];
// The package's own Markdown mail components are the shape `mail.markdown.paths` names: their
// classes come from mail/theme.blade.php, a stylesheet Blade renders, not from a CSS entry.
config(['mail.markdown.paths' => [$root.'/resources/views/mail']]);
expect(DesignGuard::scan($paths)->missingStylesheets($root.'/resources/css/all.css')->violations())->toBe([])
// …and the stylesheet check is not vacuous there: the showcase's plain tags do need imports.
->and(DesignGuard::scan($root.'/resources/views/showcase')->missingStylesheets(realpath(GUARD_FIXTURES.'/stylesheets/foundation-only.css'))->violations())->not->toBe([]);
});