diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index c15c842..2129503 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -1,26 +1,32 @@ -name: Tests +name: Run tests -on: - push: - paths: - - '**.php' - - '.github/workflows/run-tests.yml' - - 'phpunit.xml.dist' - - 'composer.json' - - 'composer.lock' +on: [push, pull_request] jobs: - test: + php-tests: runs-on: ${{ matrix.os }} - timeout-minutes: 5 + strategy: - fail-fast: true + fail-fast: false matrix: - os: [ubuntu-latest, windows-latest] - php: [8.3, 8.2, 8.1] + php: [8.0, 8.1, 8.2, 8.3] + laravel: [9.*, 10.*, 11.*] + livewire: [2.11, 3.3.5] stability: [prefer-lowest, prefer-stable] + os: [ubuntu-latest, windows-latest] + exclude: + - php: 8.0 + laravel: 10.* + - php: 8.0 + laravel: 11.* + - php: 8.1 + laravel: 11.* + - laravel: 9.* + livewire: 3.3.5 + - laravel: 11.* + livewire: 2.11 - name: P${{ matrix.php }} - ${{ matrix.stability }} - ${{ matrix.os }} + name: P${{ matrix.php }} - L${{ matrix.laravel }} - LW${{ matrix.livewire }} -${{ matrix.stability }} - ${{ matrix.os }} steps: - name: Checkout code @@ -30,19 +36,29 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo + extensions: mbstring, fileinfo, pdo_sqlite coverage: none + tools: composer:v2 - - name: Setup problem matchers - run: | - echo "::add-matcher::${{ runner.tool_cache }}/php.json" - echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + - name: Get composer cache directory + id: composer-cache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + + - name: Cache composer dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ matrix.os }}-php-${{ matrix.php }}-laravel-${{ matrix.laravel }}-composer-${{ matrix.stability }}-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ matrix.os }}-php-${{ matrix.php }}-laravel-${{ matrix.laravel }}-composer-${{ matrix.stability }}- - - name: Install dependencies - run: composer update --${{ matrix.stability }} --prefer-dist --no-interaction + - name: Install dependencies + run: | + composer require "laravel/framework:${{ matrix.laravel }}" "nesbot/carbon:^2.72|^3.0" --no-interaction --no-update + composer update --with="livewire/livewire:^${{ matrix.livewire }}" --${{ matrix.stability }} --prefer-dist --no-interaction --no-suggest - - name: List Installed Dependencies - run: composer show -D + - name: Install extra package + if: matrix.php != '8.0' + run: composer require openai-php/client - name: Execute tests - run: vendor/bin/pest --ci + run: vendor/bin/pest diff --git a/README.md b/README.md index 0d2db62..d650453 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Tests](https://img.shields.io/github/actions/workflow/status/spatie/error-solutions/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/spatie/error-solutions/actions/workflows/run-tests.yml) [![Total Downloads](https://img.shields.io/packagist/dt/spatie/error-solutions.svg?style=flat-square)](https://packagist.org/packages/spatie/error-solutions) -This is where your description should go. Try and limit it to a paragraph or two. Consider adding a small example. +At Spatie we develop multiple packages handling errors and providing solutions for these errors. This package is a collection of all these solutions. ## Support us @@ -24,10 +24,10 @@ composer require spatie/error-solutions ## Usage -```php -$skeleton = new Spatie\ErrorSolutions(); -echo $skeleton->echoPhrase('Hello, Spatie!'); -``` +We've got some excellent documentation on how to use solutions: + +- [Flare](https://flareapp.io/docs/ignition/solutions/implementing-solutions) +- [Ignition](https://github.com/spatie/ignition/?tab=readme-ov-file#displaying-solutions) ## Testing diff --git a/composer.json b/composer.json index fdf3026..a323307 100644 --- a/composer.json +++ b/composer.json @@ -19,8 +19,8 @@ }, "require-dev" : { "livewire/livewire": "^2.11|^3.3.5", - "illuminate/support": "^10.0|^11.0", - "illuminate/broadcasting" : "^10.0|^11.0", + "illuminate/support": "^9.0|^10.0|^11.0", + "illuminate/broadcasting" : "^9.0|^10.0|^11.0", "openai-php/client": "^0.10.1", "illuminate/cache" : "^9.52|^10.0|^11.0", "laravel/pint" : "^1.0", @@ -30,7 +30,8 @@ "spatie/ray" : "^1.28", "symfony/cache" : "^5.4|^6.0|^7.0", "symfony/process" : "^5.4|^6.0|^7.0", - "vlucas/phpdotenv" : "^5.5" + "vlucas/phpdotenv" : "^5.5", + "orchestra/testbench": "^7.0|8.22.3|^9.0" }, "autoload" : { "psr-4" : { diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 0ed2627..3f3c630 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -49,3 +49,28 @@ parameters: message: "#^Method Spatie\\\\ErrorSolutions\\\\Support\\\\Renderer\\:\\:renderAsString\\(\\) should return string but returns string\\|false\\.$#" count: 1 path: src/Support/Renderer.php + + - + message: "#^Parameter \\#1 \\$missingView of method Spatie\\\\ErrorSolutions\\\\Laravel\\\\Solutions\\\\SolutionProviders\\\\ViewNotFoundSolutionProvider\\:\\:findRelatedView\\(\\) expects string, string\\|null given\\.$#" + count: 1 + path: src/Laravel/Solutions/SolutionProviders/ViewNotFoundSolutionProvider.php + + - + message: "#^Method Spatie\\\\ErrorSolutions\\\\Laravel\\\\Solutions\\\\SolutionProviders\\\\UnknownValidationSolutionProvider\\:\\:getAvailableMethods\\(\\) return type with generic class Illuminate\\\\Support\\\\Collection does not specify its types\\: TKey, TValue$#" + count: 1 + path: src/Laravel/Solutions/SolutionProviders/UnknownValidationSolutionProvider.php + + - + message: "#^Parameter \\#1 \\$callback of method Illuminate\\\\Support\\\\Collection\\\\:\\:filter\\(\\) expects \\(callable\\(ReflectionMethod, int\\)\\: bool\\)\\|null, Closure\\(ReflectionMethod\\)\\: \\(0\\|1\\|false\\) given\\.$#" + count: 1 + path: src/Laravel/Solutions/SolutionProviders/UnknownValidationSolutionProvider.php + + - + message: "#^Unable to resolve the template type TMakeKey in call to method static method Illuminate\\\\Support\\\\Collection\\<\\(int\\|string\\),mixed\\>\\:\\:make\\(\\)$#" + count: 1 + path: src/Laravel/Solutions/SolutionProviders/UnknownValidationSolutionProvider.php + + - + message: "#^Unable to resolve the template type TMakeValue in call to method static method Illuminate\\\\Support\\\\Collection\\<\\(int\\|string\\),mixed\\>\\:\\:make\\(\\)$#" + count: 1 + path: src/Laravel/Solutions/SolutionProviders/UnknownValidationSolutionProvider.php diff --git a/src/Laravel/Solutions/SolutionProviders/SolutionProviderRepository.php b/src/Laravel/Solutions/SolutionProviders/SolutionProviderRepository.php index b53dc56..fc4b166 100644 --- a/src/Laravel/Solutions/SolutionProviders/SolutionProviderRepository.php +++ b/src/Laravel/Solutions/SolutionProviders/SolutionProviderRepository.php @@ -12,7 +12,7 @@ class SolutionProviderRepository implements SolutionProviderRepositoryContract { /** - * @param array $solutionProviders + * @var Collection $solutionProviders */ protected Collection $solutionProviders; @@ -50,7 +50,6 @@ public function getSolutionsForThrowable(Throwable $throwable): array $solutions[] = $throwable->getSolution(); } - /** @phpstan-ignore-next-line */ $providedSolutions = $this->solutionProviders ->filter(function (string $solutionClass) { if (! in_array(HasSolutionsForThrowable::class, class_implements($solutionClass) ?: [])) { diff --git a/src/Laravel/Support/LivewireComponentParser.php b/src/Laravel/Support/LivewireComponentParser.php index 0418d97..942b64c 100644 --- a/src/Laravel/Support/LivewireComponentParser.php +++ b/src/Laravel/Support/LivewireComponentParser.php @@ -8,7 +8,6 @@ use ReflectionClass; use ReflectionMethod; use ReflectionProperty; -use function Spatie\LaravelIgnition\Support\app; class LivewireComponentParser { diff --git a/tests/Laravel/ExceptionSolutionTest.php b/tests/Laravel/ExceptionSolutionTest.php new file mode 100644 index 0000000..41a262d --- /dev/null +++ b/tests/Laravel/ExceptionSolutionTest.php @@ -0,0 +1,80 @@ +registerSolutionProvider(AlwaysTrueSolutionProvider::class); + $repository->registerSolutionProvider(AlwaysFalseSolutionProvider::class); + + $solutions = $repository->getSolutionsForThrowable(new Exception()); + + $this->assertNotNull($solutions); + expect($solutions)->toHaveCount(1); + expect($solutions[0] instanceof BaseSolution)->toBeTrue(); +}); + +it('returns possible solutions when registered together', function () { + $repository = new SolutionProviderRepository(); + + $repository->registerSolutionProviders([ + AlwaysTrueSolutionProvider::class, + AlwaysFalseSolutionProvider::class, + ]); + + $solutions = $repository->getSolutionsForThrowable(new Exception()); + + $this->assertNotNull($solutions); + expect($solutions)->toHaveCount(1); + expect($solutions[0] instanceof BaseSolution)->toBeTrue(); +}); + +it('can suggest bad method call exceptions', function () { + if (version_compare(app()->version(), '5.6.3', '<')) { + $this->markTestSkipped('Laravel version < 5.6.3 do not support bad method call solutions'); + } + + try { + collect([])->faltten(); + } catch (Exception $exception) { + $solution = new BadMethodCallSolutionProvider(); + + expect($solution->canSolve($exception))->toBeTrue(); + } +}); + +it('can propose a solution for bad method call exceptions on collections', function () { + try { + collect([])->frist(fn ($item) => null); + } catch (Exception $exception) { + $solution = new BadMethodCallSolutionProvider(); + + expect($solution->getSolutions($exception)[0]->getSolutionDescription())->toBe('Did you mean Illuminate\Support\Collection::first() ?'); + } +}); + +it('can propose a solution for bad method call exceptions on models', function () { + try { + $user = new User(); + $user->sarve(); + } catch (Exception $exception) { + $solution = new BadMethodCallSolutionProvider(); + + expect($solution->getSolutions($exception)[0]->getSolutionDescription())->toBe('Did you mean Illuminate\Foundation\Auth\User::save() ?'); + } +}); + +it('can propose a solution for missing app key exceptions', function () { + $exception = new RuntimeException('No application encryption key has been specified.'); + + $solution = new MissingAppKeySolutionProvider(); + + expect($solution->getSolutions($exception)[0]->getSolutionActionDescription())->toBe('Generate your application encryption key using `php artisan key:generate`.'); +}); diff --git a/tests/Laravel/Exceptions/AlwaysFalseSolutionProvider.php b/tests/Laravel/Exceptions/AlwaysFalseSolutionProvider.php new file mode 100644 index 0000000..1559895 --- /dev/null +++ b/tests/Laravel/Exceptions/AlwaysFalseSolutionProvider.php @@ -0,0 +1,20 @@ +bind( + ComposerClassMap::class, + function () { + return new ComposerClassMap(__DIR__.'/../../../vendor/autoload.php'); + } + ); +}); + +it('can solve the exception', function () { + $canSolve = app(InvalidRouteActionSolutionProvider::class)->canSolve(getInvalidRouteActionException()); + + expect($canSolve)->toBeTrue(); +}); + +it('can recommend changing the routes method', function () { + Route::get('/test', TestTypoController::class); + + /** @var \Spatie\Ignition\Contracts\Solution $solution */ + $solution = app(InvalidRouteActionSolutionProvider::class)->getSolutions(getInvalidRouteActionException())[0]; + + expect(Str::contains($solution->getSolutionDescription(), 'Did you mean `TestTypoController`'))->toBeTrue(); +}); + +it('wont recommend another controller class if the names are too different', function () { + Route::get('/test', TestTypoController::class); + + $invalidController = 'UnrelatedTestTypoController'; + + /** @var \Spatie\Ignition\Contracts\Solution $solution */ + $solution = app(InvalidRouteActionSolutionProvider::class)->getSolutions(getInvalidRouteActionException($invalidController))[0]; + + expect(Str::contains($solution->getSolutionDescription(), 'Did you mean `TestTypoController`'))->toBeFalse(); +}); + +// Helpers +function getInvalidRouteActionException(string $controller = 'TestTypooController'): UnexpectedValueException +{ + return new UnexpectedValueException("Invalid route action: [{$controller}]"); +} diff --git a/tests/Laravel/Solutions/LazyLoadingViolationSolutionProviderTest.php b/tests/Laravel/Solutions/LazyLoadingViolationSolutionProviderTest.php new file mode 100644 index 0000000..ec4af20 --- /dev/null +++ b/tests/Laravel/Solutions/LazyLoadingViolationSolutionProviderTest.php @@ -0,0 +1,26 @@ +canSolve(new LazyLoadingViolationException(new User(), 'posts')); + + expect($canSolve)->toBeTrue(); + + $canSolve = app(LazyLoadingViolationSolutionProvider::class) + ->canSolve(new Exception('generic exception')); + + expect($canSolve)->toBeFalse(); +}); + +// Helpers +function it_can_provide_the_solution_for_lazy_loading_exceptions() +{ + $solutions = app(LazyLoadingViolationSolutionProvider::class) + ->getSolutions(new LazyLoadingViolationException(new User(), 'posts')); + + expect($solutions)->toHaveCount(1); +} diff --git a/tests/Laravel/Solutions/MakeViewVariableOptionalSolutionTest.php b/tests/Laravel/Solutions/MakeViewVariableOptionalSolutionTest.php new file mode 100644 index 0000000..1be584a --- /dev/null +++ b/tests/Laravel/Solutions/MakeViewVariableOptionalSolutionTest.php @@ -0,0 +1,37 @@ +bind( + ComposerClassMap::class, + function () { + return new ComposerClassMap(__DIR__.'/../../../vendor/autoload.php'); + } + ); +}); + +it('does not open scheme paths', function () { + $solution = getSolutionForPath('php://filter/resource=./tests/Laravel/stubs/views/blade-exception.blade.php'); + expect($solution->isRunnable())->toBeFalse(); +}); + +it('does open relative paths', function () { + $solution = getSolutionForPath('./tests/Laravel/stubs/views/blade-exception.blade.php'); + expect($solution->isRunnable())->toBeTrue(); +}); + +it('does not open other extensions', function () { + $solution = getSolutionForPath('./tests/Laravel/stubs/views/php-exception.php'); + expect($solution->isRunnable())->toBeFalse(); +}); + +// Helpers +function getSolutionForPath($path): MakeViewVariableOptionalSolution +{ + return new MakeViewVariableOptionalSolution('notSet', $path); +} diff --git a/tests/Laravel/Solutions/MergeConflictSolutionProviderTest.php b/tests/Laravel/Solutions/MergeConflictSolutionProviderTest.php new file mode 100644 index 0000000..8cd73bb --- /dev/null +++ b/tests/Laravel/Solutions/MergeConflictSolutionProviderTest.php @@ -0,0 +1,20 @@ +canSolve($exception); + + expect($canSolve)->toBeTrue(); +}); diff --git a/tests/Laravel/Solutions/MixManifestNotFoundSolutionProviderTest.php b/tests/Laravel/Solutions/MixManifestNotFoundSolutionProviderTest.php new file mode 100644 index 0000000..e1edc92 --- /dev/null +++ b/tests/Laravel/Solutions/MixManifestNotFoundSolutionProviderTest.php @@ -0,0 +1,20 @@ +canSolve(new Exception('Mix manifest not found.')); + + expect($canSolve)->toBeTrue(); +}); + +it('can recommend running npm install and npm run dev', function () { + /** @var \Spatie\Ignition\Contracts\Solution $solution */ + $solution = app(MissingMixManifestSolutionProvider::class) + ->getSolutions(new Exception('Mix manifest not found.'))[0]; + + expect(Str::contains($solution->getSolutionDescription(), 'Did you forget to run `npm install && npm run dev`?'))->toBeTrue(); +}); diff --git a/tests/Laravel/Solutions/RouteNotDefinedSolutionProviderTest.php b/tests/Laravel/Solutions/RouteNotDefinedSolutionProviderTest.php new file mode 100644 index 0000000..c180c1b --- /dev/null +++ b/tests/Laravel/Solutions/RouteNotDefinedSolutionProviderTest.php @@ -0,0 +1,36 @@ +canSolve(getRouteNotDefinedException()); + + expect($canSolve)->toBeTrue(); +}); + +it('can recommend changing the route name', function () { + Route::get('/test', 'TestController@typo')->name('test.typo'); + + /** @var \Spatie\Ignition\Contracts\Solution $solution */ + $solution = app(RouteNotDefinedSolutionProvider::class)->getSolutions(getRouteNotDefinedException())[0]; + + expect(Str::contains($solution->getSolutionDescription(), 'Did you mean `test.typo`?'))->toBeTrue(); +}); + +it('wont recommend another route if the names are too different', function () { + Route::get('/test', 'TestController@typo')->name('test.typo'); + + /** @var \Spatie\Ignition\Contracts\Solution $solution */ + $solution = app(RouteNotDefinedSolutionProvider::class)->getSolutions(getRouteNotDefinedException('test.is-too-different'))[0]; + + expect(Str::contains($solution->getSolutionDescription(), 'Did you mean'))->toBeFalse(); +}); + +// Helpers +function getRouteNotDefinedException(string $route = 'test.typoo'): RouteNotFoundException +{ + return new RouteNotFoundException("Route [{$route}] not defined."); +} diff --git a/tests/Laravel/Solutions/RunningLaravelDuskInProductionSolutionProviderTest.php b/tests/Laravel/Solutions/RunningLaravelDuskInProductionSolutionProviderTest.php new file mode 100644 index 0000000..acf0196 --- /dev/null +++ b/tests/Laravel/Solutions/RunningLaravelDuskInProductionSolutionProviderTest.php @@ -0,0 +1,22 @@ +canSolve($exception); + [$first_solution, $second_solution] = app(RunningLaravelDuskInProductionProvider::class)->getSolutions($exception); + + expect($canSolve)->toBeTrue(); + expect('Laravel Dusk should not be run in production.')->toBe($first_solution->getSolutionTitle()); + expect('Install the dependencies with the `--no-dev` flag.')->toBe($first_solution->getSolutionDescription()); + + expect('Laravel Dusk can be run in other environments.')->toBe($second_solution->getSolutionTitle()); + expect('Consider setting the `APP_ENV` to something other than `production` like `local` for example.')->toBe($second_solution->getSolutionDescription()); +}); + +// Helpers +function generate_dusk_exception(): Exception +{ + return new Exception('It is unsafe to run Dusk in production.'); +} diff --git a/tests/Laravel/Solutions/SolutionProviders/OpenAiSolutionProviderTest.php b/tests/Laravel/Solutions/SolutionProviders/OpenAiSolutionProviderTest.php new file mode 100644 index 0000000..77d1930 --- /dev/null +++ b/tests/Laravel/Solutions/SolutionProviders/OpenAiSolutionProviderTest.php @@ -0,0 +1,23 @@ +markTestSkipped('Cannot run AI test'); + + return; + } + + config()->set('ignition.open_ai_key', env('OPEN_API_KEY')); + + $solutionProvider = new OpenAiSolutionProvider(); + + $exception = new Exception('T_PAAMAYIM_NEKUDOTAYIM expected'); + + $solutions = $solutionProvider->getSolutions($exception); + + $solution = $solutions[0]; + + expect($solution->getSolutionDescription())->toBeString(); +}); diff --git a/tests/Laravel/Solutions/SolutionProviders/UndefinedLivewireMethodSolutionProviderTest.php b/tests/Laravel/Solutions/SolutionProviders/UndefinedLivewireMethodSolutionProviderTest.php new file mode 100644 index 0000000..1c01e30 --- /dev/null +++ b/tests/Laravel/Solutions/SolutionProviders/UndefinedLivewireMethodSolutionProviderTest.php @@ -0,0 +1,20 @@ +addAlias('test-livewire-component', TestLivewireComponent::class); + + $exception = new MethodNotFoundException('chnge', 'test-livewire-component'); + + $canSolve = app(UndefinedLivewireMethodSolutionProvider::class)->canSolve($exception); + [$solution] = app(UndefinedLivewireMethodSolutionProvider::class)->getSolutions($exception); + + expect($canSolve)->toBeTrue(); + + expect($solution->getSolutionTitle())->toBe('Possible typo `Spatie\LaravelIgnition\Tests\stubs\Components\TestLivewireComponent::chnge`'); + expect($solution->getSolutionDescription())->toBe('Did you mean `Spatie\LaravelIgnition\Tests\stubs\Components\TestLivewireComponent::change`?'); +})->skip(LIVEWIRE_VERSION_3, 'Missing Livewire 3 support.'); diff --git a/tests/Laravel/Solutions/SolutionProviders/UndefinedLivewirePropertySolutionProviderTest.php b/tests/Laravel/Solutions/SolutionProviders/UndefinedLivewirePropertySolutionProviderTest.php new file mode 100644 index 0000000..79e031e --- /dev/null +++ b/tests/Laravel/Solutions/SolutionProviders/UndefinedLivewirePropertySolutionProviderTest.php @@ -0,0 +1,39 @@ +addAlias('test-livewire-component', TestLivewireComponent::class); + + $exception = new PropertyNotFoundException('compted', 'test-livewire-component'); + + $canSolve = app(UndefinedLivewirePropertySolutionProvider::class)->canSolve($exception); + [$solution] = app(UndefinedLivewirePropertySolutionProvider::class)->getSolutions($exception); + + expect($canSolve)->toBeTrue(); + + expect($solution->getSolutionTitle())->toBe('Possible typo $compted'); + expect($solution->getSolutionDescription())->toBe('Did you mean `$computed`?'); +}); + +// Helpers +function it_can_solve_an_unknown_livewire_property() +{ + FakeLivewireManager::setUp()->addAlias('test-livewire-component', TestLivewireComponent::class); + + $exception = new PropertyNotFoundException('strng', 'test-livewire-component'); + + $canSolve = app(UndefinedLivewirePropertySolutionProvider::class)->canSolve($exception); + [$firstSolution, $secondSolution] = app(UndefinedLivewirePropertySolutionProvider::class)->getSolutions($exception); + + expect($canSolve)->toBeTrue(); + + expect($firstSolution->getSolutionTitle())->toBe('Possible typo $strng'); + expect($firstSolution->getSolutionDescription())->toBe('Did you mean `$string`?'); + + expect($secondSolution->getSolutionTitle())->toBe('Possible typo $strng'); + expect($secondSolution->getSolutionDescription())->toBe('Did you mean `$stringable`?'); +} diff --git a/tests/Laravel/Solutions/SolutionProviders/UnknownMariadbCollationSolutionProviderTest.php b/tests/Laravel/Solutions/SolutionProviders/UnknownMariadbCollationSolutionProviderTest.php new file mode 100644 index 0000000..2bafd72 --- /dev/null +++ b/tests/Laravel/Solutions/SolutionProviders/UnknownMariadbCollationSolutionProviderTest.php @@ -0,0 +1,21 @@ +getSolutions($exception); + + $solution = $solutions[0]; + + expect($solution->getSolutionDescription())->toBe("Laravel 11 changed the default collation for MySQL and MariaDB. It seems you are trying to use the MariaDB collation `utf8mb4_uca1400_ai_ci` with a MySQL database.\n\nEdit the `.env` file and use the correct database in the `DB_CONNECTION` key."); +}); diff --git a/tests/Laravel/Solutions/SolutionProviders/UnknownMysql8CollationSolutionProviderTest.php b/tests/Laravel/Solutions/SolutionProviders/UnknownMysql8CollationSolutionProviderTest.php new file mode 100644 index 0000000..b671002 --- /dev/null +++ b/tests/Laravel/Solutions/SolutionProviders/UnknownMysql8CollationSolutionProviderTest.php @@ -0,0 +1,21 @@ +getSolutions($exception); + + $solution = $solutions[0]; + + expect($solution->getSolutionDescription())->toBe("Laravel 11 changed the default collation for MySQL and MariaDB. It seems you are trying to use the MySQL 8 collation `utf8mb4_0900_ai_ci` with a MariaDB or MySQL 5.7 database.\n\nEdit the `.env` file and use the correct database in the `DB_CONNECTION` key."); +}); diff --git a/tests/Laravel/Solutions/UnknownValidationSolutionProviderTest.php b/tests/Laravel/Solutions/UnknownValidationSolutionProviderTest.php new file mode 100644 index 0000000..3558edc --- /dev/null +++ b/tests/Laravel/Solutions/UnknownValidationSolutionProviderTest.php @@ -0,0 +1,47 @@ +canSolve(getBadMethodCallException()); + + expect($canSolve)->toBeTrue(); +}); + +it('can recommend changing the rule', function (string $invalidRule, string $recommendedRule) { + Validator::extend('foo', fn ($attribute, $value, $parameters, $validator) => $value == 'foo'); + + Validator::extendImplicit('bar_a', fn ($attribute, $value, $parameters, $validator) => $value == 'bar'); + + /** @var \Spatie\Ignition\Contracts\Solution $solution */ + $solution = app(UnknownValidationSolutionProvider::class)->getSolutions(getBadMethodCallException($invalidRule))[0]; + + expect($solution->getSolutionDescription())->toEqual("Did you mean `{$recommendedRule}` ?"); + expect($solution->getSolutionTitle())->toEqual('Unknown Validation Rule'); +})->with('rulesProvider'); + +// Datasets +dataset('rulesProvider', [ + ['number', 'numeric'], + ['unik', 'unique'], + ['fooo', 'foo'], + ['bar_b', 'bar_a'], +]); + +// Helpers +function getBadMethodCallException(string $rule = 'number'): BadMethodCallException +{ + $default = new BadMethodCallException('Not a validation rule exception!'); + + try { + $validator = Validator::make(['number' => 10], ['number' => "{$rule}"]); + $validator->validate(); + + return $default; + } catch (BadMethodCallException $badMethodCallException) { + return $badMethodCallException; + } catch (Exception $exception) { + return $default; + } +} diff --git a/tests/Laravel/Solutions/ViewNotFoundSolutionProviderTest.php b/tests/Laravel/Solutions/ViewNotFoundSolutionProviderTest.php new file mode 100644 index 0000000..7e76050 --- /dev/null +++ b/tests/Laravel/Solutions/ViewNotFoundSolutionProviderTest.php @@ -0,0 +1,37 @@ +canSolve(getViewNotFoundException()); + + expect($canSolve)->toBeTrue(); +}); + +it('can recommend changing a typo in the view name', function () { + /** @var \Spatie\Ignition\Contracts\Solution $solution */ + $solution = app(ViewNotFoundSolutionProvider::class)->getSolutions(getViewNotFoundException())[0]; + + expect(Str::contains($solution->getSolutionDescription(), 'Did you mean `php-exception`?'))->toBeTrue(); +}); + +it('wont recommend another controller class if the names are too different', function () { + $unknownView = 'a-view-that-doesnt-exist-and-is-not-a-typo'; + + /** @var \Spatie\Ignition\Contracts\Solution $solution */ + $solution = app(ViewNotFoundSolutionProvider::class)->getSolutions(getViewNotFoundException($unknownView))[0]; + + expect(Str::contains($solution->getSolutionDescription(), 'Did you mean'))->toBeFalse(); +}); + +// Helpers +function getViewNotFoundException(string $view = 'phpp-exceptionn'): InvalidArgumentException +{ + return new InvalidArgumentException("View [{$view}] not found."); +} diff --git a/tests/Laravel/Solutions/ViteManifestNotFoundSolutionProviderTest.php b/tests/Laravel/Solutions/ViteManifestNotFoundSolutionProviderTest.php new file mode 100644 index 0000000..4ce0535 --- /dev/null +++ b/tests/Laravel/Solutions/ViteManifestNotFoundSolutionProviderTest.php @@ -0,0 +1,51 @@ +canSolve(new Exception('Vite manifest not found at: public/build/manifest.json')); + + expect($canSolve)->toBeTrue(); +}); + +it('recommends running `npm run dev` in a local environment', function () { + app()->detectEnvironment(fn () => 'local'); + + /** @var \Spatie\Ignition\Contracts\Solution $solution */ + $solution = app(MissingViteManifestSolutionProvider::class) + ->getSolutions(new Exception('Vite manifest not found at: public/build/manifest.json'))[0]; + + + expect(Str::contains($solution->getSolutionDescription(), 'Run `npm run dev` in your terminal and refresh the page.'))->toBeTrue(); +}); + +it('recommends running `npm run build` in a production environment', function () { + app()->detectEnvironment(fn () => 'production'); + + /** @var \Spatie\Ignition\Contracts\Solution $solution */ + $solution = app(MissingViteManifestSolutionProvider::class) + ->getSolutions(new Exception('Vite manifest not found at: public/build/manifest.json'))[0]; + + + expect(Str::contains($solution->getSolutionDescription(), 'Run `npm run build` in your deployment script.'))->toBeTrue(); +}); + +it('detects the package manager and adapts the recommended command', function (string $lockfile, string $command) { + app()->detectEnvironment(fn () => 'local'); + + file_put_contents(base_path($lockfile), ''); + + /** @var \Spatie\Ignition\Contracts\Solution $solution */ + $solution = app(MissingViteManifestSolutionProvider::class) + ->getSolutions(new Exception('Vite manifest not found at: public/build/manifest.json'))[0]; + + expect(Str::contains($solution->getSolutionDescription(), "Run `{$command}` in your terminal and refresh the page."))->toBeTrue(); + + unlink(base_path($lockfile)); +})->with([ + ['pnpm-lock.yaml', 'pnpm dev'], + ['yarn.lock', 'yarn dev'], + ['package-lock.json', 'npm run dev'], +]); diff --git a/tests/Laravel/Support/Composer/ComposerClassMapTest.php b/tests/Laravel/Support/Composer/ComposerClassMapTest.php new file mode 100644 index 0000000..8a7bb5a --- /dev/null +++ b/tests/Laravel/Support/Composer/ComposerClassMapTest.php @@ -0,0 +1,12 @@ +listClasses())->toBe([]); + expect($classMap->listClassesInPsrMaps())->toBe([]); + expect($classMap->searchClassMap('SomeClass'))->toBeNull(); + expect($classMap->searchPsrMaps('SomeClass'))->toBeNull(); +}); diff --git a/tests/Laravel/TestClasses/FakeLivewireManager.php b/tests/Laravel/TestClasses/FakeLivewireManager.php new file mode 100644 index 0000000..0a07b45 --- /dev/null +++ b/tests/Laravel/TestClasses/FakeLivewireManager.php @@ -0,0 +1,35 @@ +instance(LivewireManager::class, $manager); + + return $manager; + } + + public function isDefinitelyLivewireRequest() + { + return true; + } + + public function getClass($alias) + { + return $this->fakeAliases[$alias] ?? app(ComponentRegistry::class)->getClass($alias); + } + + public function addAlias(string $alias, string $class): void + { + $this->fakeAliases[$alias] = $class; + } +} diff --git a/tests/Laravel/stubs/Components/GitConflictController.php b/tests/Laravel/stubs/Components/GitConflictController.php new file mode 100644 index 0000000..54ef49f --- /dev/null +++ b/tests/Laravel/stubs/Components/GitConflictController.php @@ -0,0 +1,21 @@ + 'hello', + 'someOtherVariable' => 'thingy123', + ======= + 'someOtherVariable' => 'something', + >>>>>>> another + } +} diff --git a/tests/Laravel/stubs/Components/TestLivewireComponent.php b/tests/Laravel/stubs/Components/TestLivewireComponent.php new file mode 100644 index 0000000..dfb3dab --- /dev/null +++ b/tests/Laravel/stubs/Components/TestLivewireComponent.php @@ -0,0 +1,32 @@ +string = $title; + } + + public function render() + { + return 'nowp'; + } + + public function change() + { + $this->string = 'Ruben'; + } + + public function getComputedProperty() + { + return 'bla'; + } +} diff --git a/tests/Laravel/stubs/Controllers/TestTypoController.php b/tests/Laravel/stubs/Controllers/TestTypoController.php new file mode 100644 index 0000000..1eeee6a --- /dev/null +++ b/tests/Laravel/stubs/Controllers/TestTypoController.php @@ -0,0 +1,10 @@ + + + + + + + Laravel + + +àààààààà +{{ throw new Exception }} +àààààààà +
    + @foreach ([1, 2, 3] as $i => $test) +
  • {{ $test }}
  • + @endforeach +
+ + diff --git a/tests/Laravel/stubs/views/blade-exception.blade.php b/tests/Laravel/stubs/views/blade-exception.blade.php new file mode 100644 index 0000000..55ad0b4 --- /dev/null +++ b/tests/Laravel/stubs/views/blade-exception.blade.php @@ -0,0 +1,3 @@ +

This is a blade view

+ +{{ somethingBadHappens() }} \ No newline at end of file diff --git a/tests/Laravel/stubs/views/php-exception.php b/tests/Laravel/stubs/views/php-exception.php new file mode 100644 index 0000000..84b6476 --- /dev/null +++ b/tests/Laravel/stubs/views/php-exception.php @@ -0,0 +1,3 @@ +

This is a PHP view

+ + \ No newline at end of file diff --git a/tests/Laravel/stubs/views/solution-exception.blade.php b/tests/Laravel/stubs/views/solution-exception.blade.php new file mode 100644 index 0000000..03d2596 --- /dev/null +++ b/tests/Laravel/stubs/views/solution-exception.blade.php @@ -0,0 +1,10 @@ +

This is a blade view with a solution

+@php +use Spatie\LaravelIgnition\Tests\TestClasses\ExceptionWithSolution; + +$exception ??= new ExceptionWithSolution; + +throw $exception; +@endphp + +Oops! I threw up an exception. diff --git a/tests/Laravel/stubs/views/undefined-variable-1.blade.php b/tests/Laravel/stubs/views/undefined-variable-1.blade.php new file mode 100644 index 0000000..0a79693 --- /dev/null +++ b/tests/Laravel/stubs/views/undefined-variable-1.blade.php @@ -0,0 +1,11 @@ +{{-- Intentional typo for test --}} + +This is some contents + +
{{ $footerDescriptin }}
+ +@isset($something) + {{ $something }} +@endisset + +Test diff --git a/tests/Pest.php b/tests/Pest.php index d45bbcd..4f776fb 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,6 +1,20 @@ in(__DIR__); + +if (file_exists(__DIR__ . '/../.env')) { + $dotEnv = Dotenv::createImmutable(__DIR__ . '/..'); + + $dotEnv->load(); +} function canRunOpenAiTest(): bool { diff --git a/tests/Solutions/OpenAiSolutionProviderTest.php b/tests/Solutions/OpenAiSolutionProviderTest.php index 30347e4..beb8289 100644 --- a/tests/Solutions/OpenAiSolutionProviderTest.php +++ b/tests/Solutions/OpenAiSolutionProviderTest.php @@ -1,6 +1,5 @@