Files
livewire-material/tests/Feature/StylesheetsBundleTest.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

202 lines
8.7 KiB
PHP

<?php
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Str;
use NoNameWeb\LivewireMaterial\Support\Stylesheets;
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
/**
* The stylesheets the package serves on its own — the showcase's and the error page's, outside an
* application's Vite build — are prebuilt into resources/dist/ by `npm run build:stylesheets`
* (bin/stylesheets.mjs), with Vite's own postcss-import: every `@import` inlined once, first
* occurrence kept. This file pins that the committed files are current, the Vite deduplication they
* rely on, and `Stylesheets::resolvedFiles()`, the import graph `DesignGuard` reads.
*
* This lives apart from StylesheetsTest.php, which is about the *shape* of the source tree (every
* file's own header, layer statement and imports). `stylesheetsBundlePath()` is named apart from
* StylesheetsTest.php's `stylesheetPath()` for the same reason: the two files may run in the same
* Pest process, and a duplicate top-level function name is a fatal error.
*/
function stylesheetsBundlePath(string $path = ''): string
{
return (string) realpath(ComponentStylesheet::cssPath($path));
}
it('resolves the files an entry imports, transitively, without bundling any content', function () {
$files = Stylesheets::resolvedFiles([stylesheetsBundlePath('components/split-button.css')]);
expect($files)->toContain(
stylesheetsBundlePath('components/split-button.css'),
stylesheetsBundlePath('components/button.css'),
stylesheetsBundlePath('components/menu.css'),
stylesheetsBundlePath('components/icon.css'),
);
});
/**
* Deduplication keeps a file at its first position, so an override that ties a rule it imports on
* specificity (the navigation rail's header FAB over fab.css was one) still wins only while every
* stylesheet's rules land after those of each file it imports. A depth-first inline guarantees
* that for every import edge — except around a cycle, where one file of the two must come first.
* So no package stylesheet may reach itself through its imports.
*/
it('keeps the package\'s import graph free of cycles, so a stylesheet always lands after what it imports', function () {
$edges = 0;
$walk = function (string $file, array $stack) use (&$walk, &$edges): void {
expect(in_array($file, $stack, true))->toBeFalse('import cycle: '.implode(' → ', array_map(basename(...), [...$stack, $file])));
preg_match_all("/@import\\s+'([^']+)'/", ComponentStylesheet::withoutComments(File::get($file)), $matches);
foreach ($matches[1] as $import) {
$edges++;
$walk((string) realpath(dirname($file).'/'.$import), [...$stack, $file]);
}
};
$walk(stylesheetsBundlePath('all.css'), []);
expect($edges)->toBeGreaterThan(100);
});
/**
* resources/dist/ is committed so applications need no Node; a stylesheet change without
* `npm run build:stylesheets` would leave the showcase and the error pages serving the old rules.
*/
it('keeps the prebuilt stylesheets in resources/dist/ in step with resources/css/', function () {
$node = config('livewire-material.node', 'node');
if (Process::run([$node, '--version'])->failed() || ! is_dir(__DIR__.'/../../node_modules/vite')) {
$this->markTestSkipped("Node ({$node}) or Vite is missing; `npm run build:stylesheets` builds resources/dist/.");
}
$outDir = sys_get_temp_dir().'/livewire-material-dist-'.Str::random(8);
try {
$result = Process::run([$node, __DIR__.'/../../bin/stylesheets.mjs', $outDir]);
expect($result->successful())->toBeTrue($result->errorOutput());
foreach (File::files($outDir) as $built) {
$committed = __DIR__.'/../../resources/dist/'.$built->getFilename();
expect(is_file($committed) ? File::get($committed) : null)
->toBe($built->getContents(), "resources/dist/{$built->getFilename()} is stale: run `npm run build:stylesheets`");
}
} finally {
File::deleteDirectory($outDir);
}
});
/**
* Every innermost rule of a minified stylesheet — `selector{declarations}` with no block inside —
* keyed by the at-rules around it (`@layer material.components{@media (hover:hover){…`), so the
* same rule written for two different conditions counts as two rules, not one repeated.
*
* @return list<string>
*/
function stylesheetsBundleLeafRules(string $css): array
{
$leaves = [];
$stack = [];
$start = 0;
$length = strlen($css);
for ($i = 0; $i < $length; $i++) {
$char = $css[$i];
if ($char === '"' || $char === "'") {
$close = strpos($css, $char, $i + 1);
if ($close === false) {
break;
}
$i = $close;
} elseif ($char === '{') {
$stack[] = ['prelude' => trim(substr($css, $start, $i - $start)), 'open' => $i, 'leaf' => true];
if (count($stack) > 1) {
$stack[count($stack) - 2]['leaf'] = false;
}
$start = $i + 1;
} elseif ($char === '}') {
$block = array_pop($stack);
if ($block !== null && $block['leaf']) {
$context = implode('{', array_column($stack, 'prelude'));
$leaves[] = $context.'{'.$block['prelude'].'{'.substr($css, $block['open'] + 1, $i - $block['open'] - 1).'}';
}
$start = $i + 1;
} elseif ($char === ';' && ($stack === [] || ! $stack[count($stack) - 1]['leaf'])) {
// A statement between blocks (`@layer a,b;`) is not part of the next rule's selector.
$start = $i + 1;
}
}
return $leaves;
}
/**
* The Vite deduplication the prebuilt stylesheets and every application's build rely on, pinned
* against the real bundler (tests/Fixtures/dedup.vite.config.mjs, no plugin in the graph):
* `all.css`, and tests/Fixtures/dedup-app.css, an entry shaped like an application's — outside
* the package, the foundation, then split button, app bar, modal and pagination, which each
* import button.css, and button.css once more.
*
* The built CSS is minified, so this compares rules rather than files: no innermost rule may
* appear twice under the same at-rules. A build that inlined every occurrence of a shared
* stylesheet repeats hundreds of them (the Workbench's entry did while `@tailwindcss/vite` was
* still in its graph, inlining imports itself ahead of Vite's own postcss-import: button.css's
* hover and disabled rules thirteen times), while a file's plain root rule alone is no evidence —
* the minifier folds identical copies of that one together. In the application entry, button.css
* must also keep its first position: before split-button.css, the first file that imports it, not
* at the end where the application named it again.
*/
it('lands every rule once, at its first position, in a real Vite build of all.css and of an application entry', function () {
$node = config('livewire-material.node', 'node');
if (Process::run([$node, '--version'])->failed()) {
$this->markTestSkipped("Node ({$node}) could not run.");
}
$vite = __DIR__.'/../../node_modules/vite/bin/vite.js';
if (! is_file($vite)) {
$this->markTestSkipped('Vite is not installed (node_modules/vite is missing).');
}
$outDir = sys_get_temp_dir().'/livewire-material-dedup-'.Str::random(8);
try {
$result = Process::path(__DIR__.'/../..')
->env(['DEDUP_OUT_DIR' => $outDir])
->timeout(120)
->run([$node, $vite, 'build', '--config', 'tests/Fixtures/dedup.vite.config.mjs']);
expect($result->successful())->toBeTrue($result->errorOutput());
$manifest = json_decode(File::get("{$outDir}/.vite/manifest.json"), true, flags: JSON_THROW_ON_ERROR);
foreach (['resources/css/all.css', 'tests/Fixtures/dedup-app.css'] as $entry) {
$css = File::get($outDir.'/'.$manifest[$entry]['file']);
$leaves = stylesheetsBundleLeafRules($css);
$repeated = array_keys(array_filter(array_count_values($leaves), fn (int $count): bool => $count > 1));
expect(count($leaves))->toBeGreaterThan(100, $entry)
->and($repeated)->toBe([], "{$entry} repeats a rule, so a shared stylesheet landed more than once");
}
$app = File::get($outDir.'/'.$manifest['tests/Fixtures/dedup-app.css']['file']);
expect(strpos($app, '[data-md-button]{'))->toBeInt()
->toBeLessThan((int) strpos($app, '[data-md-split-button]{'))
->and(strpos($app, '[data-md-app-bar]'))->toBeGreaterThan((int) strpos($app, '[data-md-split-button]{'));
} finally {
File::deleteDirectory($outDir);
}
});