diff --git a/.gitignore b/.gitignore index 54420f5..22b79b4 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ coverage.xml *.swp *.swo .phpunit.cache + +# For now +.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..92284f8 --- /dev/null +++ b/src/CloudRun.php @@ -0,0 +1,294 @@ + $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); + + echo "Run created successfully.\n"; + echo "ID: {$response['id']}\n"; + echo "Status: {$response['status']}\n"; + + return 0; + } 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, 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, 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{id?: string, status?: string, message?: string}|null $body */ + $body = json_decode((string) $response, true); + + 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['status'])) { + throw new RuntimeException("Unexpected response (HTTP {$httpCode}): ".$response); + } + + return ['id' => $body['id'], '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/Example.php b/tests/Example.php index 3bf0e1d..b3ec2ca 100644 --- a/tests/Example.php +++ b/tests/Example.php @@ -1,11 +1,5 @@ example('foo'); -}); - -it('may be accessed as function', function () { - example('foo'); +it('has cloud plugin registered', function () { + expect(class_exists(Pest\PestCloud\Plugin::class))->toBeTrue(); });