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>
202 lines
8.7 KiB
PHP
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);
|
|
}
|
|
});
|