diff --git a/README.md b/README.md index d15a30c..7ee9815 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,9 @@ Execute business logic with a single, expressive call. No more hunting through s ### 📬 **Dispatchable Actions** Seamlessly queue your actions for background processing. It's as easy as changing `run()` to `dispatch()`! +### 💡 **Smart Code Completion** +Full IntelliSense support with auto-completion for runnable and dispatchable actions across all major IDEs. + ### 🔄 **Smart Array Conversion** Convert between arrays and objects effortlessly with our powerful attribute system. Perfect for APIs! @@ -247,6 +250,9 @@ php artisan make:action CalculateShipping --invokable # DTO with array conversion php artisan make:dto OrderData + +# Enable Smart Code Completion +php artisan ide-helper:actions ``` ## 🌟 Real-World Examples diff --git a/src/ActionableProvider.php b/src/ActionableProvider.php index 2d30a8a..daed783 100644 --- a/src/ActionableProvider.php +++ b/src/ActionableProvider.php @@ -4,6 +4,7 @@ use Illuminate\Support\ServiceProvider; use LumoSolutions\Actionable\Console\BaseStubCommand; +use LumoSolutions\Actionable\Console\Commands\ActionsIdeHelperCommand; use LumoSolutions\Actionable\Console\Commands\MakeActionCommand; use LumoSolutions\Actionable\Console\Commands\MakeDtoCommand; @@ -17,6 +18,7 @@ public function boot(): void $this->commands([ MakeActionCommand::class, MakeDtoCommand::class, + ActionsIdeHelperCommand::class, ]); $this->publishes( diff --git a/src/Console/Commands/ActionsIdeHelperCommand.php b/src/Console/Commands/ActionsIdeHelperCommand.php new file mode 100644 index 0000000..c6c6291 --- /dev/null +++ b/src/Console/Commands/ActionsIdeHelperCommand.php @@ -0,0 +1,127 @@ +service = $service; + } + + public function handle(): int + { + $namespace = $this->option('namespace'); + $dryRun = $this->option('dry-run'); + + $this->info("Scanning for Action classes in namespace: {$namespace} "); + + $files = $this->getPhpFiles($namespace); + + if (empty($files)) { + $this->error("No PHP files found in namespace: {$namespace}"); + + return self::FAILURE; + } + + $processedCount = 0; + $skippedCount = 0; + $errorCount = 0; + + foreach ($files as $file) { + $relativePath = str_replace(base_path().DIRECTORY_SEPARATOR, '', $file->getRealPath()); + + try { + $result = $this->service->processFile($file->getPathname(), $dryRun); + + if ($result['processed']) { + $processedCount++; + + if ($dryRun) { + $this->line("Would update: {$relativePath}"); + $this->showDocBlockChanges($result['docBlocks']); + } else { + $this->info("Updated: {$relativePath}"); + } + } else { + $skippedCount++; + if ($this->output->isVerbose()) { + $this->line("Skipped: {$relativePath} - {$result['reason']}"); + } + } + } catch (Exception $e) { + $errorCount++; + $this->error("Error processing {$relativePath}: {$e->getMessage()}"); + + if ($this->output->isVeryVerbose()) { + $this->line($e->getTraceAsString()); + } + } + } + + $this->showSummary($processedCount, $skippedCount, $errorCount, $dryRun); + + return ($errorCount > 0) ? self::FAILURE : self::SUCCESS; + } + + private function getPhpFiles(string $namespace): array + { + $path = app_path(str_replace(['App\\', '\\'], ['', '/'], $namespace)); + + if (! is_dir($path)) { + return []; + } + + return collect(File::allFiles($path)) + ->filter(fn ($file) => $file->getExtension() === 'php') + ->values() + ->all(); + } + + private function showDocBlockChanges(array $docBlocks): void + { + if (empty($docBlocks)) { + return; + } + + $this->line(' Doc blocks to add:'); + foreach ($docBlocks as $docBlock) { + $this->line(" * {$docBlock}"); + } + } + + private function showSummary(int $processedCount, int $skippedCount, int $errorCount, bool $dryRun): void + { + $this->line(''); + $this->info('Summary:'); + + $action = $dryRun ? 'Would be updated' : 'Updated'; + $this->line(" {$action}: {$processedCount} files"); + + if ($skippedCount > 0 || $this->output->isVerbose()) { + $this->line(" Skipped: {$skippedCount} files"); + } + + if ($errorCount > 0) { + $this->line(" Errors: {$errorCount} files"); + } + + $this->line(''); + $status = $errorCount > 0 ? 'completed with errors' : 'completed successfully'; + $this->info("Process {$status}!"); + } +} diff --git a/src/Services/ActionDocBlockService.php b/src/Services/ActionDocBlockService.php new file mode 100644 index 0000000..acceadb --- /dev/null +++ b/src/Services/ActionDocBlockService.php @@ -0,0 +1,337 @@ +extractClassInfo($content, $filePath); + + if (! $classInfo) { + return [ + 'processed' => false, + 'reason' => 'Could not extract class information', + 'docBlocks' => [], + ]; + } + + $traitInfo = $this->analyzeTraits($classInfo['className']); + + if (! $traitInfo['hasTargetTraits']) { + return [ + 'processed' => false, + 'reason' => 'Class does not use IsRunnable or IsDispatchable traits', + 'docBlocks' => [], + ]; + } + + $methodInfo = $this->analyzeHandleMethod($classInfo['className'], $classInfo['useStatements']); + + if (! $methodInfo) { + return [ + 'processed' => false, + 'reason' => 'Class missing handle method', + 'docBlocks' => [], + ]; + } + + $docBlocks = $this->generateDocBlocks( + $traitInfo['hasRunnable'], + $traitInfo['hasDispatchable'], + $methodInfo['parameters'], + $methodInfo['returnType'] + ); + + $newContent = $this->updateClassDocBlock($content, $docBlocks); + + if ($newContent === $content) { + return [ + 'processed' => false, + 'reason' => 'Doc blocks already up to date', + 'docBlocks' => [], + ]; + } + + if (! $dryRun) { + File::put($filePath, $newContent); + } + + return [ + 'processed' => true, + 'reason' => 'Success', + 'docBlocks' => $docBlocks, + ]; + } + + private function ensureClassIsLoaded(string $className, string $filePath): void + { + if (! class_exists($className, false)) { + if (File::exists($filePath)) { + require_once $filePath; + } + } + } + + private function extractClassInfo(string $content, string $filePath): ?array + { + $namespace = null; + if (preg_match('/namespace\s+([^;]+);/', $content, $matches)) { + $namespace = trim($matches[1]); + } + + $className = null; + if (preg_match('/class\s+(\w+)/', $content, $matches)) { + $className = $matches[1]; + } + + if (! $namespace || ! $className) { + return null; + } + + $fullClassName = '\\'.$namespace.'\\'.$className; + + if (! class_exists($fullClassName)) { + $this->ensureClassIsLoaded($fullClassName, $filePath); + } + + $useStatements = $this->parseUseStatements($content); + + return [ + 'className' => $fullClassName, + 'useStatements' => $useStatements, + ]; + } + + private function parseUseStatements(string $content): array + { + $useStatements = []; + + $beforeClass = strstr($content, 'class ', true) ?: $content; + + if (preg_match_all('/use\s+([^;]+);/', $beforeClass, $matches)) { + foreach ($matches[1] as $useStatement) { + $useStatement = trim($useStatement); + + if (preg_match('/^(function|const)\s+/', $useStatement)) { + continue; + } + + if (preg_match('/^(.+?)\s+as\s+(\w+)$/', $useStatement, $aliasMatch)) { + $fullName = trim($aliasMatch[1]); + $alias = trim($aliasMatch[2]); + $useStatements[$alias] = $fullName; + } else { + $parts = explode('\\', $useStatement); + $className = end($parts); + $useStatements[$className] = $useStatement; + } + } + } + + return $useStatements; + } + + private function analyzeTraits(string $className): array + { + $reflection = new ReflectionClass($className); + $traits = $reflection->getTraitNames(); + + $hasRunnable = in_array($this->targetTraits[0], $traits); + $hasDispatchable = in_array($this->targetTraits[1], $traits); + + return [ + 'hasTargetTraits' => $hasRunnable || $hasDispatchable, + 'hasRunnable' => $hasRunnable, + 'hasDispatchable' => $hasDispatchable, + ]; + } + + private function analyzeHandleMethod(string $className, array $useStatements): ?array + { + try { + $reflection = new ReflectionClass($className); + + if (! $reflection->hasMethod('handle')) { + return null; + } + + $handleMethod = $reflection->getMethod('handle'); + + return [ + 'parameters' => $this->buildParameterString($handleMethod, $useStatements), + 'returnType' => $this->getReturnTypeString($handleMethod, $useStatements), + ]; + } catch (Exception) { + return null; + } + } + + private function buildParameterString(ReflectionMethod $method, array $useStatements): string + { + $parameters = []; + + foreach ($method->getParameters() as $param) { + $paramString = ''; + + // Add type hint + if ($param->hasType()) { + $type = $param->getType(); + if ($type instanceof ReflectionNamedType) { + $typeName = $type->getName(); + $resolvedType = $this->resolveType($typeName, $useStatements); + + if ($type->allowsNull() && $typeName !== 'mixed') { + $paramString .= '?'; + } + + $paramString .= $resolvedType.' '; + } + } + + $paramString .= '$'.$param->getName(); + + if ($param->isDefaultValueAvailable()) { + $paramString .= ' = '.$this->formatDefaultValue($param->getDefaultValue()); + } + + $parameters[] = $paramString; + } + + return implode(', ', $parameters); + } + + private function getReturnTypeString(ReflectionMethod $method, array $useStatements): string + { + if (! $method->hasReturnType()) { + return 'mixed'; + } + + $returnType = $method->getReturnType(); + + if ($returnType instanceof ReflectionNamedType) { + $typeName = $returnType->getName(); + $resolvedType = $this->resolveType($typeName, $useStatements); + + if ($returnType->allowsNull() && $typeName !== 'mixed') { + return '?'.$resolvedType; + } + + return $resolvedType; + } + + return 'mixed'; + } + + private function resolveType(string $typeName, array $useStatements): string + { + if (in_array($typeName, $this->builtInTypes)) { + return $typeName; + } + + $cleanTypeName = ltrim($typeName, '\\'); + + foreach ($useStatements as $shortName => $fullName) { + $cleanFullName = ltrim($fullName, '\\'); + if ($cleanFullName === $cleanTypeName) { + return $shortName; + } + } + + return '\\'.$typeName; + } + + private function formatDefaultValue($value): string + { + if (is_null($value)) { + return 'null'; + } + + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + + if (is_string($value)) { + return "'".addslashes($value)."'"; + } + + if (is_array($value)) { + return empty($value) ? '[]' : '[...]'; + } + + return (string) $value; + } + + private function generateDocBlocks(bool $hasRunnable, bool $hasDispatchable, string $parameters, string $returnType): array + { + $docBlocks = []; + + if ($hasRunnable) { + $docBlocks[] = "@method static {$returnType} run({$parameters})"; + } + + if ($hasDispatchable) { + $docBlocks[] = "@method static void dispatch({$parameters})"; + $docBlocks[] = '@method static void dispatchOn(string $queue'.($parameters ? ", {$parameters}" : '').')'; + } + + return $docBlocks; + } + + private function updateClassDocBlock(string $content, array $docBlocks): string + { + $newDocBlockContent = implode("\n * ", $docBlocks); + + $pattern = '/\/\*\*\s*\n(.*?)\*\/\s*\n((?:(?:abstract|final)\s+)?class\s+\w+)/s'; + + if (preg_match($pattern, $content, $matches)) { + $existingDocBlock = $matches[1]; + $classDeclaration = $matches[2]; + + $cleanedDocBlock = preg_replace( + '/\s*\*\s*@method\s+static\s+[^\s]+\s+(run|dispatch|dispatchOn)\([^)]*\)\s*\n/m', + '', + $existingDocBlock + ); + + $cleanedDocBlock = preg_replace('/(\n\s*\*\s*\n){2,}/', "\n *\n", $cleanedDocBlock); + $cleanedDocBlock = rtrim($cleanedDocBlock); + + $contentOnly = preg_replace('/[\s*\n\r\t]/', '', $cleanedDocBlock); + $hasContent = ! empty($contentOnly); + + if ($hasContent) { + $updatedDocBlock = $cleanedDocBlock."\n *\n * ".$newDocBlockContent."\n"; + } else { + $updatedDocBlock = "\n * ".$newDocBlockContent."\n"; + } + + return str_replace($matches[0], "/**{$updatedDocBlock} */\n{$classDeclaration}", $content); + } else { + $pattern = '/((?:(?:abstract|final)\s+)?class\s+\w+)/'; + $newDocBlock = "/**\n * {$newDocBlockContent}\n */\n$1"; + + return preg_replace($pattern, $newDocBlock, $content, 1); + } + } +} diff --git a/tests/Unit/Actions/DispatchableActionTest.php b/tests/Feature/Actions/DispatchableActionTest.php similarity index 100% rename from tests/Unit/Actions/DispatchableActionTest.php rename to tests/Feature/Actions/DispatchableActionTest.php diff --git a/tests/Unit/Actions/InvokableActionTest.php b/tests/Feature/Actions/InvokableActionTest.php similarity index 100% rename from tests/Unit/Actions/InvokableActionTest.php rename to tests/Feature/Actions/InvokableActionTest.php diff --git a/tests/Unit/Actions/RunnableActionTest.php b/tests/Feature/Actions/RunnableActionTest.php similarity index 100% rename from tests/Unit/Actions/RunnableActionTest.php rename to tests/Feature/Actions/RunnableActionTest.php diff --git a/tests/Unit/ArchTest.php b/tests/Feature/ArchTest.php similarity index 100% rename from tests/Unit/ArchTest.php rename to tests/Feature/ArchTest.php diff --git a/tests/Feature/Console/ActionIdeHelperCommandTest.php b/tests/Feature/Console/ActionIdeHelperCommandTest.php new file mode 100644 index 0000000..4244192 --- /dev/null +++ b/tests/Feature/Console/ActionIdeHelperCommandTest.php @@ -0,0 +1,262 @@ + 'Test1']); + + $this->artisan(ActionsIdeHelperCommand::class, [ + '--namespace' => 'App\\Actions', + '--dry-run' => true, + ]) + ->assertExitCode(0) + ->expectsOutputToContain('Scanning for Action classes in namespace: App\\Actions') + ->expectsOutputToContain(join_paths('app', 'Actions', 'Test1.php')); + }); + + it('identifies and documents run in action', function () { + Artisan::call(MakeActionCommand::class, ['name' => 'Test2']); + $actionPath = join_paths(app_path(), 'Actions', 'Test2.php'); + + $this->artisan(ActionsIdeHelperCommand::class, [ + '--namespace' => 'App\\Actions', + '--dry-run' => true, + ]) + ->assertExitCode(0) + ->expectsOutputToContain('@method static void run()') + ->doesntExpectOutputToContain('@method static void dispatch') + ->doesntExpectOutputToContain('@method static void dispatchOn'); + + $this->artisan(ActionsIdeHelperCommand::class, ['--namespace' => 'App\\Actions']) + ->assertExitCode(0); + + $fileContents = file_get_contents($actionPath); + expect($fileContents)->toContain('@method static void run()') + ->and($fileContents)->not->toContain('@method static void dispatch') + ->and($fileContents)->not->toContain('@method static void dispatchOn'); + }); + + it('identifies and documents dispatch in action', function () { + Artisan::call(MakeActionCommand::class, ['name' => 'Test3', '--dispatchable' => true]); + $actionPath = join_paths(app_path(), 'Actions', 'Test3.php'); + + File::replaceInFile('use IsRunnable, IsDispatchable;', 'use IsDispatchable;', $actionPath); + + $this->artisan(ActionsIdeHelperCommand::class, [ + '--namespace' => 'App\\Actions', + '--dry-run' => true, + ]) + ->assertExitCode(0) + ->doesntExpectOutputToContain('@method static void run()') + ->expectsOutputToContain('@method static void dispatch()') + ->expectsOutputToContain('@method static void dispatchOn(string $queue)'); + + $this->artisan(ActionsIdeHelperCommand::class, ['--namespace' => 'App\\Actions']) + ->assertExitCode(0); + + $fileContents = file_get_contents($actionPath); + expect($fileContents)->not->toContain('@method static void run()') + ->and($fileContents)->toContain('@method static void dispatch()') + ->and($fileContents)->toContain('@method static void dispatchOn(string $queue)'); + }); + + it('identifies and documents both run and dispatch together', function () { + Artisan::call(MakeActionCommand::class, ['name' => 'Test4', '--dispatchable' => true]); + $actionPath = join_paths(app_path(), 'Actions', 'Test4.php'); + + $this->artisan(ActionsIdeHelperCommand::class, [ + '--namespace' => 'App\\Actions', + '--dry-run' => true, + ]) + ->assertExitCode(0) + ->expectsOutputToContain('@method static void run()') + ->expectsOutputToContain('@method static void dispatch()') + ->expectsOutputToContain('@method static void dispatchOn(string $queue)'); + + $this->artisan(ActionsIdeHelperCommand::class, ['--namespace' => 'App\\Actions']) + ->assertExitCode(0); + + $fileContents = file_get_contents($actionPath); + expect($fileContents)->toContain('@method static void run()') + ->and($fileContents)->toContain('@method static void dispatch()') + ->and($fileContents)->toContain('@method static void dispatchOn(string $queue)'); + }); + + it('identifies and documents correct single parameters', function () { + Artisan::call(MakeActionCommand::class, ['name' => 'Test5', '--dispatchable' => true]); + $actionPath = join_paths(app_path(), 'Actions', 'Test5.php'); + + File::replaceInFile('public function handle(): void', 'public function handle(string $type): array', $actionPath); + + $this->artisan(ActionsIdeHelperCommand::class, [ + '--namespace' => 'App\\Actions', + '--dry-run' => true, + ]) + ->assertExitCode(0) + ->expectsOutputToContain('@method static array run(string $type)') + ->expectsOutputToContain('@method static void dispatch(string $type)') + ->expectsOutputToContain('@method static void dispatchOn(string $queue, string $type)'); + + $this->artisan(ActionsIdeHelperCommand::class, ['--namespace' => 'App\\Actions']) + ->assertExitCode(0); + + $fileContents = file_get_contents($actionPath); + expect($fileContents)->toContain('@method static array run(string $type)') + ->and($fileContents)->toContain('@method static void dispatch(string $type)') + ->and($fileContents)->toContain('@method static void dispatchOn(string $queue, string $type)'); + }); + + it('identifies and documents correct multiple parameters', function () { + Artisan::call(MakeActionCommand::class, ['name' => 'Test6', '--dispatchable' => true]); + $actionPath = join_paths(app_path(), 'Actions', 'Test6.php'); + + File::replaceInFile('public function handle(): void', 'public function handle(string $type, Test6 $action): array', $actionPath); + + $this->artisan(ActionsIdeHelperCommand::class, [ + '--namespace' => 'App\\Actions', + '--dry-run' => true, + ]) + ->assertExitCode(0) + ->expectsOutputToContain('@method static array run(string $type, \\App\\Actions\\Test6 $action)') + ->expectsOutputToContain('@method static void dispatch(string $type, \\App\\Actions\\Test6 $action)') + ->expectsOutputToContain('@method static void dispatchOn(string $queue, string $type, \\App\\Actions\\Test6 $action)'); + + $this->artisan(ActionsIdeHelperCommand::class, ['--namespace' => 'App\\Actions']) + ->assertExitCode(0); + + $fileContents = file_get_contents($actionPath); + expect($fileContents)->toContain('@method static array run(string $type, \\App\\Actions\\Test6 $action)') + ->and($fileContents)->toContain('@method static void dispatch(string $type, \\App\\Actions\\Test6 $action)') + ->and($fileContents)->toContain('@method static void dispatchOn(string $queue, string $type, \\App\\Actions\\Test6 $action)'); + }); + + it('correctly uses usings for short-class usage', function () { + Artisan::call(MakeActionCommand::class, ['name' => 'Test7', '--dispatchable' => true]); + Artisan::call(MakeActionCommand::class, ['name' => sprintf('DifferentDir%sTest8', DIRECTORY_SEPARATOR), '--dispatchable' => true]); + Artisan::call(MakeActionCommand::class, ['name' => sprintf('AnotherFolder%sTest9', DIRECTORY_SEPARATOR), '--dispatchable' => true]); + $actionPath = join_paths(app_path(), 'Actions', 'Test7.php'); + + File::replaceInFile( + 'use LumoSolutions\\Actionable\\Traits\\IsDispatchable;', + implode(PHP_EOL, [ + 'use LumoSolutions\\Actionable\\Traits\\IsDispatchable;', + 'use App\\Actions\\Testing\\Test8;', + 'use App\\Actions\\AnotherFolder\\Test9 as DoesWork;', + ]), + $actionPath + ); + File::replaceInFile( + 'public function handle(): void', + 'public function handle(Test8 $action, DoesWork $test): void', + $actionPath + ); + + $this->artisan(ActionsIdeHelperCommand::class, [ + '--namespace' => 'App\\Actions', + '--dry-run' => true, + ]) + ->assertExitCode(0) + ->expectsOutputToContain('@method static void run(Test8 $action, DoesWork $test)') + ->expectsOutputToContain('@method static void dispatch(Test8 $action, DoesWork $test)') + ->expectsOutputToContain('@method static void dispatchOn(string $queue, Test8 $action, DoesWork $test)'); + + $this->artisan(ActionsIdeHelperCommand::class, ['--namespace' => 'App\\Actions']) + ->assertExitCode(0); + + $fileContents = file_get_contents($actionPath); + expect($fileContents)->toContain('@method static void run(Test8 $action, DoesWork $test)') + ->and($fileContents)->toContain('@method static void dispatch(Test8 $action, DoesWork $test)') + ->and($fileContents)->toContain('@method static void dispatchOn(string $queue, Test8 $action, DoesWork $test)'); + }); + + it('correctly handles default values', function () { + Artisan::call(MakeActionCommand::class, ['name' => 'Test10', '--dispatchable' => true]); + $actionPath = join_paths(app_path(), 'Actions', 'Test10.php'); + + File::replaceInFile( + 'public function handle(): void', + "public function handle(string \$default = 'test', string \$can_null = null, array \$arr = []): void", + $actionPath + ); + + $this->artisan(ActionsIdeHelperCommand::class, [ + '--namespace' => 'App\\Actions', + '--dry-run' => true, + ]) + ->assertExitCode(0) + ->expectsOutputToContain('@method static void run(string $default = \'test\', ?string $can_null = null, array $arr = [])') + ->expectsOutputToContain('@method static void dispatch(string $default = \'test\', ?string $can_null = null, array $arr = [])') + ->expectsOutputToContain('@method static void dispatchOn(string $queue, string $default = \'test\', ?string $can_null = null, array $arr = [])'); + }); + + it('correctly updates existing docblocks', function () { + Artisan::call(MakeActionCommand::class, ['name' => 'Test11', '--dispatchable' => true]); + $actionPath = join_paths(app_path(), 'Actions', 'Test11.php'); + + File::replaceInFile( + 'class Test11', + implode(PHP_EOL, [ + '/**', + ' * @method static void leave_this_one(string $valid)', + ' * @method static void run(string $invalid)', + ' * @method static void dispatch(string $invalid)', + ' * @method static void dispatchOn(string $queue, string $invalid)', + ' */', + 'class Test11', + ]), + $actionPath + ); + + $this->artisan(ActionsIdeHelperCommand::class, ['--namespace' => 'App\\Actions']) + ->assertExitCode(0); + + $fileContents = file_get_contents($actionPath); + expect($fileContents)->toContain('@method static void leave_this_one(string $valid)') + ->and($fileContents)->not->toContain('@method static void run(string $invalid)') + ->and($fileContents)->toContain('@method static void run()') + ->and($fileContents)->not->toContain('@method static void dispatch(string $invalid)') + ->and($fileContents)->toContain('@method static void dispatch()') + ->and($fileContents)->not->toContain('@method static void dispatchOn(string $queue, string $invalid)') + ->and($fileContents)->toContain('@method static void dispatchOn(string $queue)'); + }); + + it('correctly ignores non-action files', function () { + Artisan::call(MakeActionCommand::class, ['name' => 'Test12']); + $actionPath = join_paths(app_path(), 'Actions', 'Test12.php'); + + File::replaceInFile('use IsRunnable;', '', $actionPath); + + $this->artisan(ActionsIdeHelperCommand::class, ['--namespace' => 'App\\Actions']) + ->assertExitCode(0); + + $fileContents = file_get_contents($actionPath); + expect($fileContents)->not->toContain('@method static void run') + ->and($fileContents)->not->toContain('@method static void dispatch') + ->and($fileContents)->not->toContain('@method static void dispatchOn'); + }); + + it('handles no files', function () { + $this->artisan(ActionsIdeHelperCommand::class, ['--namespace' => 'App\\Actions\\DoesNotExist']) + ->assertExitCode(1); + }); + + it('handles action without handle method', function () { + Artisan::call(MakeActionCommand::class, ['name' => 'Test13']); + $actionPath = join_paths(app_path(), 'Actions', 'Test13.php'); + + File::replaceInFile('public function handle(): void', 'public function something_else(): void', $actionPath); + + $this->artisan(ActionsIdeHelperCommand::class, ['--namespace' => 'App\\Actions']) + ->assertExitCode(0); + + $fileContents = file_get_contents($actionPath); + expect($fileContents)->not->toContain('@method static void run'); + }); + + }); +}); diff --git a/tests/Unit/Console/Configuration/stubs/action.dispatchable.stub b/tests/Feature/Console/Configuration/stubs/action.dispatchable.stub similarity index 100% rename from tests/Unit/Console/Configuration/stubs/action.dispatchable.stub rename to tests/Feature/Console/Configuration/stubs/action.dispatchable.stub diff --git a/tests/Unit/Console/Configuration/stubs/action.invokable.stub b/tests/Feature/Console/Configuration/stubs/action.invokable.stub similarity index 100% rename from tests/Unit/Console/Configuration/stubs/action.invokable.stub rename to tests/Feature/Console/Configuration/stubs/action.invokable.stub diff --git a/tests/Unit/Console/Configuration/stubs/action.stub b/tests/Feature/Console/Configuration/stubs/action.stub similarity index 100% rename from tests/Unit/Console/Configuration/stubs/action.stub rename to tests/Feature/Console/Configuration/stubs/action.stub diff --git a/tests/Unit/Console/Configuration/stubs/dto.stub b/tests/Feature/Console/Configuration/stubs/dto.stub similarity index 100% rename from tests/Unit/Console/Configuration/stubs/dto.stub rename to tests/Feature/Console/Configuration/stubs/dto.stub diff --git a/tests/Unit/Console/MakeActionCommandTest.php b/tests/Feature/Console/MakeActionCommandTest.php similarity index 100% rename from tests/Unit/Console/MakeActionCommandTest.php rename to tests/Feature/Console/MakeActionCommandTest.php diff --git a/tests/Unit/Console/MakeDtoCommandTest.php b/tests/Feature/Console/MakeDtoCommandTest.php similarity index 100% rename from tests/Unit/Console/MakeDtoCommandTest.php rename to tests/Feature/Console/MakeDtoCommandTest.php diff --git a/tests/Unit/Dtos/ArrayOfAttributeTest.php b/tests/Feature/Dtos/ArrayOfAttributeTest.php similarity index 100% rename from tests/Unit/Dtos/ArrayOfAttributeTest.php rename to tests/Feature/Dtos/ArrayOfAttributeTest.php diff --git a/tests/Unit/Dtos/DateFormatAttributeTest.php b/tests/Feature/Dtos/DateFormatAttributeTest.php similarity index 100% rename from tests/Unit/Dtos/DateFormatAttributeTest.php rename to tests/Feature/Dtos/DateFormatAttributeTest.php diff --git a/tests/Unit/Dtos/FieldNameAttributeTest.php b/tests/Feature/Dtos/FieldNameAttributeTest.php similarity index 100% rename from tests/Unit/Dtos/FieldNameAttributeTest.php rename to tests/Feature/Dtos/FieldNameAttributeTest.php diff --git a/tests/Unit/Dtos/IgnoreAttributeTest.php b/tests/Feature/Dtos/IgnoreAttributeTest.php similarity index 100% rename from tests/Unit/Dtos/IgnoreAttributeTest.php rename to tests/Feature/Dtos/IgnoreAttributeTest.php diff --git a/tests/Unit/Dtos/NestedClassTest.php b/tests/Feature/Dtos/NestedClassTest.php similarity index 100% rename from tests/Unit/Dtos/NestedClassTest.php rename to tests/Feature/Dtos/NestedClassTest.php diff --git a/tests/Pest.php b/tests/Pest.php index df2ced7..db984e7 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,7 +1,5 @@ in(__DIR__ . "/Unit/Console"); uses(TestCase::class)->in(__DIR__); diff --git a/tests/TestCase.php b/tests/TestCase.php index 15b51ad..f16222a 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -17,19 +17,21 @@ protected function setUp(): void protected function tearDown(): void { - // Clean up the app/Actions directory if it exists - if (is_dir(app_path('Actions'))) { - File::deleteDirectory(app_path('Actions')); - } - - // Clean up the app/DTOs directory if it exists - if (is_dir(app_path('Dtos'))) { - File::deleteDirectory(app_path('Dtos')); - } + $directories = [ + app_path('Actions'), + app_path('Dtos'), + join_paths(base_path(), 'stubs', 'lumosolutions', 'actionable'), + ]; - // Clean up the stubs directory if it exists - if (is_dir(base_path('stubs/lumosolutions/actionable'))) { - File::deleteDirectory(base_path('stubs/lumosolutions/actionable')); + foreach ($directories as $dir) { + if (is_dir($dir)) { + File::deleteDirectory($dir); + clearstatcache(); + if (is_dir($dir)) { + dump("Failed to delete directory: {$dir}"); + throw new \RuntimeException("Failed to delete directory: {$dir}"); + } + } } parent::tearDown(); @@ -37,7 +39,7 @@ protected function tearDown(): void protected function copyStubs(): void { - $source = join_paths(__DIR__, 'Unit', 'Console', 'Configuration', 'stubs'); + $source = join_paths(__DIR__, 'Feature', 'Console', 'Configuration', 'stubs'); $destination = join_paths(base_path(), 'stubs', 'lumosolutions', 'actionable'); if (! is_dir($destination)) { @@ -45,6 +47,11 @@ protected function copyStubs(): void } File::copyDirectory($source, $destination); + + if (! File::exists(join_paths($destination, 'action.stub'))) { + dump("Failed to copy stubs to {$destination}"); + throw new \RuntimeException("Failed to copy stubs to {$destination}"); + } } protected function getPackageProviders($app): array