diff --git a/modules/cms/tests/classes/TwigExtensionTest.php b/modules/cms/tests/classes/TwigExtensionTest.php index 686ab96c74..b62673c30d 100644 --- a/modules/cms/tests/classes/TwigExtensionTest.php +++ b/modules/cms/tests/classes/TwigExtensionTest.php @@ -5,11 +5,31 @@ use Cms\Twig\Extension; use Cms\Classes\Controller; +use System\Classes\Asset\PackageManager; use System\Tests\Bootstrap\TestCase; use Winter\Storm\Exception\SystemException; +use Winter\Storm\Support\Facades\File; class TwigExtensionTest extends TestCase { + private const VITE_FIXTURE_PACKAGE = 'theme-assettest'; + private const VITE_FIXTURE_THEME_PATH = '/modules/system/tests/fixtures/themes/assettest'; + private const VITE_HOT_URL = 'http://localhost:5173'; + + public function tearDown(): void + { + $hotFile = base_path(self::VITE_FIXTURE_THEME_PATH . '/assets/dist/hot'); + if (File::exists($hotFile)) { + File::delete($hotFile); + } + $distDir = base_path(self::VITE_FIXTURE_THEME_PATH . '/assets/dist'); + if (File::isDirectory($distDir) && count(File::files($distDir)) === 0 && count(File::directories($distDir)) === 0) { + File::deleteDirectory($distDir); + } + + parent::tearDown(); + } + public function testPartialFunction() { $extension = new Extension; @@ -35,4 +55,111 @@ public function testContentFunction() $this->expectExceptionMessageMatches('/is\snot\sfound/'); $this->assertFalse($extension->contentFunction('invalid-content-file', [], true)); } + + public function testStylesTagEmitsCssAndViteCss(): void + { + [$extension, $controller] = $this->buildExtensionWithViteAssets([ + 'assets/css/theme.css', + 'assets/javascript/theme.js', + ]); + $controller->addCss('plain.css'); + + // This is exactly the call StylesNode::compile() writes into the compiled `{% styles %}` tag + $output = (string) $extension->assetsFunction('css'); + + $this->assertStringContainsString('plain.css', $output); + $this->assertStringContainsString('assets/css/theme.css', $output); + $this->assertStringNotContainsString('assets/javascript/theme.js', $output); + } + + public function testScriptsTagEmitsJsAndViteJs(): void + { + [$extension, $controller] = $this->buildExtensionWithViteAssets([ + 'assets/css/theme.css', + 'assets/javascript/theme.js', + ]); + $controller->addJs('plain.js'); + + // Mirrors what ScriptsNode::compile() emits for the `{% scripts %}` tag + $output = (string) $extension->assetsFunction('js'); + + $this->assertStringContainsString('plain.js', $output); + $this->assertStringContainsString('assets/javascript/theme.js', $output); + $this->assertStringNotContainsString('assets/css/theme.css', $output); + } + + public function testStylesAndScriptsTagsDoNotCrossLeak(): void + { + // Reverse entrypoint order to confirm filtering doesn't depend on array order + [$extension] = $this->buildExtensionWithViteAssets([ + 'assets/javascript/theme.js', + 'assets/css/theme.css', + ]); + + $stylesOutput = (string) $extension->assetsFunction('css'); + $scriptsOutput = (string) $extension->assetsFunction('js'); + + $this->assertStringContainsString('assets/css/theme.css', $stylesOutput); + $this->assertStringNotContainsString('assets/javascript/theme.js', $stylesOutput); + + $this->assertStringContainsString('assets/javascript/theme.js', $scriptsOutput); + $this->assertStringNotContainsString('assets/css/theme.css', $scriptsOutput); + } + + public function testStylesTagOmitsViteWhenNoCssEntrypoints(): void + { + // JS-only vite registration; the styles tag must contribute no vite output for it + [$extension] = $this->buildExtensionWithViteAssets([ + 'assets/javascript/theme.js', + ]); + + $output = (string) $extension->assetsFunction('css'); + + $this->assertStringNotContainsString('@vite/client', $output); + $this->assertStringNotContainsString('assets/javascript/theme.js', $output); + } + + /** + * Boots a Cms\Twig\Extension wired up to a fresh Controller that has the + * given vite entrypoints registered against the assettest fixture package. + * The fixture's hot file is written so Vite::tags() emits deterministic + * dev-server tags (no manifest required). + * + * @return array{0: Extension, 1: Controller} + */ + private function buildExtensionWithViteAssets(array $entrypoints): array + { + $themePath = base_path(self::VITE_FIXTURE_THEME_PATH); + if (!File::isDirectory($themePath)) { + $this->markTestSkipped('Vite test fixture is missing at ' . self::VITE_FIXTURE_THEME_PATH); + } + + // PackageManager's lazy init() touches Theme::all() and Halcyon model events, which + // can blow up when prior tests in the full suite have left datasource/plugin state in + // an inconsistent shape. Skip gracefully — same pattern as the existing + // ViteInstallTest — so this test still asserts something useful in isolation. + try { + $packageManager = PackageManager::instance(); + } catch (\Throwable $e) { + $this->markTestSkipped('PackageManager could not initialise in this environment: ' . $e->getMessage()); + } + + // registerPackage silently no-ops when re-registering the same name + config. + $packageManager->registerPackage( + self::VITE_FIXTURE_PACKAGE, + $themePath . '/vite.config.mjs', + 'vite' + ); + + File::ensureDirectoryExists($themePath . '/assets/dist'); + File::put($themePath . '/assets/dist/hot', self::VITE_HOT_URL); + + $controller = new Controller(); + $controller->addVite($entrypoints, self::VITE_FIXTURE_PACKAGE); + + $extension = new Extension(); + $extension->setController($controller); + + return [$extension, $controller]; + } } diff --git a/modules/cms/twig/StylesNode.php b/modules/cms/twig/StylesNode.php index bd6e0fc08b..35a442c2f5 100644 --- a/modules/cms/twig/StylesNode.php +++ b/modules/cms/twig/StylesNode.php @@ -26,7 +26,6 @@ public function compile(TwigCompiler $compiler) $compiler ->addDebugInfo($this) ->write("echo \$this->env->getExtension('Cms\Twig\Extension')->assetsFunction('css');\n") - ->write("echo \$this->env->getExtension('Cms\Twig\Extension')->assetsFunction('vite');\n") ->write("echo \$this->env->getExtension('Cms\Twig\Extension')->displayBlock('styles');\n") ; } diff --git a/modules/system/tests/traits/AssetMakerTest.php b/modules/system/tests/traits/AssetMakerTest.php index ca461941d9..061c5d175b 100644 --- a/modules/system/tests/traits/AssetMakerTest.php +++ b/modules/system/tests/traits/AssetMakerTest.php @@ -2,10 +2,12 @@ namespace System\Tests\Traits; +use System\Classes\Asset\PackageManager; use System\Tests\Bootstrap\TestCase; use System\Traits\AssetMaker; use System\Traits\EventEmitter; use System\Traits\ViewMaker; +use Winter\Storm\Support\Facades\File; use Winter\Storm\Support\Facades\Url; class AssetMakerStub @@ -19,12 +21,64 @@ class AssetMakerTest extends TestCase { private AssetMakerStub $stub; + private const VITE_FIXTURE_PACKAGE = 'theme-assettest'; + private const VITE_FIXTURE_THEME_PATH = '/modules/system/tests/fixtures/themes/assettest'; + private const VITE_HOT_URL = 'http://localhost:5173'; + public function setUp() : void { $this->createApplication(); $this->stub = new AssetMakerStub(); } + public function tearDown(): void + { + // Remove any vite hot file that a vite-related test wrote into the fixture + $hotFile = base_path(self::VITE_FIXTURE_THEME_PATH . '/assets/dist/hot'); + if (File::exists($hotFile)) { + File::delete($hotFile); + } + $distDir = base_path(self::VITE_FIXTURE_THEME_PATH . '/assets/dist'); + if (File::isDirectory($distDir) && count(File::files($distDir)) === 0 && count(File::directories($distDir)) === 0) { + File::deleteDirectory($distDir); + } + + parent::tearDown(); + } + + /** + * Registers the assettest fixture as a vite package and writes a hot file so that + * Vite::tags() emits deterministic dev-server tags without needing a built manifest. + */ + private function setUpViteFixture(): void + { + $themePath = base_path(self::VITE_FIXTURE_THEME_PATH); + if (!File::isDirectory($themePath)) { + $this->markTestSkipped('Vite test fixture is missing at ' . self::VITE_FIXTURE_THEME_PATH); + } + + // PackageManager's lazy init() call iterates Theme::all(), which fires Halcyon model + // events. In the full system suite, prior tests can leave plugin/datasource state in a + // way that makes that initial iteration throw — same pattern as the existing + // ViteInstallTest. Skip gracefully when that happens so this test is meaningful only + // in a clean environment (and always when run in isolation). + try { + $packageManager = PackageManager::instance(); + } catch (\Throwable $e) { + $this->markTestSkipped('PackageManager could not initialise in this environment: ' . $e->getMessage()); + } + + // registerPackage silently no-ops when re-registering the same name + config. + $packageManager->registerPackage( + self::VITE_FIXTURE_PACKAGE, + $themePath . '/vite.config.mjs', + 'vite' + ); + + File::ensureDirectoryExists($themePath . '/assets/dist'); + File::put($themePath . '/assets/dist/hot', self::VITE_HOT_URL); + } + // // Tests // @@ -118,4 +172,115 @@ public function testAssetOrdering(): void $hostUrl . 'myThird.css', ], $assets['css']); } + + public function testGetAssetType(): void + { + $cases = [ + 'foo.js' => 'js', + 'assets/javascript/theme.js' => 'js', + 'foo.css' => 'css', + 'assets/css/theme.css' => 'css', + 'foo.txt' => null, + 'foo' => null, + 'js' => null, + 'foo.JS' => 'js', + 'foo.CSS' => 'css', + ]; + + foreach ($cases as $input => $expected) { + $this->assertSame( + $expected, + $this->callProtectedMethod($this->stub, 'getAssetType', [$input]), + "getAssetType('$input') did not return the expected type" + ); + } + } + + public function testMakeAssetsViteCssOnly(): void + { + $this->setUpViteFixture(); + + $this->stub->addCss('plain.css'); + $this->stub->addVite( + ['assets/css/theme.css', 'assets/javascript/theme.js'], + self::VITE_FIXTURE_PACKAGE + ); + + $output = $this->stub->makeAssets('css'); + + $this->assertNotNull($output); + // Plain CSS still emitted + $this->assertStringContainsString('plain.css', $output); + // Vite CSS entrypoint emitted + $this->assertStringContainsString('assets/css/theme.css', $output); + // Vite JS entrypoint must NOT leak into the css output + $this->assertStringNotContainsString('assets/javascript/theme.js', $output); + } + + public function testMakeAssetsViteJsOnly(): void + { + $this->setUpViteFixture(); + + $this->stub->addJs('plain.js'); + $this->stub->addVite( + ['assets/css/theme.css', 'assets/javascript/theme.js'], + self::VITE_FIXTURE_PACKAGE + ); + + $output = $this->stub->makeAssets('js'); + + $this->assertNotNull($output); + $this->assertStringContainsString('plain.js', $output); + $this->assertStringContainsString('assets/javascript/theme.js', $output); + $this->assertStringNotContainsString('assets/css/theme.css', $output); + } + + public function testMakeAssetsViteTypeUnfiltered(): void + { + $this->setUpViteFixture(); + + $this->stub->addVite( + ['assets/css/theme.css', 'assets/javascript/theme.js'], + self::VITE_FIXTURE_PACKAGE + ); + + $output = $this->stub->makeAssets('vite'); + + $this->assertNotNull($output); + // Legacy 'vite' type must still emit both entrypoints unfiltered + $this->assertStringContainsString('assets/css/theme.css', $output); + $this->assertStringContainsString('assets/javascript/theme.js', $output); + } + + public function testMakeAssetsAllTypesSplitsViteAcrossGroups(): void + { + $this->setUpViteFixture(); + + $this->stub->addVite( + ['assets/css/theme.css', 'assets/javascript/theme.js'], + self::VITE_FIXTURE_PACKAGE + ); + + $output = $this->stub->makeAssets(); + + $this->assertNotNull($output); + // Each entrypoint should appear exactly once: in its corresponding group, not in the + // standalone vite block (which must NOT fire when $type is null). + $this->assertSame(1, substr_count($output, 'assets/css/theme.css')); + $this->assertSame(1, substr_count($output, 'assets/javascript/theme.js')); + } + + public function testMakeAssetsViteSkipsAssetWithNoMatchingEntrypoints(): void + { + $this->setUpViteFixture(); + + // JS-only entrypoint registered as a vite asset + $this->stub->addVite(['assets/javascript/theme.js'], self::VITE_FIXTURE_PACKAGE); + + $output = $this->stub->makeAssets('css'); + + // No css entrypoints to filter to, so the vite asset must contribute nothing + // and not produce an empty Vite::tags() call (which would still emit @vite/client). + $this->assertEmpty($output); + } } diff --git a/modules/system/traits/AssetMaker.php b/modules/system/traits/AssetMaker.php index faf2c66962..4a82ca1a1f 100644 --- a/modules/system/traits/AssetMaker.php +++ b/modules/system/traits/AssetMaker.php @@ -73,6 +73,17 @@ public function makeAssets(?string $type = null): ?string $result .= '' . PHP_EOL; } + + foreach ($this->assets['vite'] as $asset) { + $asset['attributes']['entrypoints'] = array_filter( + $asset['attributes']['entrypoints'], + fn ($entrypoint) => $this->getAssetType($entrypoint) === 'css' + ); + + if ($asset['attributes']['entrypoints']) { + $result .= Vite::tags($asset['attributes']['entrypoints'], $asset['path']); + } + } } if ($type == null || $type == 'rss') { @@ -102,9 +113,20 @@ public function makeAssets(?string $type = null): ?string $result .= '' . PHP_EOL; } + + foreach ($this->assets['vite'] as $asset) { + $asset['attributes']['entrypoints'] = array_filter( + $asset['attributes']['entrypoints'], + fn ($entrypoint) => $this->getAssetType($entrypoint) === 'js' + ); + + if ($asset['attributes']['entrypoints']) { + $result .= Vite::tags($asset['attributes']['entrypoints'], $asset['path']); + } + } } - if ($type == null || $type == 'vite') { + if ($type == 'vite') { foreach ($this->assets['vite'] as $asset) { $result .= Vite::tags($asset['attributes']['entrypoints'], $asset['path']); } @@ -458,4 +480,16 @@ public function orderAssets(array $assets): array return $sortedAssets; } + + protected function getAssetType(string $asset): ?string + { + $path = strtolower(parse_url($asset, PHP_URL_PATH) ?? $asset); + $ext = pathinfo($path, PATHINFO_EXTENSION); + + return match ($ext) { + 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx', 'vue', 'svelte' => 'js', + 'css', 'scss', 'sass', 'less', 'styl', 'stylus', 'pcss', 'postcss' => 'css', + default => null, + }; + } }