From 7bc612564e993da7165ae63814478ddecebce898 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Thu, 15 Oct 2026 09:06:14 +0200 Subject: [PATCH] test: pin the URLs each asset container resolves, in build and hot --- .../Asset/AssetContainersContractTest.php | 259 ++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 tests/Feature/Asset/AssetContainersContractTest.php diff --git a/tests/Feature/Asset/AssetContainersContractTest.php b/tests/Feature/Asset/AssetContainersContractTest.php new file mode 100644 index 00000000..d224f416 --- /dev/null +++ b/tests/Feature/Asset/AssetContainersContractTest.php @@ -0,0 +1,259 @@ +suffix = uniqid(); + $this->names = [ + 'theme' => 'apiary-'.$this->suffix, + 'plugin' => 'acme-'.$this->suffix, + 'module' => 'crm-'.$this->suffix, + ]; + + // The manager AssetFile resolves through the container + $this->assetManager = new AssetManager(Mockery::mock(AssetRetrievalService::class)); + $this->app->instance(AssetManager::class, $this->assetManager); + $this->app->singleton(MissingBuilds::class); + + $this->modules = new ModuleAssetManager($this->app); + $this->modules->setupModuleAssetContainer($this->names['theme'], '/themes/apiary', 'theme'); + $this->modules->setupModuleAssetContainer($this->names['plugin'], '/plugins/acme', 'plugin', 'acme'); + $this->modules->setupModuleAssetContainer($this->names['module'], '/modules/Crm', 'module', 'crm'); + + $this->hotFiles = []; + $this->builds = []; + + /** Write the production build of a container: its manifest */ + $this->build = function (string $type, string $name, array $manifest): void { + $directory = public_path('build/'.$type.'/'.$name); + (new Filesystem)->ensureDirectoryExists($directory); + file_put_contents($directory.'/manifest.json', json_encode($manifest)); + $this->builds[] = public_path('build/'.$type); + }; + + /** Start the dev server of a container: its hot file */ + $this->serve = function (string $name, string $url): void { + $this->hotFiles[] = $file = public_path($name.'.hot'); + file_put_contents($file, $url); + }; +}); + +afterEach(function (): void { + foreach (array_unique($this->builds) as $directory) { + foreach ($this->names as $name) { + (new Filesystem)->deleteDirectory($directory.'/'.$name); + } + } + + foreach ($this->hotFiles as $file) { + @unlink($file); + } +}); + +describe('the containers of a theme, a plugin and a module', function (): void { + it('name each one after its kind and its slug, and keep the layout @pollora/vite-config builds to', function (): void { + $theme = $this->assetManager->getContainer('theme'); + $plugin = $this->assetManager->getContainer('plugin.acme'); + $module = $this->assetManager->getContainer('module.crm'); + + expect($theme)->toBeInstanceOf(AssetContainer::class) + ->and([$theme->getHotFile(), $theme->getBuildDirectory(), $theme->getManifestPath(), $theme->getBasePath()]) + ->toBe([public_path($this->names['theme'].'.hot'), 'build/theme/'.$this->names['theme'], 'manifest.json', 'resources/assets/']) + ->and([$plugin->getHotFile(), $plugin->getBuildDirectory(), $plugin->getManifestPath(), $plugin->getBasePath()]) + ->toBe([public_path($this->names['plugin'].'.hot'), 'build/plugin/'.$this->names['plugin'], 'manifest.json', 'resources/assets/']) + ->and([$module->getHotFile(), $module->getBuildDirectory(), $module->getManifestPath(), $module->getBasePath()]) + ->toBe([public_path($this->names['module'].'.hot'), 'build/module/'.$this->names['module'], 'manifest.json', 'resources/assets/']) + ->and(array_keys($this->assetManager->containers()))->toBe(['theme', 'plugin.acme', 'module.crm']); + }); + + it('are the ones pollora:doctor expects', function (): void { + expect($this->modules->expectedAssetConfiguration($this->names['plugin'], 'plugin', 'acme'))->toBe([ + 'container' => 'plugin.acme', + 'hot_file' => public_path($this->names['plugin'].'.hot'), + 'build_directory' => 'build/plugin/'.$this->names['plugin'], + 'manifest_path' => 'manifest.json', + ]); + }); +}); + +describe('the root container', function (): void { + it('is the default one, on public/hot and public/build', function (): void { + (new RootAssetManager($this->assetManager))->registerRootAssets(); + + $root = $this->assetManager->getDefaultContainer(); + + expect($root->getName())->toBe('root') + ->and([$root->getHotFile(), $root->getBuildDirectory(), $root->getManifestPath(), $root->getBasePath()]) + ->toBe([public_path('hot'), 'build', 'manifest.json', '']) + ->and($this->assetManager->getContainer('root'))->toBe($root); + }); +}); + +describe('the blocks container of a theme, a plugin or a module', function (): void { + it('shares the hot file, the build and the manifest of its parent, with no base path', function (): void { + $registrar = new class($this->assetManager, Mockery::mock(HookFilter::class)) extends BlockRegistrar + { + public function blocksManager(string $parent): ?ViteManager + { + return $this->getBlocksViteManager($parent); + } + }; + + $manager = $registrar->blocksManager('plugin.acme'); + $parent = $this->assetManager->getContainer('plugin.acme'); + $blocks = $manager->container(); + + expect($blocks->getName())->toBe('plugin.acme.blocks') + ->and([$blocks->getHotFile(), $blocks->getBuildDirectory(), $blocks->getManifestPath(), $blocks->getBasePath()]) + ->toBe([$parent->getHotFile(), $parent->getBuildDirectory(), $parent->getManifestPath(), '']) + ->and($this->assetManager->getContainer('plugin.acme.blocks'))->toBe($blocks) + ->and($registrar->blocksManager('plugin.nothing'))->toBeNull(); + }); +}); + +describe('built for production', function (): void { + beforeEach(function (): void { + ($this->build)('theme', $this->names['theme'], [ + 'resources/assets/app.js' => ['file' => 'assets/app-AAA.js', 'css' => ['assets/app-BBB.css']], + 'resources/assets/app.css' => ['file' => 'assets/app-CCC.css'], + 'resources/assets/images/logo.svg' => ['file' => 'assets/logo-DDD.svg'], + ]); + ($this->build)('plugin', $this->names['plugin'], [ + 'resources/assets/admin.js' => ['file' => 'assets/admin-EEE.js'], + 'resources/views/blocks/card/index.jsx' => ['file' => 'assets/index-FFF.js', 'css' => ['assets/index-GGG.css']], + ]); + ($this->build)('module', $this->names['module'], [ + 'resources/assets/app.js' => ['file' => 'assets/app-HHH.js'], + ]); + }); + + it('gives each container the URLs of its own build', function (): void { + $theme = new ViteManager($this->assetManager->getContainer('theme')); + $plugin = new ViteManager($this->assetManager->getContainer('plugin.acme')); + $module = new ViteManager($this->assetManager->getContainer('module.crm')); + + expect($theme->isRunningHot())->toBeFalse() + ->and($theme->getAssetUrls(['app.js']))->toBe([ + 'js' => [asset('build/theme/'.$this->names['theme'].'/assets/app-AAA.js')], + 'css' => [asset('build/theme/'.$this->names['theme'].'/assets/app-BBB.css')], + ]) + ->and($theme->getAssetUrls(['app.css']))->toBe([ + 'js' => [], + 'css' => [asset('build/theme/'.$this->names['theme'].'/assets/app-CCC.css')], + ]) + ->and($plugin->getAssetUrls(['admin.js']))->toBe([ + 'js' => [asset('build/plugin/'.$this->names['plugin'].'/assets/admin-EEE.js')], + 'css' => [], + ]) + ->and($module->getAssetUrls(['app.js']))->toBe([ + 'js' => [asset('build/module/'.$this->names['module'].'/assets/app-HHH.js')], + 'css' => [], + ]); + }); + + it('resolves a file the way the manifest names it, under the base path of its container', function (): void { + $theme = new ViteManager($this->assetManager->getContainer('theme')); + + expect($theme->asset('images/logo.svg'))->toBe(asset('build/theme/'.$this->names['theme'].'/assets/logo-DDD.svg')) + ->and((string) (new AssetFile('images/logo.svg'))->from('theme')) + ->toBe(asset('build/theme/'.$this->names['theme'].'/assets/logo-DDD.svg')); + }); + + it('resolves block entries from the blocks container, whose base path is empty', function (): void { + $registrar = new class($this->assetManager, Mockery::mock(HookFilter::class)) extends BlockRegistrar + { + public function blocksManager(string $parent): ?ViteManager + { + return $this->getBlocksViteManager($parent); + } + }; + + expect($registrar->blocksManager('plugin.acme')->getAssetUrls(['resources/views/blocks/card/index.jsx']))->toBe([ + 'js' => [asset('build/plugin/'.$this->names['plugin'].'/assets/index-FFF.js')], + 'css' => [asset('build/plugin/'.$this->names['plugin'].'/assets/index-GGG.css')], + ]); + }); + + it('gives nothing, and says so once, for a container whose build is missing', function (): void { + $this->modules->setupModuleAssetContainer('nothing-'.$this->suffix, '/plugins/nothing', 'plugin', 'nothing'); + $unbuilt = new ViteManager($this->assetManager->getContainer('plugin.nothing')); + + expect($unbuilt->getAssetUrls(['admin.js']))->toBe(['js' => [], 'css' => []]) + ->and($unbuilt->isMissingBuild())->toBeTrue() + ->and(array_keys(resolve(MissingBuilds::class)->all()))->toBe(['plugin.nothing']); + }); + + it('answers an empty string for an unknown container', function (): void { + expect((string) (new AssetFile('images/logo.svg'))->from('plugin.unknown'))->toBe(''); + }); +}); + +describe('served by the dev servers', function (): void { + beforeEach(function (): void { + // The three servers run at once: each hot file names its own origin and build path, as @pollora/vite-config writes them + ($this->serve)($this->names['theme'], 'https://site.test:5173/build/theme/'.$this->names['theme']); + ($this->serve)($this->names['plugin'], 'https://site.test:5174/build/plugin/'.$this->names['plugin']); + // The module stays on its production build + ($this->build)('module', $this->names['module'], [ + 'resources/assets/app.js' => ['file' => 'assets/app-HHH.js'], + ]); + }); + + it('sends each container to its own dev server, and leaves the one that is not served on its build', function (): void { + $theme = new ViteManager($this->assetManager->getContainer('theme')); + $plugin = new ViteManager($this->assetManager->getContainer('plugin.acme')); + $module = new ViteManager($this->assetManager->getContainer('module.crm')); + + expect($theme->isRunningHot())->toBeTrue() + ->and($plugin->isRunningHot())->toBeTrue() + ->and($module->isRunningHot())->toBeFalse() + ->and($theme->asset('app.js'))->toBe('https://site.test:5173/build/theme/'.$this->names['theme'].'/resources/assets/app.js') + ->and($plugin->asset('admin.js'))->toBe('https://site.test:5174/build/plugin/'.$this->names['plugin'].'/resources/assets/admin.js') + ->and($module->getAssetUrls(['app.js']))->toBe(['js' => [asset('build/module/'.$this->names['module'].'/assets/app-HHH.js')], 'css' => []]); + }); + + it('gives each served container the Vite client of its own server', function (): void { + $theme = new ViteManager($this->assetManager->getContainer('theme')); + $plugin = new ViteManager($this->assetManager->getContainer('plugin.acme')); + $module = new ViteManager($this->assetManager->getContainer('module.crm')); + + expect($theme->clientUrl())->toBe('https://site.test:5173/build/theme/'.$this->names['theme'].'/@vite/client') + ->and($plugin->clientUrl())->toBe('https://site.test:5174/build/plugin/'.$this->names['plugin'].'/@vite/client') + ->and($module->clientUrl())->toBe(''); + }); + + it('does not count a served container as missing its build', function (): void { + $theme = new ViteManager($this->assetManager->getContainer('theme')); + + expect($theme->isMissingBuild())->toBeFalse() + ->and(resolve(MissingBuilds::class)->all())->toBe([]); + }); + + it('resolves a theme file through the dev server of the theme', function (): void { + expect((string) (new AssetFile('images/logo.svg'))->from('theme')) + ->toBe('https://site.test:5173/build/theme/'.$this->names['theme'].'/resources/assets/images/logo.svg'); + }); +});