diff --git a/modules/system/.eslintignore b/modules/system/.eslintignore index 8cdcdeed59..20eac9f232 100644 --- a/modules/system/.eslintignore +++ b/modules/system/.eslintignore @@ -13,4 +13,6 @@ assets/vendor # Ignore test fixtures tests/js tests/fixtures/themes/test/assets/js -tests/fixtures/themes/vitetest/assets/javascript +tests/fixtures/themes/npmtest +tests/fixtures/themes/assettest +tests/fixtures/plugins/mix diff --git a/modules/system/ServiceProvider.php b/modules/system/ServiceProvider.php index 618dc111fc..652fae5e52 100644 --- a/modules/system/ServiceProvider.php +++ b/modules/system/ServiceProvider.php @@ -338,8 +338,9 @@ protected function registerConsole() $this->registerConsoleCommand('vite.list', Console\Asset\Vite\ViteList::class); $this->registerConsoleCommand('vite.watch', Console\Asset\Vite\ViteWatch::class); - $this->registerConsoleCommand('npm.run', Console\Asset\NpmRun::class); - $this->registerConsoleCommand('npm.update', Console\Asset\NpmUpdate::class); + $this->registerConsoleCommand('npm.run', Console\Asset\Npm\NpmRun::class); + $this->registerConsoleCommand('npm.install', Console\Asset\Npm\NpmInstall::class); + $this->registerConsoleCommand('npm.update', Console\Asset\Npm\NpmUpdate::class); } /* diff --git a/modules/system/classes/asset/PackageJson.php b/modules/system/classes/asset/PackageJson.php index be6469c10b..ef115b7724 100644 --- a/modules/system/classes/asset/PackageJson.php +++ b/modules/system/classes/asset/PackageJson.php @@ -19,17 +19,24 @@ class PackageJson /** * The contents of the package.json being modified */ - protected array $data; + protected array $data = []; /** * Create a new instance with optional path, loads file if file already exists + * @throws \JsonException */ public function __construct( protected ?string $path = null ) { - $this->data = File::exists($this->path) - ? json_decode(File::get($this->path), JSON_OBJECT_AS_ARRAY) - : []; + if (File::exists($this->path)) { + // Test the json to insure it's valid + $json = json_decode(File::get($this->path), JSON_OBJECT_AS_ARRAY); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \JsonException('The contents of the file "' . $this->path . '" is not valid json.'); + } + + $this->data = $json; + } } /** diff --git a/modules/system/classes/asset/PackageManager.php b/modules/system/classes/asset/PackageManager.php index edb04c8571..bf84d147d6 100644 --- a/modules/system/classes/asset/PackageManager.php +++ b/modules/system/classes/asset/PackageManager.php @@ -24,10 +24,14 @@ class PackageManager { use \Winter\Storm\Support\Traits\Singleton; + public const TYPE_THEME = 'theme'; + public const TYPE_MODULE = 'module'; + public const TYPE_PLUGIN = 'plugin'; + /** * The filename that stores the package definition. */ - protected string $packageJson = 'package.json'; + protected PackageJson $packageJson; /** * @var array> List of package types and registration methods @@ -56,6 +60,8 @@ class PackageManager */ public function init(): void { + $this->setPackageJsonPath(base_path('package.json')); + $packagePaths = []; /* @@ -183,12 +189,14 @@ public static function registerCallback(callable $callback): void /** * Calls the deferred callbacks. */ - public function fireCallbacks(): void + public function fireCallbacks(): static { // Call callbacks foreach (static::$callbacks as $callback) { $callback($this); } + + return $this; } /** @@ -206,6 +214,10 @@ public function getPackages(string $type, bool $includeIgnored = false): array { $packages = $this->packages[$type] ?? []; + foreach ($packages as $index => $package) { + $packages[$index]['ignored'] = $this->isPackageIgnored($package['path']); + } + ksort($packages); if (!$includeIgnored) { @@ -223,9 +235,13 @@ public function getPackages(string $type, bool $includeIgnored = false): array public function hasPackage(string $name, bool $includeIgnored = false): bool { foreach ($this->packages ?? [] as $packages) { - foreach ($packages as $packageName => $config) { - if (($name === $packageName) && (!$config['ignored'] || $includeIgnored)) { - return true; + foreach ($packages as $packageName => $package) { + if ($name === $packageName) { + if ((!$this->isPackageIgnored($package['path']) || $includeIgnored)) { + return true; + } + + return false; } } } @@ -239,10 +255,12 @@ public function hasPackage(string $name, bool $includeIgnored = false): bool public function getPackage(string $name, bool $includeIgnored = false): array { $results = []; - foreach ($this->packages ?? [] as $packages) { - foreach ($packages as $packageName => $config) { - if (($name === $packageName) && (!$config['ignored'] || $includeIgnored)) { - $results[] = $config; + foreach ($this->packages ?? [] as $type => $packages) { + foreach ($packages as $packageName => $package) { + if (($name === $packageName)) { + if (!$this->isPackageIgnored($package['path']) || $includeIgnored) { + $results[] = $package + ['type' => $type]; + } } } } @@ -297,6 +315,17 @@ public function registerPackage(string $name, string $path, string $type = 'mix' )); } + $package = $path . '/package.json'; + $config = $path . DIRECTORY_SEPARATOR . $configFile; + + if (!File::exists($config)) { + throw new SystemException(sprintf( + 'Cannot register "%s" as a compilable package; the config file "%s" does not exist.', + $name, + $config + )); + } + // Check for any existing packages already registered under the provided name if (isset($this->packages[$name])) { throw new SystemException(sprintf( @@ -306,9 +335,6 @@ public function registerPackage(string $name, string $path, string $type = 'mix' )); } - $package = "$path/{$this->packageJson}"; - $config = $path . DIRECTORY_SEPARATOR . $configFile; - // Check for any existing package that already registers the given compilable config path foreach ($this->packages[$type] ?? [] as $packageName => $settings) { if ($settings['config'] === $config) { @@ -325,11 +351,46 @@ public function registerPackage(string $name, string $path, string $type = 'mix' $this->packages[$type][$name] = [ 'path' => $path, 'package' => $package, - 'config' => $config, - 'ignored' => $this->isPackageIgnored($path), + 'config' => $config ]; } + /** + * Returns an expected package type from its name + */ + public function getPackageTypeFromName(string $package): ?string + { + // Check if package could be a module + if (Str::startsWith($package, 'module-') && !in_array($package, ['system', 'backend', 'cms'])) { + return static::TYPE_MODULE; + } + + // Check if package could be a theme + if ( + in_array('Cms', Config::get('cms.loadModules')) + && Str::startsWith($package, 'theme-') + && Theme::exists(Str::after($package, 'theme-')) + ) { + return static::TYPE_THEME; + } + + // Check if a package could be a plugin + if (PluginManager::instance()->exists($package)) { + return static::TYPE_PLUGIN; + } + + return null; + } + + /** + * Set the package.json file path used for checking if packages are in workspaces or ignored + */ + public function setPackageJsonPath(string $packageJsonPath): static + { + $this->packageJson = new PackageJson($packageJsonPath); + return $this; + } + /** * Returns the registration method for a compiler type */ @@ -343,8 +404,6 @@ protected function getRegistrationMethod(string $type): string */ protected function isPackageIgnored(string $packagePath): bool { - // Load the main package.json for the project - $packageJson = new PackageJson(base_path($this->packageJson)); - return $packageJson->hasIgnoredPackage($packagePath); + return $this->packageJson->hasIgnoredPackage($packagePath); } } diff --git a/modules/system/console/asset/AssetInstall.php b/modules/system/console/asset/AssetInstall.php index 10f5c78e95..fcd9013c66 100644 --- a/modules/system/console/asset/AssetInstall.php +++ b/modules/system/console/asset/AssetInstall.php @@ -9,6 +9,7 @@ use System\Classes\Asset\PackageManager; use System\Classes\PluginManager; use Winter\Storm\Console\Command; +use Winter\Storm\Exception\SystemException; use Winter\Storm\Support\Facades\Config; use Winter\Storm\Support\Facades\File; use Winter\Storm\Support\Str; @@ -29,9 +30,9 @@ abstract class AssetInstall extends Command ]; /** - * The NPM command to run. + * Path to package json, if null use base_path. */ - protected string $npmCommand = 'install'; + protected ?string $packageJsonPath = null; /** * Type of asset to be installed, @see PackageManager @@ -44,17 +45,21 @@ abstract class AssetInstall extends Command protected string $configFile; /** - * The required packages for this compiler + * The required dependencies for this compiler */ - protected array $requiredPackages = []; + protected array $requiredDependencies = []; /** * Execute the console command. */ public function handle(): int { - if ($this->option('npm')) { - $this->npmPath = $this->option('npm', 'npm'); + if ($npmPath = $this->option('npm')) { + if (!File::exists($npmPath) || !is_executable($npmPath)) { + $this->error('The supplied --npm path does not exist or is not executable.'); + return 1; + } + $this->npmPath = $npmPath; } if (!version_compare($this->getNpmVersion(), '7', '>')) { @@ -62,109 +67,122 @@ public function handle(): int return 1; } - [$requestedPackages, $registeredPackages] = $this->getRequestedAndRegisteredPackages(); + // If a custom path is passed, then validate it + if ($packageJsonPath = $this->option('package-json')) { + // If this is not an absolute path, then make it relative + if (!str_starts_with($packageJsonPath, '/')) { + $packageJsonPath = base_path($packageJsonPath); + } - if (!count($registeredPackages)) { - if (count($requestedPackages)) { - $this->error('No registered packages matched the requested packages for installation.'); + if (!File::exists($packageJsonPath)) { + $this->error('The supplied --package-json path does not exist.'); return 1; - } else { - $this->info('No packages registered for mixing.'); - return 0; } + + $this->packageJsonPath = $packageJsonPath; } - // Load the main package.json for the project - $packageJsonPath = base_path('package.json'); + // Get any packages the user has requested + $requestedPackages = $this->argument('assetPackage') ?: []; + + $registeredPackages = $this->getRegisteredPackages($requestedPackages); + + if (!$registeredPackages) { + if ($requestedPackages) { + $this->error('No registered packages matched the requested packages for installation.'); + return 1; + } + + $this->info('No packages registered for mixing.'); + return 0; + } // Get base package.json - $packageJson = new PackageJson($packageJsonPath); + $packageJson = new PackageJson($this->packageJsonPath ?? base_path('package.json')); + // Ensure asset compiling packages are set in package.json, then save - $this->validateRequirePackagesPresent($packageJson) + $this->validateRequireDependenciesPresent($packageJson) ->save(); + // Process compilable asset packages, then save $this->processPackages($registeredPackages, $packageJson) ->save(); - // Ensure separation between package.json modification messages and rest of output - $this->info(''); + if (!$this->option('no-install')) { + // Ensure separation between package.json modification messages and rest of output + $this->info(''); - if ($this->installPackageDeps() !== 0) { - $this->error("Unable to {$this->terms['complete']} dependencies."); - return 1; - } + if ($this->runNpmInstall() !== 0) { + $this->error("Unable to {$this->terms['complete']} dependencies."); + return 1; + } - $this->info("Dependencies successfully {$this->terms['completed']}!"); + $this->info("Dependencies successfully {$this->terms['completed']}!"); + } return 0; } - protected function getRequestedAndRegisteredPackages(): array + /** + * Returns all packages registered by the system filtered by requestedPackages if defined + * @throws SystemException + */ + protected function getRegisteredPackages(array $requestedPackages = []): array { - $compilableAssets = PackageManager::instance(); - $compilableAssets->fireCallbacks(); + $packageManager = $this->getPackageManager(); - $registeredPackages = $compilableAssets->getPackages($this->assetType); - $requestedPackages = $this->option('package') ?: []; + $registeredPackages = $packageManager->getPackages($this->assetType, true); // Normalize the requestedPackages option - if (count($requestedPackages)) { - foreach ($requestedPackages as &$name) { - $name = strtolower($name); - } - unset($name); - } + $requestedPackages = array_map(fn ($name) => strtolower($name), $requestedPackages); // Filter the registered packages to only include requested packages if (count($requestedPackages) && count($registeredPackages)) { - $availablePackages = array_keys($registeredPackages); $cmsEnabled = in_array('Cms', Config::get('cms.loadModules')); // Autogenerate config files for packages that don't exist but can be autodiscovered foreach ($requestedPackages as $package) { // Check if the package is already registered - if (in_array($package, $availablePackages)) { - continue; - } - - // Check if package could be a module (but explicitly ignore core Winter modules) - if (Str::startsWith($package, 'module-') && !in_array($package, ['system', 'backend', 'cms'])) { - $compilableAssets->registerPackage( - $package, - base_path('modules/' . Str::after($package, 'module-') . '/' . $this->configFile), - $this->assetType - ); - continue; - } - - // Check if package could be a theme - if ( - $cmsEnabled - && Str::startsWith($package, 'theme-') - && Theme::exists(Str::after($package, 'theme-')) - ) { - $theme = Theme::load(Str::after($package, 'theme-')); - $compilableAssets->registerPackage( - $package, - $theme->getPath() . '/' . $this->configFile, - $this->assetType - ); + if (isset($registeredPackages[$package])) { continue; } - // Check if a package could be a plugin - if (PluginManager::instance()->exists($package)) { - $compilableAssets->registerPackage( - $package, - PluginManager::instance()->getPluginPath($package) . '/' . $this->configFile, - $this->assetType - ); - continue; + switch ($packageManager->getPackageTypeFromName($package)) { + case PackageManager::TYPE_MODULE: + $packageManager->registerPackage( + $package, + base_path('modules/' . Str::after($package, 'module-') . '/' . $this->configFile), + $this->assetType + ); + break; + case PackageManager::TYPE_THEME: + if (!$cmsEnabled) { + break; + } + $theme = Theme::load(Str::after($package, 'theme-')); + $packageManager->registerPackage( + $package, + $theme->getPath() . '/' . $this->configFile, + $this->assetType + ); + break; + case PackageManager::TYPE_PLUGIN: + $packageManager->registerPackage( + $package, + PluginManager::instance()->getPluginPath($package) . '/' . $this->configFile, + $this->assetType + ); + break; + case null: + throw new SystemException(sprintf( + 'PackageNotFoundException: The package `%s` does not exist.', + $package + )); } } // Get an updated list of packages including any newly added packages - $registeredPackages = $compilableAssets->getPackages($this->assetType); + $registeredPackages = $packageManager->getPackages($this->assetType, true); // Filter the registered packages to only deal with the requested packages foreach (array_keys($registeredPackages) as $name) { @@ -174,84 +192,179 @@ protected function getRequestedAndRegisteredPackages(): array } } - return [$requestedPackages, $registeredPackages]; + return $registeredPackages; } - protected function validateRequirePackagesPresent(PackageJson $packageJson): PackageJson + /** + * Checks if the package.json of a package has the dependencies required for this command and asks the user if + * they want to install them if not present. + */ + protected function validateRequireDependenciesPresent(PackageJson $packageJson): PackageJson { // Check to see if required packages are already present as a dependency - foreach ($this->requiredPackages as $package => $version) { + foreach ($this->requiredDependencies as $dependency => $version) { if ( - !$packageJson->hasDependency($package) - && $this->confirm($package . ' was not found as a dependency in package.json, would you like to add it?', true) + !$packageJson->hasDependency($dependency) + && $this->confirm($dependency . ' was not found as a dependency in package.json, would you like to add it?', true) ) { - $packageJson->addDependency($package, $version, dev: true); + $packageJson->addDependency($dependency, $version, dev: true); } } return $packageJson; } + /** + * Validates if the packages passed can be installed and if possible, installs them. + * @throws SystemException + * @throws PackageNotFoundException + */ protected function processPackages(array $registeredPackages, PackageJson $packageJson): PackageJson { - // Process each package - foreach ($registeredPackages as $name => $package) { - // Normalize package path across OS types - $packagePath = Str::replace(DIRECTORY_SEPARATOR, '/', $package['path']); - // Add the package path to the instance's package.json->workspaces->packages property if not present - if (!$packageJson->hasWorkspace($packagePath) && !$packageJson->hasIgnoredPackage($packagePath)) { - if ( - $this->confirm( - sprintf( - "Detected %s (%s), should it be added to your package.json?", - $name, - $packagePath - ), - true - ) - ) { - $packageJson->addWorkspace($packagePath); - $this->info(sprintf( - 'Adding %s (%s) to the workspaces.packages property in package.json', - $name, - $packagePath + // Check if the user requested a specific package for install + if ($requestedPackages = array_map(fn ($name) => strtolower($name), $this->argument('assetPackage'))) { + $packageManager = $this->getPackageManager(); + foreach ($requestedPackages as $requestedPackage) { + // We did not find the package, exit + if (!isset($registeredPackages[$requestedPackage])) { + if ($detected = $packageManager->getPackage($requestedPackage, true)) { + switch (count($detected)) { + case 1: + if ($detected[0]['type'] !== $this->assetType) { + throw new SystemException(sprintf( + 'PackageNotConfiguredException: The requested package `%s` is only configured for %s. Run `php artisan %s:create %1$s`', + $requestedPackage, + $detected[0]['type'], + $this->assetType + )); + } + + if ($detected[0]['ignored']) { + throw new SystemException(sprintf( + 'PackageIgnoredException: The requested package `%s` is ignored, remove it from package.json to continue', + $requestedPackage, + )); + } + break; + case 2: + default: + if (($detected[0]['ignored'] ?? false) || ($detected[1]['ignored'] ?? false)) { + throw new SystemException(sprintf( + 'PackageIgnoredException: The requested package `%s` is ignored, remove it from package.json to continue', + $requestedPackage, + )); + } + break; + } + } + + throw new SystemException(sprintf( + 'PackageNotFoundException: The requested package `%s` could not be found.', + $requestedPackage, )); - } else { - $packageJson->addIgnoredPackage($packagePath); - $this->warn( - sprintf('Ignoring %s (%s)', $name, $packagePath) - ); } + + $this->processPackage($packageJson, $requestedPackage, $registeredPackages[$requestedPackage], true); } - // Detect missing config files and install them - if (!File::exists($package['config'])) { + return $packageJson; + } + + // Process each found package + foreach ($registeredPackages as $name => $package) { + $this->processPackage($packageJson, $name, $package); + } + + return $packageJson; + } + + /** + * Adds a package to the project workspace or mark it as ignored based on user input + */ + protected function processPackage(PackageJson $packageJson, string $name, array $package, bool $force = false): bool + { + // Normalize package path across OS types + $packagePath = Str::replace(DIRECTORY_SEPARATOR, '/', $package['path']); + + // Nicely report if the package is already in the workspace + if ($packageJson->hasWorkspace($packagePath)) { + $this->warn(sprintf( + 'Package %s (%s) is already included in workspaces.packages.', + $name, + $packagePath + )); + + return true; + } + + if ($packageJson->hasIgnoredPackage($packagePath)) { + $this->warn(sprintf( + 'The requested package %s (%s) is ignored, remove it from package.json to continue.', + $name, + $packagePath + )); + + return true; + } + + // Add the package path to the instance's package.json->workspaces->packages property if not present + if (!$packageJson->hasWorkspace($packagePath) && !$packageJson->hasIgnoredPackage($packagePath)) { + if ( + $force + || $this->confirm( + sprintf( + "Detected %s (%s), should it be added to your package.json?", + $name, + $packagePath + ), + true + ) + ) { + $packageJson->addWorkspace($packagePath); $this->info(sprintf( - 'No config file found for %s, you should run %s:config', + 'Adding %s (%s) to the workspaces.packages property in package.json', $name, - $this->assetType + $packagePath )); + } else { + $packageJson->addIgnoredPackage($packagePath); + $this->warn( + sprintf('Ignoring %s (%s)', $name, $packagePath) + ); } } - return $packageJson; + // Detect missing config files and provide feedback + if (!File::exists($package['config'])) { + $this->info(sprintf( + 'No config file found for %s, you should run %s:config', + $name, + $this->assetType + )); + + return false; + } + + return true; } /** * Installs the dependencies for the given package. */ - protected function installPackageDeps(): int + protected function runNpmInstall(): int { - $command = $this->argument('npmArgs') ?? []; - array_unshift($command, 'npm', $this->npmCommand); - - $process = new Process($command, base_path(), null, null, null); - - // Attempt to set tty mode, catch and warn with the exception message if unsupported - try { - $process->setTty(true); - } catch (\Throwable $e) { - $this->warn($e->getMessage()); + $process = new Process( + command: [$this->npmPath, 'install'], + cwd: $this->packageJsonPath ? dirname($this->packageJsonPath) : base_path(), + timeout: null + ); + + if (!$this->option('disable-tty')) { + try { + $process->setTty(true); + } catch (\Throwable $e) { + // This will fail on unsupported systems + } } try { @@ -265,10 +378,21 @@ protected function installPackageDeps(): int return 1; } + } - $this->info(''); + /** + * Returns the root package.json as a PackageManager object + */ + protected function getPackageManager(): PackageManager + { + // Flush the instance + $packageManager = PackageManager::instance()->fireCallbacks(); + // Ensure the instance follows any custom package.json + if ($this->packageJsonPath) { + $packageManager->setPackageJsonPath($this->packageJsonPath); + } - return $process->getExitCode(); + return $packageManager; } /** @@ -276,7 +400,7 @@ protected function installPackageDeps(): int */ protected function getNpmVersion(): string { - $process = new Process(['npm', '--version']); + $process = new Process([$this->npmPath, '--version']); $process->run(); return $process->getOutput(); } diff --git a/modules/system/console/asset/NpmRun.php b/modules/system/console/asset/NpmRun.php deleted file mode 100644 index 8297cd41cd..0000000000 --- a/modules/system/console/asset/NpmRun.php +++ /dev/null @@ -1,99 +0,0 @@ -fireCallbacks(); - - $name = $this->argument('package'); - $script = $this->argument('script'); - - if (!$compilableAssets->hasPackage($name, true)) { - $this->error( - sprintf('Package "%s" is not a registered package.', $name) - ); - return 1; - } - - $package = $compilableAssets->getPackage($name, true)[0] ?? []; - - // Assume that packages with matching names have matching package.json files - $packageJson = new PackageJson($package['package'] ?? null); - - if (!$packageJson->hasScript($script)) { - $this->error( - sprintf('Script "%s" is not defined in package "%s".', $script, $name) - ); - return 1; - } - - $this->info(sprintf('Running script "%s" in package "%s"', $script, $name)); - - $command = ($this->argument('additionalArgs')) ?? []; - if (count($command)) { - array_unshift($command, 'npm', 'run', $script, '--'); - } else { - array_unshift($command, 'npm', 'run', $script); - } - - - $process = new Process( - $command, - base_path($package['path']), - ['NODE_ENV' => $this->option('production', false) ? 'production' : 'development'], - null, - null - ); - - try { - $process->setTty(true); - } catch (\Throwable $e) { - // This will fail on unsupported systems - } - - return $process->run(function ($status, $stdout) { - if (!$this->option('silent')) { - $this->getOutput()->write($stdout); - } - }); - } -} diff --git a/modules/system/console/asset/NpmUpdate.php b/modules/system/console/asset/NpmUpdate.php deleted file mode 100644 index 66665bca1a..0000000000 --- a/modules/system/console/asset/NpmUpdate.php +++ /dev/null @@ -1,46 +0,0 @@ - 'update', - 'completed' => 'updated', - ]; - - /** - * @inheritDoc - */ - protected string $npmCommand = 'update'; - - /** - * @inheritDoc - */ - public $replaces = [ - 'mix:update' - ]; -} diff --git a/modules/system/console/asset/mix/MixInstall.php b/modules/system/console/asset/mix/MixInstall.php index 589a25d935..272ad6852a 100644 --- a/modules/system/console/asset/mix/MixInstall.php +++ b/modules/system/console/asset/mix/MixInstall.php @@ -13,9 +13,11 @@ class MixInstall extends AssetInstall * @var string The name and signature of this command. */ protected $signature = 'mix:install - {npmArgs?* : Arguments to pass through to the "npm" binary} - {--npm= : Defines a custom path to the "npm" binary} - {--p|package=* : Defines one or more packages to install}'; + {assetPackage?* : The asset package name to install.} + {--no-install : Tells Winter not to run npm install after config update.} + {--npm= : Defines a custom path to the "npm" binary.} + {--d|disable-tty : Disable tty mode.} + {--p|package-json= : Defines a custom path to "package.json" file. Must be above the workspace path.}'; /** * @var string The console command description. @@ -35,7 +37,7 @@ class MixInstall extends AssetInstall /** * The required packages for this compiler */ - protected array $requiredPackages = [ + protected array $requiredDependencies = [ 'laravel-mix' => '^6.0.41', ]; } diff --git a/modules/system/console/asset/npm/NpmCommand.php b/modules/system/console/asset/npm/NpmCommand.php new file mode 100644 index 0000000000..cb0d54f715 --- /dev/null +++ b/modules/system/console/asset/npm/NpmCommand.php @@ -0,0 +1,87 @@ +fireCallbacks(); + + $name = $this->argument('package'); + + if (!$name) { + return null; + } + + if (!$compilableAssets->hasPackage($name, true)) { + throw new SystemException(sprintf('Package "%s" is not a registered package.', $name)); + } + + $package = $compilableAssets->getPackage($name, true)[0] ?? []; + + // Assume that packages with matching names have matching package.json files + $packageJson = new PackageJson($package['package'] ?? null); + + return [$package, $packageJson]; + } + + /** + * Starts a npm process with the command and cwd provided + */ + protected function npmRun(array $command, string $path): int + { + $process = new Process( + $command, + base_path($path), + ['NODE_ENV' => $this->getNodeEnv()], + null, + null + ); + + if (!$this->option('disable-tty') && !$this->option('silent')) { + try { + $process->setTty(true); + } catch (ProcessSignaledException $e) { + if (extension_loaded('pcntl') && $e->getSignal() !== SIGINT) { + throw $e; + } + + return 1; + } catch (\Throwable $e) { + // This will fail on unsupported systems + } + } + + return $process->run(function ($status, $stdout) { + if (!$this->option('silent')) { + $this->getOutput()->write($stdout); + } + }); + } + + /** + * Get the env env to provide to node + */ + protected function getNodeEnv(): string + { + if (!$this->hasOption('production')) { + return 'development'; + } + + return $this->option('production', false) ? 'production' : 'development'; + } +} diff --git a/modules/system/console/asset/npm/NpmInstall.php b/modules/system/console/asset/npm/NpmInstall.php new file mode 100644 index 0000000000..da697e42ae --- /dev/null +++ b/modules/system/console/asset/npm/NpmInstall.php @@ -0,0 +1,55 @@ +argument('npmArgs')) ?? []; + + try { + [$package, $packageJson] = $this->getPackage(); + } catch (SystemException $e) { + if (!str_contains($e->getMessage(), 'is not a registered package.')) { + throw $e; + } + array_unshift($command, $this->argument('package')); + } + + array_unshift($command, 'npm', 'install'); + + if ($this->option('dev')) { + $command[] = '--save-dev'; + } + + return $this->npmRun($command, $package['path'] ?? ''); + } +} diff --git a/modules/system/console/asset/npm/NpmRun.php b/modules/system/console/asset/npm/NpmRun.php new file mode 100644 index 0000000000..cd45313acd --- /dev/null +++ b/modules/system/console/asset/npm/NpmRun.php @@ -0,0 +1,66 @@ +getPackage(); + + $script = $this->argument('script'); + + if (!$packageJson->hasScript($script)) { + $this->error( + sprintf('Script "%s" is not defined in package "%s".', $script, $this->argument('package')) + ); + return 1; + } + + if (!$this->option('silent')) { + $this->info(sprintf('Running script "%s" in package "%s"', $script, $this->argument('package'))); + } + + $command = ($this->argument('additionalArgs')) ?? []; + if (count($command)) { + array_unshift($command, 'npm', 'run', $script, '--'); + } else { + array_unshift($command, 'npm', 'run', $script); + } + + return $this->npmRun($command, $package['path']); + } +} diff --git a/modules/system/console/asset/npm/NpmUpdate.php b/modules/system/console/asset/npm/NpmUpdate.php new file mode 100644 index 0000000000..f578c5c4c1 --- /dev/null +++ b/modules/system/console/asset/npm/NpmUpdate.php @@ -0,0 +1,68 @@ +argument('npmArgs')) ?? []; + + try { + [$package, $packageJson] = $this->getPackage(); + } catch (SystemException $e) { + if (!str_contains($e->getMessage(), 'is not a registered package.')) { + throw $e; + } + array_unshift($command, $this->argument('package')); + } + + $args = ['npm', 'update']; + + if ($this->option('save')) { + $args[] = '--save'; + } + + if (count($command)) { + $args[] = '--'; + } + + array_unshift($command, ...$args); + + return $this->npmRun($command, $package['path'] ?? ''); + } +} diff --git a/modules/system/console/asset/vite/ViteInstall.php b/modules/system/console/asset/vite/ViteInstall.php index 0e0c4ea8bf..c7355a1096 100644 --- a/modules/system/console/asset/vite/ViteInstall.php +++ b/modules/system/console/asset/vite/ViteInstall.php @@ -15,9 +15,11 @@ class ViteInstall extends AssetInstall * @var string The name and signature of this command. */ protected $signature = 'vite:install - {npmArgs?* : Arguments to pass through to the "npm" binary} - {--npm= : Defines a custom path to the "npm" binary} - {--p|package=* : Defines one or more packages to install}'; + {assetPackage?* : The asset package name to install.} + {--no-install : Tells Winter not to run npm install after config update.} + {--npm= : Defines a custom path to the "npm" binary.} + {--d|disable-tty : Disable tty mode.} + {--p|package-json= : Defines a custom path to "package.json" file. Must be above the workspace path.}'; /** * @var string The console command description. @@ -37,7 +39,7 @@ class ViteInstall extends AssetInstall /** * The required packages for this compiler */ - protected array $requiredPackages = [ + protected array $requiredDependencies = [ 'vite' => '^5.2.11', 'laravel-vite-plugin' => '^1.0.4', ]; diff --git a/modules/system/tests/classes/asset/PackageJsonTest.php b/modules/system/tests/classes/asset/PackageJsonTest.php index ed8d244e9b..e7b1ebdcd1 100644 --- a/modules/system/tests/classes/asset/PackageJsonTest.php +++ b/modules/system/tests/classes/asset/PackageJsonTest.php @@ -34,6 +34,15 @@ public function testLoadFile(): void $this->assertIsArray($contents['workspaces']['packages']); } + /** + * Test loading a corrupted package.json file correctly errors + */ + public function testLoadCorruptFile(): void + { + $this->expectException(\JsonException::class); + new PackageJson(__DIR__ . '/../../fixtures/npm/package-corrupt.json'); + } + /** * Test creating an instance with non-existing file */ diff --git a/modules/system/tests/console/asset/NpmTestTrait.php b/modules/system/tests/console/asset/NpmTestTrait.php new file mode 100644 index 0000000000..a1ce5d677e --- /dev/null +++ b/modules/system/tests/console/asset/NpmTestTrait.php @@ -0,0 +1,19 @@ +jsonPath, $this->backupPath); + $callback(); + rename($this->backupPath, $this->jsonPath); + } +} diff --git a/modules/system/tests/console/asset/MixCompileTest.php b/modules/system/tests/console/asset/mix/MixCompileTest.php similarity index 99% rename from modules/system/tests/console/asset/MixCompileTest.php rename to modules/system/tests/console/asset/mix/MixCompileTest.php index 4f81885f0c..9589dee4f2 100644 --- a/modules/system/tests/console/asset/MixCompileTest.php +++ b/modules/system/tests/console/asset/mix/MixCompileTest.php @@ -1,9 +1,9 @@ markTestSkipped('This test requires node_modules to be installed'); + } + + // Define some helpful paths + $this->fixturePath = base_path('modules/system/tests'); + $this->jsonPath = $this->fixturePath . '/package.json'; + $this->lockPath = $this->fixturePath . '/package-lock.json'; + $this->backupPath = $this->fixturePath . '/package-testing.json'; + + // Add our testing theme because it won't be auto discovered + PackageManager::instance()->registerPackage( + 'theme-assettest', + base_path('modules/system/tests/fixtures/themes/assettest/winter.mix.js'), + 'mix' + ); + PackageManager::instance()->registerPackage( + 'theme-npmtest', + base_path('modules/system/tests/fixtures/themes/npmtest/winter.mix.js'), + 'mix' + ); + } + + public function testMixInstallMissingPackageJson(): void + { + $this->artisan('mix:install', [ + 'assetPackage' => ['theme-assettest'], + '--package-json' => '/some/file', + '--no-install' => true + ]) + ->expectsOutputToContain('The supplied --package-json path does not exist.') + ->assertExitCode(1); + } + + public function testMixInstallSinglePackage(): void + { + $this->withPackageJsonRestore(function () { + $this->artisan('mix:install', [ + 'assetPackage' => ['theme-assettest'], + '--package-json' => $this->jsonPath, + '--no-install' => true + ]) + ->expectsQuestion('laravel-mix was not found as a dependency in package.json, would you like to add it?', true) + ->expectsOutput('Adding theme-assettest (modules/system/tests/fixtures/themes/assettest) to the workspaces.packages property in package.json') + ->assertExitCode(0); + + $this->assertFileExists($this->jsonPath); + $packageJson = json_decode(File::get($this->jsonPath), JSON_OBJECT_AS_ARRAY); + $this->assertArrayHasKey('devDependencies', $packageJson); + $this->assertArrayHasKey('laravel-mix', $packageJson['devDependencies']); + + $this->assertArrayHasKey('workspaces', $packageJson); + $this->assertArrayHasKey('packages', $packageJson['workspaces']); + $this->assertContains('modules/system/tests/fixtures/themes/assettest', $packageJson['workspaces']['packages']); + }); + } + + public function testMixInstallMultiplePackages(): void + { + $this->withPackageJsonRestore(function () { + $this->artisan('mix:install', [ + 'assetPackage' => ['theme-assettest', 'theme-npmtest'], + '--package-json' => $this->jsonPath, + '--no-install' => true + ]) + ->expectsQuestion('laravel-mix was not found as a dependency in package.json, would you like to add it?', true) + ->expectsOutput('Adding theme-assettest (modules/system/tests/fixtures/themes/assettest) to the workspaces.packages property in package.json') + ->expectsOutput('Adding theme-npmtest (modules/system/tests/fixtures/themes/npmtest) to the workspaces.packages property in package.json') + ->assertExitCode(0); + + $this->assertFileExists($this->jsonPath); + $packageJson = json_decode(File::get($this->jsonPath), JSON_OBJECT_AS_ARRAY); + $this->assertArrayHasKey('devDependencies', $packageJson); + $this->assertArrayHasKey('laravel-mix', $packageJson['devDependencies']); + + $this->assertArrayHasKey('workspaces', $packageJson); + $this->assertArrayHasKey('packages', $packageJson['workspaces']); + $this->assertContains('modules/system/tests/fixtures/themes/assettest', $packageJson['workspaces']['packages']); + $this->assertContains('modules/system/tests/fixtures/themes/npmtest', $packageJson['workspaces']['packages']); + }); + } + + public function testMixInstallMissingPackage(): void + { + // We should receive an exception for a missing package + $this->withPackageJsonRestore(function () { + $this->expectException(SystemException::class); + + $this->artisan('mix:install', [ + 'assetPackage' => ['theme-assettest2'], + '--package-json' => $this->jsonPath, + '--no-install' => true + ]) + ->assertExitCode(1); + }); + } + + public function testMixInstallRelativePath(): void + { + $this->withPackageJsonRestore(function () { + $this->artisan('mix:install', [ + 'assetPackage' => ['theme-assettest'], + '--package-json' => 'modules/system/tests/package.json', + '--no-install' => true + ]) + ->expectsQuestion('laravel-mix was not found as a dependency in package.json, would you like to add it?', true) + ->expectsOutput('Adding theme-assettest (modules/system/tests/fixtures/themes/assettest) to the workspaces.packages property in package.json') + ->assertExitCode(0); + }); + } + + public function testMixInstallIgnoredPackage(): void + { + $this->withPackageJsonRestore(function () { + $packageJson = json_decode(File::get($this->jsonPath), JSON_OBJECT_AS_ARRAY); + $packageJson['workspaces'] = [ + 'ignoredPackages' => [ + 'modules/system/tests/fixtures/themes/assettest' + ] + ]; + File::put($this->jsonPath, json_encode($packageJson, JSON_PRETTY_PRINT)); + + $this->artisan('mix:install', [ + 'assetPackage' => ['theme-assettest'], + '--package-json' => $this->jsonPath, + '--no-install' => true + ]) + ->expectsQuestion('laravel-mix was not found as a dependency in package.json, would you like to add it?', false) + ->expectsOutput('The requested package theme-assettest (modules/system/tests/fixtures/themes/assettest) is ignored, remove it from package.json to continue.') + ->assertExitCode(0); + + $packageJson = json_decode(File::get($this->jsonPath), JSON_OBJECT_AS_ARRAY); + $this->assertArrayHasKey('workspaces', $packageJson); + $this->assertArrayNotHasKey('packages', $packageJson['workspaces']); + $this->assertArrayNotHasKey('dependencies', $packageJson); + $this->assertArrayNotHasKey('devDependencies', $packageJson); + }); + } + + public function testMixInstallWithNpmInstall(): void + { + $this->withPackageJsonRestore(function () { + $this->assertDirectoryDoesNotExist($this->fixturePath . '/node_modules'); + + $this->artisan('mix:install', [ + 'assetPackage' => ['theme-assettest'], + '--package-json' => $this->jsonPath, + '--disable-tty' => true + ]) + ->expectsQuestion('laravel-mix was not found as a dependency in package.json, would you like to add it?', true) + ->expectsOutput('Adding theme-assettest (modules/system/tests/fixtures/themes/assettest) to the workspaces.packages property in package.json') + ->expectsOutputToContain('packages, and audited') // output from npm i + ->assertExitCode(0); + + $this->assertFileExists($this->jsonPath); + $packageJson = json_decode(File::get($this->jsonPath), JSON_OBJECT_AS_ARRAY); + $this->assertArrayHasKey('devDependencies', $packageJson); + $this->assertArrayHasKey('laravel-mix', $packageJson['devDependencies']); + + $this->assertArrayHasKey('workspaces', $packageJson); + $this->assertArrayHasKey('packages', $packageJson['workspaces']); + $this->assertContains('modules/system/tests/fixtures/themes/assettest', $packageJson['workspaces']['packages']); + + $this->assertFileExists($this->lockPath); + + $this->assertDirectoryExists($this->fixturePath . '/node_modules'); + $this->assertDirectoryExists($this->fixturePath . '/node_modules/laravel-mix'); + }); + } + + /** + * Helper to run test logic and handle restoring package.json file after + */ + protected function withPackageJsonRestore(callable $callback): void + { + File::copy($this->backupPath, $this->jsonPath); + $callback(); + File::delete($this->jsonPath); + } + + public function tearDown(): void + { + if (File::isDirectory($this->fixturePath . '/node_modules')) { + File::deleteDirectory($this->fixturePath . '/node_modules'); + } + + if (File::exists($this->lockPath)) { + File::delete($this->lockPath); + } + + parent::tearDown(); + } +} diff --git a/modules/system/tests/console/asset/npm/NpmInstallTest.php b/modules/system/tests/console/asset/npm/NpmInstallTest.php new file mode 100644 index 0000000000..611ab20df7 --- /dev/null +++ b/modules/system/tests/console/asset/npm/NpmInstallTest.php @@ -0,0 +1,209 @@ +markTestSkipped('This test requires node_modules to be installed'); + } + + // Define some helpful paths + $this->themePath = base_path('modules/system/tests/fixtures/themes/npmtest'); + $this->jsonPath = $this->themePath . '/package.json'; + $this->lockPath = $this->themePath . '/package-lock.json'; + $this->backupPath = $this->themePath . '/package.backup.json'; + + // Add our testing theme because it won't be auto discovered + PackageManager::instance()->registerPackage( + 'theme-npmtest', + $this->themePath . '/vite.config.mjs', + 'vite' + ); + } + + /** + * Test the ability to install a single npm package via artisan + */ + public function testNpmInstallSingle(): void + { + // Validate the package Json does not have dependencies + $packageJson = json_decode(File::get($this->jsonPath), JSON_OBJECT_AS_ARRAY); + $this->assertArrayNotHasKey('dependencies', $packageJson); + + // Validate node_modules not found + $this->assertDirectoryDoesNotExist($this->themePath . '/node_modules'); + $this->assertFileNotExists($this->lockPath); + + $this->withPackageJsonRestore(function () { + // Run npm install for a single non-dev package + $this->artisan('npm:install', [ + 'package' => 'theme-npmtest', + 'npmArgs' => ['is-odd'], + '--disable-tty' => true + ]) + ->assertExitCode(0); + + // Validate lock file was generated successfully + $this->assertFileExists($this->lockPath); + + // Validate the contents of package.json + $packageJson = json_decode(File::get($this->jsonPath), JSON_OBJECT_AS_ARRAY); + $this->assertArrayHasKey('dependencies', $packageJson); + $this->assertArrayHasKey('is-odd', $packageJson['dependencies']); + + // Validate that node_modules paths exist + $this->assertDirectoryExists($this->themePath . '/node_modules'); + $this->assertDirectoryExists($this->themePath . '/node_modules/is-odd'); + }); + } + + /** + * Test the ability to install multiple npm packages via artisan + */ + public function testNpmInstallMultiple(): void + { + // Validate the package Json does not have dependencies + $packageJson = json_decode(File::get($this->jsonPath), JSON_OBJECT_AS_ARRAY); + $this->assertArrayNotHasKey('dependencies', $packageJson); + + // Validate node_modules not found + $this->assertDirectoryDoesNotExist($this->themePath . '/node_modules'); + $this->assertFileNotExists($this->lockPath); + + $this->withPackageJsonRestore(function () { + // Run npm install for multiple non-dev packages + $this->artisan('npm:install', [ + 'package' => 'theme-npmtest', + 'npmArgs' => ['is-odd', 'is-even'], + '--disable-tty' => true + ]) + ->assertExitCode(0); + + // Validate lock file was generated successfully + $this->assertFileExists($this->lockPath); + + // Validate the contents of package.json + $packageJson = json_decode(File::get($this->jsonPath), JSON_OBJECT_AS_ARRAY); + $this->assertArrayHasKey('dependencies', $packageJson); + $this->assertArrayHasKey('is-odd', $packageJson['dependencies']); + $this->assertArrayHasKey('is-even', $packageJson['dependencies']); + + // Validate that node_modules paths exist + $this->assertDirectoryExists($this->themePath . '/node_modules'); + $this->assertDirectoryExists($this->themePath . '/node_modules/is-odd'); + $this->assertDirectoryExists($this->themePath . '/node_modules/is-even'); + }); + } + + + /** + * Test the ability to install a single dev npm package via artisan + */ + public function testNpmInstallSingleDev(): void + { + // Validate the package Json does not have dependencies + $packageJson = json_decode(File::get($this->jsonPath), JSON_OBJECT_AS_ARRAY); + $this->assertArrayNotHasKey('devDependencies', $packageJson); + + // Validate node_modules not found + $this->assertDirectoryDoesNotExist($this->themePath . '/node_modules'); + $this->assertFileNotExists($this->lockPath); + + $this->withPackageJsonRestore(function () { + // Run npm install for a single dev package + $this->artisan('npm:install', [ + 'package' => 'theme-npmtest', + 'npmArgs' => ['is-odd'], + '--dev' => true, + '--disable-tty' => true + ]) + ->assertExitCode(0); + + // Validate lock file was generated successfully + $this->assertFileExists($this->lockPath); + + // Validate the contents of package.json + $packageJson = json_decode(File::get($this->jsonPath), JSON_OBJECT_AS_ARRAY); + $this->assertArrayHasKey('devDependencies', $packageJson); + $this->assertArrayHasKey('is-odd', $packageJson['devDependencies']); + + // Validate that node_modules paths exist + $this->assertDirectoryExists($this->themePath . '/node_modules'); + $this->assertDirectoryExists($this->themePath . '/node_modules/is-odd'); + }); + } + + /** + * Test the ability to install multiple dev npm packages via artisan + */ + public function testNpmInstallMultipleDev(): void + { + // Validate the package Json does not have dependencies + $packageJson = json_decode(File::get($this->jsonPath), JSON_OBJECT_AS_ARRAY); + $this->assertArrayNotHasKey('devDependencies', $packageJson); + + // Validate node_modules not found + $this->assertDirectoryDoesNotExist($this->themePath . '/node_modules'); + $this->assertFileNotExists($this->lockPath); + + $this->withPackageJsonRestore(function () { + // Run npm install for multiple dev packages + $this->artisan('npm:install', [ + 'package' => 'theme-npmtest', + 'npmArgs' => ['is-odd', 'is-even'], + '--dev' => true, + '--disable-tty' => true + ]) + ->assertExitCode(0); + + // Validate lock file was generated successfully + $this->assertFileExists($this->lockPath); + + // Validate the contents of package.json + $packageJson = json_decode(File::get($this->jsonPath), JSON_OBJECT_AS_ARRAY); + $this->assertArrayHasKey('devDependencies', $packageJson); + $this->assertArrayHasKey('is-odd', $packageJson['devDependencies']); + $this->assertArrayHasKey('is-even', $packageJson['devDependencies']); + + // Validate that node_modules paths exist + $this->assertDirectoryExists($this->themePath . '/node_modules'); + $this->assertDirectoryExists($this->themePath . '/node_modules/is-odd'); + $this->assertDirectoryExists($this->themePath . '/node_modules/is-even'); + }); + } + + /** + * Cleanup the test theme + */ + public function tearDown(): void + { + if (File::isDirectory($this->themePath . '/node_modules')) { + File::deleteDirectory($this->themePath . '/node_modules'); + } + + foreach ([$this->backupPath, $this->lockPath] as $path) { + if (File::exists($path)) { + File::delete($path); + } + } + + parent::tearDown(); + } +} diff --git a/modules/system/tests/console/asset/npm/NpmRunTest.php b/modules/system/tests/console/asset/npm/NpmRunTest.php new file mode 100644 index 0000000000..51f699dbb0 --- /dev/null +++ b/modules/system/tests/console/asset/npm/NpmRunTest.php @@ -0,0 +1,55 @@ +markTestSkipped('This test requires node_modules to be installed'); + } + + // Add our testing theme because it won't be auto discovered + PackageManager::instance()->registerPackage( + 'theme-npmtest', + base_path('modules/system/tests/fixtures/themes/npmtest/vite.config.mjs'), + 'vite' + ); + } + + /** + * Test the ability to run a npm script via artisan + */ + public function testNpmRunScript(): void + { + $this->artisan('npm:run', [ + 'package' => 'theme-npmtest', + 'script' => 'testScript', + '--disable-tty' => true + ]) + ->expectsOutputToContain('> echo "Winter says $((1+2))"') + ->expectsOutputToContain('Winter says 3') + ->assertExitCode(0); + } + + /** + * Test the error handling of a missing script + */ + public function testNpmRunScriptFailed(): void + { + $this->artisan('npm:run', [ + 'package' => 'theme-npmtest', + 'script' => 'testMissingScript', + '--disable-tty' => true + ]) + ->expectsOutputToContain('Script "testMissingScript" is not defined in package "theme-npmtest"') + ->assertExitCode(1); + } +} diff --git a/modules/system/tests/console/asset/npm/NpmUpdateTest.php b/modules/system/tests/console/asset/npm/NpmUpdateTest.php new file mode 100644 index 0000000000..38b63d9827 --- /dev/null +++ b/modules/system/tests/console/asset/npm/NpmUpdateTest.php @@ -0,0 +1,104 @@ +markTestSkipped('This test requires node_modules to be installed'); + } + + // Define some helpful paths + $this->themePath = base_path('modules/system/tests/fixtures/themes/npmtest'); + $this->jsonPath = $this->themePath . '/package.json'; + $this->lockPath = $this->themePath . '/package-lock.json'; + $this->backupPath = $this->themePath . '/package.backup.json'; + + // Add our testing theme because it won't be auto discovered + PackageManager::instance()->registerPackage( + 'theme-npmtest', + $this->themePath . '/vite.config.mjs', + 'vite' + ); + } + + /** + * Test the ability to install a single npm package via artisan + */ + public function testNpmUpdate(): void + { + // Validate the package Json does not have dependencies + $packageJson = json_decode(File::get($this->jsonPath), JSON_OBJECT_AS_ARRAY); + $this->assertArrayNotHasKey('dependencies', $packageJson); + + // Validate node_modules not found + $this->assertDirectoryDoesNotExist($this->themePath . '/node_modules'); + $this->assertFileNotExists($this->lockPath); + + $this->withPackageJsonRestore(function () { + // Update the contents of package.json to include a package at an old patch + $packageJson = json_decode(File::get($this->jsonPath), JSON_OBJECT_AS_ARRAY); + $packageJson['dependencies'] = [ + 'is-odd' => '^3.0.0' + ]; + File::put($this->jsonPath, json_encode($packageJson, JSON_PRETTY_PRINT)); + + // Run npm update + $this->artisan('npm:update', [ + 'package' => 'theme-npmtest', + '--save' => true, + '--disable-tty' => true + ]) + ->assertExitCode(0); + + // Validate lock file was generated successfully + $this->assertFileExists($this->lockPath); + + // Get the new contents of package.json + $packageJson = json_decode(File::get($this->jsonPath), JSON_OBJECT_AS_ARRAY); + + // Validate that the package.json contents does not match the old patch value we added above + $this->assertArrayHasKey('dependencies', $packageJson); + $this->assertArrayHasKey('is-odd', $packageJson['dependencies']); + $this->assertNotEquals('^3.0.0', $packageJson['dependencies']['is-odd']); + + // Validate that node_modules paths exist + $this->assertDirectoryExists($this->themePath . '/node_modules'); + $this->assertDirectoryExists($this->themePath . '/node_modules/is-odd'); + }); + } + + /** + * Cleanup the test theme + */ + public function tearDown(): void + { + if (File::isDirectory($this->themePath . '/node_modules')) { + File::deleteDirectory($this->themePath . '/node_modules'); + } + + foreach ([$this->backupPath, $this->lockPath] as $path) { + if (File::exists($path)) { + File::delete($path); + } + } + + parent::tearDown(); + } +} diff --git a/modules/system/tests/console/asset/ViteCompileTest.php b/modules/system/tests/console/asset/vite/ViteCompileTest.php similarity index 95% rename from modules/system/tests/console/asset/ViteCompileTest.php rename to modules/system/tests/console/asset/vite/ViteCompileTest.php index d51a1c6e56..1ace48758a 100644 --- a/modules/system/tests/console/asset/ViteCompileTest.php +++ b/modules/system/tests/console/asset/vite/ViteCompileTest.php @@ -1,6 +1,6 @@ markTestSkipped('This test requires the vite package to be installed'); } - $this->themePath = base_path('modules/system/tests/fixtures/themes/vitetest'); + $this->themePath = base_path('modules/system/tests/fixtures/themes/assettest'); // Add our testing theme because it won't be auto discovered PackageManager::instance()->registerPackage( - 'theme-vitetest', + 'theme-assettest', $this->themePath . '/vite.config.mjs', 'vite' ); @@ -36,7 +36,7 @@ public function testThemeCompile(): void { // Run the vite:compile command $this->artisan('vite:compile', [ - 'theme-vitetest', + 'theme-assettest', '--manifest' => 'modules/system/tests/fixtures/npm/package-vitetheme.json', '--silent' => true ])->assertExitCode(0); @@ -73,7 +73,7 @@ public function testThemeCompileFailed(): void // Run the vite:compile command $this->artisan('vite:compile', [ - 'theme-vitetest', + 'theme-assettest', '--manifest' => 'modules/system/tests/fixtures/npm/package-vitetheme.json', '--disable-tty' => true ]) @@ -96,7 +96,7 @@ public function testThemeCompileWarning(): void // Run the vite:compile command $this->artisan('vite:compile', [ - 'theme-vitetest', + 'theme-assettest', '--manifest' => 'modules/system/tests/fixtures/npm/package-vitetheme.json', '--disable-tty' => true ]) @@ -112,7 +112,7 @@ public function testThemeCompileWarning(): void public function tearDown(): void { - File::deleteDirectory('modules/system/tests/fixtures/themes/vitetest/public'); + File::deleteDirectory('modules/system/tests/fixtures/themes/assettest/public'); parent::tearDown(); } } diff --git a/modules/system/tests/console/asset/ViteCreateTest.php b/modules/system/tests/console/asset/vite/ViteCreateTest.php similarity index 99% rename from modules/system/tests/console/asset/ViteCreateTest.php rename to modules/system/tests/console/asset/vite/ViteCreateTest.php index 4669e1fb20..a8fd7deab3 100644 --- a/modules/system/tests/console/asset/ViteCreateTest.php +++ b/modules/system/tests/console/asset/vite/ViteCreateTest.php @@ -1,6 +1,6 @@ markTestSkipped('This test requires node_modules to be installed'); + } + + // Define some helpful paths + $this->fixturePath = base_path('modules/system/tests'); + $this->jsonPath = $this->fixturePath . '/package.json'; + $this->lockPath = $this->fixturePath . '/package-lock.json'; + $this->backupPath = $this->fixturePath . '/package-testing.json'; + + // Add our testing theme because it won't be auto discovered + PackageManager::instance()->registerPackage( + 'theme-assettest', + base_path('modules/system/tests/fixtures/themes/assettest/vite.config.mjs'), + 'vite' + ); + PackageManager::instance()->registerPackage( + 'theme-npmtest', + base_path('modules/system/tests/fixtures/themes/npmtest/vite.config.mjs'), + 'vite' + ); + } + + public function testViteInstallMissingPackageJson(): void + { + $this->artisan('vite:install', [ + 'assetPackage' => ['theme-assettest'], + '--package-json' => '/some/file', + '--no-install' => true + ]) + ->expectsOutputToContain('The supplied --package-json path does not exist.') + ->assertExitCode(1); + } + + public function testViteInstallSinglePackage(): void + { + $this->withPackageJsonRestore(function () { + $this->artisan('vite:install', [ + 'assetPackage' => ['theme-assettest'], + '--package-json' => $this->jsonPath, + '--no-install' => true + ]) + ->expectsQuestion('vite was not found as a dependency in package.json, would you like to add it?', true) + ->expectsQuestion('laravel-vite-plugin was not found as a dependency in package.json, would you like to add it?', true) + ->expectsOutput('Adding theme-assettest (modules/system/tests/fixtures/themes/assettest) to the workspaces.packages property in package.json') + ->assertExitCode(0); + + $this->assertFileExists($this->jsonPath); + $packageJson = json_decode(File::get($this->jsonPath), JSON_OBJECT_AS_ARRAY); + $this->assertArrayHasKey('devDependencies', $packageJson); + $this->assertArrayHasKey('vite', $packageJson['devDependencies']); + $this->assertArrayHasKey('laravel-vite-plugin', $packageJson['devDependencies']); + + $this->assertArrayHasKey('workspaces', $packageJson); + $this->assertArrayHasKey('packages', $packageJson['workspaces']); + $this->assertContains('modules/system/tests/fixtures/themes/assettest', $packageJson['workspaces']['packages']); + }); + } + + public function testViteInstallMultiplePackages(): void + { + $this->withPackageJsonRestore(function () { + $this->artisan('vite:install', [ + 'assetPackage' => ['theme-assettest', 'theme-npmtest'], + '--package-json' => $this->jsonPath, + '--no-install' => true + ]) + ->expectsQuestion('vite was not found as a dependency in package.json, would you like to add it?', true) + ->expectsQuestion('laravel-vite-plugin was not found as a dependency in package.json, would you like to add it?', true) + ->expectsOutput('Adding theme-assettest (modules/system/tests/fixtures/themes/assettest) to the workspaces.packages property in package.json') + ->expectsOutput('Adding theme-npmtest (modules/system/tests/fixtures/themes/npmtest) to the workspaces.packages property in package.json') + ->assertExitCode(0); + + $this->assertFileExists($this->jsonPath); + $packageJson = json_decode(File::get($this->jsonPath), JSON_OBJECT_AS_ARRAY); + $this->assertArrayHasKey('devDependencies', $packageJson); + $this->assertArrayHasKey('vite', $packageJson['devDependencies']); + $this->assertArrayHasKey('laravel-vite-plugin', $packageJson['devDependencies']); + + $this->assertArrayHasKey('workspaces', $packageJson); + $this->assertArrayHasKey('packages', $packageJson['workspaces']); + $this->assertContains('modules/system/tests/fixtures/themes/assettest', $packageJson['workspaces']['packages']); + $this->assertContains('modules/system/tests/fixtures/themes/npmtest', $packageJson['workspaces']['packages']); + }); + } + + public function testViteInstallMissingPackage(): void + { + // We should receive an exception for a missing package + $this->withPackageJsonRestore(function () { + $this->expectException(SystemException::class); + $this->expectExceptionMessage('PackageNotFoundException: The package `theme-assettest2` does not exist.'); + + $this->artisan('vite:install', [ + 'assetPackage' => ['theme-assettest2'], + '--package-json' => $this->jsonPath, + '--no-install' => true + ]) + ->assertExitCode(1); + }); + } + + public function testViteInstallRelativePath(): void + { + $this->withPackageJsonRestore(function () { + $this->artisan('vite:install', [ + 'assetPackage' => ['theme-assettest'], + '--package-json' => 'modules/system/tests/package.json', + '--no-install' => true + ]) + ->expectsQuestion('vite was not found as a dependency in package.json, would you like to add it?', true) + ->expectsQuestion('laravel-vite-plugin was not found as a dependency in package.json, would you like to add it?', true) + ->expectsOutput('Adding theme-assettest (modules/system/tests/fixtures/themes/assettest) to the workspaces.packages property in package.json') + ->assertExitCode(0); + }); + } + + public function testViteInstallIgnoredPackage(): void + { + $this->withPackageJsonRestore(function () { + $packageJson = json_decode(File::get($this->jsonPath), JSON_OBJECT_AS_ARRAY); + $packageJson['workspaces'] = [ + 'ignoredPackages' => [ + 'modules/system/tests/fixtures/themes/assettest' + ] + ]; + + File::put($this->jsonPath, json_encode($packageJson, JSON_PRETTY_PRINT)); + + $this->artisan('vite:install', [ + 'assetPackage' => ['theme-assettest'], + '--package-json' => $this->jsonPath, + '--no-install' => true + ]) + ->expectsQuestion('vite was not found as a dependency in package.json, would you like to add it?', false) + ->expectsQuestion('laravel-vite-plugin was not found as a dependency in package.json, would you like to add it?', false) + ->expectsOutput('The requested package theme-assettest (modules/system/tests/fixtures/themes/assettest) is ignored, remove it from package.json to continue.') + ->assertExitCode(0); + + $packageJson = json_decode(File::get($this->jsonPath), JSON_OBJECT_AS_ARRAY); + $this->assertArrayHasKey('workspaces', $packageJson); + $this->assertArrayNotHasKey('packages', $packageJson['workspaces']); + $this->assertArrayNotHasKey('dependencies', $packageJson); + $this->assertArrayNotHasKey('devDependencies', $packageJson); + }); + } + + public function testViteInstallWithNpmInstall(): void + { + $this->withPackageJsonRestore(function () { + $this->assertDirectoryDoesNotExist($this->fixturePath . '/node_modules'); + + $this->artisan('vite:install', [ + 'assetPackage' => ['theme-assettest'], + '--package-json' => $this->jsonPath, + '--disable-tty' => true + ]) + ->expectsQuestion('vite was not found as a dependency in package.json, would you like to add it?', true) + ->expectsQuestion('laravel-vite-plugin was not found as a dependency in package.json, would you like to add it?', true) + ->expectsOutput('Adding theme-assettest (modules/system/tests/fixtures/themes/assettest) to the workspaces.packages property in package.json') + ->expectsOutputToContain('packages, and audited') // output from npm i + ->assertExitCode(0); + + $this->assertFileExists($this->jsonPath); + $packageJson = json_decode(File::get($this->jsonPath), JSON_OBJECT_AS_ARRAY); + $this->assertArrayHasKey('devDependencies', $packageJson); + $this->assertArrayHasKey('vite', $packageJson['devDependencies']); + $this->assertArrayHasKey('laravel-vite-plugin', $packageJson['devDependencies']); + + $this->assertArrayHasKey('workspaces', $packageJson); + $this->assertArrayHasKey('packages', $packageJson['workspaces']); + $this->assertContains('modules/system/tests/fixtures/themes/assettest', $packageJson['workspaces']['packages']); + + $this->assertFileExists($this->lockPath); + + $this->assertDirectoryExists($this->fixturePath . '/node_modules'); + $this->assertDirectoryExists($this->fixturePath . '/node_modules/vite'); + $this->assertDirectoryExists($this->fixturePath . '/node_modules/laravel-vite-plugin'); + }); + } + + /** + * Helper to run test logic and handle restoring package.json file after + */ + protected function withPackageJsonRestore(callable $callback): void + { + File::copy($this->backupPath, $this->jsonPath); + $callback(); + File::delete($this->jsonPath); + } + + public function tearDown(): void + { + if (File::isDirectory($this->fixturePath . '/node_modules')) { + File::deleteDirectory($this->fixturePath . '/node_modules'); + } + + if (File::exists($this->lockPath)) { + File::delete($this->lockPath); + } + + parent::tearDown(); + } +} diff --git a/modules/system/tests/fixtures/npm/package-corrupt.json b/modules/system/tests/fixtures/npm/package-corrupt.json new file mode 100644 index 0000000000..35f6291a84 --- /dev/null +++ b/modules/system/tests/fixtures/npm/package-corrupt.json @@ -0,0 +1,8 @@ +{ + "workspaces": { + "packages": [ + "plugins/winter/demo", + "themes/demo" + ] + }, + "dependencies": diff --git a/modules/system/tests/fixtures/npm/package-vitetheme.json b/modules/system/tests/fixtures/npm/package-vitetheme.json index 7eb3504b86..b50becfa6b 100644 --- a/modules/system/tests/fixtures/npm/package-vitetheme.json +++ b/modules/system/tests/fixtures/npm/package-vitetheme.json @@ -2,7 +2,7 @@ "type": "module", "workspaces": { "packages": [ - "modules/system/tests/fixtures/themes/vitetest" + "modules/system/tests/fixtures/themes/assettest" ] }, "devDependencies": { diff --git a/modules/system/tests/fixtures/themes/vitetest/assets/css/theme.css b/modules/system/tests/fixtures/themes/assettest/assets/css/theme.css similarity index 100% rename from modules/system/tests/fixtures/themes/vitetest/assets/css/theme.css rename to modules/system/tests/fixtures/themes/assettest/assets/css/theme.css diff --git a/modules/system/tests/fixtures/themes/vitetest/assets/javascript/theme.js b/modules/system/tests/fixtures/themes/assettest/assets/javascript/theme.js similarity index 100% rename from modules/system/tests/fixtures/themes/vitetest/assets/javascript/theme.js rename to modules/system/tests/fixtures/themes/assettest/assets/javascript/theme.js diff --git a/modules/system/tests/fixtures/themes/vitetest/vite.config.mjs b/modules/system/tests/fixtures/themes/assettest/vite.config.mjs similarity index 100% rename from modules/system/tests/fixtures/themes/vitetest/vite.config.mjs rename to modules/system/tests/fixtures/themes/assettest/vite.config.mjs diff --git a/modules/system/tests/fixtures/themes/assettest/winter.mix.js b/modules/system/tests/fixtures/themes/assettest/winter.mix.js new file mode 100644 index 0000000000..6f6731ca3a --- /dev/null +++ b/modules/system/tests/fixtures/themes/assettest/winter.mix.js @@ -0,0 +1,4 @@ +const mix = require('laravel-mix'); +mix.setPublicPath(__dirname); + +mix.js('assets/javascript/theme.js', 'dist/javascript/theme.js'); diff --git a/modules/system/tests/fixtures/themes/npmtest/package.json b/modules/system/tests/fixtures/themes/npmtest/package.json new file mode 100644 index 0000000000..98a117bfe0 --- /dev/null +++ b/modules/system/tests/fixtures/themes/npmtest/package.json @@ -0,0 +1,11 @@ +{ + "type": "module", + "workspaces": { + "packages": [ + "modules/system/tests/fixtures/themes/npmtest" + ] + }, + "scripts": { + "testScript": "echo \"Winter says $((1+2))\"" + } +} diff --git a/modules/system/tests/fixtures/themes/npmtest/vite.config.mjs b/modules/system/tests/fixtures/themes/npmtest/vite.config.mjs new file mode 100644 index 0000000000..0b79ce8fee --- /dev/null +++ b/modules/system/tests/fixtures/themes/npmtest/vite.config.mjs @@ -0,0 +1,2 @@ +import { defineConfig } from 'vite'; +export default defineConfig({}); diff --git a/modules/system/tests/fixtures/themes/npmtest/winter.mix.js b/modules/system/tests/fixtures/themes/npmtest/winter.mix.js new file mode 100644 index 0000000000..ed859bfc52 --- /dev/null +++ b/modules/system/tests/fixtures/themes/npmtest/winter.mix.js @@ -0,0 +1,2 @@ +const mix = require('laravel-mix'); +mix.setPublicPath(__dirname); diff --git a/modules/system/tests/package-testing.json b/modules/system/tests/package-testing.json new file mode 100644 index 0000000000..bfc9f50eea --- /dev/null +++ b/modules/system/tests/package-testing.json @@ -0,0 +1,3 @@ +{ + "name": "winter-test-root" +}