Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions modules/cms/tests/classes/TwigExtensionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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];
}
}
1 change: 0 additions & 1 deletion modules/cms/twig/StylesNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -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")
;
}
Expand Down
165 changes: 165 additions & 0 deletions modules/system/tests/traits/AssetMakerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
//
Expand Down Expand Up @@ -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);
}
}
Loading
Loading