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 */ 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); } });