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
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>
415 lines
26 KiB
PHP
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([]);
|
|
});
|