diff --git a/.gitignore b/.gitignore index 54420f5..f281815 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ coverage.xml *.swp *.swo .phpunit.cache +.claude diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..81e3847 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). + +## [Unreleased] + +### Added + +- Implemented `CloudRun::handle()` — packages project into `project.tar.gz` and uploads to Pest Cloud API +- Config file support via `pest.cloud.json` for `respectGitignore` and `exclude` patterns +- `.gitignore`-aware file collection using `git ls-files` +- Tarball size validation (50 MB limit) +- Upload retry with exponential backoff (3 attempts) +- Error handling for 401, 422, and 5xx API responses diff --git a/composer.json b/composer.json index afcda8b..94e597e 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,5 @@ { - "name": "pestphp/pest-plugin-template", + "name": "pestphp/pest-plugin-cloud", "description": "My awesome plugin", "keywords": [ "php", @@ -18,7 +18,7 @@ }, "autoload": { "psr-4": { - "Pest\\PluginName\\": "src/" + "Pest\\PestCloud\\": "src/" }, "files": [ "src/Autoload.php" @@ -29,6 +29,13 @@ }, "minimum-stability": "dev", "prefer-stable": true, + "extra": { + "pest": { + "plugins": [ + "Pest\\PestCloud\\Plugin" + ] + } + }, "config": { "sort-packages": true, "preferred-install": "dist", diff --git a/src/Autoload.php b/src/Autoload.php index ce516f1..0e62507 100644 --- a/src/Autoload.php +++ b/src/Autoload.php @@ -2,17 +2,4 @@ declare(strict_types=1); -namespace Pest\PluginName; - -use Pest\Plugin; -use PHPUnit\Framework\TestCase; - -Plugin::uses(Example::class); - -/** - * @return TestCase - */ -function example(string $argument) -{ - return test()->example(...func_get_args()); // @phpstan-ignore-line -} +namespace Pest\PestCloud; diff --git a/src/CloudRun.php b/src/CloudRun.php new file mode 100644 index 0000000..0cb20f9 --- /dev/null +++ b/src/CloudRun.php @@ -0,0 +1,373 @@ + $arguments + */ + public function handle(array $arguments): int + { + $projectPath = (string) getcwd(); + $config = $this->loadConfig($projectPath); + + $apiUrl = is_string($_SERVER['PEST_CLOUD_URL'] ?? null) + ? $_SERVER['PEST_CLOUD_URL'] + : 'https://cloud.pestphp.com'; + + $apiToken = is_string($_SERVER['PEST_CLOUD_TOKEN'] ?? null) + ? $_SERVER['PEST_CLOUD_TOKEN'] + : ''; + + if ($apiToken === '') { + fwrite(STDERR, "Error: No API token configured. Set the PEST_CLOUD_TOKEN environment variable.\n"); + + return 1; + } + + $pestArguments = implode(' ', $arguments); + + $files = $config['respectGitignore'] + ? $this->getGitTrackedFiles($projectPath) + : $this->getAllFiles($projectPath); + + $files = $this->applyExclusions($files, $config['exclude']); + + if ($files === []) { + fwrite(STDERR, "Error: No files to include in the tarball.\n"); + + return 1; + } + + $tarballPath = $this->createTarball($projectPath, $files); + + try { + $size = filesize($tarballPath); + + if ($size === false || $size > self::MAX_TARBALL_SIZE) { + fwrite(STDERR, "Error: Project tarball exceeds the 50 MB limit. Add more exclusions to pest.cloud.json.\n"); + + return 1; + } + + $response = $this->upload($apiUrl, $apiToken, $tarballPath, $pestArguments); + + fwrite(STDOUT, "Run started, with ID: {$response['id']}\n"); + + $result = $this->poll($response['url'], $apiToken); + + return match ($result['status']) { + 'passed' => 0, + default => 1, + }; + } finally { + if (file_exists($tarballPath)) { + unlink($tarballPath); + } + } + } + + /** + * @return array{respectGitignore: bool, exclude: list} + */ + private function loadConfig(string $projectPath): array + { + $defaults = [ + 'respectGitignore' => true, + 'exclude' => [], + ]; + + $configPath = $projectPath.'/pest.cloud.json'; + + if (! file_exists($configPath)) { + return $defaults; + } + + $content = file_get_contents($configPath); + + if ($content === false) { + return $defaults; + } + + $config = json_decode($content, true); + + if (! is_array($config)) { + return $defaults; + } + + /** @var array{respectGitignore?: bool, exclude?: list} $config */ + + return [ + 'respectGitignore' => $config['respectGitignore'] ?? true, + 'exclude' => $config['exclude'] ?? [], + ]; + } + + /** + * @return list + */ + private function getGitTrackedFiles(string $projectPath): array + { + $command = 'cd '.escapeshellarg($projectPath).' && git ls-files --cached --others --exclude-standard'; + $output = []; + $resultCode = 0; + + exec($command, $output, $resultCode); + + if ($resultCode !== 0) { + return $this->getAllFiles($projectPath); + } + + return array_values(array_filter( + $output, + fn (string $file): bool => $file !== '' && is_file($projectPath.'/'.$file), + )); + } + + /** + * @return list + */ + private function getAllFiles(string $projectPath): array + { + $files = []; + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($projectPath, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::LEAVES_ONLY, + ); + + /** @var \SplFileInfo $file */ + foreach ($iterator as $file) { + if ($file->isFile()) { + $files[] = substr($file->getPathname(), strlen($projectPath) + 1); + } + } + + return $files; + } + + /** + * @param list $files + * @param list $exclusions + * @return list + */ + private function applyExclusions(array $files, array $exclusions): array + { + if ($exclusions === []) { + return $files; + } + + return array_values(array_filter($files, function (string $file) use ($exclusions): bool { + foreach ($exclusions as $pattern) { + if (fnmatch($pattern, $file) || fnmatch($pattern, basename($file)) || str_starts_with($file, rtrim($pattern, '/').'/')) { + return false; + } + } + + return true; + })); + } + + /** + * @param list $files + */ + private function createTarball(string $projectPath, array $files): string + { + $tarPath = sys_get_temp_dir().'/pest_cloud_'.bin2hex(random_bytes(8)).'.tar'; + $tarballPath = $tarPath.'.gz'; + + $phar = new PharData($tarPath); + + foreach ($files as $file) { + $fullPath = $projectPath.'/'.$file; + + if (is_file($fullPath)) { + $phar->addFile($fullPath, $file); + } + } + + $phar->compress(Phar::GZ); + + if (file_exists($tarPath)) { + unlink($tarPath); + } + + return $tarballPath; + } + + /** + * @return array{id: string, url: string, status: string, exit_code: int|null, output: string|null, started_at: string|null, finished_at: string|null} + */ + private function poll(string $url, string $apiToken): array + { + $terminalStatuses = ['passed', 'failed', 'errored', 'cancelled']; + $deadline = time() + self::POLL_TIMEOUT; + $outputOffset = 0; + + while (time() < $deadline) { + sleep(1); + + fwrite(STDOUT, '.'); + + while (ob_get_level() > 0) { + ob_end_flush(); + } + + flush(); + + $ch = curl_init($url); + + curl_setopt_array($ch, [ + CURLOPT_HTTPHEADER => [ + 'Authorization: Bearer '.$apiToken, + 'Accept: application/json', + ], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + + curl_close($ch); + + if ($response === false || $httpCode !== 200) { + fwrite(STDERR, "Warning: Failed to get the run status (HTTP {$httpCode}). Retrying...\n"); + + continue; + } + + $body = json_decode((string) $response, true); + + if (is_array($body) && isset($body['data']) && is_array($body['data'])) { + $body = $body['data']; + } + + if (! is_array($body) || ! isset($body['status'])) { + fwrite(STDERR, "Warning: Unexpected response while updating status. Retrying...\n"); + + continue; + } + + /** @var array{id: string, url: string, status: string, exit_code: int|null, output: string|null, started_at: string|null, finished_at: string|null} $body */ + if (is_string($body['output']) && strlen($body['output']) > $outputOffset) { + echo substr($body['output'], $outputOffset); + $outputOffset = strlen($body['output']); + } + + if (in_array($body['status'], $terminalStatuses, true)) { + return $body; + } + + fwrite(STDOUT, '_'); + } + + throw new RuntimeException('Timed out after '.self::POLL_TIMEOUT.' seconds.'); + } + + /** + * @return array{id: string, url: string, status: string} + */ + private function upload(string $apiUrl, string $apiToken, string $tarballPath, string $pestArguments): array + { + $url = rtrim($apiUrl, '/').'/api/run'; + $lastException = null; + + for ($attempt = 1; $attempt <= self::MAX_RETRIES; $attempt++) { + try { + return $this->doUpload($url, $apiToken, $tarballPath, $pestArguments); + } catch (RuntimeException $e) { + $lastException = $e; + + if (str_contains($e->getMessage(), '401') || str_contains($e->getMessage(), '422')) { + throw $e; + } + + if ($attempt < self::MAX_RETRIES) { + sleep(2 ** ($attempt - 1)); + } + } + } + + throw $lastException; + } + + /** + * @return array{id: string, url: string, status: string} + */ + private function doUpload(string $url, string $apiToken, string $tarballPath, string $pestArguments): array + { + $ch = curl_init($url); + + $postFields = [ + 'tarball' => new CURLFile($tarballPath, 'application/gzip', 'project.tar.gz'), + ]; + + if ($pestArguments !== '') { + $postFields['pest_arguments'] = $pestArguments; + } + + curl_setopt_array($ch, [ + CURLOPT_POSTFIELDS => $postFields, + CURLOPT_HTTPHEADER => [ + 'Authorization: Bearer '.$apiToken, + 'Accept: application/json', + ], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 120, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + + curl_close($ch); + + if ($response === false) { + throw new RuntimeException('Network error: '.$error); + } + + /** @var array{data?: array{id?: string, url?: string, status?: string}, message?: string}|null $body */ + $body = json_decode((string) $response, true); + + if (is_array($body) && isset($body['data'])) { + $body = $body['data']; + } + + if ($httpCode === 401) { + throw new RuntimeException('Authentication failed (401): Check your API token.'); + } + + if ($httpCode === 422) { + $message = is_array($body) ? ($body['message'] ?? 'Validation error') : 'Validation error'; + + throw new RuntimeException('Validation error (422): '.$message); + } + + if ($httpCode >= 500) { + throw new RuntimeException("Server error ({$httpCode}): The service may be temporarily unavailable."); + } + + if ($httpCode !== 201 || ! is_array($body) || ! isset($body['id'], $body['url'], $body['status'])) { + throw new RuntimeException("Unexpected response (HTTP {$httpCode}): ".$response); + } + + return ['id' => $body['id'], 'url' => $body['url'], 'status' => $body['status']]; + } +} diff --git a/src/Example.php b/src/Example.php deleted file mode 100644 index 787c4f9..0000000 --- a/src/Example.php +++ /dev/null @@ -1,23 +0,0 @@ -toBeString(); - - return $this; - } -} diff --git a/src/Plugin.php b/src/Plugin.php index 78afb25..f01a1c1 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -2,15 +2,32 @@ declare(strict_types=1); -namespace Pest\PluginName; +namespace Pest\PestCloud; -// use Pest\Contracts\Plugins\AddsOutput; -// use Pest\Contracts\Plugins\HandlesArguments; +use Pest\Contracts\Plugins\HandlesArguments; +use Pest\Plugins\Concerns\HandleArguments; +use Pest\Support\Container; /** * @internal */ -final class Plugin +final class Plugin implements HandlesArguments { - // + use HandleArguments; + + public function handleArguments(array $arguments): array + { + if (! $this->hasArgument('--cloud', $arguments)) { + return $arguments; + } + + $arguments = $this->popArgument('--cloud', $arguments); + + /** @var CloudRun $cloudRun */ + $cloudRun = Container::getInstance()->get(CloudRun::class); + + $result = $cloudRun->handle($arguments); + + exit($result); + } } diff --git a/tests/CloudRun.php b/tests/CloudRun.php new file mode 100644 index 0000000..06e0b7d --- /dev/null +++ b/tests/CloudRun.php @@ -0,0 +1,260 @@ +invoke($object, ...$args); +} + +describe('loadConfig', function () { + it('returns defaults when no config file exists', function () { + $dir = sys_get_temp_dir().'/pest_test_'.bin2hex(random_bytes(4)); + mkdir($dir); + + try { + $result = callMethod(new CloudRun, 'loadConfig', $dir); + + expect($result)->toBe([ + 'respectGitignore' => true, + 'exclude' => [], + ]); + } finally { + rmdir($dir); + } + }); + + it('reads pest.cloud.json when present', function () { + $dir = sys_get_temp_dir().'/pest_test_'.bin2hex(random_bytes(4)); + mkdir($dir); + + file_put_contents($dir.'/pest.cloud.json', json_encode([ + 'respectGitignore' => false, + 'exclude' => ['vendor', '*.log'], + ])); + + try { + $result = callMethod(new CloudRun, 'loadConfig', $dir); + + expect($result)->toBe([ + 'respectGitignore' => false, + 'exclude' => ['vendor', '*.log'], + ]); + } finally { + unlink($dir.'/pest.cloud.json'); + rmdir($dir); + } + }); + + it('returns defaults for invalid JSON', function () { + $dir = sys_get_temp_dir().'/pest_test_'.bin2hex(random_bytes(4)); + mkdir($dir); + + file_put_contents($dir.'/pest.cloud.json', 'not valid json'); + + try { + $result = callMethod(new CloudRun, 'loadConfig', $dir); + + expect($result)->toBe([ + 'respectGitignore' => true, + 'exclude' => [], + ]); + } finally { + unlink($dir.'/pest.cloud.json'); + rmdir($dir); + } + }); + + it('merges partial config with defaults', function () { + $dir = sys_get_temp_dir().'/pest_test_'.bin2hex(random_bytes(4)); + mkdir($dir); + + file_put_contents($dir.'/pest.cloud.json', json_encode([ + 'exclude' => ['node_modules'], + ])); + + try { + $result = callMethod(new CloudRun, 'loadConfig', $dir); + + expect($result)->toBe([ + 'respectGitignore' => true, + 'exclude' => ['node_modules'], + ]); + } finally { + unlink($dir.'/pest.cloud.json'); + rmdir($dir); + } + }); +}); + +describe('applyExclusions', function () { + it('returns all files when no exclusions', function () { + $files = ['src/Foo.php', 'tests/Bar.php']; + + $result = callMethod(new CloudRun, 'applyExclusions', $files, []); + + expect($result)->toBe($files); + }); + + it('excludes files matching glob patterns', function () { + $files = ['src/Foo.php', 'logs/app.log', 'logs/error.log']; + + $result = callMethod(new CloudRun, 'applyExclusions', $files, ['*.log']); + + expect($result)->toBe(['src/Foo.php']); + }); + + it('excludes files by directory prefix', function () { + $files = ['src/Foo.php', 'vendor/autoload.php', 'vendor/bin/pest']; + + $result = callMethod(new CloudRun, 'applyExclusions', $files, ['vendor']); + + expect($result)->toBe(['src/Foo.php']); + }); + + it('supports multiple exclusion patterns', function () { + $files = ['src/Foo.php', 'vendor/autoload.php', 'storage/app.log', 'tests/Unit.php']; + + $result = callMethod(new CloudRun, 'applyExclusions', $files, ['vendor', 'storage']); + + expect($result)->toBe(['src/Foo.php', 'tests/Unit.php']); + }); + + it('excludes by basename match', function () { + $files = ['src/Foo.php', 'src/.env', 'config/.env']; + + $result = callMethod(new CloudRun, 'applyExclusions', $files, ['.env']); + + expect($result)->toBe(['src/Foo.php']); + }); +}); + +describe('getAllFiles', function () { + it('lists all files recursively', function () { + $dir = sys_get_temp_dir().'/pest_test_'.bin2hex(random_bytes(4)); + mkdir($dir.'/sub', recursive: true); + + file_put_contents($dir.'/root.txt', 'root'); + file_put_contents($dir.'/sub/nested.txt', 'nested'); + + try { + $result = callMethod(new CloudRun, 'getAllFiles', $dir); + + sort($result); + expect($result)->toBe(['root.txt', 'sub/nested.txt']); + } finally { + unlink($dir.'/sub/nested.txt'); + unlink($dir.'/root.txt'); + rmdir($dir.'/sub'); + rmdir($dir); + } + }); +}); + +describe('createTarball', function () { + it('creates a valid gzipped tarball', function () { + $dir = sys_get_temp_dir().'/pest_test_'.bin2hex(random_bytes(4)); + mkdir($dir); + + file_put_contents($dir.'/file.txt', 'hello'); + + try { + $tarballPath = callMethod(new CloudRun, 'createTarball', $dir, ['file.txt']); + + expect($tarballPath)->toEndWith('.tar.gz') + ->and(file_exists($tarballPath))->toBeTrue() + ->and(filesize($tarballPath))->toBeGreaterThan(0); + } finally { + if (isset($tarballPath) && file_exists($tarballPath)) { + unlink($tarballPath); + } + + unlink($dir.'/file.txt'); + rmdir($dir); + } + }); + + it('includes only specified files', function () { + $dir = sys_get_temp_dir().'/pest_test_'.bin2hex(random_bytes(4)); + mkdir($dir); + + file_put_contents($dir.'/included.txt', 'yes'); + file_put_contents($dir.'/excluded.txt', 'no'); + + try { + $tarballPath = callMethod(new CloudRun, 'createTarball', $dir, ['included.txt']); + + $phar = new PharData($tarballPath); + $files = []; + + foreach ($phar as $file) { + $files[] = $file->getFilename(); + } + + expect($files)->toBe(['included.txt']); + } finally { + if (isset($tarballPath) && file_exists($tarballPath)) { + unlink($tarballPath); + } + + unlink($dir.'/included.txt'); + unlink($dir.'/excluded.txt'); + rmdir($dir); + } + }); +}); + +describe('handle', function () { + it('returns 1 when no API token is set', function () { + $original = $_SERVER['PEST_CLOUD_TOKEN'] ?? null; + unset($_SERVER['PEST_CLOUD_TOKEN']); + + try { + $result = (new CloudRun)->handle([]); + + expect($result)->toBe(1); + } finally { + if ($original !== null) { + $_SERVER['PEST_CLOUD_TOKEN'] = $original; + } + } + }); + + it('returns 1 when all files are excluded', function () { + $dir = sys_get_temp_dir().'/pest_test_'.bin2hex(random_bytes(4)); + mkdir($dir); + + file_put_contents($dir.'/pest.cloud.json', json_encode([ + 'respectGitignore' => false, + 'exclude' => ['*'], + ])); + file_put_contents($dir.'/test.txt', 'hello'); + + $originalToken = $_SERVER['PEST_CLOUD_TOKEN'] ?? null; + $_SERVER['PEST_CLOUD_TOKEN'] = 'test-token'; + $originalDir = getcwd(); + chdir($dir); + + try { + $result = (new CloudRun)->handle([]); + + expect($result)->toBe(1); + } finally { + chdir($originalDir); + + if ($originalToken !== null) { + $_SERVER['PEST_CLOUD_TOKEN'] = $originalToken; + } else { + unset($_SERVER['PEST_CLOUD_TOKEN']); + } + + unlink($dir.'/pest.cloud.json'); + unlink($dir.'/test.txt'); + rmdir($dir); + } + }); +}); diff --git a/tests/Example.php b/tests/Example.php deleted file mode 100644 index 3bf0e1d..0000000 --- a/tests/Example.php +++ /dev/null @@ -1,11 +0,0 @@ -example('foo'); -}); - -it('may be accessed as function', function () { - example('foo'); -});