Files
livewire-material/tests/Feature/StylesheetsBundleTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 247c596c3a Cut duplicated and speculative code across the package
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>
2026-09-17 19:29:21 +02:00

222 lines
9.5 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'),
);
});
it('resolves past what an application entry imports and only a Vite build resolves, without throwing', function () {
$dir = sys_get_temp_dir().'/livewire-material-resolved-'.Str::random(8);
File::makeDirectory($dir);
File::put($dir.'/theme.css', '.theme {}');
File::put($dir.'/app.css', implode("\n", [
"@import 'tailwindcss';",
"@import url('https://fonts.googleapis.com/css2?family=Roboto');",
"@import './theme.css' layer(app);",
"@import './missing.css';",
"@import '".stylesheetsBundlePath('components/icon.css')."';",
]));
try {
$files = Stylesheets::resolvedFiles([$dir.'/app.css']);
} finally {
File::deleteDirectory($dir);
}
expect($files)->toBe([realpath(sys_get_temp_dir()).'/'.basename($dir).'/app.css', realpath(sys_get_temp_dir()).'/'.basename($dir).'/theme.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 it shared one file with
* `@tailwindcss/vite`: 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);
}
});