Files
livewire-material/tests/Feature/StylesheetsBundleTest.php
T
Andreas Reinhold / reiniandClaude Sonnet 5 792ec44d5e Add Stylesheets::bundle(), a build-free CSS import resolver
Plan step 37: the showcase and the error page's fallback serve CSS
without the application's Vite build, so nothing deduplicates their
@imports for them the way Vite's bundled postcss-import does — a
browser's native @import fetches every occurrence, it does not skip a
file it already loaded. Stylesheets::bundle(array $files, ?string
$base = null): string does in PHP what that build step does: it
inlines every @import depth-first, each file once, first occurrence
kept; leaves a bare specifier or an absolute URL untouched; rewrites a
relative url() against $base (or, without one, against the directory
of $files[0]); breaks a cycle instead of looping; throws naming the
importer when an import is missing; and caches per resolved file list
and mtime, with resetCache() for tests.

tests/Feature/StylesheetsBundleTest.php covers bundle()'s own
behaviour (dedup, url() rewriting, cycles, the missing-import
exception, the cache) and, at the end, pins the Vite deduplication
bundle() is modelled on against a real build — of all.css alone
(tests/Fixtures/dedup.vite.config.mjs), whose docblock explains why:
the Workbench's own entry still shares one file with @tailwindcss/vite
until plan step 39 removes it, and that plugin bundles its whole
reachable module graph itself, without the same dedup guarantee.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 02:49:59 +02:00

273 lines
12 KiB
PHP

<?php
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Str;
use NoNameWeb\LivewireMaterial\Support\Stylesheets;
/**
* `Stylesheets::bundle()` (plan step 37) is what the showcase and the error page's fallback serve
* without the application's own Vite build, so it has to do in PHP what Vite's bundled
* postcss-import already does for a normal build: inline every `@import` once, first occurrence
* kept, and leave a `url()` reachable from wherever the bundle ends up.
*
* This lives apart from StylesheetsTest.php, which is about the *shape* of the source tree (every
* file's own header, layer statement and imports) — a concern that holds whether or not anything
* ever bundles them. This file is about the bundler itself: what `bundle()` produces, and, at the
* end, a real Vite build to pin the claim it is modelled on.
*
* Every helper here is named `stylesheetsBundle*` rather than reusing StylesheetsTest.php's
* `stylesheet*` names — the two files may run in the same Pest process, and a duplicate top-level
* function name is a fatal error, the same reason the four component-group stylesheet test files
* each name their own `assert…TokensAndPxBreakpoints()` uniquely.
*/
function stylesheetsBundlePath(string $path = ''): string
{
return (string) realpath(__DIR__.'/../../resources/css'.($path === '' ? '' : '/'.$path));
}
/**
* `$css` with every comment and quoted string blanked out — the same idea StylesheetsTest.php's
* `stylesheetWithoutComments()` uses, kept local here for the reason above.
*/
function stylesheetsBundleWithoutComments(string $css): string
{
return (string) preg_replace('~("(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\')|/\*.*?\*/~s', '$1', $css);
}
/**
* The first line of a stylesheet's header comment, the sentence naming what it draws — unique per
* file (checked below), untouched by `bundle()` since it only ever rewrites an `@import` or a
* `url()`, never a comment. A reliable per-file fingerprint in `bundle()`'s output, which is not
* minified.
*/
function stylesheetsBundleMarker(string $file): string
{
$lines = explode("\n", File::get($file));
return trim((string) preg_replace('/^\s*\*\s?/', '', $lines[1] ?? ''));
}
/**
* @return list<string>
*/
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('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);
}
});
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');
} 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-<name>]{…}`
* 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);
}
});