From 8d54ffead1a272ff0c486e2b70e5970dd7bfb627 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sat, 10 Oct 2026 02:09:14 +0400 Subject: [PATCH 1/3] feat: read the GitHub API URL from RR_GITHUB_API_URL Releases can be fetched from GitHub Enterprise, a mirror or a mock server. The variable is not `GITHUB_API_URL` because GitHub Actions sets that one in every job. Releases are requested 100 per page, so listing them takes 2 requests instead of about 7. Assisted-By: Claude Opus 5.5 --- README.md | 2 +- src/Command.php | 15 +++++++- src/Repository/GitHub/GitHubRepository.php | 35 +++++++++++++++---- tests/Unit/CommandTest.php | 31 +++++++++++++--- .../GitHub/GitHubRepositoryTest.php | 20 +++++++++-- 5 files changed, 88 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index fcf4bb9..3f2f1b2 100644 --- a/README.md +++ b/README.md @@ -72,4 +72,4 @@ See the [RoadRunner documentation](https://docs.roadrunner.dev) for the configur ### Common options -The binary commands accept `--filter` (`-f`, version constraint), `--stability` (`-s`), `--os` (`-o`), `--arch` (`-a`) and `--location` (`-l`, target directory) options. Releases are fetched from the GitHub API; set the `GITHUB_TOKEN` environment variable to avoid its rate limits. +The binary commands accept `--filter` (`-f`, version constraint), `--stability` (`-s`), `--os` (`-o`), `--arch` (`-a`) and `--location` (`-l`, target directory) options. Releases are fetched from the GitHub API; set the `GITHUB_TOKEN` environment variable to avoid its rate limits, and `RR_GITHUB_API_URL` to use another API endpoint (GitHub Enterprise, a mirror; defaults to `https://api.github.com`). diff --git a/src/Command.php b/src/Command.php index ef3fb4b..27e726a 100644 --- a/src/Command.php +++ b/src/Command.php @@ -31,9 +31,17 @@ abstract class Command extends BaseCommand */ private const ENV_GITHUB_TOKEN = 'GITHUB_TOKEN'; + /** + * Not `GITHUB_API_URL`: GitHub Actions sets that one in every job. + * + * @var string + */ + private const ENV_GITHUB_API_URL = 'RR_GITHUB_API_URL'; + protected function getRepository(): RepositoryInterface { $token = Environment::get(self::ENV_GITHUB_TOKEN); + $apiUrl = Environment::get(self::ENV_GITHUB_API_URL); $client = HttpClient::create([ 'headers' => \array_filter([ @@ -42,7 +50,12 @@ protected function getRepository(): RepositoryInterface ]); return new RepositoriesCollection([ - GitHubRepository::create('roadrunner-server', 'roadrunner', $client), + GitHubRepository::create( + 'roadrunner-server', + 'roadrunner', + $client, + $apiUrl !== null && $apiUrl !== '' ? $apiUrl : GitHubRepository::DEFAULT_API_URL, + ), ]); } diff --git a/src/Repository/GitHub/GitHubRepository.php b/src/Repository/GitHub/GitHubRepository.php index 339eb81..6c79ee5 100644 --- a/src/Repository/GitHub/GitHubRepository.php +++ b/src/Repository/GitHub/GitHubRepository.php @@ -24,13 +24,21 @@ */ final class GitHubRepository implements RepositoryInterface { + public const DEFAULT_API_URL = 'https://api.github.com'; + /** * @var string */ - private const URL_RELEASES = 'https://api.github.com/repos/%s/releases'; + private const URL_RELEASES = '%s/repos/%s/releases'; + + /** + * The largest page size GitHub accepts. + */ + private const PER_PAGE = 100; private HttpClientInterface $client; private string $name; + private string $apiUrl; /** * @var array|string[] @@ -39,15 +47,27 @@ final class GitHubRepository implements RepositoryInterface 'accept' => 'application/vnd.github.v3+json', ]; - public function __construct(string $owner, string $repository, ?HttpClientInterface $client = null) - { + /** + * @param string $apiUrl Base URL of the GitHub REST API: GitHub Enterprise, a mirror or a mock server. + */ + public function __construct( + string $owner, + string $repository, + ?HttpClientInterface $client = null, + string $apiUrl = self::DEFAULT_API_URL, + ) { $this->name = $owner . '/' . $repository; $this->client = $client ?? HttpClient::create(); + $this->apiUrl = \rtrim($apiUrl, '/'); } - public static function create(string $owner, string $name, ?HttpClientInterface $client = null): GitHubRepository - { - return new GitHubRepository($owner, $name, $client); + public static function create( + string $owner, + string $name, + ?HttpClientInterface $client = null, + string $apiUrl = self::DEFAULT_API_URL, + ): GitHubRepository { + return new GitHubRepository($owner, $name, $client, $apiUrl); } /** @@ -98,13 +118,14 @@ private function releasesRequest(int $page): ResponseInterface return $this->request('GET', $this->uri(self::URL_RELEASES), [ 'query' => [ 'page' => $page, + 'per_page' => self::PER_PAGE, ], ]); } private function uri(string $pattern): string { - return \sprintf($pattern, $this->getName()); + return \sprintf($pattern, $this->apiUrl, $this->getName()); } /** diff --git a/tests/Unit/CommandTest.php b/tests/Unit/CommandTest.php index 1453bb7..6fcb39a 100644 --- a/tests/Unit/CommandTest.php +++ b/tests/Unit/CommandTest.php @@ -8,12 +8,38 @@ use Spiral\RoadRunner\Console\Repository\RepositoriesCollection; use Spiral\RoadRunner\Console\Repository\RepositoryInterface; use Testo\Assert; +use Testo\Expect; use Testo\Test; #[Test] final class CommandTest { + private const ENV_API_URL = 'RR_GITHUB_API_URL'; + public function repositoryAggregatesRoadRunnerReleases(): void + { + $repository = self::repository(); + + Assert::instanceOf($repository, RepositoriesCollection::class); + Assert::same($repository->getName(), 'unknown/unknown'); + } + + public function repositoryRequestsApiUrlFromEnvironment(): void + { + // The unsupported scheme makes the client fail before any connection, with the URL in the message. + $_SERVER[self::ENV_API_URL] = 'ftp://github.mock/api'; + + try { + Expect::exception(\InvalidArgumentException::class) + ->withMessageContaining('ftp://github.mock/api/repos/roadrunner-server/roadrunner/releases'); + + self::repository()->getReleases()->empty(); + } finally { + unset($_SERVER[self::ENV_API_URL]); + } + } + + private static function repository(): RepositoryInterface { $command = new class('test') extends Command { public function repository(): RepositoryInterface @@ -22,9 +48,6 @@ public function repository(): RepositoryInterface } }; - $repository = $command->repository(); - - Assert::instanceOf($repository, RepositoriesCollection::class); - Assert::same($repository->getName(), 'unknown/unknown'); + return $command->repository(); } } diff --git a/tests/Unit/Repository/GitHub/GitHubRepositoryTest.php b/tests/Unit/Repository/GitHub/GitHubRepositoryTest.php index b0a6989..f6fb8d5 100644 --- a/tests/Unit/Repository/GitHub/GitHubRepositoryTest.php +++ b/tests/Unit/Repository/GitHub/GitHubRepositoryTest.php @@ -40,11 +40,27 @@ static function (string $method, string $url, array $options) use (&$requests, $ Assert::same(Releases::versions($releases), ['v2024.2.0', 'v2024.1.0']); Assert::same($requests, [ - ['GET', 'https://api.github.com/repos/roadrunner-server/roadrunner/releases?page=1', 'accept: application/vnd.github.v3+json'], - ['GET', 'https://api.github.com/repos/roadrunner-server/roadrunner/releases?page=2', 'accept: application/vnd.github.v3+json'], + ['GET', 'https://api.github.com/repos/roadrunner-server/roadrunner/releases?page=1&per_page=100', 'accept: application/vnd.github.v3+json'], + ['GET', 'https://api.github.com/repos/roadrunner-server/roadrunner/releases?page=2&per_page=100', 'accept: application/vnd.github.v3+json'], ]); } + public function getReleasesUsesCustomApiUrl(): void + { + $urls = []; + $client = new MockHttpClient(static function (string $method, string $url) use (&$urls): MockResponse { + $urls[] = $url; + + return new MockResponse('[]'); + }); + + GitHubRepository::create('roadrunner-server', 'roadrunner', $client, 'http://127.0.0.1:8080/api/v3/') + ->getReleases() + ->empty(); + + Assert::same($urls, ['http://127.0.0.1:8080/api/v3/repos/roadrunner-server/roadrunner/releases?page=1&per_page=100']); + } + public function getReleasesStopsWithoutLinkHeader(): void { $calls = 0; From dee4c5f8e30e6ad374ef66f77cf478d90fe33963 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sat, 10 Oct 2026 02:19:55 +0400 Subject: [PATCH 2/3] test: add acceptance tests for rr get The tests run `bin/rr` as a separate process against a fake GitHub API served by `php -S`, reached through RR_GITHUB_API_URL, and check the exit code, the installed binary, `.rr.yaml` and the requests made. They run on Linux and Windows in their own workflow and are kept out of Infection, since mutants in a child process are never covered. A live suite against the real GitHub runs only on manual dispatch. Six cases that expose bugs are skipped with the reason: RC tags sort below beta ones, an unknown --os/--arch still fetches the releases, and a kept binary still exits with 0. Assisted-By: Claude Opus 5.5 --- .github/workflows/acceptance.yml | 81 +++++++++++ .github/workflows/testo.yml | 1 + infection.json5 | 1 + testo.php | 26 +++- tests/Acceptance/ConfigurationTest.php | 71 +++++++++ tests/Acceptance/ExistingBinaryTest.php | 65 +++++++++ tests/Acceptance/FailureTest.php | 81 +++++++++++ tests/Acceptance/Server/releases.json | 75 ++++++++++ tests/Acceptance/Server/router.php | 151 ++++++++++++++++++++ tests/Acceptance/Support/FakeGitHub.php | 141 ++++++++++++++++++ tests/Acceptance/Support/FakeGitHubCase.php | 48 +++++++ tests/Acceptance/Support/Result.php | 24 ++++ tests/Acceptance/Support/Rr.php | 114 +++++++++++++++ tests/Acceptance/Support/Workdir.php | 75 ++++++++++ tests/Acceptance/VersionSelectionTest.php | 129 +++++++++++++++++ tests/Live/GitHubTest.php | 50 +++++++ 16 files changed, 1127 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/acceptance.yml create mode 100644 tests/Acceptance/ConfigurationTest.php create mode 100644 tests/Acceptance/ExistingBinaryTest.php create mode 100644 tests/Acceptance/FailureTest.php create mode 100644 tests/Acceptance/Server/releases.json create mode 100644 tests/Acceptance/Server/router.php create mode 100644 tests/Acceptance/Support/FakeGitHub.php create mode 100644 tests/Acceptance/Support/FakeGitHubCase.php create mode 100644 tests/Acceptance/Support/Result.php create mode 100644 tests/Acceptance/Support/Rr.php create mode 100644 tests/Acceptance/Support/Workdir.php create mode 100644 tests/Acceptance/VersionSelectionTest.php create mode 100644 tests/Live/GitHubTest.php 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..8d89eff --- /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 skipped installation exits with 0 and reports "Your project is now ready"')] + 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..1b72842 --- /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 is only a warning: the releases are still fetched before the command fails')] + 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..f40c271 --- /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('Releases tagged "-rc.N" sort below "-beta.N" (the sort key only rewrites the "-RC" spelling), and a lowercase --stability=rc means "dev"')] + 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); + } +} From 9dc9d5cd8b2b477349b654603f7a7783904d2da1 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sat, 10 Oct 2026 02:30:46 +0400 Subject: [PATCH 3/3] test: link skipped acceptance tests to their issues Assisted-By: Claude Opus 5.5 --- tests/Acceptance/ExistingBinaryTest.php | 2 +- tests/Acceptance/FailureTest.php | 2 +- tests/Acceptance/VersionSelectionTest.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Acceptance/ExistingBinaryTest.php b/tests/Acceptance/ExistingBinaryTest.php index 8d89eff..7aaf5ec 100644 --- a/tests/Acceptance/ExistingBinaryTest.php +++ b/tests/Acceptance/ExistingBinaryTest.php @@ -43,7 +43,7 @@ public function keepsBinaryWhenOverwriteIsDeclined(): void #[DataSet([null], 'no interaction')] #[DataSet([['no']], 'declined')] - #[Skip('A skipped installation exits with 0 and reports "Your project is now ready"')] + #[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); diff --git a/tests/Acceptance/FailureTest.php b/tests/Acceptance/FailureTest.php index 1b72842..f78d2ea 100644 --- a/tests/Acceptance/FailureTest.php +++ b/tests/Acceptance/FailureTest.php @@ -69,7 +69,7 @@ public function failsForUnknownPlatform(array $options): void #[DataSet([['--os=plan9']], 'operating system')] #[DataSet([['--arch=sparc']], 'architecture')] - #[Skip('An unknown --os or --arch is only a warning: the releases are still fetched before the command fails')] + #[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]); diff --git a/tests/Acceptance/VersionSelectionTest.php b/tests/Acceptance/VersionSelectionTest.php index f40c271..6937e7f 100644 --- a/tests/Acceptance/VersionSelectionTest.php +++ b/tests/Acceptance/VersionSelectionTest.php @@ -54,7 +54,7 @@ public function honoursMinimumStability(string $stability, string $filter, strin #[DataSet(['rc'])] #[DataSet(['beta'])] - #[Skip('Releases tagged "-rc.N" sort below "-beta.N" (the sort key only rewrites the "-RC" spelling), and a lowercase --stability=rc means "dev"')] + #[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']);