diff --git a/.harness.json b/.harness.json index 209b201..261b5b7 100644 --- a/.harness.json +++ b/.harness.json @@ -5,9 +5,18 @@ "laravel/prompts" ], "allow_commands": [ - { "command": "new", "reason": "the installer is named for the tool, like laravel new; renaming it would change the public API of the CLI" }, - { "command": "self-update", "reason": "the command is named for the tool, like composer self-update; renaming it would change the public API of the CLI" }, - { "command": "version", "reason": "the command is named for the tool; renaming it would change the public API of the CLI" } + { + "command": "new", + "reason": "the installer is named for the tool, like laravel new; renaming it would change the public API of the CLI" + }, + { + "command": "self-update", + "reason": "the command is named for the tool, like composer self-update; renaming it would change the public API of the CLI" + }, + { + "command": "version", + "reason": "the command is named for the tool; renaming it would change the public API of the CLI" + } ], "ratchet": { "phpstan_level": 8, diff --git a/tests/Support/FileOverlay.php b/tests/Support/FileOverlay.php new file mode 100644 index 0000000..0a6f5ff --- /dev/null +++ b/tests/Support/FileOverlay.php @@ -0,0 +1,169 @@ + suffix => content, false = unreadable, null = absent */ + private static array $files = []; + + /** @var resource|null */ + private $handle; + + private string $data = ''; + + private int $position = 0; + + private bool $virtual = false; + + /** + * @param array $files suffix => content; false = exists but cannot be read, null = does not exist + */ + public static function install(array $files): void + { + self::$files = $files; + stream_wrapper_unregister('file'); + stream_wrapper_register('file', self::class); + } + + public static function uninstall(): void + { + self::$files = []; + // Safe to call twice: the native wrapper is simply restored. + @stream_wrapper_unregister('file'); + stream_wrapper_restore('file'); + } + + /** + * @return array{0: bool, 1: string|false|null} + */ + private function match(string $path): array + { + foreach (self::$files as $suffix => $content) { + if (str_ends_with($path, $suffix)) { + return [true, $content]; + } + } + + return [false, null]; + } + + private function native(callable $call): mixed + { + stream_wrapper_restore('file'); + // A missing file is an ordinary answer here, not a warning. + set_error_handler(static fn (): bool => true); + + try { + return $call(); + } finally { + restore_error_handler(); + stream_wrapper_unregister('file'); + stream_wrapper_register('file', self::class); + } + } + + public function stream_open(string $path, string $mode): bool + { + [$matched, $content] = $this->match($path); + + if ($matched) { + if (! is_string($content)) { + return false; + } + + $this->virtual = true; + $this->data = $content; + + return true; + } + + $handle = $this->native(static fn (): mixed => fopen($path, $mode)); + + if (! is_resource($handle)) { + return false; + } + + $this->handle = $handle; + + return true; + } + + public function stream_read(int $count): string|false + { + if ($this->virtual) { + $chunk = substr($this->data, $this->position, $count); + $this->position += strlen($chunk); + + return $chunk; + } + + return fread($this->handle, $count); // @phpstan-ignore argument.type + } + + public function stream_write(string $data): int + { + return $this->virtual ? 0 : (int) fwrite($this->handle, $data); // @phpstan-ignore argument.type + } + + public function stream_eof(): bool + { + return $this->virtual ? $this->position >= strlen($this->data) : feof($this->handle); // @phpstan-ignore argument.type + } + + public function stream_close(): void + { + if (is_resource($this->handle)) { + fclose($this->handle); + } + } + + /** + * @return array|false + */ + public function stream_stat(): array|false + { + if ($this->virtual) { + return ['mode' => 0100644, 'size' => strlen($this->data)]; + } + + return fstat($this->handle); // @phpstan-ignore argument.type + } + + public function stream_set_option(): bool + { + return false; + } + + /** + * @return array|false + */ + public function url_stat(string $path): array|false + { + [$matched, $content] = $this->match($path); + + if ($matched) { + if ($content === null) { + return false; + } + + return ['mode' => 0100644, 'size' => is_string($content) ? strlen($content) : 0]; + } + + return $this->native(static fn (): array|false => stat($path)); + } +} diff --git a/tests/Support/Sandbox.php b/tests/Support/Sandbox.php new file mode 100644 index 0000000..27f779a --- /dev/null +++ b/tests/Support/Sandbox.php @@ -0,0 +1,155 @@ + */ + private array $originalEnv = []; + + /** @var array */ + private array $originalServer = []; + + public function __construct() + { + $this->root = realpath(sys_get_temp_dir()).'/pollora-cli-'.bin2hex(random_bytes(6)); + $this->work = $this->root.'/work'; + $this->bin = $this->root.'/bin'; + $this->log = $this->root.'/calls.log'; + + mkdir($this->work, 0755, true); + mkdir($this->bin, 0755, true); + touch($this->log); + + $this->originalCwd = getcwd(); + $this->env('FAKE_LOG', $this->log); + $this->env('FAKE_FAIL_ON', ''); + $this->env('FAKE_NO_INSTALL', ''); + $this->env('HTTPS_PROXY', 'http://127.0.0.1:1'); + $this->env('HOME', $this->root.'/home'); + $this->env('PATH', $this->bin.':'.getenv('PATH')); + + chdir($this->work); + } + + public function env(string $name, string $value): void + { + if (! array_key_exists($name, $this->originalEnv)) { + $this->originalEnv[$name] = getenv($name); + } + + if (! array_key_exists($name, $this->originalServer)) { + $this->originalServer[$name] = $_SERVER[$name] ?? null; + } + + // Symfony Process only passes on the variables that $_SERVER also knows. + putenv($name.'='.$value); + $_SERVER[$name] = $value; + } + + /** + * Restrict PATH to the fake bin folder plus the system essentials. + */ + public function onlyFakePath(): void + { + $this->env('PATH', $this->bin.':/usr/bin:/bin'); + } + + public function script(string $name, string $body): void + { + $path = $this->bin.'/'.$name; + file_put_contents($path, "#!/bin/sh\n".$body."\n"); + chmod($path, 0755); + } + + /** + * Fake `composer`, `ddev`, `php` and `git` that record their arguments. + */ + public function fakeTools(): void + { + $skeleton = <<<'SH' +mkdir -p "$1/vendor" +touch "$1/artisan" +echo '{}' > "$1/composer.json" +[ -n "$FAKE_NO_ENV" ] || printf 'APP_KEY=\n#DB_HOST=127.0.0.1\n# DB_PASSWORD=\nAPP_URL=http://localhost\n' > "$1/.env.example" +SH; + + $guard = <<<'SH' +echo "$(basename "$0") $*" >> "$FAKE_LOG" +if [ -n "$FAKE_FAIL_ON" ]; then + case "$*" in *"$FAKE_FAIL_ON"*) exit 1;; esac +fi +SH; + + $this->script('composer', $guard."\n".'if [ "$1" = create-project ] && [ -z "$FAKE_NO_INSTALL" ]; then set -- "$3"; '.$skeleton."\nfi\nexit 0"); + $this->script('ddev', $guard."\n".'if [ "$1" = composer ] && [ "$2" = create-project ] && [ -z "$FAKE_NO_INSTALL" ]; then set -- "."; '.$skeleton."\nfi\nexit 0"); + $this->script('php', $guard."\nexit 0"); + $this->script('git', $guard."\nexit 0"); + } + + public function calls(): string + { + return (string) file_get_contents($this->log); + } + + public function restore(): void + { + if ($this->originalCwd !== false) { + chdir($this->originalCwd); + } + + foreach ($this->originalEnv as $name => $value) { + putenv($value === false ? $name : $name.'='.$value); + } + + foreach ($this->originalServer as $name => $value) { + if ($value === null) { + unset($_SERVER[$name]); + } else { + $_SERVER[$name] = $value; + } + } + + self::removeTree($this->root); + } + + public static function removeTree(string $path): void + { + if (is_link($path) || is_file($path)) { + unlink($path); + + return; + } + + if (! is_dir($path)) { + return; + } + + foreach (scandir($path) ?: [] as $entry) { + if ($entry !== '.' && $entry !== '..') { + self::removeTree($path.'/'.$entry); + } + } + + rmdir($path); + } +} diff --git a/tests/Unit/Commands/NewCommandInstallTest.php b/tests/Unit/Commands/NewCommandInstallTest.php new file mode 100644 index 0000000..8a77f6f --- /dev/null +++ b/tests/Unit/Commands/NewCommandInstallTest.php @@ -0,0 +1,259 @@ +sandbox = new Sandbox; + $this->sandbox->fakeTools(); + + $this->tester = new CommandTester(Application::create()->find('new')); +}); + +afterEach(function (): void { + Prompt::fallbackWhen(false); + $this->sandbox->restore(); +}); + +it('installs a project with Composer then runs the artisan setup commands', function (): void { + $status = $this->tester->execute(['name' => 'demo'], ['interactive' => false]); + $calls = $this->sandbox->calls(); + + expect($status)->toBe(0) + ->and($calls)->toContain('composer create-project pollora/pollora demo --remove-vcs --prefer-dist --no-scripts --stability=beta --no-ansi') + ->and($calls)->toContain('php artisan pollora:env:setup --no-interaction') + ->and($calls)->toContain('php artisan pollora:install --no-interaction') + ->and($this->tester->getDisplay())->toContain('Pollora was installed successfully') + ->and($this->tester->getDisplay())->toContain('cd demo') + ->and($this->tester->getDisplay())->not->toContain('ddev.site') + ->and((string) file_get_contents($this->sandbox->work.'/demo/.env'))->toContain('APP_KEY=base64:'); +}); + +it('pins the requested version and the stable channel on the create-project call', function (): void { + $this->tester->execute(['name' => 'demo', '--ver' => '13.1.0'], ['interactive' => false]); + $this->tester->execute(['name' => 'other', '--stable' => true], ['interactive' => false]); + + expect($this->sandbox->calls()) + ->toContain('composer create-project pollora/pollora:v13.1.0 demo --remove-vcs --prefer-dist --no-scripts --no-ansi') + ->toContain('composer create-project pollora/pollora other --remove-vcs --prefer-dist --no-scripts --no-ansi'); +}); + +it('forwards --quiet to Composer and leaves ANSI on when the output is decorated', function (): void { + $this->tester->execute(['name' => 'demo', '--quiet' => true], ['interactive' => false, 'decorated' => true]); + + $calls = $this->sandbox->calls(); + + expect($calls)->toContain('--stability=beta --quiet') + ->and($calls)->not->toContain('--no-ansi'); +}); + +it('fails with a message when Composer leaves no project behind', function (): void { + $this->sandbox->env('FAKE_NO_INSTALL', '1'); + + $status = $this->tester->execute(['name' => 'demo'], ['interactive' => false]); + + expect($status)->toBe(1) + ->and($this->tester->getDisplay())->toContain('problem installing Pollora') + ->and($this->sandbox->calls())->not->toContain('artisan'); +}); + +it('fails when an artisan setup command fails', function (): void { + $this->sandbox->env('FAKE_FAIL_ON', 'pollora:install'); + + $status = $this->tester->execute(['name' => 'demo'], ['interactive' => false]); + + expect($status)->toBe(1) + ->and($this->tester->getDisplay())->toContain('php artisan pollora:install failed'); +}); + +it('replaces an existing directory when --force is given', function (): void { + mkdir($this->sandbox->work.'/demo'); + file_put_contents($this->sandbox->work.'/demo/old.txt', 'old'); + + $status = $this->tester->execute(['name' => 'demo', '--force' => true], ['interactive' => false]); + + expect($status)->toBe(0) + ->and(file_exists($this->sandbox->work.'/demo/old.txt'))->toBeFalse() + ->and(is_file($this->sandbox->work.'/demo/artisan'))->toBeTrue(); +}); + +it('refuses --force in the current directory', function (): void { + $status = $this->tester->execute(['name' => '.', '--force' => true], ['interactive' => false]); + + expect($status)->toBe(1) + ->and($this->sandbox->calls())->toBe(''); +}); + +it('refuses to install into a current directory that already holds a project', function (): void { + file_put_contents($this->sandbox->work.'/composer.json', '{}'); + + $status = $this->tester->execute(['name' => '.'], ['interactive' => false]); + + expect($status)->toBe(1) + ->and($this->tester->getDisplay())->toContain('already exists'); +}); + +it('installs into the current directory when the name is a dot', function (): void { + $status = $this->tester->execute(['name' => '.'], ['interactive' => false]); + + expect($status)->toBe(0) + ->and($this->sandbox->calls())->toContain('composer create-project pollora/pollora . ') + ->and(is_file($this->sandbox->work.'/artisan'))->toBeTrue(); +}); + +it('uses a composer.phar found in the current directory', function (): void { + file_put_contents( + $this->sandbox->work.'/composer.phar', + 'tester->execute(['name' => 'demo'], ['interactive' => false]); + + expect($status)->toBe(0) + ->and($this->sandbox->calls())->not->toContain('composer create-project'); +}); + +it('initialises a Git repository on the requested branch with --git', function (): void { + $status = $this->tester->execute(['name' => 'demo', '--git' => true, '--branch' => 'trunk'], ['interactive' => false]); + $calls = $this->sandbox->calls(); + + expect($status)->toBe(0) + ->and($calls)->toContain('git --version') + ->and($calls)->toContain('git init -q') + ->and($calls)->toContain('git add .') + ->and($calls)->toContain('git commit -q -m Initial Pollora project') + ->and($calls)->toContain('git branch -M trunk'); +}); + +it('skips the Git repository when git is not available', function (): void { + $this->sandbox->env('FAKE_FAIL_ON', '--version'); + + $status = $this->tester->execute(['name' => 'demo', '--git' => true], ['interactive' => false]); + + expect($status)->toBe(0) + ->and($this->sandbox->calls())->not->toContain('git init'); +}); + +it('installs a DDEV project and points its environment at the DDEV database', function (): void { + $status = $this->tester->execute(['name' => 'demo', '--ddev' => true], ['interactive' => false]); + $calls = $this->sandbox->calls(); + $env = (string) file_get_contents($this->sandbox->work.'/demo/.env'); + + expect($status)->toBe(0) + ->and($calls)->toContain('ddev config --project-name=demo --project-type=wordpress') + ->and($calls)->toContain('ddev start') + ->and($calls)->toContain('ddev composer create-project pollora/pollora --no-interaction --remove-vcs --prefer-dist --no-scripts --stability=beta') + ->and($calls)->toContain('ddev composer update pollora/framework --no-scripts') + ->and($calls)->toContain('ddev exec php artisan pollora:env-setup --install') + ->and($calls)->toContain('ddev exec php artisan pollora:install --no-interaction') + ->and($calls)->toContain('ddev exec mysql') + ->and($env)->toContain('DB_HOST=db') + ->and($env)->toContain('DB_PASSWORD=db') + ->and($env)->toContain('APP_URL=https://demo.ddev.site') + ->and($env)->toContain('APP_KEY=base64:') + ->and($this->tester->getDisplay())->toContain('https://demo.ddev.site') + ->and($this->tester->getDisplay())->toContain('ddev launch'); +}); + +it('still finishes a DDEV install when the skeleton ships no env file', function (): void { + $this->sandbox->env('FAKE_NO_ENV', '1'); + + $status = $this->tester->execute(['name' => 'demo', '--ddev' => true], ['interactive' => false]); + + expect($status)->toBe(0) + ->and(file_exists($this->sandbox->work.'/demo/.env'))->toBeFalse(); +}); + +it('fails when --ddev is asked and DDEV is not installed', function (): void { + $this->sandbox->onlyFakePath(); + unlink($this->sandbox->bin.'/ddev'); + + $status = $this->tester->execute(['name' => 'demo', '--ddev' => true], ['interactive' => false]); + + expect($status)->toBe(1) + ->and(is_dir($this->sandbox->work.'/demo'))->toBeFalse(); +}); + +it('fails when the DDEV Composer install leaves no project behind', function (): void { + $this->sandbox->env('FAKE_NO_INSTALL', '1'); + + $status = $this->tester->execute(['name' => 'demo', '--ddev' => true], ['interactive' => false]); + + expect($status)->toBe(1) + ->and($this->sandbox->calls())->not->toContain('pollora:install'); +}); + +/** + * Run the `new` command with typed answers, going through the prompt fallbacks + * (the same path the CLI takes without a real terminal). + * + * @param array $arguments + * @return array{int, string} + */ +function runNewWithAnswers(array $arguments, string $answers): array +{ + $command = Application::create()->find('new'); + $command->mergeApplicationDefinition(); + + $input = new ArrayInput(['command' => 'new'] + $arguments, $command->getDefinition()); + $input->setInteractive(false); + + $output = new BufferedOutput; + + $invoke = static fn (string $method, mixed ...$args): mixed => (new ReflectionMethod($command, $method))->invoke($command, ...$args); + + // initialize() switches the prompts to their fallbacks while the input is not interactive... + $invoke('initialize', $input, $output); + + // ...then the answers are fed to them as if typed in a terminal. + $stream = fopen('php://memory', 'r+'); + fwrite($stream, $answers); + rewind($stream); + $input->setStream($stream); + $input->setInteractive(true); + + $invoke('interact', $input, $output); + $status = $invoke('execute', $input, $output); + + return [(int) $status, $output->fetch()]; +} + +it('asks for the project name, rejects invalid ones and then installs', function (): void { + // Empty (required), "a b" (invalid characters), "ac" (valid). DDEV: no. Git: no. + [$status, $display] = runNewWithAnswers([], "\na b\nac\nno\nno\n"); + + expect($status)->toBe(0) + ->and($display)->toContain('The project name is required.') + ->and($display)->toContain('letters, numbers, dashes, underscores, and periods') + ->and(is_file($this->sandbox->work.'/ac/artisan'))->toBeTrue() + ->and($this->sandbox->calls())->toContain('php artisan pollora:install') + ->and($this->sandbox->calls())->not->toContain('--no-interaction') + ->and($this->sandbox->calls())->not->toContain('ddev config') + ->and($this->sandbox->calls())->not->toContain('git init'); +}); + +it('offers DDEV and Git interactively and follows the answers', function (): void { + [$status] = runNewWithAnswers(['name' => 'demo'], "yes\nyes\n"); + + expect($status)->toBe(0) + ->and($this->sandbox->calls())->toContain('ddev config') + ->and($this->sandbox->calls())->toContain('git init -q'); +}); + +it('does not offer DDEV interactively when it is not installed', function (): void { + $this->sandbox->onlyFakePath(); + unlink($this->sandbox->bin.'/ddev'); + + // Only the Git question is asked. + [$status] = runNewWithAnswers(['name' => 'demo'], "no\n"); + + expect($status)->toBe(0) + ->and($this->sandbox->calls())->not->toContain('ddev') + ->and($this->sandbox->calls())->not->toContain('git init'); +}); diff --git a/tests/Unit/Commands/SelfUpdateCommandRunTest.php b/tests/Unit/Commands/SelfUpdateCommandRunTest.php new file mode 100644 index 0000000..c5156f6 --- /dev/null +++ b/tests/Unit/Commands/SelfUpdateCommandRunTest.php @@ -0,0 +1,55 @@ +sandbox = new Sandbox; + $this->sandbox->fakeTools(); + $this->sandbox->script('composer', <<<'SH' +echo "composer $*" >> "$FAKE_LOG" +[ -z "$FAKE_FAIL_ON" ] || { echo "composer broke"; exit 1; } +echo "composer ok" +SH); + $this->tester = new CommandTester(Application::create()->find('self-update')); +}); + +afterEach(function (): void { + $this->sandbox->restore(); +}); + +it('updates through composer global require when the latest version is unknown', function (): void { + $status = $this->tester->execute([]); + $display = $this->tester->getDisplay(); + + expect($status)->toBe(0) + ->and($display)->toContain('Current version:') + ->and($display)->toContain('Checking for updates...') + ->and($display)->toContain('composer ok') + ->and($display)->toContain('Pollora CLI updated successfully!') + ->and($this->sandbox->calls())->toContain('composer global require pollora/cli --no-interaction'); +}); + +it('is also reachable as self:update', function (): void { + $application = Application::create(); + + expect($application->find('self:update'))->toBe($application->find('self-update')); +}); + +it('reports the manual command when composer fails', function (): void { + $this->sandbox->env('FAKE_FAIL_ON', '1'); + + $status = $this->tester->execute([]); + $display = $this->tester->getDisplay(); + + expect($status)->toBe(1) + ->and($display)->toContain('composer broke') + ->and($display)->toContain('Failed to update Pollora CLI.') + ->and($display)->toContain('composer global require pollora/cli'); +}); diff --git a/tests/Unit/ConfiguresPromptsTest.php b/tests/Unit/ConfiguresPromptsTest.php new file mode 100644 index 0000000..71fc5bd --- /dev/null +++ b/tests/Unit/ConfiguresPromptsTest.php @@ -0,0 +1,102 @@ +configurePrompts($input, $output); + } + + public function until(Closure $prompt, bool|string $required, ?Closure $validate, BufferedOutput $output): mixed + { + return $this->promptUntilValid($prompt, $required, $validate, $output); + } + }; + + $stream = fopen('php://memory', 'r+'); + fwrite($stream, $answers); + rewind($stream); + + $input = new ArrayInput([]); + $input->setInteractive(false); + $output ??= new BufferedOutput; + $host->setUp($input, $output); + + $input->setStream($stream); + $input->setInteractive(true); + + return $host; +} + +it('answers a select prompt from the typed choice', function (): void { + promptHost("b\n"); + + expect((new SelectPrompt('Pick one', ['a' => 'Alpha', 'b' => 'Beta'], 'a'))->prompt())->toBe('b'); +}); + +it('answers a confirm prompt from the typed answer', function (): void { + promptHost("no\n"); + + expect((new ConfirmPrompt('Sure?', default: true))->prompt())->toBeFalse(); +}); + +it('asks again until a required answer is given', function (): void { + $output = new BufferedOutput; + $answers = ['', 'value']; + + $result = promptHost('', $output)->until(static function () use (&$answers): string { + return array_shift($answers); + }, 'Please answer.', null, $output); + + expect($result)->toBe('value') + ->and($output->fetch())->toContain('Please answer.'); +}); + +it('uses a generic message when a required answer has no custom one', function (): void { + $output = new BufferedOutput; + $answers = [false, true]; + + $result = promptHost('', $output)->until(static function () use (&$answers): bool { + return array_shift($answers); + }, true, null, $output); + + expect($result)->toBeTrue() + ->and($output->fetch())->toContain('Required.'); +}); + +it('asks again until the validator accepts the answer', function (): void { + $output = new BufferedOutput; + $answers = ['bad', 'good']; + + $result = promptHost('', $output)->until( + static function () use (&$answers): string { + return array_shift($answers); + }, + false, + static fn (string $value): ?string => $value === 'bad' ? 'That is bad.' : null, + $output, + ); + + expect($result)->toBe('good') + ->and($output->fetch())->toContain('That is bad.'); +}); diff --git a/tests/Unit/ProjectProxyTest.php b/tests/Unit/ProjectProxyTest.php new file mode 100644 index 0000000..7953e70 --- /dev/null +++ b/tests/Unit/ProjectProxyTest.php @@ -0,0 +1,181 @@ +work.'/project'; + mkdir($project.'/vendor/pollora/framework', 0755, true); + file_put_contents($project.'/artisan', " [ + ['name' => 'list', 'description' => 'List commands'], + ['name' => 'pollora:status', 'description' => 'Show the status'], + ['name' => 'pollora:new', 'description' => 'Clashes with a global command'], + ['name' => 'pollora:bare'], + ['name' => 'migrate', 'description' => 'Not a Pollora command'], + ]]); +} +PHP; + +beforeEach(function (): void { + $this->sandbox = new Sandbox; + $this->sandbox->fakeTools(); + + $this->argv = $_SERVER['argv'] ?? []; +}); + +afterEach(function (): void { + $_SERVER['argv'] = $this->argv; + $this->sandbox->restore(); +}); + +it('proxies the command and its arguments to the project artisan', function (): void { + $project = fakeProject($this->sandbox, FAKE_ARTISAN_RECORDING); + $_SERVER['argv'] = ['pollora', 'status', '--flag', 'value']; + + $proxy = new ProxyCommand(new BufferedOutput, new ProjectDetector($project)); + $status = $proxy->execute(new ArgvInput($_SERVER['argv'])); + + expect($status)->toBe(0) + ->and(json_decode((string) file_get_contents($project.'/call.json'), true))->toBe(['pollora:status', '--flag', 'value']); +}); + +it('returns the exit code of the proxied artisan command', function (): void { + $project = fakeProject($this->sandbox, FAKE_ARTISAN_RECORDING); + $_SERVER['argv'] = ['pollora', 'status', 'fail']; + + $proxy = new ProxyCommand(new BufferedOutput, new ProjectDetector($project)); + + expect($proxy->execute(new ArgvInput($_SERVER['argv'])))->toBe(7); +}); + +it('does not forward arguments it cannot read from a command line', function (): void { + $project = fakeProject($this->sandbox, FAKE_ARTISAN_RECORDING); + + $proxy = new ProxyCommand(new BufferedOutput, new ProjectDetector($project)); + $status = $proxy->execute(new ArrayInput(['command' => 'status'])); + + expect($status)->toBe(0) + ->and(json_decode((string) file_get_contents($project.'/call.json'), true))->toBe(['pollora:status']); +}); + +it('fails when asked to proxy without any command', function (): void { + $project = fakeProject($this->sandbox, FAKE_ARTISAN_RECORDING); + + $proxy = new ProxyCommand(new BufferedOutput, new ProjectDetector($project)); + + expect($proxy->execute(new ArrayInput([])))->toBe(1) + ->and(file_exists($project.'/call.json'))->toBeFalse(); +}); + +it('runs the command inside DDEV when the project has a .ddev directory', function (): void { + $project = fakeProject($this->sandbox, FAKE_ARTISAN_RECORDING); + mkdir($project.'/.ddev'); + $this->sandbox->script('ddev', 'echo "ddev $*" >> "$FAKE_LOG"; exit 3'); + $_SERVER['argv'] = ['pollora', 'status', '-v']; + + $proxy = new ProxyCommand(new BufferedOutput, new ProjectDetector($project)); + $status = $proxy->execute(new ArgvInput($_SERVER['argv'])); + + expect($status)->toBe(3) + ->and($this->sandbox->calls())->toContain('ddev exec php artisan pollora:status -v') + ->and(file_exists($project.'/call.json'))->toBeFalse(); +}); + +it('discovers the pollora commands of the project by their short name', function (): void { + $project = fakeProject($this->sandbox, FAKE_ARTISAN_RECORDING); + + $commands = (new ArtisanCommandDiscoverer(new ProjectDetector($project)))->discover(); + + expect($commands)->toBe([ + 'status' => 'Show the status', + 'new' => 'Clashes with a global command', + 'bare' => '', + ]); +}); + +it('discovers nothing when artisan fails', function (): void { + $project = fakeProject($this->sandbox, "exit(1);\n"); + + expect((new ArtisanCommandDiscoverer(new ProjectDetector($project)))->discover())->toBe([]); +}); + +it('discovers nothing when artisan does not answer with JSON', function (): void { + $project = fakeProject($this->sandbox, "echo 'not json';\n"); + + expect((new ArtisanCommandDiscoverer(new ProjectDetector($project)))->discover())->toBe([]); +}); + +it('discovers nothing when the JSON answer lists no commands', function (): void { + $project = fakeProject($this->sandbox, "echo '{}';\n"); + + expect((new ArtisanCommandDiscoverer(new ProjectDetector($project)))->discover())->toBe([]); +}); + +it('hands any other command over to the project when run inside a Pollora project', function (): void { + $project = fakeProject($this->sandbox, FAKE_ARTISAN_RECORDING); + chdir($project); + $_SERVER['argv'] = ['pollora', 'status']; + + $application = Application::create(); + $application->setAutoExit(false); + + $status = $application->doRun(new ArgvInput($_SERVER['argv']), new BufferedOutput); + + expect($status)->toBe(0) + ->and(json_decode((string) file_get_contents($project.'/call.json'), true))->toBe(['pollora:status']); +}); + +it('lists the project commands next to the global ones inside a Pollora project', function (): void { + $project = fakeProject($this->sandbox, FAKE_ARTISAN_RECORDING); + chdir($project); + $_SERVER['argv'] = ['pollora', 'list']; + + $application = Application::create(); + $application->setAutoExit(false); + + $output = new BufferedOutput; + $status = $application->doRun(new ArgvInput($_SERVER['argv']), $output); + $display = $output->fetch(); + + expect($status)->toBe(0) + ->and($display)->toContain('Show the status') + ->and($display)->not->toContain('Clashes with a global command') + ->and($display)->toContain('Create a new Pollora application'); +}); + +it('runs its own commands outside a Pollora project', function (): void { + $_SERVER['argv'] = ['pollora', 'list']; + + $application = Application::create(); + $application->setAutoExit(false); + + $output = new BufferedOutput; + $status = $application->doRun(new ArgvInput($_SERVER['argv']), $output); + + expect($status)->toBe(0) + ->and($output->fetch())->not->toContain('Show the status'); +}); diff --git a/tests/Unit/VersionLookupTest.php b/tests/Unit/VersionLookupTest.php new file mode 100644 index 0000000..e202e78 --- /dev/null +++ b/tests/Unit/VersionLookupTest.php @@ -0,0 +1,154 @@ + [ + ['name' => 'monolog/monolog', 'version' => '3.0.0'], + ['name' => 'pollora/cli', 'version' => $version], + ]]); +} + +it('reads its version from the lock file of the project that installed it', function (): void { + FileOverlay::install([LOCK_SUFFIX => lockWith('v1.2.3')]); + + expect(Version::get())->toBe('v1.2.3'); +}); + +it('falls back to the composer.json version when the lock does not list the CLI', function (): void { + FileOverlay::install([ + LOCK_SUFFIX => (string) json_encode(['packages' => [['name' => 'monolog/monolog', 'version' => '3.0.0']]]), + COMPOSER_SUFFIX => '{"name":"pollora/cli","version":"9.9.9"}', + ]); + + expect(Version::get())->toBe('9.9.9'); +}); + +it('falls back to dev-main when neither the lock nor composer.json gives a version', function (string $lock): void { + FileOverlay::install([LOCK_SUFFIX => $lock, COMPOSER_SUFFIX => '{"name":"pollora/cli"}']); + + expect(Version::get())->toBe('dev-main'); +})->with([ + 'lock is not JSON' => 'nope', + 'lock has no packages' => '{}', + 'lock is unreadable JSON scalar' => '"text"', +]); + +it('falls back to dev-main when the lock file cannot be read', function (): void { + FileOverlay::install([LOCK_SUFFIX => false, COMPOSER_SUFFIX => '{"name":"pollora/cli"}']); + + // file_get_contents() warns about the unreadable lock before returning false. + set_error_handler(static fn (): bool => true); + + try { + expect(Version::get())->toBe('dev-main'); + } finally { + restore_error_handler(); + } +}); + +it('reports UNKNOWN when composer.json is missing, unreadable or invalid', function (string|false|null $composer): void { + FileOverlay::install([COMPOSER_SUFFIX => $composer]); + set_error_handler(static fn (): bool => true); + + try { + expect(Version::get())->toBe('UNKNOWN'); + } finally { + restore_error_handler(); + } +})->with([ + 'missing' => [null], + 'unreadable' => [false], + 'invalid JSON' => ['not json'], +]); + +describe('update notification', function (): void { + beforeEach(function (): void { + $this->sandbox = new Sandbox; + // Packagist is never reached: the proxy is a closed local port. + mkdir($this->sandbox->root.'/home/.pollora', 0755, true); + }); + + afterEach(function (): void { + $this->sandbox->restore(); + }); + + function seedUpdateCache(string $home, string $version, int $checkedAt): void + { + file_put_contents($home.'/.pollora/update-check.json', (string) json_encode([ + 'version' => $version, + 'checked_at' => $checkedAt, + ])); + } + + it('announces a newer version found in a fresh cache', function (): void { + seedUpdateCache($this->sandbox->root.'/home', 'v2.0.0', time()); + FileOverlay::install([LOCK_SUFFIX => lockWith('v1.2.3')]); + + $output = new BufferedOutput; + UpdateChecker::notify($output); + + expect($output->fetch()) + ->toContain('A new version of Pollora CLI is available: v2.0.0 (current: v1.2.3)') + ->toContain('pollora self-update'); + }); + + it('stays silent when the cached version is not newer than the current one', function (string $cached): void { + seedUpdateCache($this->sandbox->root.'/home', $cached, time()); + FileOverlay::install([LOCK_SUFFIX => lockWith('v1.2.3')]); + + $output = new BufferedOutput; + UpdateChecker::notify($output); + + expect($output->fetch())->toBe(''); + })->with(['v1.2.3', '1.0.0']); + + it('stays silent when the current version is not a release number', function (): void { + seedUpdateCache($this->sandbox->root.'/home', 'v2.0.0', time()); + FileOverlay::install([LOCK_SUFFIX => lockWith('dev-main')]); + + $output = new BufferedOutput; + UpdateChecker::notify($output); + + expect($output->fetch())->toBe(''); + }); + + it('stays silent when the cache is stale and Packagist cannot be reached', function (string $content): void { + file_put_contents($this->sandbox->root.'/home/.pollora/update-check.json', $content); + FileOverlay::install([LOCK_SUFFIX => lockWith('v1.2.3')]); + + $output = new BufferedOutput; + UpdateChecker::notify($output); + + expect($output->fetch())->toBe('') + ->and((string) file_get_contents($this->sandbox->root.'/home/.pollora/update-check.json'))->toBe($content); + })->with([ + 'expired entry' => [(string) json_encode(['version' => 'v9.0.0', 'checked_at' => 1])], + 'corrupt file' => ['not json'], + 'incomplete entry' => ['{"version":"v9.0.0"}'], + ]); + + it('stays silent when there is no cache and Packagist cannot be reached', function (): void { + $output = new BufferedOutput; + UpdateChecker::notify($output); + + expect($output->fetch())->toBe('') + ->and(file_exists($this->sandbox->root.'/home/.pollora/update-check.json'))->toBeFalse(); + }); +});