diff --git a/src/Illuminate/Foundation/Console/DevCommand.php b/src/Illuminate/Foundation/Console/DevCommand.php new file mode 100644 index 000000000000..cd13afa05083 --- /dev/null +++ b/src/Illuminate/Foundation/Console/DevCommand.php @@ -0,0 +1,82 @@ +isProhibited()) { + return self::FAILURE; + } + + $devCommands = DevCommands::commands(); + + $commands = array_column($devCommands, 'command'); + $colors = array_column($devCommands, 'color'); + $names = array_column($devCommands, 'name'); + + $longestName = max(array_map(strlen(...), $names)); + + $this->line(''); + + foreach ($devCommands as $devCommand) { + $this->line( + sprintf( + '[%s]%s%s', + $devCommand['color'], + $devCommand['name'], + str_repeat(' ', ($longestName - strlen($devCommand['name'])) + 1), + $devCommand['command'], + ), + ); + } + + $this->line(''); + + $command = $packageManager->getExecCommand(sprintf( + 'concurrently -c "%s" "%s" --names=%s --kill-others', + implode(',', $colors), + implode('" "', $commands), + implode(',', $names) + )); + + if (extension_loaded('pcntl')) { + pcntl_exec('/usr/bin/env', ['sh', '-c', $command]); + } + + passthru($command, $exitCode); + + return $exitCode; + } +} diff --git a/src/Illuminate/Foundation/DevCommand.php b/src/Illuminate/Foundation/DevCommand.php new file mode 100644 index 000000000000..b077d9a0f02f --- /dev/null +++ b/src/Illuminate/Foundation/DevCommand.php @@ -0,0 +1,122 @@ +name ??= strstr($command, ' ', true); + } + + /** + * Get the command name. + * + * @return string + */ + public function name(): string + { + return $this->name; + } + + /** + * Set the command color. + * + * @param string $color + * @return self + */ + public function color(string $color): self + { + $this->color = $color; + + return $this; + } + + /** + * Set the command color to blue. + * + * @return self + */ + public function blue(): self + { + return $this->color(DevCommandColor::BLUE->value); + } + + /** + * Set the command color to purple. + * + * @return self + */ + public function purple(): self + { + return $this->color(DevCommandColor::PURPLE->value); + } + + /** + * Set the command color to pink. + * + * @return self + */ + public function pink(): self + { + return $this->color(DevCommandColor::PINK->value); + } + + /** + * Set the command color to orange. + * + * @return self + */ + public function orange(): self + { + return $this->color(DevCommandColor::ORANGE->value); + } + + /** + * Set the command color to green. + * + * @return self + */ + public function green(): self + { + return $this->color(DevCommandColor::GREEN->value); + } + + /** + * Set the command color to yellow. + * + * @return self + */ + public function yellow(): self + { + return $this->color(DevCommandColor::YELLOW->value); + } + + /** + * Get the command as an array. + * + * @return array{command: string, name: string, color: string|null} + */ + public function toArray(): array + { + return [ + 'command' => $this->command, + 'name' => $this->name, + 'color' => $this->color, + ]; + } +} diff --git a/src/Illuminate/Foundation/DevCommandColor.php b/src/Illuminate/Foundation/DevCommandColor.php new file mode 100644 index 000000000000..7a67b3ad0731 --- /dev/null +++ b/src/Illuminate/Foundation/DevCommandColor.php @@ -0,0 +1,13 @@ + + */ + protected static $except = []; + + /** + * The names of commands that should be included when running the "dev" command. + * + * @var array + */ + protected static $only = []; + + /** + * Register the default development commands. + * + * @return void + */ + public static function registerDefaults() + { + self::artisan('serve --host=localhost', 'server'); + self::artisan('queue:listen --tries=1 --timeout=0', 'queue'); + self::artisan('pail --timeout=0', 'logs'); + self::node('dev', 'vite'); + } + + /** + * Register a development command. + * + * @param string $command + * @param string|null $name + * @return DevCommand + */ + public static function register(string $command, ?string $name = null): DevCommand + { + if (! app()->runningInConsole()) { + // If we're not running in the console, just return a dummy DevCommand instance. + return new DevCommand('', ''); + } + + self::preventVendorRegistration($name ?? $command); + + $devCommand = new DevCommand($command, $name); + + self::$commands[$devCommand->name()] = $devCommand; + + return $devCommand; + } + + /** + * Registers an Artisan command, automatically prefixing it with "php artisan". + * + * @param string $command + * @param string|null $name + * @return DevCommand + */ + public static function artisan(string $command, ?string $name = null): DevCommand + { + return self::register("php artisan {$command}", $name ?? self::nameFromCommand($command)); + } + + /** + * Registers a Node command, automatically prefixing it with the detected package manager's run command. + * + * @param string $command + * @param string|null $name + * @return DevCommand + */ + public static function node(string $command, ?string $name = null): DevCommand + { + return self::register(self::getPackageManager()->getRunCommand($command), $name ?? self::nameFromCommand($command)); + } + + /** + * Registers a Node command, automatically prefixing it with the detected package manager's exec command. + * + * @param string $command + * @param string|null $name + * @return DevCommand + */ + public static function nodeExec(string $command, ?string $name = null): DevCommand + { + return self::register(self::getPackageManager()->getExecCommand($command), $name ?? self::nameFromCommand($command)); + } + + /** + * Set the commands that should be excluded when running the "dev" command. + * + * @param string ...$names + * @return void + */ + public static function except(...$names): void + { + self::$except = $names; + } + + /** + * Set the commands that should be included when running the "dev" command. + * + * @param string ...$names + * @return void + */ + public static function only(...$names): void + { + self::$only = $names; + } + + /** + * Get the registered development commands. + * + * @return array + */ + public static function commands(): array + { + $commands = []; + + foreach (self::$commands as $command) { + $cmd = $command->toArray(); + + if ((! empty(self::$only) && ! in_array($cmd['name'], self::$only)) || in_array($cmd['name'], self::$except)) { + continue; + } + + $commands[] = $cmd; + } + + $commands = self::fillInEmptyColors($commands); + + return $commands; + } + + /** + * Clear all registered development commands and reset the state of the DevCommands class, + * including registered commands, exceptions, inclusions, and color assignments. + * + * @return void + */ + public static function clear(): void + { + self::$commands = []; + self::$except = []; + self::$only = []; + self::$colorCount = 0; + } + + /** + * Resolve and return the NodePackageManager instance. + * + * @return NodePackageManager + */ + protected static function getPackageManager(): NodePackageManager + { + return self::$packageManager ??= new NodePackageManager(); + } + + /** + * Prevent automatic registration of DevCommands from within vendor packages. + * + * @param string $name + * @return void + * + * @throws Exception + */ + protected static function preventVendorRegistration(string $name) + { + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS); + + foreach ($trace as $frame) { + $file = $frame['file'] ?? null; + $class = $frame['class'] ?? null; + + if ($class === self::class) { + continue; + } + + if (! $file && $class) { + $file = (new ReflectionClass($class))->getFileName(); + } + + if ($file === base_path('artisan')) { + continue; + } + + if (! $file) { + continue; + } + + if (! str_contains($file, base_path('vendor'))) { + // We found at least one frame that came from userland code, we're good. + return; + } + } + + throw new Exception( + "DevCommands should be registered in application code, not within vendor packages. Attempted to register command: {$name}" + ); + } + + /** + * Derive a command name from the given command string by taking the first word. + * + * @param string $command + * @return string + */ + protected static function nameFromCommand(string $command): string + { + return strstr($command, ' ', true); + } + + /** + * Fill in any empty colors in the given commands array, ensuring each command has a color assigned. + * + * @param array $commands + * @return array + */ + protected static function fillInEmptyColors(array $commands): array + { + foreach ($commands as &$command) { + if (empty($command['color'])) { + $command['color'] = self::getColor($commands); + } + } + + return $commands; + } + + /** + * Get a color for a command, ensuring that colors are reused only after all available colors have been used at least once. + * + * @param array $commands + * @return string + */ + protected static function getColor(array $commands): string + { + $colors = array_map(fn ($color) => $color->value, DevCommandColor::cases()); + $existing = array_values(array_filter(array_column($commands, 'color'))); + $available = array_values(array_diff($colors, $existing)); + + return $available[0] ?? $colors[self::$colorCount++ % count($colors)]; + } +} diff --git a/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php b/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php index 09397ac7b983..aa9ca5454aa2 100755 --- a/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php +++ b/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php @@ -45,6 +45,7 @@ use Illuminate\Foundation\Console\ConfigPublishCommand; use Illuminate\Foundation\Console\ConfigShowCommand; use Illuminate\Foundation\Console\ConsoleMakeCommand; +use Illuminate\Foundation\Console\DevCommand; use Illuminate\Foundation\Console\DocsCommand; use Illuminate\Foundation\Console\DownCommand; use Illuminate\Foundation\Console\EnumMakeCommand; @@ -91,6 +92,7 @@ use Illuminate\Foundation\Console\ViewCacheCommand; use Illuminate\Foundation\Console\ViewClearCommand; use Illuminate\Foundation\Console\ViewMakeCommand; +use Illuminate\Foundation\DevCommands; use Illuminate\Notifications\Console\NotificationTableCommand; use Illuminate\Queue\Console\BatchesTableCommand; use Illuminate\Queue\Console\ClearCommand as QueueClearCommand; @@ -204,6 +206,7 @@ class ArtisanServiceProvider extends ServiceProvider implements DeferrableProvid 'ConfigPublish' => ConfigPublishCommand::class, 'ConsoleMake' => ConsoleMakeCommand::class, 'ControllerMake' => ControllerMakeCommand::class, + 'Dev' => DevCommand::class, 'Docs' => DocsCommand::class, 'EnumMake' => EnumMakeCommand::class, 'EventGenerate' => EventGenerateCommand::class, @@ -259,6 +262,16 @@ public function register() }); } + /** + * Bootstrap the application services. + * + * @return void + */ + public function boot() + { + DevCommands::registerDefaults(); + } + /** * Register the given commands. * diff --git a/src/Illuminate/Support/Contracts/NodePackageManager.php b/src/Illuminate/Support/Contracts/NodePackageManager.php new file mode 100644 index 000000000000..433b31f23528 --- /dev/null +++ b/src/Illuminate/Support/Contracts/NodePackageManager.php @@ -0,0 +1,12 @@ +packageManager ??= $this->detect(); + } + + /** + * Get the command to execute a package using the detected package manager. + * + * @param string $command + * @return string + */ + public function getExecCommand(string $command): string + { + return $this->packageManager()->getExecCommand($command); + } + + /** + * Get the command to run a script using the detected package manager. + * + * @param string $command + * @return string + */ + public function getRunCommand(string $command): string + { + return $this->packageManager()->getRunCommand($command); + } + + /** + * Detect the current package manager. + * + * @return NodePackageManagerContract + */ + protected function detect(): NodePackageManagerContract + { + $candidates = [ + NodePackageManagers\Bun::class, + NodePackageManagers\Pnpm::class, + NodePackageManagers\Yarn::class, + ]; + + foreach ($candidates as $packageManager) { + if ($packageManager::matches()) { + return new $packageManager; + } + } + + return new NodePackageManagers\Npm; + } +} diff --git a/src/Illuminate/Support/NodePackageManagers/Bun.php b/src/Illuminate/Support/NodePackageManagers/Bun.php new file mode 100644 index 000000000000..01a9c2f9ba3c --- /dev/null +++ b/src/Illuminate/Support/NodePackageManagers/Bun.php @@ -0,0 +1,46 @@ +assertSame('php', $command->name()); + } + + public function testNameCanBeExplicitlySet() + { + $command = new DevCommand('php artisan serve', 'server'); + + $this->assertSame('server', $command->name()); + } + + public function testToArrayReturnsCommandDetails() + { + $command = new DevCommand('php artisan serve', 'server'); + + $this->assertSame([ + 'command' => 'php artisan serve', + 'name' => 'server', + 'color' => null, + ], $command->toArray()); + } + + public function testColorCanBeSet() + { + $command = new DevCommand('php artisan serve', 'server'); + $result = $command->color('#ff0000'); + + $this->assertSame($command, $result); + $this->assertSame('#ff0000', $command->toArray()['color']); + } + + public function testBlueColor() + { + $command = new DevCommand('cmd', 'test'); + $result = $command->blue(); + + $this->assertSame($command, $result); + $this->assertSame(DevCommandColor::BLUE->value, $command->toArray()['color']); + } + + public function testPurpleColor() + { + $command = new DevCommand('cmd', 'test'); + $command->purple(); + + $this->assertSame(DevCommandColor::PURPLE->value, $command->toArray()['color']); + } + + public function testPinkColor() + { + $command = new DevCommand('cmd', 'test'); + $command->pink(); + + $this->assertSame(DevCommandColor::PINK->value, $command->toArray()['color']); + } + + public function testOrangeColor() + { + $command = new DevCommand('cmd', 'test'); + $command->orange(); + + $this->assertSame(DevCommandColor::ORANGE->value, $command->toArray()['color']); + } + + public function testGreenColor() + { + $command = new DevCommand('cmd', 'test'); + $command->green(); + + $this->assertSame(DevCommandColor::GREEN->value, $command->toArray()['color']); + } + + public function testYellowColor() + { + $command = new DevCommand('cmd', 'test'); + $command->yellow(); + + $this->assertSame(DevCommandColor::YELLOW->value, $command->toArray()['color']); + } + + public function testColorMethodsAreFluent() + { + $command = new DevCommand('cmd', 'test'); + + $this->assertSame($command, $command->blue()); + $this->assertSame($command, $command->purple()); + $this->assertSame($command, $command->pink()); + $this->assertSame($command, $command->orange()); + $this->assertSame($command, $command->green()); + $this->assertSame($command, $command->yellow()); + } +} diff --git a/tests/Foundation/FoundationDevCommandsTest.php b/tests/Foundation/FoundationDevCommandsTest.php new file mode 100644 index 000000000000..6fd10cdd96f4 --- /dev/null +++ b/tests/Foundation/FoundationDevCommandsTest.php @@ -0,0 +1,267 @@ +assertInstanceOf(DevCommand::class, $devCommand); + + $commands = DevCommands::commands(); + + $this->assertCount(1, $commands); + $this->assertSame('echo hello', $commands[0]['command']); + $this->assertSame('greeter', $commands[0]['name']); + } + + public function testRegisterDerivesNameFromCommand() + { + DevCommands::register('echo hello world'); + + $commands = DevCommands::commands(); + + $this->assertSame('echo', $commands[0]['name']); + } + + public function testArtisanPrefixesCommand() + { + DevCommands::artisan('serve --host=localhost', 'server'); + + $commands = DevCommands::commands(); + + $this->assertSame('php artisan serve --host=localhost', $commands[0]['command']); + $this->assertSame('server', $commands[0]['name']); + } + + public function testArtisanDerivesNameFromCommand() + { + DevCommands::artisan('queue:listen --tries=1'); + + $commands = DevCommands::commands(); + + $this->assertSame('queue:listen', $commands[0]['name']); + } + + public function testExceptExcludesCommands() + { + DevCommands::register('echo one', 'one'); + DevCommands::register('echo two', 'two'); + DevCommands::register('echo three', 'three'); + + DevCommands::except('two'); + + $commands = DevCommands::commands(); + + $this->assertCount(2, $commands); + $this->assertSame('one', $commands[0]['name']); + $this->assertSame('three', $commands[1]['name']); + } + + public function testOnlyIncludesOnlySpecifiedCommands() + { + DevCommands::register('echo one', 'one'); + DevCommands::register('echo two', 'two'); + DevCommands::register('echo three', 'three'); + + DevCommands::only('one', 'three'); + + $commands = DevCommands::commands(); + + $this->assertCount(2, $commands); + $this->assertSame('one', $commands[0]['name']); + $this->assertSame('three', $commands[1]['name']); + } + + public function testOnlyTakesPrecedenceOverExcept() + { + DevCommands::register('echo one', 'one'); + DevCommands::register('echo two', 'two'); + DevCommands::register('echo three', 'three'); + + DevCommands::only('one', 'two'); + DevCommands::except('two'); + + $commands = DevCommands::commands(); + + $this->assertCount(1, $commands); + $this->assertSame('one', $commands[0]['name']); + } + + public function testClearResetsState() + { + DevCommands::register('echo one', 'one'); + DevCommands::except('something'); + DevCommands::only('something'); + + DevCommands::clear(); + + $this->assertEmpty(DevCommands::commands()); + } + + public function testCommandsGetAutoAssignedColors() + { + DevCommands::register('echo one', 'one'); + DevCommands::register('echo two', 'two'); + + $commands = DevCommands::commands(); + + $this->assertNotNull($commands[0]['color']); + $this->assertNotNull($commands[1]['color']); + $this->assertNotSame($commands[0]['color'], $commands[1]['color']); + } + + public function testExplicitColorIsPreserved() + { + DevCommands::register('echo one', 'one')->pink(); + DevCommands::register('echo two', 'two'); + + $commands = DevCommands::commands(); + + $this->assertSame(DevCommandColor::PINK->value, $commands[0]['color']); + $this->assertNotSame(DevCommandColor::PINK->value, $commands[1]['color']); + } + + public function testAutoColorSkipsExplicitlyUsedColors() + { + DevCommands::register('echo one', 'one')->blue(); + DevCommands::register('echo two', 'two'); + + $commands = DevCommands::commands(); + + $this->assertSame(DevCommandColor::BLUE->value, $commands[0]['color']); + $this->assertNotSame(DevCommandColor::BLUE->value, $commands[1]['color']); + } + + public function testColorsRecycleWhenAllUsed() + { + DevCommands::register('cmd1', 'c1'); + DevCommands::register('cmd2', 'c2'); + DevCommands::register('cmd3', 'c3'); + DevCommands::register('cmd4', 'c4'); + DevCommands::register('cmd5', 'c5'); + DevCommands::register('cmd6', 'c6'); + DevCommands::register('cmd7', 'c7'); + + $commands = DevCommands::commands(); + + $this->assertCount(7, $commands); + + foreach ($commands as $command) { + $this->assertNotNull($command['color']); + } + } + + public function testRegisteringCommandWithSameNameOverwritesPrevious() + { + DevCommands::register('echo old', 'myname'); + DevCommands::register('echo new', 'myname'); + + $commands = DevCommands::commands(); + + $this->assertCount(1, $commands); + $this->assertSame('echo new', $commands[0]['command']); + } + + public function testRegisterDefaultsRegistersExpectedCommands() + { + DevCommands::registerDefaults(); + + $commands = DevCommands::commands(); + + $this->assertCount(4, $commands); + + $names = array_column($commands, 'name'); + $this->assertContains('server', $names); + $this->assertContains('queue', $names); + $this->assertContains('logs', $names); + $this->assertContains('vite', $names); + } + + public function testVendorRegistrationIsPrevented() + { + $basePath = realpath(__DIR__.'/../..'); + + $process = new PhpProcess(<<getMessage(); +} +EOF, $basePath); + + $process->run(); + + $this->assertStringContainsString('EXCEPTION:', $process->getOutput()); + $this->assertStringContainsString('DevCommands should be registered in application code', $process->getOutput()); + } + + public function testUserlandRegistrationIsAllowed() + { + $devCommand = DevCommands::register('echo hello', 'test-cmd'); + + $this->assertInstanceOf(DevCommand::class, $devCommand); + $this->assertSame('echo hello', DevCommands::commands()[0]['command']); + } + + public function testVendorHelperCalledFromUserlandIsAllowed() + { + $basePath = realpath(__DIR__.'/../..'); + $vendorHelper = $basePath.'/vendor/_test_helper_'.uniqid().'.php'; + + file_put_contents($vendorHelper, <<<'PHP' +assertCount(1, $commands); + $this->assertSame('echo from-vendor-helper', $commands[0]['command']); + $this->assertSame('vendor-helper', $commands[0]['name']); + } finally { + unlink($vendorHelper); + } + } +} diff --git a/tests/Support/SupportNodePackageManagerTest.php b/tests/Support/SupportNodePackageManagerTest.php new file mode 100644 index 000000000000..91843792d4f9 --- /dev/null +++ b/tests/Support/SupportNodePackageManagerTest.php @@ -0,0 +1,283 @@ +assertSame('npm run dev', $npm->getRunCommand('dev')); + } + + public function testNpmExecCommand() + { + $npm = new Npm; + + $this->assertSame('npx concurrently', $npm->getExecCommand('concurrently')); + } + + public function testNpmMatches() + { + $dir = sys_get_temp_dir().'/npm_test_'.uniqid(); + mkdir($dir); + touch($dir.'/package-lock.json'); + + $original = getcwd(); + chdir($dir); + + try { + $this->assertTrue(Npm::matches()); + } finally { + chdir($original); + unlink($dir.'/package-lock.json'); + rmdir($dir); + } + } + + public function testNpmDoesNotMatchWithoutLockFile() + { + $dir = sys_get_temp_dir().'/npm_test_'.uniqid(); + mkdir($dir); + + $original = getcwd(); + chdir($dir); + + try { + $this->assertFalse(Npm::matches()); + } finally { + chdir($original); + rmdir($dir); + } + } + + public function testYarnRunCommand() + { + $yarn = new Yarn; + + $this->assertSame('yarn run dev', $yarn->getRunCommand('dev')); + } + + public function testYarnExecCommand() + { + $yarn = new Yarn; + + $this->assertSame('yarn dlx concurrently', $yarn->getExecCommand('concurrently')); + } + + public function testYarnMatches() + { + $dir = sys_get_temp_dir().'/yarn_test_'.uniqid(); + mkdir($dir); + touch($dir.'/yarn.lock'); + + $original = getcwd(); + chdir($dir); + + try { + $this->assertTrue(Yarn::matches()); + } finally { + chdir($original); + unlink($dir.'/yarn.lock'); + rmdir($dir); + } + } + + public function testPnpmRunCommand() + { + $pnpm = new Pnpm; + + $this->assertSame('pnpm run dev', $pnpm->getRunCommand('dev')); + } + + public function testPnpmExecCommand() + { + $pnpm = new Pnpm; + + $this->assertSame('pnpm dlx concurrently', $pnpm->getExecCommand('concurrently')); + } + + public function testPnpmMatches() + { + $dir = sys_get_temp_dir().'/pnpm_test_'.uniqid(); + mkdir($dir); + touch($dir.'/pnpm-lock.yaml'); + + $original = getcwd(); + chdir($dir); + + try { + $this->assertTrue(Pnpm::matches()); + } finally { + chdir($original); + unlink($dir.'/pnpm-lock.yaml'); + rmdir($dir); + } + } + + public function testBunRunCommand() + { + $bun = new Bun; + + $this->assertSame('bun run dev', $bun->getRunCommand('dev')); + } + + public function testBunExecCommand() + { + $bun = new Bun; + + $this->assertSame('bunx concurrently', $bun->getExecCommand('concurrently')); + } + + public function testBunMatchesWithBunLock() + { + $dir = sys_get_temp_dir().'/bun_test_'.uniqid(); + mkdir($dir); + touch($dir.'/bun.lock'); + + $original = getcwd(); + chdir($dir); + + try { + $this->assertTrue(Bun::matches()); + } finally { + chdir($original); + unlink($dir.'/bun.lock'); + rmdir($dir); + } + } + + public function testBunMatchesWithBunLockb() + { + $dir = sys_get_temp_dir().'/bun_test_'.uniqid(); + mkdir($dir); + touch($dir.'/bun.lockb'); + + $original = getcwd(); + chdir($dir); + + try { + $this->assertTrue(Bun::matches()); + } finally { + chdir($original); + unlink($dir.'/bun.lockb'); + rmdir($dir); + } + } + + public function testManagerDelegatesToInjectedPackageManager() + { + $mock = new class implements NodePackageManagerContract + { + public static function matches(): bool + { + return true; + } + + public function getRunCommand(string $command): string + { + return "custom run {$command}"; + } + + public function getExecCommand(string $command): string + { + return "custom exec {$command}"; + } + }; + + $manager = new NodePackageManager($mock); + + $this->assertSame('custom run dev', $manager->getRunCommand('dev')); + $this->assertSame('custom exec vite', $manager->getExecCommand('vite')); + } + + public function testManagerDetectsPackageManagerWhenNoneInjected() + { + $dir = sys_get_temp_dir().'/detect_test_'.uniqid(); + mkdir($dir); + touch($dir.'/package-lock.json'); + + $original = getcwd(); + chdir($dir); + + try { + $manager = new NodePackageManager; + + $this->assertSame('npm run dev', $manager->getRunCommand('dev')); + $this->assertSame('npx vite', $manager->getExecCommand('vite')); + } finally { + chdir($original); + unlink($dir.'/package-lock.json'); + rmdir($dir); + } + } + + public function testDetectionPriorityBunOverNpm() + { + $dir = sys_get_temp_dir().'/priority_test_'.uniqid(); + mkdir($dir); + touch($dir.'/bun.lock'); + touch($dir.'/package-lock.json'); + + $original = getcwd(); + chdir($dir); + + try { + $manager = new NodePackageManager; + + $this->assertSame('bun run dev', $manager->getRunCommand('dev')); + } finally { + chdir($original); + unlink($dir.'/bun.lock'); + unlink($dir.'/package-lock.json'); + rmdir($dir); + } + } + + public function testDetectionFallsBackToNpm() + { + $dir = sys_get_temp_dir().'/fallback_test_'.uniqid(); + mkdir($dir); + + $original = getcwd(); + chdir($dir); + + try { + $manager = new NodePackageManager; + + $this->assertSame('npm run dev', $manager->getRunCommand('dev')); + } finally { + chdir($original); + rmdir($dir); + } + } + + public function testPackageManagerMethodReturnsDetectedManager() + { + $dir = sys_get_temp_dir().'/pm_method_test_'.uniqid(); + mkdir($dir); + touch($dir.'/yarn.lock'); + + $original = getcwd(); + chdir($dir); + + try { + $manager = new NodePackageManager; + + $this->assertInstanceOf(Yarn::class, $manager->packageManager()); + } finally { + chdir($original); + unlink($dir.'/yarn.lock'); + rmdir($dir); + } + } +}