*/ function stylesheetsBundleComponentAndLayoutFiles(): array { return collect(File::files(stylesheetsBundlePath('components'))) ->merge(File::files(stylesheetsBundlePath('layout'))) ->map(fn (SplFileInfo $file): string => (string) $file->getRealPath()) ->values() ->all(); } it('bundles all.css with every import resolved, foundation first, every component and layout file once', function () { $bundled = Stylesheets::bundle([stylesheetsBundlePath('all.css')]); expect(stylesheetsBundleWithoutComments($bundled))->not->toContain('@import'); $files = stylesheetsBundleComponentAndLayoutFiles(); $markers = collect($files)->mapWithKeys(fn (string $file): array => [$file => stylesheetsBundleMarker($file)]); expect($markers)->toHaveCount(count($files)) ->and($markers->unique()->count())->toBe($markers->count(), 'two files share a header marker'); foreach ($markers as $file => $marker) { expect(substr_count($bundled, $marker))->toBe(1, basename($file).' should appear exactly once in the bundle'); } $foundationPosition = strpos($bundled, stylesheetsBundleMarker(stylesheetsBundlePath('foundation.css'))); $earliestOtherPosition = $markers->map(fn (string $marker): int => (int) strpos($bundled, $marker))->min(); expect($foundationPosition)->toBeInt() ->and($foundationPosition)->toBeLessThan($earliestOtherPosition); }); it('keeps a file two others import once, at its first position', function () { $dir = sys_get_temp_dir().'/livewire-material-bundle-'.Str::random(8); File::makeDirectory($dir); File::put("{$dir}/shared.css", "@layer material.reset;\n.shared { color: green; }\n"); File::put("{$dir}/a.css", "@import './shared.css';\n.a { color: red; }\n"); File::put("{$dir}/b.css", "@import './shared.css';\n.b { color: blue; }\n"); try { $bundled = Stylesheets::bundle(["{$dir}/a.css", "{$dir}/b.css"]); expect(substr_count($bundled, '.shared { color: green; }'))->toBe(1) ->and(strpos($bundled, '.shared'))->toBeLessThan(strpos($bundled, '.a { color: red; }')) ->and($bundled)->toContain('.b { color: blue; }') ->and(stylesheetsBundleWithoutComments($bundled))->not->toContain('@import'); } finally { File::deleteDirectory($dir); } }); it('inlines every plain import form once, and nothing written in a comment or a string', function () { $dir = sys_get_temp_dir().'/livewire-material-bundle-'.Str::random(8); File::makeDirectory("{$dir}/components", recursive: true); foreach (['double', 'quoted-url', 'bare-url'] as $name) { // A leading byte-order mark and @charset mean nothing past a stylesheet's first byte. File::put("{$dir}/components/{$name}.css", "\u{FEFF}@charset \"UTF-8\";\n.{$name} {}\n"); } File::put("{$dir}/entry.css", <<<'CSS' /* An example in a header: @import './components/never.css'; url(never.png) — it's fine. */ @layer a, b; @import "./components/double.css"; @import url('./components/quoted-url.css'); @IMPORT url( ./components/bare-url.css ); @import './components/../components/double.css'; .entry::before { content: "@import './components/never.css'; url(never.png)"; } CSS); try { $bundled = Stylesheets::bundle(["{$dir}/entry.css"]); expect(substr_count($bundled, '.double {}'))->toBe(1) ->and($bundled)->toContain('.quoted-url {}') ->toContain('.bare-url {}') ->toContain("/* An example in a header: @import './components/never.css'; url(never.png) — it's fine. */") ->toContain(".entry::before { content: \"@import './components/never.css'; url(never.png)\"; }") ->not->toContain('@charset') ->not->toContain("\u{FEFF}") // The two left are the comment's and the string's. ->and(substr_count(strtolower($bundled), '@import'))->toBe(2); } finally { File::deleteDirectory($dir); } }); it('throws, naming the file, for an import the bundle could not inline without changing its meaning', function (string $css, string $reason) { $dir = sys_get_temp_dir().'/livewire-material-bundle-'.Str::random(8); File::makeDirectory($dir); File::put("{$dir}/x.css", '.x {}'); File::put("{$dir}/entry.css", $css); try { Stylesheets::bundle(["{$dir}/entry.css"]); $this->fail("Stylesheets::bundle() should have thrown for {$css}"); } catch (RuntimeException $exception) { expect($exception->getMessage())->toContain('entry.css')->toContain($reason); } finally { File::deleteDirectory($dir); } })->with([ 'a layer() condition' => ["@import './x.css' layer(x);", 'without layer()'], 'a supports() condition' => ["@import './x.css' supports(display: grid);", 'without layer()'], 'a media condition' => ['@import url(./x.css) screen;', 'without layer()'], 'an absolute URL' => ["@import 'https://example.com/x.css';", 'absolute URL'], 'an absolute path' => ["@import '/x.css';", 'absolute URL'], 'a package specifier' => ["@import 'tailwindcss';", '"tailwindcss", which does not exist'], 'an import after a rule' => [".a {}\n@import './x.css';", 'after a rule'], 'an import inside a block' => ["@layer b { @import './x.css'; }", 'inside a block'], ]); it('rewrites a relative url() against the entry file\'s directory, or against $base when given', function () { // Mirrors resources/css/'s own shape: an entry (all.css) sitting where the bundle is "from", // a component one level under it (components/menu.css), and an asset one level above that // again (resources/fonts/, resources/svg/, siblings of resources/css/) — so a url() two // directories away from the file that wrote it lands one directory away from the entry. $dir = sys_get_temp_dir().'/livewire-material-bundle-'.Str::random(8); File::makeDirectory("{$dir}/css/components", recursive: true); File::makeDirectory("{$dir}/fonts"); File::put("{$dir}/css/entry.css", "@import './components/widget.css';\n"); File::put("{$dir}/css/components/widget.css", <<<'CSS' @font-face { src: url('../../fonts/widget.woff2') format('woff2'); } .widget { background: url("icon.svg"), url(https://example.com/a.png), url(#gradient), url(data:image/png;base64,AA==); } CSS); try { $bundled = Stylesheets::bundle(["{$dir}/css/entry.css"]); // Resolved against the entry's own directory (css/) when no $base is given: the font // climbs out of css/ to the sibling fonts/ directory, the same shape resources/css/ has // relative to resources/fonts/; the icon, sitting beside widget.css, stays inside components/. expect($bundled)->toContain("url('../fonts/widget.woff2')") ->toContain('url("components/icon.svg")') // An absolute URL, a # fragment and a data: URI are left exactly as written. ->toContain('url(https://example.com/a.png)') ->toContain('url(#gradient)') ->toContain('url(data:image/png;base64,AA==)'); // $base stands for wherever the entry itself (the thing "resources/css/" is here) is // served from: the icon, which never left that directory, lands right under $base, while // the font's one directory climbed above the entry the same way it did with no $base at // all, so it climbs one directory above $base too — off the end of "assets", not inside it. Stylesheets::resetCache(); $withBase = Stylesheets::bundle(["{$dir}/css/entry.css"], 'https://cdn.example.com/assets'); expect($withBase)->toContain("url('https://cdn.example.com/fonts/widget.woff2')") ->toContain('url("https://cdn.example.com/assets/components/icon.svg")') ->toContain('url(https://example.com/a.png)'); } finally { File::deleteDirectory($dir); } }); /** * 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+'([^']+)'/", stylesheetsBundleWithoutComments(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); }); it('does not loop on a cycle, and keeps both files\' rules', function () { $dir = sys_get_temp_dir().'/livewire-material-bundle-'.Str::random(8); File::makeDirectory($dir); File::put("{$dir}/a.css", "@import './b.css';\n.a {}\n"); File::put("{$dir}/b.css", "@import './a.css';\n.b {}\n"); try { $bundled = Stylesheets::bundle(["{$dir}/a.css"]); expect($bundled)->toContain('.a {}')->toContain('.b {}'); } finally { File::deleteDirectory($dir); } }); it('throws naming the importer when an import does not exist', function () { $dir = sys_get_temp_dir().'/livewire-material-bundle-'.Str::random(8); File::makeDirectory($dir); File::put("{$dir}/broken.css", "@import './missing.css';\n"); try { Stylesheets::bundle(["{$dir}/broken.css"]); $this->fail('Stylesheets::bundle() should have thrown.'); } catch (RuntimeException $exception) { expect($exception->getMessage())->toContain('broken.css')->toContain('missing.css'); } finally { File::deleteDirectory($dir); } }); it('caches per file list and mtime, and resetCache() forces a fresh read', function () { $dir = sys_get_temp_dir().'/livewire-material-bundle-'.Str::random(8); File::makeDirectory($dir); File::put("{$dir}/cached.css", '.before {}'); touch("{$dir}/cached.css", 1_700_000_000); try { expect(Stylesheets::bundle(["{$dir}/cached.css"]))->toBe('.before {}'); // The mtime is unchanged, so the cache serves the old content even though the file itself // now holds something else. File::put("{$dir}/cached.css", '.changed-but-same-mtime {}'); touch("{$dir}/cached.css", 1_700_000_000); expect(Stylesheets::bundle(["{$dir}/cached.css"])) ->toBe('.before {}', 'an unchanged mtime should still be served from the cache'); Stylesheets::resetCache(); expect(Stylesheets::bundle(["{$dir}/cached.css"])) ->toBe('.changed-but-same-mtime {}', 'resetCache() should force a fresh read'); // A later mtime invalidates the cache on its own, with no resetCache() needed. File::put("{$dir}/cached.css", '.after {}'); touch("{$dir}/cached.css", 1_700_000_100); expect(Stylesheets::bundle(["{$dir}/cached.css"])) ->toBe('.after {}', 'a changed mtime should invalidate the cache on its own'); // So does a later mtime on a file the bundle reached only through an @import — a changed // button.css must not leave a cached all.css bundle standing. File::put("{$dir}/entry.css", "@import './cached.css';\n"); touch("{$dir}/entry.css", 1_700_000_000); expect(Stylesheets::bundle(["{$dir}/entry.css"]))->toContain('.after {}'); File::put("{$dir}/cached.css", '.nested-change {}'); touch("{$dir}/cached.css", 1_700_000_200); expect(Stylesheets::bundle(["{$dir}/entry.css"])) ->toContain('.nested-change {}') ->and(Stylesheets::bundle(["{$dir}/entry.css"], ''))->toContain('.nested-change {}'); } finally { Stylesheets::resetCache(); File::deleteDirectory($dir); } }); /** * The Vite deduplication `bundle()` is modelled on, pinned against the real bundler: a Vite build * of `all.css` alone, no other plugin in the graph (tests/Fixtures/dedup.vite.config.mjs — its * header explains why this is a separate, minimal entry rather than the Workbench's own). * * The built CSS is minified, so rather than compare byte-for-byte against `bundle()`'s own * (unminified) output, this counts one plain, unwrapped rule per file — `[data-md-]{…}` * exactly as button.css, icon.css, menu.css and list-item.css each write it before any `:not()`, * `:is()` or `:where()` wrapping — and asserts each appears exactly once. A rule behind such * wrapping is not used as a marker because Lightning CSS (Tailwind's minifier, which the * Workbench's own build also carries) may re-split a single nested rule into several flat ones * sharing a declaration block, which would inflate a literal count for a reason that has nothing * to do with import deduplication; these four files' very first, plain, unwrapped selector is not * affected by that and stays a reliable one-file-once fingerprint. They are also each imported * from several other stylesheets (button.css: 11 places; icon.css and menu.css: a dozen more * between them; list-item.css: list.css and carousel-item.css), so a broken dedup would double or * triple the count rather than leave it at one. */ it('lands each of four widely-imported component stylesheets exactly once in a real Vite build', 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); $css = File::get($outDir.'/'.$manifest['resources/css/all.css']['file']); foreach (['button', 'icon', 'menu', 'list-item'] as $name) { $needle = "[data-md-{$name}]{"; $start = strpos($css, $needle); expect($start)->toBeInt("{$needle} should be in the built CSS"); $rule = substr($css, $start, strpos($css, '}', $start) - $start + 1); expect(substr_count($css, $rule))->toBe(1, "{$name}.css's root rule should land once"); } } finally { File::deleteDirectory($outDir); } });