diff --git a/CHANGELOG.md b/CHANGELOG.md index c67c849a..56435d99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). ## [5.2.0](https://github.com/userfrosting/sprinkle-core/compare/5.1.0...5.2.0) +- [New Feature] Add [Vite](https://vitejs.dev) support : + - New Vite Bakery command, `assets:vite`. This command can be used to + - Add [Vite](https://vitejs.dev) Twig function : `vite_js`, `vite_css` and `vite_preload` to include Vite entrypoints into any Twig template. + - The default bundler (Webpack or Vite) used by `assets:build` command can be defined using the `assets.bundler` config, or `ASSETS_BUNDLER` env variable. Webpack is used by default. + - Added `assets.vite` config array in `app/config/default.php` to configure Twig integration. + - `assets.vite.dev` (bool) : Indicates whether the application is running in development mode (i.e. using vite server). Defaults to false. Tied to `VITE_DEV_ENABLED` env variable by default too. + - `assets.vite.base` (string) : Public base path from which Vite's published assets are served. The assets paths will be relative to the `outDir` in your vite configuration. + - `assets.vite.server` (string) : The vite server url, including port. +- [Bakery] The default sub commands in `AssetsBuildCommand` are now in `AssetsBuildCommandListener` +- [Bakery] Added the server option to `assets:webpack` to run HMR server (`npm run webpack:server`) plus use new npm command syntax. +- [Bakery] `AbstractAggregateCommandEvent` construction is now optional. Added `addCommands` and `prependCommands`. All setters methods return `$this`. ## [5.1.1](https://github.com/userfrosting/sprinkle-core/compare/5.1.0...5.1.1) - Fix issue with sprunje using multiple listable fetched from database ([Chat Reference](https://chat.userfrosting.com/channel/support?msg=sgMq8sbAjsCN2ZGXj)) diff --git a/app/config/default.php b/app/config/default.php index 8b1c25d9..8c39f57e 100755 --- a/app/config/default.php +++ b/app/config/default.php @@ -44,6 +44,22 @@ 'key' => 'site.alerts', // the key to use to store flash messages ], + /* + * ---------------------------------------------------------------------- + * Asset bundler Config + * ---------------------------------------------------------------------- + * Frontend assets can be handle either by Vite or Webpack. This section + * is used to define which bundler is used, and their configuration. + */ + 'assets' => [ + 'bundler' => env('ASSETS_BUNDLER'), // Either 'vite' or 'webpack' + 'vite' => [ + 'dev' => env('VITE_DEV_ENABLED'), + 'base' => '', + 'server' => 'http://[::1]:3000/' + ] + ], + /* * ---------------------------------------------------------------------- * Bakery Config diff --git a/app/src/Bakery/AssetsBuildCommand.php b/app/src/Bakery/AssetsBuildCommand.php index 14071a4c..aac31496 100644 --- a/app/src/Bakery/AssetsBuildCommand.php +++ b/app/src/Bakery/AssetsBuildCommand.php @@ -28,14 +28,6 @@ final class AssetsBuildCommand extends Command { use WithSymfonyStyle; - /** - * @var string[] Commands to run - */ - protected array $commands = [ - 'assets:install', - 'assets:webpack', - ]; - /** * @param \UserFrosting\Event\EventDispatcher $eventDispatcher */ @@ -53,9 +45,9 @@ protected function configure(): void $list = implode(', ', $this->aggregateCommands()); $this->setName('assets:build') - ->setDescription('Build the assets using npm and Webpack Encore') + ->setDescription('Build the assets using npm and Webpack Encore or Vite') ->addOption('production', 'p', InputOption::VALUE_NONE, 'Create a production build') - ->addOption('watch', 'w', InputOption::VALUE_NONE, 'Watch for changes and recompile automatically') + ->addOption('watch', 'w', InputOption::VALUE_NONE, 'Watch for changes and recompile automatically (Webpack only)') ->setHelp("This command combine the following commands : {$list}. For more info, see https://learn.userfrosting.com/asset-management.") ->setAliases(['build-assets', 'webpack']); } @@ -87,7 +79,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int */ protected function aggregateCommands(): array { - $event = new AssetsBuildCommandEvent($this->commands); + $event = new AssetsBuildCommandEvent(); $event = $this->eventDispatcher->dispatch($event); return $event->getCommands(); diff --git a/app/src/Bakery/AssetsViteCommand.php b/app/src/Bakery/AssetsViteCommand.php new file mode 100644 index 00000000..bcdb48a0 --- /dev/null +++ b/app/src/Bakery/AssetsViteCommand.php @@ -0,0 +1,114 @@ +Vite, using the config defined in vite.config.js.', + 'It will automatically compile the frontend dependencies in the public/assets/ directory, or use the Vite development server', + 'Everything will be executed in the same dir the bakery command is executed.', + 'For more info, see https://learn.userfrosting.com/asset-management', + ]; + + $this->setName('assets:vite') + ->setDescription('Alias for `npm run vite:dev` or `npm run vite:build` commands.') + ->addOption('production', 'p', InputOption::VALUE_NONE, 'Force the creation of a production build using `vite:build`') + ->setHelp(implode(' ', $help)); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $this->io->title('Running Vite'); + + // Get options + $production = (bool) $input->getOption('production'); + + // Validate dependencies + try { + $this->nodeVersionValidator->validate(); + $this->npmVersionValidator->validate(); + } catch (VersionCompareException $e) { + $this->io->error($e->getMessage()); + + return self::FAILURE; + } + + // Get path + $path = getcwd(); + if ($path === false) { + $this->io->error('Error getting working directory'); + + return self::FAILURE; + } + + // Execute Vite + if (!file_exists($path . '/vite.config.js') && !file_exists($path . '/vite.config.ts')) { + $this->io->warning('Vite config not found. Skipping.'); + + return self::SUCCESS; + } + + // Select command based on command arguments + $command = match (true) { + ($production || $this->envMode === 'production') => 'npm run vite:build', + default => 'npm run vite:dev', + }; + + $this->io->info("Running command: $command"); + if ($this->executeCommand($command) !== 0) { + $this->io->error('Vite command has failed'); + + return self::FAILURE; + } + + // If all went well and there's no fatal errors, we are successful + $this->io->success('Vite command completed'); + + return self::SUCCESS; + } +} diff --git a/app/src/Bakery/AssetsWebpackCommand.php b/app/src/Bakery/AssetsWebpackCommand.php index b5b53f14..b8e9aaa6 100644 --- a/app/src/Bakery/AssetsWebpackCommand.php +++ b/app/src/Bakery/AssetsWebpackCommand.php @@ -24,7 +24,8 @@ use UserFrosting\Sprinkle\Core\Validators\NpmVersionValidator; /** - * Alias for `npm run dev`, `npm run build` and `npm run watch` commands. + * Alias for `npm run webpack:dev`, `npm run webpack:build`, + * `npm run webpack:server` and `npm run webpack:watch` commands. */ final class AssetsWebpackCommand extends Command { @@ -56,6 +57,7 @@ protected function configure(): void ->setDescription('Alias for `npm run dev`, `npm run build` or `npm run dev` command') ->addOption('production', 'p', InputOption::VALUE_NONE, 'Create a production build') ->addOption('watch', 'w', InputOption::VALUE_NONE, 'Watch for changes and recompile automatically') + ->addOption('server', 's', InputOption::VALUE_NONE, 'Run the development server with Hot Module Replacement (HMR)') ->setHelp(implode(' ', $help)); } @@ -69,6 +71,7 @@ protected function execute(InputInterface $input, OutputInterface $output) // Get options $production = (bool) $input->getOption('production'); $watch = (bool) $input->getOption('watch'); + $server = (bool) $input->getOption('server'); // Validate dependencies try { @@ -98,9 +101,10 @@ protected function execute(InputInterface $input, OutputInterface $output) // Select command based on command arguments $command = match (true) { - ($production || $this->envMode === 'production') => 'npm run build', - $watch => 'npm run watch', - default => 'npm run dev', + ($production || $this->envMode === 'production') => 'npm run webpack:build', + $server => 'npm run webpack:server', + $watch => 'npm run webpack:watch', + default => 'npm run webpack:dev', }; $this->io->info("Running command: $command"); diff --git a/app/src/Bakery/Event/AbstractAggregateCommandEvent.php b/app/src/Bakery/Event/AbstractAggregateCommandEvent.php index 3c4fc37b..c889b83e 100644 --- a/app/src/Bakery/Event/AbstractAggregateCommandEvent.php +++ b/app/src/Bakery/Event/AbstractAggregateCommandEvent.php @@ -23,7 +23,7 @@ abstract class AbstractAggregateCommandEvent /** * @param string[] $commands */ - public function __construct(protected array $commands) + public function __construct(protected array $commands = []) { } @@ -37,25 +37,61 @@ public function getCommands(): array /** * @param string[] $commands + * + * @return self */ - public function setCommands(array $commands): void + public function setCommands(array $commands): self { $this->commands = $commands; + + return $this; } /** * @param string $command + * + * @return self */ - public function addCommand(string $command): void + public function addCommand(string $command): self { $this->commands[] = $command; + + return $this; + } + + /** + * @param string[] $commands + * + * @return self + */ + public function addCommands(array $commands): self + { + $this->commands = array_merge($this->commands, $commands); + + return $this; } /** * @param string $command + * + * @return self */ - public function prependCommand(string $command): void + public function prependCommand(string $command): self { array_unshift($this->commands, $command); + + return $this; + } + + /** + * @param string[] $commands + * + * @return self + */ + public function prependCommands(array $commands): self + { + $this->commands = array_merge($commands, $this->commands); + + return $this; } } diff --git a/app/src/Core.php b/app/src/Core.php index 29436369..a5021819 100644 --- a/app/src/Core.php +++ b/app/src/Core.php @@ -21,6 +21,7 @@ use UserFrosting\Sprinkle\Core\Bakery\AssetsBuildCommand; use UserFrosting\Sprinkle\Core\Bakery\AssetsInstallCommand; use UserFrosting\Sprinkle\Core\Bakery\AssetsUpdateCommand; +use UserFrosting\Sprinkle\Core\Bakery\AssetsViteCommand; use UserFrosting\Sprinkle\Core\Bakery\AssetsWebpackCommand; use UserFrosting\Sprinkle\Core\Bakery\BakeCommand; use UserFrosting\Sprinkle\Core\Bakery\ClearCacheCommand; @@ -32,6 +33,7 @@ use UserFrosting\Sprinkle\Core\Bakery\DebugMailCommand; use UserFrosting\Sprinkle\Core\Bakery\DebugTwigCommand; use UserFrosting\Sprinkle\Core\Bakery\DebugVersionCommand; +use UserFrosting\Sprinkle\Core\Bakery\Event\AssetsBuildCommandEvent; use UserFrosting\Sprinkle\Core\Bakery\LocaleCompareCommand; use UserFrosting\Sprinkle\Core\Bakery\LocaleDictionaryCommand; use UserFrosting\Sprinkle\Core\Bakery\LocaleInfoCommand; @@ -58,6 +60,7 @@ use UserFrosting\Sprinkle\Core\Error\ExceptionHandlerMiddleware; use UserFrosting\Sprinkle\Core\Error\RegisterShutdownHandler; use UserFrosting\Sprinkle\Core\Event\ResourceLocatorInitiatedEvent; +use UserFrosting\Sprinkle\Core\Listeners\AssetsBuildCommandListener; use UserFrosting\Sprinkle\Core\Listeners\ModelInitiated; use UserFrosting\Sprinkle\Core\Listeners\ResourceLocatorInitiated; use UserFrosting\Sprinkle\Core\Listeners\SetRouteCaching; @@ -136,6 +139,7 @@ public function getBakeryCommands(): array AssetsUpdateCommand::class, AssetsInstallCommand::class, AssetsWebpackCommand::class, + AssetsViteCommand::class, BakeCommand::class, ClearCacheCommand::class, DebugCommand::class, @@ -288,6 +292,9 @@ public function getEventListeners(): array ResourceLocatorInitiatedEvent::class => [ ResourceLocatorInitiated::class, ], + AssetsBuildCommandEvent::class => [ + AssetsBuildCommandListener::class, + ], ]; } } diff --git a/app/src/Listeners/AssetsBuildCommandListener.php b/app/src/Listeners/AssetsBuildCommandListener.php new file mode 100644 index 00000000..f5e51dbe --- /dev/null +++ b/app/src/Listeners/AssetsBuildCommandListener.php @@ -0,0 +1,59 @@ +config->getString('assets.bundler', 'webpack'); + $commands = match ($bundler) { + 'vite' => $this->viteCommands, + 'webpack' => $this->webpackCommands, + default => $this->webpackCommands, + }; + + $event->addCommands($commands); + } +} diff --git a/app/tests/Integration/Bakery/DebugCommandTest.php b/app/tests/Integration/Bakery/DebugCommandTest.php index fbb0afb8..0da1806b 100644 --- a/app/tests/Integration/Bakery/DebugCommandTest.php +++ b/app/tests/Integration/Bakery/DebugCommandTest.php @@ -19,8 +19,15 @@ use Symfony\Component\Console\Application; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Tester\CommandTester; -use UserFrosting\Bakery\SprinkleCommandsRepository; use UserFrosting\Sprinkle\Core\Bakery\DebugCommand; +use UserFrosting\Sprinkle\Core\Bakery\DebugConfigCommand; +use UserFrosting\Sprinkle\Core\Bakery\DebugDbCommand; +use UserFrosting\Sprinkle\Core\Bakery\DebugEventsCommand; +use UserFrosting\Sprinkle\Core\Bakery\DebugLocatorCommand; +use UserFrosting\Sprinkle\Core\Bakery\DebugMailCommand; +use UserFrosting\Sprinkle\Core\Bakery\DebugTwigCommand; +use UserFrosting\Sprinkle\Core\Bakery\DebugVersionCommand; +use UserFrosting\Sprinkle\Core\Bakery\SprinkleListCommand; use UserFrosting\Sprinkle\Core\Exceptions\VersionCompareException; use UserFrosting\Sprinkle\Core\Tests\CoreTestCase; use UserFrosting\Sprinkle\Core\Validators\PhpDeprecationValidator; @@ -93,11 +100,21 @@ private function getCommandTester(): CommandTester // add the sub-command to the app $app = new Application(); - // Add all registered commands to make it simple - /** @var \Symfony\Component\Console\Command\Command[] */ - $commands = $this->ci->get(SprinkleCommandsRepository::class); + // Add all required commands + $commands = [ + DebugCommand::class, + DebugVersionCommand::class, + DebugVersionCommand::class, + SprinkleListCommand::class, + DebugConfigCommand::class, + DebugDbCommand::class, + DebugMailCommand::class, + DebugLocatorCommand::class, + DebugEventsCommand::class, + DebugTwigCommand::class, + ]; foreach ($commands as $command) { - $app->add($command); + $app->add($command = $this->ci->get($command)); } // Get command to test diff --git a/app/tests/Unit/Bakery/AssetsBuildCommandTest.php b/app/tests/Unit/Bakery/AssetsBuildCommandTest.php index cfc182cf..0be0e5da 100644 --- a/app/tests/Unit/Bakery/AssetsBuildCommandTest.php +++ b/app/tests/Unit/Bakery/AssetsBuildCommandTest.php @@ -23,11 +23,16 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Tester\CommandTester; +use UserFrosting\Bakery\WithSymfonyStyle; use UserFrosting\Event\EventDispatcher; use UserFrosting\Sprinkle\Core\Bakery\AssetsBuildCommand; use UserFrosting\Sprinkle\Core\Bakery\Event\AssetsBuildCommandEvent; use UserFrosting\Testing\ContainerStub; +/** + * N.B.: This test doesn't actually call the predefined sub-commands. It only + * tests the listener can overwrite them, and the stub command are called. + */ class AssetsBuildCommandTest extends TestCase { use MockeryPHPUnitIntegration; @@ -55,6 +60,7 @@ public function testBaseCommand(): void // Assert some output $this->assertSame(0, $commandTester->getStatusCode()); + $this->assertSame('SUCCESS', $commandTester->getDisplay()); } public function testOneCommandFails(): void @@ -80,6 +86,7 @@ public function testOneCommandFails(): void // Assert some output $this->assertSame(1, $commandTester->getStatusCode()); + $this->assertSame('FAILURE', $commandTester->getDisplay()); } public function testArgumentPassthrough(): void @@ -138,6 +145,8 @@ public function __invoke(AssetsBuildCommandEvent $event): void class AssetsStubCommand extends Command { + use WithSymfonyStyle; + protected function configure(): void { $this->setName('stub'); @@ -145,6 +154,8 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { + $this->io->write('SUCCESS'); + return self::SUCCESS; } } @@ -173,6 +184,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int class AssetsStubFailCommand extends Command { + use WithSymfonyStyle; + protected function configure(): void { $this->setName('fail'); @@ -180,6 +193,8 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { + $this->io->write('FAILURE'); + return self::FAILURE; } } diff --git a/app/tests/Unit/Bakery/AssetsViteCommandTest.php b/app/tests/Unit/Bakery/AssetsViteCommandTest.php new file mode 100644 index 00000000..f394ad54 --- /dev/null +++ b/app/tests/Unit/Bakery/AssetsViteCommandTest.php @@ -0,0 +1,305 @@ +getNamespaceName(); + PHPMockery::mock($namespace, 'getcwd')->andReturn('foo'); + PHPMockery::mock($namespace, 'file_exists')->andReturn(true, false); + + // Mock passthru, from ShellCommandHelper + $reflection_class = new ReflectionClass(ShellCommandHelper::class); + $namespace = $reflection_class->getNamespaceName(); + PHPMockery::mock($namespace, 'passthru')->andReturn(null); + + // Set Validator mock + $node = Mockery::mock(NodeVersionValidator::class) + ->shouldReceive('validate')->andReturn(true) + ->getMock(); + $npm = Mockery::mock(NpmVersionValidator::class) + ->shouldReceive('validate')->andReturn(true) + ->getMock(); + + // Set mock in CI and run command + $ci = ContainerStub::create(); + $ci->set(NodeVersionValidator::class, $node); + $ci->set(NpmVersionValidator::class, $npm); + $ci->set('UF_MODE', ''); + + /** @var AssetsViteCommand */ + $command = $ci->get(AssetsViteCommand::class); + $result = BakeryTester::runCommand($command); + + // Assert some output + $this->assertSame(0, $result->getStatusCode()); + $this->assertStringContainsString('npm run vite:dev', $result->getDisplay()); + $this->assertStringContainsString('Vite command completed', $result->getDisplay()); + } + + public function testCommandProductionEnv(): void + { + // Mock built-in function from main class + $reflection_class = new ReflectionClass(AssetsViteCommand::class); + $namespace = $reflection_class->getNamespaceName(); + PHPMockery::mock($namespace, 'getcwd')->andReturn('foo'); + PHPMockery::mock($namespace, 'file_exists')->andReturn(true, false); + + // Mock passthru, from ShellCommandHelper + $reflection_class = new ReflectionClass(ShellCommandHelper::class); + $namespace = $reflection_class->getNamespaceName(); + PHPMockery::mock($namespace, 'passthru')->andReturn(null); + + // Set Validator mock + $node = Mockery::mock(NodeVersionValidator::class) + ->shouldReceive('validate')->andReturn(true) + ->getMock(); + $npm = Mockery::mock(NpmVersionValidator::class) + ->shouldReceive('validate')->andReturn(true) + ->getMock(); + + // Set mock in CI and run command + $ci = ContainerStub::create(); + $ci->set(NodeVersionValidator::class, $node); + $ci->set(NpmVersionValidator::class, $npm); + $ci->set('UF_MODE', 'production'); // Set production mode + + /** @var AssetsViteCommand */ + $command = $ci->get(AssetsViteCommand::class); + $result = BakeryTester::runCommand($command); + + // Assert some output + $this->assertSame(0, $result->getStatusCode()); + $this->assertStringContainsString('npm run vite:build', $result->getDisplay()); + $this->assertStringContainsString('Vite command completed', $result->getDisplay()); + } + + public function testCommandProduction(): void + { + // Mock built-in function from main class + $reflection_class = new ReflectionClass(AssetsViteCommand::class); + $namespace = $reflection_class->getNamespaceName(); + PHPMockery::mock($namespace, 'getcwd')->andReturn('foo'); + PHPMockery::mock($namespace, 'file_exists')->andReturn(true, false); + + // Mock passthru, from ShellCommandHelper + $reflection_class = new ReflectionClass(ShellCommandHelper::class); + $namespace = $reflection_class->getNamespaceName(); + PHPMockery::mock($namespace, 'passthru')->andReturn(null); + + // Set Validator mock + $node = Mockery::mock(NodeVersionValidator::class) + ->shouldReceive('validate')->andReturn(true) + ->getMock(); + $npm = Mockery::mock(NpmVersionValidator::class) + ->shouldReceive('validate')->andReturn(true) + ->getMock(); + + // Set mock in CI and run command + $ci = ContainerStub::create(); + $ci->set(NodeVersionValidator::class, $node); + $ci->set(NpmVersionValidator::class, $npm); + $ci->set('UF_MODE', ''); + + /** @var AssetsViteCommand */ + $command = $ci->get(AssetsViteCommand::class); + $result = BakeryTester::runCommand($command, input: ['--production' => true]); + + // Assert some output + $this->assertSame(0, $result->getStatusCode()); + $this->assertStringContainsString('npm run vite:build', $result->getDisplay()); + $this->assertStringContainsString('Vite command completed', $result->getDisplay()); + } + + public function testCommandWithMissingFiles(): void + { + // Mock built-in error_get_last + $reflection_class = new ReflectionClass(AssetsViteCommand::class); + $namespace = $reflection_class->getNamespaceName(); + PHPMockery::mock($namespace, 'getcwd')->andReturn('./foo'); + PHPMockery::mock($namespace, 'file_exists')->andReturn(false); + + // Set Validator mock + $node = Mockery::mock(NodeVersionValidator::class) + ->shouldReceive('validate')->andReturn(true) + ->getMock(); + $npm = Mockery::mock(NpmVersionValidator::class) + ->shouldReceive('validate')->andReturn(true) + ->getMock(); + + // Set mock in CI and run command + $ci = ContainerStub::create(); + $ci->set(NodeVersionValidator::class, $node); + $ci->set(NpmVersionValidator::class, $npm); + $ci->set('UF_MODE', ''); + + /** @var AssetsViteCommand */ + $command = $ci->get(AssetsViteCommand::class); + $result = BakeryTester::runCommand($command); + + // Assert some output + $this->assertSame(0, $result->getStatusCode()); + $this->assertStringContainsString('Vite config not found. Skipping.', $result->getDisplay()); + } + + public function testCommandWithErrorInGetcwd(): void + { + // Mock built-in error_get_last + $reflection_class = new ReflectionClass(AssetsViteCommand::class); + $namespace = $reflection_class->getNamespaceName(); + PHPMockery::mock($namespace, 'getcwd')->andReturn(false); + + // Set Validator mock + $node = Mockery::mock(NodeVersionValidator::class) + ->shouldReceive('validate')->andReturn(true) + ->getMock(); + $npm = Mockery::mock(NpmVersionValidator::class) + ->shouldReceive('validate')->andReturn(true) + ->getMock(); + + // Set mock in CI and run command + $ci = ContainerStub::create(); + $ci->set(NodeVersionValidator::class, $node); + $ci->set(NpmVersionValidator::class, $npm); + $ci->set('UF_MODE', ''); + + /** @var AssetsViteCommand */ + $command = $ci->get(AssetsViteCommand::class); + $result = BakeryTester::runCommand($command); + + // Assert some output + $this->assertSame(1, $result->getStatusCode()); + $this->assertStringContainsString('Error getting working directory', $result->getDisplay()); + } + + public function testCommandWithNodeError(): void + { + // Set Validator mock + $node = Mockery::mock(NodeVersionValidator::class) + ->shouldReceive('validate')->andThrow(new VersionCompareException()) + ->getMock(); + $npm = Mockery::mock(NpmVersionValidator::class); + + // Set mock in CI and run command + $ci = ContainerStub::create(); + $ci->set(NodeVersionValidator::class, $node); + $ci->set(NpmVersionValidator::class, $npm); + $ci->set('UF_MODE', ''); + + /** @var AssetsViteCommand */ + $command = $ci->get(AssetsViteCommand::class); + $result = BakeryTester::runCommand($command); + + // Assert some output + $this->assertSame(1, $result->getStatusCode()); + } + + public function testCommandWithNpmError(): void + { + // Set Validator mock + $node = Mockery::mock(NodeVersionValidator::class) + ->shouldReceive('validate')->andReturn(true) + ->getMock(); + $npm = Mockery::mock(NpmVersionValidator::class) + ->shouldReceive('validate')->andThrow(new VersionCompareException()) + ->getMock(); + + // Set mock in CI and run command + $ci = ContainerStub::create(); + $ci->set(NodeVersionValidator::class, $node); + $ci->set(NpmVersionValidator::class, $npm); + $ci->set('UF_MODE', ''); + + /** @var AssetsViteCommand */ + $command = $ci->get(AssetsViteCommand::class); + $result = BakeryTester::runCommand($command); + + // Assert some output + $this->assertSame(1, $result->getStatusCode()); + } + + public function testCommandWithNpmPassthruError(): void + { + // Mock built-in error_get_last + $reflection_class = new ReflectionClass(AssetsViteCommand::class); + $namespace = $reflection_class->getNamespaceName(); + PHPMockery::mock($namespace, 'getcwd')->andReturn('foo'); + PHPMockery::mock($namespace, 'file_exists')->andReturn(true); + + // Mock passthru, from ShellCommandHelper + $reflection_class = new ReflectionClass(ShellCommandHelper::class); + $shellNamespace = $reflection_class->getNamespaceName(); + + // Use `MockBuilder` for more control + $builder = new MockBuilder(); + $builder->setNamespace($shellNamespace) + ->setName('passthru') + ->setFunction( + function (string $command, int &$exitCode) { + $exitCode = 1; + } + ); + $mock = $builder->build(); + $mock->enable(); + + // Set Validator mock + $node = Mockery::mock(NodeVersionValidator::class) + ->shouldReceive('validate')->andReturn(true) + ->getMock(); + $npm = Mockery::mock(NpmVersionValidator::class) + ->shouldReceive('validate')->andReturn(true) + ->getMock(); + + // Set mock in CI and run command + $ci = ContainerStub::create(); + $ci->set(NodeVersionValidator::class, $node); + $ci->set(NpmVersionValidator::class, $npm); + $ci->set('UF_MODE', ''); + + /** @var AssetsViteCommand */ + $command = $ci->get(AssetsViteCommand::class); + $result = BakeryTester::runCommand($command); + + // Assert some output + $this->assertSame(1, $result->getStatusCode()); + $this->assertStringContainsString('Vite command has failed', $result->getDisplay()); + + // Disable mock manually + $mock->disable(); + } +} diff --git a/app/tests/Unit/Bakery/AssetsWebpackCommandTest.php b/app/tests/Unit/Bakery/AssetsWebpackCommandTest.php index 1e4f5d6d..492e2459 100644 --- a/app/tests/Unit/Bakery/AssetsWebpackCommandTest.php +++ b/app/tests/Unit/Bakery/AssetsWebpackCommandTest.php @@ -68,7 +68,7 @@ public function testCommand(): void // Assert some output $this->assertSame(0, $result->getStatusCode()); - $this->assertStringContainsString('npm run dev', $result->getDisplay()); + $this->assertStringContainsString('npm run webpack:dev', $result->getDisplay()); $this->assertStringContainsString('Webpack Encore run completed', $result->getDisplay()); } @@ -101,11 +101,11 @@ public function testCommandProductionEnv(): void /** @var AssetsWebpackCommand */ $command = $ci->get(AssetsWebpackCommand::class); - $result = BakeryTester::runCommand($command, input: ['--production' => true]); + $result = BakeryTester::runCommand($command); // Assert some output $this->assertSame(0, $result->getStatusCode()); - $this->assertStringContainsString('npm run build', $result->getDisplay()); + $this->assertStringContainsString('npm run webpack:build', $result->getDisplay()); $this->assertStringContainsString('Webpack Encore run completed', $result->getDisplay()); } @@ -142,7 +142,7 @@ public function testCommandProduction(): void // Assert some output $this->assertSame(0, $result->getStatusCode()); - $this->assertStringContainsString('npm run build', $result->getDisplay()); + $this->assertStringContainsString('npm run webpack:build', $result->getDisplay()); $this->assertStringContainsString('Webpack Encore run completed', $result->getDisplay()); } @@ -179,7 +179,7 @@ public function testCommandWatch(): void // Assert some output $this->assertSame(0, $result->getStatusCode()); - $this->assertStringContainsString('npm run watch', $result->getDisplay()); + $this->assertStringContainsString('npm run webpack:watch', $result->getDisplay()); $this->assertStringContainsString('Webpack Encore run completed', $result->getDisplay()); } @@ -217,7 +217,7 @@ public function testCommandWatchAndProduction(): void // Assert some output // N.B.: When both production & watch are used, production has priority $this->assertSame(0, $result->getStatusCode()); - $this->assertStringContainsString('npm run build', $result->getDisplay()); + $this->assertStringContainsString('npm run webpack:build', $result->getDisplay()); $this->assertStringContainsString('Webpack Encore run completed', $result->getDisplay()); } diff --git a/app/tests/Unit/Bakery/Event/AbstractAggregateCommandEventTest.php b/app/tests/Unit/Bakery/Event/AbstractAggregateCommandEventTest.php index b4e7f141..d9d36e8c 100644 --- a/app/tests/Unit/Bakery/Event/AbstractAggregateCommandEventTest.php +++ b/app/tests/Unit/Bakery/Event/AbstractAggregateCommandEventTest.php @@ -30,6 +30,12 @@ public function testBaseCommand(): void $event->prependCommand('bar'); $this->assertSame(['bar', 'foo'], $event->getCommands()); + + $event->addCommands(['foobar', '123']); + $this->assertSame(['bar', 'foo', 'foobar', '123'], $event->getCommands()); + + $event->prependCommands(['owl', 'egg']); + $this->assertSame(['owl', 'egg', 'bar', 'foo', 'foobar', '123'], $event->getCommands()); } } diff --git a/app/tests/Unit/Listeners/AssetsBuildCommandListenerTest.php b/app/tests/Unit/Listeners/AssetsBuildCommandListenerTest.php new file mode 100644 index 00000000..5dac2511 --- /dev/null +++ b/app/tests/Unit/Listeners/AssetsBuildCommandListenerTest.php @@ -0,0 +1,55 @@ +shouldReceive('getString')->with('assets.bundler', 'webpack')->once()->andReturn($bundler) + ->getMock(); + + $event = new AssetsBuildCommandEvent(); + + $listener = new AssetsBuildCommandListener($config); + $listener($event); + + $this->assertSame($expected, $event->getCommands()); + } + + /** + * @return array[] + */ + public static function bundlerProvider(): array + { + return [ + ['webpack', ['assets:install', 'assets:webpack']], + ['foobar', ['assets:install', 'assets:webpack']], + ['vite', ['assets:install', 'assets:vite']], + ]; + } +} diff --git a/composer.json b/composer.json index 075fcc9c..338ac1f3 100644 --- a/composer.json +++ b/composer.json @@ -43,7 +43,8 @@ "slim/csrf": "^1.3", "slim/twig-view": "^3.0", "vlucas/phpdotenv": "^5.3", - "userfrosting/framework": "~5.2.0@dev" + "userfrosting/framework": "~5.2.0@dev", + "userfrosting/vite-php-twig": "^1.0" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.0",