From 6cb5846c0e4ad56453407c3c40b4786355596871 Mon Sep 17 00:00:00 2001 From: Tim MacDonald Date: Sat, 30 Jul 2022 05:21:39 +1000 Subject: [PATCH] [9.x] Introduce artisan docs command (#43357) * Introduce artisan docs command * register command in the service provider * formatting Co-authored-by: Taylor Otwell --- .styleci.yml | 3 + composer.json | 2 +- .../Foundation/Console/DocsCommand.php | 528 ++++++++++++++++++ .../Providers/ArtisanServiceProvider.php | 12 + .../Foundation/FoundationDocsCommandTest.php | 336 +++++++++++ .../fixtures/always-dusk-ask-strategy.php | 3 + .../fixtures/bad-return-strategy.php | 3 + .../fixtures/bad-syntax-strategy.php | 4 + tests/Foundation/fixtures/docs.json | 96 ++++ .../fixtures/exception-throwing-strategy.php | 3 + tests/Foundation/fixtures/open-strategy.php | 7 + .../fixtures/process-failure-strategy.php | 27 + .../fixtures/process-interrupt-strategy.php | 22 + 13 files changed, 1045 insertions(+), 1 deletion(-) create mode 100644 src/Illuminate/Foundation/Console/DocsCommand.php create mode 100644 tests/Foundation/FoundationDocsCommandTest.php create mode 100644 tests/Foundation/fixtures/always-dusk-ask-strategy.php create mode 100644 tests/Foundation/fixtures/bad-return-strategy.php create mode 100644 tests/Foundation/fixtures/bad-syntax-strategy.php create mode 100644 tests/Foundation/fixtures/docs.json create mode 100644 tests/Foundation/fixtures/exception-throwing-strategy.php create mode 100644 tests/Foundation/fixtures/open-strategy.php create mode 100644 tests/Foundation/fixtures/process-failure-strategy.php create mode 100644 tests/Foundation/fixtures/process-interrupt-strategy.php diff --git a/.styleci.yml b/.styleci.yml index 9cd91cf68fdc..44f7cb91093b 100644 --- a/.styleci.yml +++ b/.styleci.yml @@ -1,6 +1,9 @@ php: preset: laravel version: 8.1 + finder: + not-name: + - bad-syntax-strategy.php js: finder: not-name: diff --git a/composer.json b/composer.json index 0082c4481806..082e1dee54a6 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,7 @@ "psr/log": "^1.0|^2.0|^3.0", "psr/simple-cache": "^1.0|^2.0|^3.0", "ramsey/uuid": "^4.2.2", - "symfony/console": "^6.0", + "symfony/console": "^6.0.3", "symfony/error-handler": "^6.0", "symfony/finder": "^6.0", "symfony/http-foundation": "^6.0", diff --git a/src/Illuminate/Foundation/Console/DocsCommand.php b/src/Illuminate/Foundation/Console/DocsCommand.php new file mode 100644 index 000000000000..c004a3766002 --- /dev/null +++ b/src/Illuminate/Foundation/Console/DocsCommand.php @@ -0,0 +1,528 @@ +php artisan docs -- search query here'; + + /** + * The HTTP client instance + * + * @var \Illuminate\Http\Client\Factory + */ + protected $http; + + /** + * The cache repository implementation. + * + * @var \Illuminate\Contracts\Cache\Repository + */ + protected $cache; + + /** + * The custom URL opener. + * + * @var callable|null + */ + protected $urlOpener; + + /** + * The custom documentation version to open. + * + * @var string|null + */ + protected $version; + + /** + * The operating system family. + * + * @var string + */ + protected $systemOsFamily = PHP_OS_FAMILY; + + /** + * Create a new command instance. + * + * @param \Illuminate\Http\Client\Factory $http + * @param \Illuminate\Contracts\Cache\Repository $cache + * @return void + */ + public function __construct(Http $http, Cache $cache) + { + parent::__construct(); + + $this->http = $http; + $this->cache = $cache; + } + + /** + * Configure the current command. + * + * @return void + */ + protected function configure() + { + parent::configure(); + + if ($this->isSearching()) { + $this->ignoreValidationErrors(); + } + } + + /** + * Execute the console command. + * + * @return int + */ + public function handle() + { + try { + $this->openUrl(); + } catch (ProcessFailedException $e) { + if ($e->getProcess()->getExitCodeText() === 'Interrupt') { + return $e->getProcess()->getExitCode(); + } + + throw $e; + } + + $this->refreshDocs(); + + return Command::SUCCESS; + } + + /** + * Open the documentation URL. + * + * @return void + */ + protected function openUrl() + { + with($this->url(), function ($url) { + $this->components->info("Opening the docs to: {$url}"); + + $this->open($url); + }); + } + + /** + * The URL to the documentation page. + * + * @return string + */ + protected function url() + { + if ($this->isSearching()) { + return "https://laravel.com/docs/{$this->version()}?".Arr::query([ + 'q' => $this->searchQuery(), + ]); + } + + return with($this->page(), function ($page) { + return trim("https://laravel.com/docs/{$this->version()}/{$page}#{$this->section($page)}", '#/'); + }); + } + + /** + * The page the user is opening. + * + * @return string + */ + protected function page() + { + return with($this->resolvePage(), function ($page) { + if ($page === null) { + $this->components->warn('Unable to determine the page you are trying to visit.'); + + return '/'; + } + + return $page; + }); + } + + /** + * Determine the page to open. + * + * @return ?string + */ + protected function resolvePage() + { + if ($this->option('no-interaction') && $this->didNotRequestPage()) { + return '/'; + } + + return $this->didNotRequestPage() + ? $this->askForPage() + : $this->guessPage(); + } + + /** + * Determine if the user requested a specific page when calling the command. + * + * @return bool + */ + protected function didNotRequestPage() + { + return $this->argument('page') === null; + } + + /** + * Ask the user which page they would like to open. + * + * @return ?string + */ + protected function askForPage() + { + return $this->askForPageViaCustomStrategy() ?? $this->askForPageViaAutocomplete(); + } + + /** + * Ask the user which page they would like to open via a custom strategy. + * + * @return ?string + */ + protected function askForPageViaCustomStrategy() + { + try { + $strategy = require Env::get('ARTISAN_DOCS_ASK_STRATEGY'); + } catch (Throwable $e) { + return null; + } + + if (! is_callable($strategy)) { + return null; + } + + return $strategy($this) ?? '/'; + } + + /** + * Ask the user which page they would like to open using autocomplete. + * + * @return ?string + */ + protected function askForPageViaAutocomplete() + { + $choice = $this->components->choice( + 'Which page would you like to open?', + $this->pages()->mapWithKeys(fn ($option) => [ + Str::lower($option['title']) => $option['title'], + ])->all(), + 'installation', + 3 + ); + + return $this->pages()->filter( + fn ($page) => $page['title'] === $choice || Str::lower($page['title']) === $choice + )->keys()->first() ?: null; + } + + /** + * Guess the page the user is attempting to open. + * + * @return ?string + */ + protected function guessPage() + { + return $this->pages() + ->filter(fn ($page) => str_starts_with( + Str::slug($page['title'], ' '), + Str::slug($this->argument('page'), ' ') + ))->keys()->first() ?? $this->pages()->map(fn ($page) => similar_text( + Str::slug($page['title'], ' '), + Str::slug($this->argument('page'), ' '), + )) + ->filter(fn ($score) => $score >= min(3, Str::length($this->argument('page')))) + ->sortDesc() + ->keys() + ->sortByDesc(fn ($slug) => Str::contains( + Str::slug($this->pages()[$slug]['title'], ' '), + Str::slug($this->argument('page'), ' ') + ) ? 1 : 0) + ->first(); + } + + /** + * The section the user specifically asked to open. + * + * @param string $page + * @return ?string + */ + protected function section($page) + { + return $this->didNotRequestSection() + ? null + : $this->guessSection($page); + } + + /** + * Determine if the user requested a specific section when calling the command. + * + * @return bool + */ + protected function didNotRequestSection() + { + return $this->argument('section') === null; + } + + /** + * Guess the section the user is attempting to open. + * + * @param string $page + * @return ?string + */ + protected function guessSection($page) + { + return $this->sectionsFor($page) + ->filter(fn ($section) => str_starts_with( + Str::slug($section['title'], ' '), + Str::slug($this->argument('section'), ' ') + ))->keys()->first() ?? $this->sectionsFor($page)->map(fn ($section) => similar_text( + Str::slug($section['title'], ' '), + Str::slug($this->argument('section'), ' '), + )) + ->filter(fn ($score) => $score >= min(3, Str::length($this->argument('section')))) + ->sortDesc() + ->keys() + ->sortByDesc(fn ($slug) => Str::contains( + Str::slug($this->sectionsFor($page)[$slug]['title'], ' '), + Str::slug($this->argument('section'), ' ') + ) ? 1 : 0) + ->first(); + } + + /** + * Open the URL in the user's browser. + * + * @param string $url + * @return void + */ + protected function open($url) + { + ($this->urlOpener ?? function ($url) { + if (Env::get('ARTISAN_DOCS_OPEN_STRATEGY')) { + $this->openViaCustomStrategy($url); + } elseif (in_array($this->systemOsFamily, ['Darwin', 'Windows', 'Linux'])) { + $this->openViaBuiltInStrategy($url); + } else { + $this->components->warn('Unable to open the URL on your system. You will need to open it yourself.'); + } + })($url); + } + + /** + * Open the URL via a custom strategy. + * + * @param string $url + * @return void + */ + protected function openViaCustomStrategy($url) + { + try { + $command = require Env::get('ARTISAN_DOCS_OPEN_STRATEGY'); + } catch (Throwable $e) { + $command = null; + } + + if (! is_callable($command)) { + $this->components->warn('Unable to open the URL with your custom strategy. You will need to open it yourself.'); + + return; + } + + $command($url); + } + + /** + * Open the URL via the built in strategy. + * + * @param string $url + * @return void + */ + protected function openViaBuiltInStrategy($url) + { + $process = tap(Process::fromShellCommandline(match ($this->systemOsFamily) { + 'Darwin' => 'open', + 'Windows' => 'start', + 'Linux' => 'xdg-open', + }.' '.escapeshellarg($url)))->run(); + + if (! $process->isSuccessful()) { + throw new ProcessFailedException($process); + } + } + + /** + * The available sections for the page. + * + * @param string $page + * @return \Illuminate\Support\Collection + */ + public function sectionsFor($page) + { + return new Collection($this->pages()[$page]['sections']); + } + + /** + * The pages available to open. + * + * @return \Illuminate\Support\Collection + */ + public function pages() + { + return new Collection($this->docs()['pages']); + } + + /** + * Get the documentation index as a collection. + * + * @return \Illuminate\Support\Collection + */ + public function docs() + { + return $this->cache->remember( + "artisan.docs.{{$this->version()}}.index", + CarbonInterval::months(2), + fn () => $this->fetchDocs()->throw()->collect() + ); + } + + /** + * Refresh the cached copy of the documentation index. + * + * @return void + */ + protected function refreshDocs() + { + with($this->fetchDocs(), function ($response) { + if ($response->successful()) { + $this->cache->put("artisan.docs.{{$this->version()}}.index", $response->collect(), CarbonInterval::months(2)); + } + }); + } + + /** + * Fetch the documentation index from the Laravel website. + * + * @return \Illuminate\Http\Client\Response + */ + protected function fetchDocs() + { + return $this->http->get("https://laravel.com/docs/{$this->version()}/index.json"); + } + + /** + * Determine the version of the docs to open. + * + * @return string + */ + protected function version() + { + return Str::before(($this->version ?? $this->laravel->version()), '.').'.x'; + } + + /** + * The search query the user provided. + * + * @return string + */ + protected function searchQuery() + { + return Collection::make($_SERVER['argv'])->skip(3)->implode(' '); + } + + /** + * Determine if the command is intended to perform a search. + * + * @return bool + */ + protected function isSearching() + { + return ($_SERVER['argv'][2] ?? null) === '--'; + } + + /** + * Set the documentation version. + * + * @param string $version + * @return $this + */ + public function setVersion($version) + { + $this->version = $version; + + return $this; + } + + /** + * Set a custom URL opener. + * + * @param callable|null $opener + * @return $this + */ + public function setUrlOpener($opener) + { + $this->urlOpener = $opener; + + return $this; + } + + /** + * Set the system operating system family. + * + * @param string $family + * @return $this + */ + public function setSystemOsFamily($family) + { + $this->systemOsFamily = $family; + + return $this; + } +} diff --git a/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php b/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php index 89f384391aac..10dcec6138d3 100755 --- a/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php +++ b/src/Illuminate/Foundation/Providers/ArtisanServiceProvider.php @@ -28,6 +28,7 @@ use Illuminate\Foundation\Console\ConfigCacheCommand; use Illuminate\Foundation\Console\ConfigClearCommand; use Illuminate\Foundation\Console\ConsoleMakeCommand; +use Illuminate\Foundation\Console\DocsCommand; use Illuminate\Foundation\Console\DownCommand; use Illuminate\Foundation\Console\EnvironmentCommand; use Illuminate\Foundation\Console\EventCacheCommand; @@ -154,6 +155,7 @@ class ArtisanServiceProvider extends ServiceProvider implements DeferrableProvid 'ComponentMake' => ComponentMakeCommand::class, 'ConsoleMake' => ConsoleMakeCommand::class, 'ControllerMake' => ControllerMakeCommand::class, + 'Docs' => DocsCommand::class, 'EventGenerate' => EventGenerateCommand::class, 'EventMake' => EventMakeCommand::class, 'ExceptionMake' => ExceptionMakeCommand::class, @@ -392,6 +394,16 @@ protected function registerDbWipeCommand() $this->app->singleton(WipeCommand::class); } + /** + * Register the command. + * + * @return void + */ + protected function registerDocsCommand() + { + $this->app->singleton(DocsCommand::class); + } + /** * Register the command. * diff --git a/tests/Foundation/FoundationDocsCommandTest.php b/tests/Foundation/FoundationDocsCommandTest.php new file mode 100644 index 000000000000..b725e2c91830 --- /dev/null +++ b/tests/Foundation/FoundationDocsCommandTest.php @@ -0,0 +1,336 @@ +fake([ + 'https://laravel.com/docs/8.x/index.json' => Http::response(file_get_contents(__DIR__.'/fixtures/docs.json')), + ]); + + $this->app[Kernel::class]->registerCommand($this->command()); + } + + protected function tearDown(): void + { + parent::tearDown(); + + putenv('ARTISAN_DOCS_ASK_STRATEGY'); + putenv('ARTISAN_DOCS_OPEN_STRATEGY'); + } + + public function testItCanOpenTheLaravelDocumentation(): void + { + $this->artisan('docs') + ->expectsQuestion('Which page would you like to open?', '') + ->expectsOutputToContain('Opening the docs to: https://laravel.com/docs/8.x') + ->assertSuccessful(); + + $this->assertSame($this->openedUrl, 'https://laravel.com/docs/8.x'); + } + + public function testItCanSpecifyAutocompleteInOriginalCasing(): void + { + $this->artisan('docs') + ->expectsQuestion('Which page would you like to open?', 'Laravel Dusk') + ->expectsOutputToContain('Opening the docs to: https://laravel.com/docs/8.x/dusk') + ->assertSuccessful(); + + $this->assertSame($this->openedUrl, 'https://laravel.com/docs/8.x/dusk'); + } + + public function testItCanSpecifyAutocompleteInLowerCasing(): void + { + $this->artisan('docs') + ->expectsQuestion('Which page would you like to open?', 'laravel dusk') + ->expectsOutputToContain('Opening the docs to: https://laravel.com/docs/8.x/dusk') + ->assertSuccessful(); + + $this->assertSame($this->openedUrl, 'https://laravel.com/docs/8.x/dusk'); + } + + public function testItMatchesSectionsThatStartWithInput() + { + $this->artisan('docs el-col uni') + ->expectsOutputToContain('Opening the docs to: https://laravel.com/docs/8.x/eloquent-collections#method-unique') + ->assertSuccessful(); + + $this->assertSame($this->openedUrl, 'https://laravel.com/docs/8.x/eloquent-collections#method-unique'); + } + + public function testItMatchesSectionsWithFuzzyMatching() + { + $this->artisan('docs el-col qery') + ->expectsOutputToContain('Opening the docs to: https://laravel.com/docs/8.x/eloquent-collections#method-toquery') + ->assertSuccessful(); + + $this->assertSame($this->openedUrl, 'https://laravel.com/docs/8.x/eloquent-collections#method-toquery'); + } + + public function testItCanProvidePageToVisit(): void + { + $this->artisan('docs eloquent\ collections') + ->expectsOutputToContain('Opening the docs to: https://laravel.com/docs/8.x/eloquent-collections') + ->assertSuccessful(); + + $this->assertSame($this->openedUrl, 'https://laravel.com/docs/8.x/eloquent-collections'); + } + + public function testItCanUseHyphensInsteadOfEscapingSpaces(): void + { + $this->artisan('docs eloquent-collections') + ->expectsOutputToContain('Opening the docs to: https://laravel.com/docs/8.x/eloquent-collections') + ->assertSuccessful(); + + $this->assertSame($this->openedUrl, 'https://laravel.com/docs/8.x/eloquent-collections'); + } + + public function testItHasMinimumScoreToMatch(): void + { + $this->artisan('docs zag') + ->expectsOutputToContain('Unable to determine the page you are trying to visit.') + ->assertSuccessful(); + + $this->assertSame($this->openedUrl, 'https://laravel.com/docs/8.x'); + } + + public function testItMinimumScoreAccountsForInputLength(): void + { + $this->artisan('docs z') + ->expectsOutputToContain('Opening the docs to: https://laravel.com/docs/8.x/localization') + ->assertSuccessful(); + + $this->assertSame($this->openedUrl, 'https://laravel.com/docs/8.x/localization'); + } + + public function testItCanUseCustomAskStrategy() + { + putenv('ARTISAN_DOCS_ASK_STRATEGY='.__DIR__.'/fixtures/always-dusk-ask-strategy.php'); + + $this->artisan('docs') + ->expectsOutputToContain('Opening the docs to: https://laravel.com/docs/8.x/dusk') + ->assertSuccessful(); + + $this->assertSame($this->openedUrl, 'https://laravel.com/docs/8.x/dusk'); + } + + public function testItFallsbackToAutocompleteWhenAskStrategyContainsBadSyntax(): void + { + putenv('ARTISAN_DOCS_ASK_STRATEGY='.__DIR__.'/fixtures/bad-syntax-strategy.php'); + + $this->artisan('docs') + ->expectsQuestion('Which page would you like to open?', 'laravel dusk') + ->expectsOutputToContain('Opening the docs to: https://laravel.com/docs/8.x/dusk') + ->assertSuccessful(); + + $this->assertSame($this->openedUrl, 'https://laravel.com/docs/8.x/dusk'); + } + + public function testItFallsbackToAutocompleteWithBadAskStrategyReturnValue(): void + { + putenv('ARTISAN_DOCS_ASK_STRATEGY='.__DIR__.'/fixtures/bad-return-strategy.php'); + + $this->artisan('docs') + ->expectsQuestion('Which page would you like to open?', 'laravel dusk') + ->expectsOutputToContain('Opening the docs to: https://laravel.com/docs/8.x/dusk') + ->assertSuccessful(); + + $this->assertSame($this->openedUrl, 'https://laravel.com/docs/8.x/dusk'); + } + + public function testItCatchesAndHandlesProcessInterruptExceptionsInAskStrategies() + { + putenv('ARTISAN_DOCS_ASK_STRATEGY='.__DIR__.'/fixtures/process-interrupt-strategy.php'); + + $this->artisan('docs')->assertExitCode(130); + } + + public function testItBubblesUpAskStrategyExceptions() + { + putenv('ARTISAN_DOCS_ASK_STRATEGY='.__DIR__.'/fixtures/exception-throwing-strategy.php'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('strategy failed'); + + $this->artisan('docs'); + } + + public function testItBubblesUpNonProcessInterruptExceptionsInAskStratgies() + { + putenv('ARTISAN_DOCS_ASK_STRATEGY='.__DIR__.'/fixtures/process-failure-strategy.php'); + + $this->expectException(ProcessFailedException::class); + + if (PHP_OS_FAMILY === 'Windows') { + $this->expectExceptionMessage('The command "expected-command" failed. + +Exit Code: 1(General error) + +Working directory: expected-working-directory'); + } else { + $this->expectExceptionMessage('The command "\'expected-command\'" failed. + +Exit Code: 1(General error) + +Working directory: expected-working-directory'); + } + + $this->artisan('docs'); + } + + public function testItCanGuessTheRequestedPageWhenItIsTheStartOfAPageTitle() + { + $this->artisan('docs elo') + ->expectsOutputToContain('Opening the docs to: https://laravel.com/docs/8.x/eloquent') + ->assertSuccessful(); + + $this->assertSame($this->openedUrl, 'https://laravel.com/docs/8.x/eloquent'); + } + + public function testItCanGuessTheRequestedPageWhenItIsContainedSomewhereInThePageTitle() + { + $this->artisan('docs quent') + ->expectsOutputToContain('Opening the docs to: https://laravel.com/docs/8.x/eloquent') + ->assertSuccessful(); + + $this->assertSame($this->openedUrl, 'https://laravel.com/docs/8.x/eloquent'); + } + + public function testItCanGuessTheWithTopAndTailMatching() + { + $this->artisan('docs elo-col') + ->expectsOutputToContain('Opening the docs to: https://laravel.com/docs/8.x/eloquent-collections') + ->assertSuccessful(); + + $this->assertSame($this->openedUrl, 'https://laravel.com/docs/8.x/eloquent-collections'); + } + + public function testItCanSpecifyCustomOpenCommandsViaEnvVariables() + { + $GLOBALS['open-strategy-output-path'] = __DIR__.'/output.txt'; + putenv('ARTISAN_DOCS_OPEN_STRATEGY='.__DIR__.'/fixtures/open-strategy.php'); + $this->app[Kernel::class]->registerCommand($this->command()->setUrlOpener(null)); + + @unlink($GLOBALS['open-strategy-output-path']); + + $this->artisan('docs installation') + ->expectsOutputToContain('Opening the docs to: https://laravel.com/docs/8.x/installation') + ->assertSuccessful(); + + if (PHP_OS_FAMILY === 'Windows') { + $this->assertSame('"https://laravel.com/docs/8.x/installation?expected-query=1"', trim(file_get_contents($GLOBALS['open-strategy-output-path']))); + } else { + $this->assertSame('https://laravel.com/docs/8.x/installation?expected-query=1', trim(file_get_contents($GLOBALS['open-strategy-output-path']))); + } + + @unlink($GLOBALS['open-strategy-output-path']); + unset($GLOBALS['open-strategy-output-path']); + } + + public function testItHandlesBadSyntaxInOpeners() + { + putenv('ARTISAN_DOCS_OPEN_STRATEGY='.__DIR__.'/fixtures/bad-syntax-strategy.php'); + $this->app[Kernel::class]->registerCommand($this->command()->setUrlOpener(null)); + + $this->artisan('docs installation') + ->expectsOutputToContain('Unable to open the URL with your custom strategy. You will need to open it yourself.') + ->assertSuccessful(); + } + + public function testItHandlesBadReturnTypesInOpeners() + { + putenv('ARTISAN_DOCS_OPEN_STRATEGY='.__DIR__.'/fixtures/bad-return-strategy.php'); + $this->app[Kernel::class]->registerCommand($this->command()->setUrlOpener(null)); + + $this->artisan('docs installation') + ->expectsOutputToContain('Unable to open the URL with your custom strategy. You will need to open it yourself.') + ->assertSuccessful(); + } + + public function testItCanPerformSearchAgainstLaravelDotCom() + { + $argCache = $_SERVER['argv']; + $_SERVER['argv'] = explode(' ', 'artisan docs -- here is my search term for the laravel website'); + $this->app[Kernel::class]->registerCommand($this->command()); + + $this->artisan('docs -- here is my search term for the laravel website') + ->expectsOutputToContain('Opening the docs to: https://laravel.com/docs/8.x?q=here%20is%20my%20search%20term%20for%20the%20laravel%20website') + ->assertSuccessful(); + + $this->assertSame($this->openedUrl, 'https://laravel.com/docs/8.x?q=here%20is%20my%20search%20term%20for%20the%20laravel%20website'); + + $_SERVER['argv'] = $argCache; + } + + public function testUnknownSystemNotifiedToOpenManualy() + { + $this->app[Kernel::class]->registerCommand($this->command()->setUrlOpener(null)->setSystemOsFamily('Laravel OS')); + + $this->artisan('docs validation') + ->expectsOutputToContain('Unable to open the URL on your system. You will need to open it yourself.') + ->assertSuccessful(); + } + + public function testGuessedMatchesThatDirectlyContainTheGivenStringRankHigerThanArbitraryMatches() + { + $this->artisan('docs ora') + ->expectsOutputToContain('Opening the docs to: https://laravel.com/docs/8.x/filesystem') + ->assertSuccessful(); + + $this->assertSame($this->openedUrl, 'https://laravel.com/docs/8.x/filesystem'); + } + + public function testItHandlesPoorSpelling() + { + $this->artisan('docs vewis') + ->expectsOutputToContain('Opening the docs to: https://laravel.com/docs/8.x/views') + ->assertSuccessful(); + + $this->assertSame($this->openedUrl, 'https://laravel.com/docs/8.x/views'); + } + + public function testItHandlesNoInteractionOption() + { + $this->artisan('docs -n') + ->expectsOutputToContain('Opening the docs to: https://laravel.com/docs/8.x') + ->assertSuccessful(); + + $this->assertSame($this->openedUrl, 'https://laravel.com/docs/8.x'); + } + + protected function command() + { + $this->app->forgetInstance(DocsCommand::class); + + return $this->app->make(DocsCommand::class) + ->setVersion('8.30.12') + ->setUrlOpener(function ($url) { + $this->openedUrl = $url; + }); + } +} diff --git a/tests/Foundation/fixtures/always-dusk-ask-strategy.php b/tests/Foundation/fixtures/always-dusk-ask-strategy.php new file mode 100644 index 000000000000..28d131fa17e4 --- /dev/null +++ b/tests/Foundation/fixtures/always-dusk-ask-strategy.php @@ -0,0 +1,3 @@ + 'dusk'; diff --git a/tests/Foundation/fixtures/bad-return-strategy.php b/tests/Foundation/fixtures/bad-return-strategy.php new file mode 100644 index 000000000000..f6a245bed1bf --- /dev/null +++ b/tests/Foundation/fixtures/bad-return-strategy.php @@ -0,0 +1,3 @@ + throw new RuntimeException('strategy failed'); diff --git a/tests/Foundation/fixtures/open-strategy.php b/tests/Foundation/fixtures/open-strategy.php new file mode 100644 index 000000000000..34465a144feb --- /dev/null +++ b/tests/Foundation/fixtures/open-strategy.php @@ -0,0 +1,7 @@ + %s', escapeshellarg($url.'?expected-query=1'), escapeshellarg($GLOBALS['open-strategy-output-path'])))->run(); +}; diff --git a/tests/Foundation/fixtures/process-failure-strategy.php b/tests/Foundation/fixtures/process-failure-strategy.php new file mode 100644 index 000000000000..df6300ae70ab --- /dev/null +++ b/tests/Foundation/fixtures/process-failure-strategy.php @@ -0,0 +1,27 @@ + throw new ProcessFailedException(new class(['expected-command']) extends Process +{ + public function isSuccessful(): bool + { + return false; + } + + public function getExitCode(): ?int + { + return 1; + } + + public function isOutputDisabled(): bool + { + return true; + } + + public function getWorkingDirectory(): ?string + { + return 'expected-working-directory'; + } +}); diff --git a/tests/Foundation/fixtures/process-interrupt-strategy.php b/tests/Foundation/fixtures/process-interrupt-strategy.php new file mode 100644 index 000000000000..3ef14b53d467 --- /dev/null +++ b/tests/Foundation/fixtures/process-interrupt-strategy.php @@ -0,0 +1,22 @@ + throw new ProcessFailedException(new class([]) extends Process +{ + public function isSuccessful(): bool + { + return false; + } + + public function getExitCode(): ?int + { + return 130; + } + + public function isOutputDisabled(): bool + { + return true; + } +});