diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml new file mode 100644 index 0000000..1d6666c --- /dev/null +++ b/.github/workflows/acceptance.yml @@ -0,0 +1,81 @@ +--- + +name: Acceptance Tests + +on: # yamllint disable-line rule:truthy + pull_request: + push: + branches: [2.x] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + acceptance: + name: PHP ${{ matrix.php }}, ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + php: ['8.2', '8.5'] + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: sockets + coverage: none + + - name: Install Composer dependencies + uses: ramsey/composer-install@v3 + with: + dependency-versions: highest + + # rr runs against a fake GitHub started by the tests (tests/Acceptance/Server) + - name: Run acceptance tests + run: php vendor/bin/testo --suite=Acceptance + + live: + name: Live GitHub, ${{ matrix.os }} + if: github.event_name == 'workflow_dispatch' + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: sockets + coverage: none + + - name: Install Composer dependencies + uses: ramsey/composer-install@v3 + with: + dependency-versions: highest + + - name: Run live tests + run: php vendor/bin/testo --suite=Live + env: + RR_CLI_LIVE_TESTS: '1' + GITHUB_TOKEN: ${{ github.token }} diff --git a/.github/workflows/testo.yml b/.github/workflows/testo.yml index 8db815b..f64153d 100644 --- a/.github/workflows/testo.yml +++ b/.github/workflows/testo.yml @@ -15,3 +15,4 @@ jobs: with: os: >- ['ubuntu-latest'] + run: vendor/bin/testo --suite=Unit diff --git a/infection.json5 b/infection.json5 index b6307ae..06f2696 100644 --- a/infection.json5 +++ b/infection.json5 @@ -4,6 +4,7 @@ "directories": ["src"] }, "testFramework": "testo", + "testFrameworkOptions": "--suite=Unit", "tmpDir": "runtime", "timeout": 10, "logs": { diff --git a/testo.php b/testo.php index 0320172..3959f13 100644 --- a/testo.php +++ b/testo.php @@ -5,12 +5,26 @@ use Testo\Application\Config\ApplicationConfig; use Testo\Application\Config\SuiteConfig; +$suites = [ + new SuiteConfig( + name: 'Unit', + location: ['tests/Unit'], + ), + new SuiteConfig( + name: 'Acceptance', + location: ['tests/Acceptance'], + ), +]; + +# Downloads real RoadRunner releases from GitHub, so it runs only on request +if (\filter_var(\getenv('RR_CLI_LIVE_TESTS') ?: '0', \FILTER_VALIDATE_BOOLEAN)) { + $suites[] = new SuiteConfig( + name: 'Live', + location: ['tests/Live'], + ); +} + return new ApplicationConfig( src: ['src'], - suites: [ - new SuiteConfig( - name: 'Unit', - location: ['tests/Unit'], - ), - ], + suites: $suites, ); diff --git a/tests/Acceptance/ConfigurationTest.php b/tests/Acceptance/ConfigurationTest.php new file mode 100644 index 0000000..83ee974 --- /dev/null +++ b/tests/Acceptance/ConfigurationTest.php @@ -0,0 +1,71 @@ +rr()->run(self::ARGS); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::array($this->dir->configSections())->contains('version')->contains('server')->contains('http')->contains('kv'); + } + + public function skipsConfigurationWithNoConfigOption(): void + { + $result = $this->rr()->run([...self::ARGS, '--no-config']); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::true($this->dir->has('rr')); + Assert::false($this->dir->has('.rr.yaml')); + } + + #[DataSet([['-p', 'http', '-p', 'jobs']], 'plugins')] + #[DataSet([['--preset=web']], 'preset')] + public function generatesConfigurationForSelectedPlugins(array $options): void + { + $result = $this->rr()->run([...self::ARGS, ...$options]); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::array($this->dir->configSections())->contains('http')->contains('jobs')->notContains('kv'); + } + + public function keepsExistingConfiguration(): void + { + $this->dir->write('.rr.yaml', "version: '3'\n# mine\n"); + + $result = $this->rr()->run(self::ARGS); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::true($this->dir->has('rr')); + Assert::same($this->dir->read('.rr.yaml'), "version: '3'\n# mine\n"); + } + + #[DataSet(['yes', true])] + #[DataSet(['no', false])] + public function asksWhetherToCreateConfiguration(string $answer, bool $created): void + { + $result = $this->rr()->run(self::ARGS, [$answer]); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::string($result->output())->matchesRegex('/\.rr\.yaml/'); + Assert::true($this->dir->has('rr')); + Assert::same($this->dir->has('.rr.yaml'), $created); + } +} diff --git a/tests/Acceptance/ExistingBinaryTest.php b/tests/Acceptance/ExistingBinaryTest.php new file mode 100644 index 0000000..7aaf5ec --- /dev/null +++ b/tests/Acceptance/ExistingBinaryTest.php @@ -0,0 +1,65 @@ +dir->write('rr', self::OLD); + + $result = $this->rr()->run(self::ARGS); + + Assert::string($result->output())->matchesRegex('/already exists/i'); + Assert::same($this->dir->read('rr'), self::OLD); + } + + public function keepsBinaryWhenOverwriteIsDeclined(): void + { + $this->dir->write('rr', self::OLD); + + $result = $this->rr()->run(self::ARGS, ['no']); + + Assert::string($result->output())->matchesRegex('/already exists/i'); + Assert::same($this->dir->read('rr'), self::OLD); + } + + #[DataSet([null], 'no interaction')] + #[DataSet([['no']], 'declined')] + #[Skip('A kept binary exits with 0 and reports success, see https://github.com/roadrunner-php/cli/issues/69')] + public function failsWhenBinaryIsKept(?array $answers): void + { + $this->dir->write('rr', self::OLD); + + $result = $this->rr()->run(self::ARGS, $answers); + + Assert::same($result->exitCode, 1, (string) $result); + } + + public function overwritesBinaryWhenConfirmed(): void + { + $this->dir->write('rr', self::OLD); + + $result = $this->rr()->run(self::ARGS, ['yes']); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::same(\trim($this->dir->read('rr')), 'fake rr 3.4.0 linux amd64'); + } +} diff --git a/tests/Acceptance/FailureTest.php b/tests/Acceptance/FailureTest.php new file mode 100644 index 0000000..f78d2ea --- /dev/null +++ b/tests/Acceptance/FailureTest.php @@ -0,0 +1,81 @@ +rr(FakeGitHub::url('rate-limit'))->run(self::ARGS); + + Assert::notSame($result->exitCode, 0, (string) $result); + Assert::string($result->output())->matchesRegex('/403|rate limit/i'); + Assert::same($this->dir->files(), []); + } + + public function failsWhenAssetIsMissing(): void + { + $result = $this->rr(FakeGitHub::url('missing-asset'))->run(self::ARGS); + + Assert::notSame($result->exitCode, 0, (string) $result); + Assert::string($result->output())->matchesRegex('/404|not found/i'); + Assert::false($this->dir->has('rr')); + } + + public function failsWhenGitHubIsUnreachable(): void + { + $result = $this->rr(FakeGitHub::unreachableUrl())->run(self::ARGS); + + Assert::notSame($result->exitCode, 0, (string) $result); + Assert::same($this->dir->files(), []); + } + + public function rejectsMissingLocationWithoutRequests(): void + { + $result = $this->rr()->run([...self::ARGS, '--location=does-not-exist']); + + Assert::notSame($result->exitCode, 0, (string) $result); + Assert::same($this->dir->files(), []); + Assert::same(FakeGitHub::requests(), []); + } + + #[DataSet([['--os=plan9']], 'operating system')] + #[DataSet([['--arch=sparc']], 'architecture')] + public function failsForUnknownPlatform(array $options): void + { + $result = $this->rr()->run([...self::ARGS, ...$options]); + + Assert::notSame($result->exitCode, 0, (string) $result); + Assert::same($this->dir->files(), []); + Assert::same(FakeGitHub::downloads(), []); + } + + #[DataSet([['--os=plan9']], 'operating system')] + #[DataSet([['--arch=sparc']], 'architecture')] + #[Skip('An unknown --os or --arch still fetches the releases, see https://github.com/roadrunner-php/cli/issues/68')] + public function rejectsUnknownPlatformWithoutRequests(array $options): void + { + $result = $this->rr()->run([...self::ARGS, ...$options]); + + Assert::notSame($result->exitCode, 0, (string) $result); + Assert::same($this->dir->files(), []); + Assert::same(FakeGitHub::requests(), []); + } +} diff --git a/tests/Acceptance/Server/releases.json b/tests/Acceptance/Server/releases.json new file mode 100644 index 0000000..90047a7 --- /dev/null +++ b/tests/Acceptance/Server/releases.json @@ -0,0 +1,75 @@ +[ + { + "tag_name": "v3.5.0-rc.1", + "prerelease": true, + "assets": [ + "roadrunner-3.5.0-rc.1-linux-amd64.tar.gz", + "roadrunner-3.5.0-rc.1-linux-arm64.tar.gz", + "roadrunner-3.5.0-rc.1-darwin-arm64.tar.gz", + "roadrunner-3.5.0-rc.1-windows-amd64.zip" + ] + }, + { + "tag_name": "v3.5.0-beta.1", + "prerelease": true, + "assets": [ + "roadrunner-3.5.0-beta.1-linux-amd64.tar.gz", + "roadrunner-3.5.0-beta.1-linux-arm64.tar.gz", + "roadrunner-3.5.0-beta.1-darwin-arm64.tar.gz", + "roadrunner-3.5.0-beta.1-windows-amd64.zip" + ] + }, + { + "tag_name": "v3.4.0", + "prerelease": false, + "assets": [ + "protoc-gen-php-grpc-3.4.0-linux-amd64.tar.gz", + "protoc-gen-php-grpc-3.4.0-linux-arm64.tar.gz", + "roadrunner-3.4.0-linux-amd64.deb", + "roadrunner-3.4.0-linux-arm64.deb", + "roadrunner-3.4.0-linux-amd64.tar.gz", + "roadrunner-3.4.0-darwin-arm64.tar.gz", + "roadrunner-3.4.0-windows-amd64.zip" + ] + }, + { + "tag_name": "v2025.1.5", + "prerelease": false, + "assets": [ + "roadrunner-2025.1.5-linux-amd64.tar.gz", + "roadrunner-2025.1.5-linux-arm64.tar.gz", + "roadrunner-2025.1.5-darwin-arm64.tar.gz", + "roadrunner-2025.1.5-windows-amd64.zip" + ] + }, + { + "tag_name": "v3.2.0", + "prerelease": false, + "assets": [ + "roadrunner-3.2.0-linux-amd64.tar.gz", + "roadrunner-3.2.0-linux-arm64.tar.gz", + "roadrunner-3.2.0-darwin-arm64.tar.gz", + "roadrunner-3.2.0-windows-amd64.zip" + ] + }, + { + "tag_name": "v3.1.0", + "prerelease": false, + "assets": [ + "roadrunner-3.1.0-linux-amd64.tar.gz", + "roadrunner-3.1.0-linux-arm64.tar.gz", + "roadrunner-3.1.0-darwin-arm64.tar.gz", + "roadrunner-3.1.0-windows-amd64.zip" + ] + }, + { + "tag_name": "v2.12.3", + "prerelease": false, + "assets": [ + "roadrunner-2.12.3-linux-amd64.tar.gz", + "roadrunner-2.12.3-linux-arm64.tar.gz", + "roadrunner-2.12.3-darwin-amd64.zip", + "roadrunner-2.12.3-windows-amd64.zip" + ] + } +] diff --git a/tests/Acceptance/Server/router.php b/tests/Acceptance/Server/router.php new file mode 100644 index 0000000..89bc072 --- /dev/null +++ b/tests/Acceptance/Server/router.php @@ -0,0 +1,151 @@ + router.php`. + * + * The first path segment selects a scenario, so one server serves every test through a different base URL: + * - `github` — releases from releases.json, paginated by `per_page` like GitHub (30 by default, 100 at most); + * - `paged` — the same releases, 2 per page whatever `per_page` asks for; + * - `rate-limit` — every API request fails with the 403 GitHub sends when the rate limit is exhausted; + * - `missing-asset` — releases are listed, but every download is a 404. + * + * Release lists are served at `//repos/roadrunner-server/roadrunner/releases`, assets at + * `//download//`. Archives are built on request: `roadrunner---/rr[.exe]` + * holding the text `fake rr `, so a test can tell which asset ended up installed. + * + * Each request is appended to the JSON Lines file named by the FAKE_GITHUB_LOG environment variable. + * + * No named functions or classes here: Testo includes files that declare functions while it looks for tests. + */ + +declare(strict_types=1); + +$path = (string) \parse_url($_SERVER['REQUEST_URI'], \PHP_URL_PATH); +\parse_str((string) \parse_url($_SERVER['REQUEST_URI'], \PHP_URL_QUERY), $query); +$segments = \explode('/', \trim($path, '/')); +$scenario = \array_shift($segments); +$base = 'http://' . $_SERVER['HTTP_HOST'] . '/' . $scenario; + +$respond = static function (int $status, string $body, array $headers = []) use ($path, $query): void { + $log = \getenv('FAKE_GITHUB_LOG'); + if (\is_string($log) && $log !== '') { + $entry = [ + 'method' => $_SERVER['REQUEST_METHOD'], + 'path' => $path, + 'query' => $query, + 'status' => $status, + 'authorized' => isset($_SERVER['HTTP_AUTHORIZATION']), + ]; + \file_put_contents($log, \json_encode($entry, \JSON_UNESCAPED_SLASHES) . "\n", \FILE_APPEND | \LOCK_EX); + } + + \http_response_code($status); + foreach ($headers as $name => $value) { + \header($name . ': ' . $value); + } + echo $body; +}; + +$archive = static function (string $name): ?string { + $pattern = '/^(roadrunner|protoc-gen-php-grpc)-(.+)-(linux|darwin|freebsd|windows)-(amd64|arm64)\.(tar\.gz|zip)$/'; + if (\preg_match($pattern, $name, $m) !== 1) { + return null; + } + + [, $software, $version, $os, $arch, $format] = $m; + $binary = ($software === 'roadrunner' ? 'rr' : $software) . ($os === 'windows' ? '.exe' : ''); + $root = "$software-$version-$os-$arch"; + + $dir = \sys_get_temp_dir() . '/rr-cli-fake-github'; + @\mkdir($dir, 0777, true); + $file = $dir . '/archive-' . \bin2hex(\random_bytes(8)) . ($format === 'zip' ? '.zip' : '.tar'); + + $phar = new \PharData($file); + $phar->addFromString("$root/$binary", "fake rr $version $os $arch\n"); + $phar->addFromString("$root/LICENSE", "MIT\n"); + if ($format === 'tar.gz') { + $phar->compress(\Phar::GZ); + } + unset($phar); + + $result = $format === 'zip' ? $file : $file . '.gz'; + $content = (string) \file_get_contents($result); + @\unlink($file); + @\unlink($file . '.gz'); + + return $content; +}; + +if ($scenario === 'rate-limit') { + $respond(403, \json_encode([ + 'message' => 'API rate limit exceeded for 127.0.0.1. (But here\'s the good news: Authenticated requests get a higher rate limit.)', + 'documentation_url' => 'https://docs.github.com/rest/overview/resources-in-the-rest-api#rate-limiting', + ]), [ + 'Content-Type' => 'application/json; charset=utf-8', + 'X-RateLimit-Limit' => '60', + 'X-RateLimit-Remaining' => '0', + 'X-RateLimit-Reset' => (string) (\time() + 3600), + ]); + + return true; +} + +if (\implode('/', $segments) === 'repos/roadrunner-server/roadrunner/releases') { + $releases = \json_decode((string) \file_get_contents(__DIR__ . '/releases.json'), true); + $perPage = $scenario === 'paged' ? 2 : \min(100, \max(1, (int) ($query['per_page'] ?? 30))); + $page = \max(1, (int) ($query['page'] ?? 1)); + $last = (int) \ceil(\count($releases) / $perPage); + + $body = []; + foreach (\array_slice($releases, ($page - 1) * $perPage, $perPage) as $release) { + $tag = $release['tag_name']; + $body[] = [ + 'tag_name' => $tag, + 'name' => $tag, + 'draft' => false, + 'prerelease' => $release['prerelease'], + 'assets' => \array_map(static fn(string $asset): array => [ + 'name' => $asset, + 'browser_download_url' => "$base/download/$tag/$asset", + ], $release['assets']), + ]; + } + + $link = static fn(int $to, string $rel): string => \sprintf( + '<%s/repos/roadrunner-server/roadrunner/releases?per_page=%d&page=%d>; rel="%s"', + $base, + $perPage, + $to, + $rel, + ); + $links = []; + if ($page > 1) { + $links[] = $link($page - 1, 'prev'); + $links[] = $link(1, 'first'); + } + if ($page < $last) { + $links[] = $link($page + 1, 'next'); + $links[] = $link($last, 'last'); + } + + $respond(200, \json_encode($body, \JSON_UNESCAPED_SLASHES), \array_filter([ + 'Content-Type' => 'application/json; charset=utf-8', + 'Link' => \implode(', ', $links), + ])); + + return true; +} + +if (($segments[0] ?? '') === 'download' && \count($segments) === 3 && $scenario !== 'missing-asset') { + $content = $archive($segments[2]) ?? \str_repeat("\0", 1024); + $respond(200, $content, [ + 'Content-Type' => 'application/octet-stream', + 'Content-Length' => (string) \strlen($content), + ]); + + return true; +} + +$respond(404, '{"message":"Not Found"}', ['Content-Type' => 'application/json; charset=utf-8']); + +return true; diff --git a/tests/Acceptance/Support/FakeGitHub.php b/tests/Acceptance/Support/FakeGitHub.php new file mode 100644 index 0000000..130f23c --- /dev/null +++ b/tests/Acceptance/Support/FakeGitHub.php @@ -0,0 +1,141 @@ + ['pipe', 'r'], 1 => ['file', self::$output, 'a'], 2 => ['file', self::$output, 'a']], + $pipes, + null, + $env, + ); + \assert(\is_resource($process)); + \fclose($pipes[0]); + + self::$process = $process; + self::$url = "http://127.0.0.1:$port"; + + $deadline = \microtime(true) + 10; + while (($socket = @\fsockopen('127.0.0.1', $port, $errno, $error, 0.2)) === false) { + if (\microtime(true) > $deadline || ! \proc_get_status($process)['running']) { + self::stop(); + throw new \RuntimeException('The fake GitHub server did not start: ' . self::serverOutput()); + } + \usleep(50_000); + } + \fclose($socket); + } + + public static function stop(): void + { + if (self::$process !== null) { + \proc_terminate(self::$process); + \proc_close(self::$process); + self::$process = null; + } + + @\unlink(self::$log); + @\unlink(self::$output); + } + + /** + * Base API URL of a scenario, see the router for the list. + */ + public static function url(string $scenario = 'github'): string + { + return self::$url . '/' . $scenario; + } + + /** + * A base URL nothing listens on. + */ + public static function unreachableUrl(): string + { + return 'http://127.0.0.1:' . self::freePort() . '/github'; + } + + public static function resetRequests(): void + { + \file_put_contents(self::$log, ''); + } + + /** + * @return list + */ + public static function requests(): array + { + $lines = \file(self::$log, \FILE_IGNORE_NEW_LINES | \FILE_SKIP_EMPTY_LINES) ?: []; + + return \array_map(static fn(string $line): array => \json_decode($line, true), $lines); + } + + /** + * @return list + */ + public static function releaseRequests(): array + { + return \array_values(\array_filter( + self::requests(), + static fn(array $request): bool => \str_ends_with($request['path'], '/releases'), + )); + } + + /** + * @return list Names of the downloaded assets. + */ + public static function downloads(): array + { + $names = []; + foreach (self::requests() as $request) { + if (\str_contains($request['path'], '/download/')) { + $names[] = \basename($request['path']); + } + } + + return $names; + } + + private static function serverOutput(): string + { + return (string) @\file_get_contents(self::$output); + } + + private static function freePort(): int + { + $server = \stream_socket_server('tcp://127.0.0.1:0', $errno, $error); + \assert($server !== false, $error); + $name = (string) \stream_socket_get_name($server, false); + \fclose($server); + + return (int) \substr($name, \strrpos($name, ':') + 1); + } +} diff --git a/tests/Acceptance/Support/FakeGitHubCase.php b/tests/Acceptance/Support/FakeGitHubCase.php new file mode 100644 index 0000000..7c93e01 --- /dev/null +++ b/tests/Acceptance/Support/FakeGitHubCase.php @@ -0,0 +1,48 @@ +dir = new Workdir(); + FakeGitHub::resetRequests(); + } + + #[AfterTest] + public function cleanup(): void + { + $this->dir->remove(); + } + + private function rr(?string $apiUrl = null): Rr + { + return new Rr($this->dir->path, $apiUrl ?? FakeGitHub::url()); + } +} diff --git a/tests/Acceptance/Support/Result.php b/tests/Acceptance/Support/Result.php new file mode 100644 index 0000000..cc31a11 --- /dev/null +++ b/tests/Acceptance/Support/Result.php @@ -0,0 +1,24 @@ +stdout . $this->stderr; + } + + public function __toString(): string + { + return \sprintf("exit code %d\n--- stdout ---\n%s\n--- stderr ---\n%s", $this->exitCode, $this->stdout, $this->stderr); + } +} diff --git a/tests/Acceptance/Support/Rr.php b/tests/Acceptance/Support/Rr.php new file mode 100644 index 0000000..af6510c --- /dev/null +++ b/tests/Acceptance/Support/Rr.php @@ -0,0 +1,114 @@ + */ + private array $env; + + /** + * @param string|null $apiUrl Value of RR_GITHUB_API_URL; null leaves the real GitHub API. + */ + public function __construct( + public readonly string $workdir, + ?string $apiUrl, + ) { + $this->env = self::baseEnvironment() + [ + 'DLOAD_CACHE_DIR' => $workdir . '/.dload-cache', + // A fixed width keeps Symfony Console from wrapping messages at the terminal width + 'COLUMNS' => '300', + ]; + + if ($apiUrl !== null) { + $this->env['RR_GITHUB_API_URL'] = $apiUrl; + } + } + + /** + * @return array + */ + public static function baseEnvironment(): array + { + $env = []; + foreach (self::INHERITED as $name) { + $value = \getenv($name); + if (\is_string($value)) { + $env[$name] = $value; + } + } + + return $env; + } + + public function withEnv(string $name, string $value): self + { + $clone = clone $this; + $clone->env[$name] = $value; + + return $clone; + } + + /** + * @param list $args + * @param list|null $answers Lines typed into an interactive session; null runs with `--no-interaction`. + */ + public function run(array $args, ?array $answers = null): Result + { + $env = $this->env; + if ($answers === null) { + $args[] = '--no-interaction'; + } else { + // Symfony Console turns interaction off when stdin is not a TTY, unless this is set + $env['SHELL_INTERACTIVE'] = '1'; + } + + $stdout = (string) \tempnam(\sys_get_temp_dir(), 'rr-out-'); + $stderr = (string) \tempnam(\sys_get_temp_dir(), 'rr-err-'); + + $process = \proc_open( + [\PHP_BINARY, \dirname(__DIR__, 3) . '/bin/rr', ...$args], + [0 => ['pipe', 'r'], 1 => ['file', $stdout, 'w'], 2 => ['file', $stderr, 'w']], + $pipes, + $this->workdir, + $env, + ); + \assert(\is_resource($process)); + + \fwrite($pipes[0], $answers === null ? '' : \implode(\PHP_EOL, $answers) . \PHP_EOL); + \fclose($pipes[0]); + + $deadline = \microtime(true) + self::TIMEOUT; + while (($status = \proc_get_status($process))['running']) { + if (\microtime(true) > $deadline) { + \proc_terminate($process); + \proc_close($process); + throw new \RuntimeException(\sprintf('rr %s timed out', \implode(' ', $args))); + } + \usleep(20_000); + } + \proc_close($process); + + $result = new Result( + $status['exitcode'], + (string) \file_get_contents($stdout), + (string) \file_get_contents($stderr), + ); + @\unlink($stdout); + @\unlink($stderr); + + return $result; + } +} diff --git a/tests/Acceptance/Support/Workdir.php b/tests/Acceptance/Support/Workdir.php new file mode 100644 index 0000000..5fbf41d --- /dev/null +++ b/tests/Acceptance/Support/Workdir.php @@ -0,0 +1,75 @@ +path = \sys_get_temp_dir() . '/rr-cli-acceptance/' . \uniqid('run-', true); + \mkdir($this->path, 0777, true); + } + + public function file(string $name): string + { + return $this->path . '/' . $name; + } + + public function has(string $name): bool + { + return \is_file($this->file($name)); + } + + public function read(string $name): string + { + return (string) \file_get_contents($this->file($name)); + } + + public function write(string $name, string $content): void + { + \file_put_contents($this->file($name), $content); + } + + /** + * Top-level sections of the generated `.rr.yaml`. + * + * @return list + */ + public function configSections(): array + { + return \array_keys((array) Yaml::parse($this->read('.rr.yaml'))); + } + + /** + * Files left in the directory, the DLoad cache aside. + * + * @return list + */ + public function files(): array + { + return \array_values(\array_diff(\scandir($this->path), ['.', '..', '.dload-cache'])); + } + + public function remove(): void + { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($this->path, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($iterator as $file) { + $file->isDir() ? @\rmdir($file->getPathname()) : @\unlink($file->getPathname()); + } + + @\rmdir($this->path); + } +} diff --git a/tests/Acceptance/VersionSelectionTest.php b/tests/Acceptance/VersionSelectionTest.php new file mode 100644 index 0000000..6937e7f --- /dev/null +++ b/tests/Acceptance/VersionSelectionTest.php @@ -0,0 +1,129 @@ +rr()->run([$command, '--os=linux', '--arch=amd64']); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::same(\trim($this->dir->read('rr')), 'fake rr 3.4.0 linux amd64'); + Assert::same(FakeGitHub::downloads(), ['roadrunner-3.4.0-linux-amd64.tar.gz']); + } + + #[DataSet(['3.1.*', '3.1.0'])] + #[DataSet(['2025.*', '2025.1.5'])] + #[DataSet(['^2.12', '2.12.3'])] + public function installsNewestReleaseMatchingFilter(string $filter, string $version): void + { + $result = $this->rr()->run(['get', '--filter=' . $filter, '--os=linux', '--arch=amd64']); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::same(\trim($this->dir->read('rr')), "fake rr $version linux amd64"); + } + + #[DataSet(['stable', '3.*', '3.4.0'])] + #[DataSet(['RC', '3.*', '3.5.0-rc.1'])] + #[DataSet(['beta', '3.5.0-beta.1', '3.5.0-beta.1'])] + public function honoursMinimumStability(string $stability, string $filter, string $version): void + { + $result = $this->rr()->run(['get', '--stability=' . $stability, '--filter=' . $filter, '--os=linux', '--arch=amd64']); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::same(\trim($this->dir->read('rr')), "fake rr $version linux amd64"); + } + + #[DataSet(['rc'])] + #[DataSet(['beta'])] + #[Skip('-rc tags sort below -beta ones and --stability=rc means dev, see https://github.com/roadrunner-php/cli/issues/58 and https://github.com/roadrunner-php/cli/issues/67')] + public function prefersReleaseCandidateOverOlderBeta(string $stability): void + { + $result = $this->rr()->run(['get', '--stability=' . $stability, '--os=linux', '--arch=amd64']); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::same(\trim($this->dir->read('rr')), 'fake rr 3.5.0-rc.1 linux amd64'); + } + + public function rejectsPreReleaseBelowRequestedStability(): void + { + $result = $this->rr()->run(['get', '--stability=stable', '--filter=3.5.0-beta.1', '--os=linux', '--arch=amd64']); + + Assert::notSame($result->exitCode, 0, (string) $result); + Assert::false($this->dir->has('rr')); + Assert::same(FakeGitHub::downloads(), []); + } + + #[DataSet(['windows', 'amd64', 'rr.exe', 'roadrunner-3.4.0-windows-amd64.zip'])] + #[DataSet(['darwin', 'arm64', 'rr', 'roadrunner-3.4.0-darwin-arm64.tar.gz'])] + public function installsBinaryForAnotherPlatform(string $os, string $arch, string $binary, string $asset): void + { + $result = $this->rr()->run(['get', "--os=$os", "--arch=$arch"]); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::same(\trim($this->dir->read($binary)), "fake rr 3.4.0 $os $arch"); + Assert::same(FakeGitHub::downloads(), [$asset]); + } + + public function fallsBackToOlderReleaseWhenNewestHasNoAssembly(): void + { + $result = $this->rr()->run(['get', '--os=linux', '--arch=arm64']); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::same(\trim($this->dir->read('rr')), 'fake rr 3.2.0 linux arm64'); + // 3.4.0 has a linux-arm64 .deb package and protoc plugin, neither is a RoadRunner archive + Assert::same(FakeGitHub::downloads(), ['roadrunner-3.2.0-linux-arm64.tar.gz']); + } + + public function failsWhenNoReleaseMatchesFilter(): void + { + $result = $this->rr()->run(['get', '--filter=9.*', '--os=linux', '--arch=amd64']); + + Assert::notSame($result->exitCode, 0, (string) $result); + Assert::string($result->output())->matchesRegex('/9\.\*/'); + Assert::same($this->dir->files(), []); + Assert::same(FakeGitHub::downloads(), []); + } + + public function followsPagination(): void + { + $result = $this->rr(FakeGitHub::url('paged'))->run(['get', '--os=linux', '--arch=amd64']); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::same(\trim($this->dir->read('rr')), 'fake rr 3.4.0 linux amd64'); + Assert::same( + \array_map(static fn(array $request): string => (string) $request['query']['page'], FakeGitHub::releaseRequests()), + ['1', '2', '3', '4'], + ); + } + + public function staysWithinRequestBudget(): void + { + $result = $this->rr()->run(['get', '--os=linux', '--arch=amd64']); + + Assert::same($result->exitCode, 0, (string) $result); + $releases = FakeGitHub::releaseRequests(); + Assert::count($releases, 1); + Assert::same($releases[0]['query']['per_page'] ?? null, '100'); + Assert::count(FakeGitHub::requests(), 2); + Assert::false(\in_array(true, \array_column(FakeGitHub::requests(), 'authorized'), true)); + } +} diff --git a/tests/Live/GitHubTest.php b/tests/Live/GitHubTest.php new file mode 100644 index 0000000..85569b6 --- /dev/null +++ b/tests/Live/GitHubTest.php @@ -0,0 +1,50 @@ +dir = new Workdir(); + } + + #[AfterTest] + public function cleanup(): void + { + $this->dir->remove(); + } + + #[DataSet(['linux', 'amd64', 'rr'])] + #[DataSet(['windows', 'amd64', 'rr.exe'])] + public function downloadsRealBinary(string $os, string $arch, string $binary): void + { + $rr = new Rr($this->dir->path, null); + $token = \getenv('GITHUB_TOKEN'); + if (\is_string($token) && $token !== '') { + $rr = $rr->withEnv('GITHUB_TOKEN', $token); + } + + $result = $rr->run(['get', '--filter=2025.*', "--os=$os", "--arch=$arch", '--no-config']); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::int((int) \filesize($this->dir->file($binary)))->greaterThan(1_000_000); + } +}