From d9cda6baf6781e8b33b8ca7f7f45bdb6c8a14d80 Mon Sep 17 00:00:00 2001 From: Patricio Date: Thu, 19 Feb 2026 12:26:47 +0000 Subject: [PATCH 1/6] Setup CloudRun action --- composer.json | 11 +++++++++-- src/Autoload.php | 15 +-------------- src/CloudRun.php | 21 +++++++++++++++++++++ src/Example.php | 23 ----------------------- src/Plugin.php | 27 ++++++++++++++++++++++----- tests/Example.php | 10 ++-------- 6 files changed, 55 insertions(+), 52 deletions(-) create mode 100644 src/CloudRun.php delete mode 100644 src/Example.php 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..5b7e19c --- /dev/null +++ b/src/CloudRun.php @@ -0,0 +1,21 @@ + $arguments + */ + public function handle(array $arguments): int + { + dd($arguments); + + return 0; + } +} 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(); }); From 67507c39fed238f02b41c4013497296dc3d8150d Mon Sep 17 00:00:00 2001 From: Patricio Date: Fri, 20 Feb 2026 15:01:07 +0000 Subject: [PATCH 2/6] Create a Run --- .gitignore | 3 + CHANGELOG.md | 16 +++ src/CloudRun.php | 277 ++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 294 insertions(+), 2 deletions(-) create mode 100644 CHANGELOG.md 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/src/CloudRun.php b/src/CloudRun.php index 5b7e19c..92284f8 100644 --- a/src/CloudRun.php +++ b/src/CloudRun.php @@ -4,18 +4,291 @@ namespace Pest\PestCloud; +use CURLFile; +use Phar; +use PharData; +use RuntimeException; + /** * @internal */ class CloudRun { + private const int MAX_TARBALL_SIZE = 50 * 1024 * 1024; + + private const int MAX_RETRIES = 3; + /** * @param array $arguments */ public function handle(array $arguments): int { - dd($arguments); + $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 0; + return ['id' => $body['id'], 'status' => $body['status']]; } } From 3bc6bdc67264a3818c9a809a25fbe8ba2d11c4ec Mon Sep 17 00:00:00 2001 From: Patricio Date: Fri, 13 Mar 2026 12:37:42 +0000 Subject: [PATCH 3/6] wip --- .gitignore | 2 - src/CloudRun.php | 76 +++++++++++-- tests/CloudRun.php | 260 +++++++++++++++++++++++++++++++++++++++++++++ tests/Example.php | 5 - 4 files changed, 329 insertions(+), 14 deletions(-) create mode 100644 tests/CloudRun.php delete mode 100644 tests/Example.php diff --git a/.gitignore b/.gitignore index 22b79b4..f281815 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,4 @@ coverage.xml *.swp *.swo .phpunit.cache - -# For now .claude diff --git a/src/CloudRun.php b/src/CloudRun.php index 92284f8..fe5390e 100644 --- a/src/CloudRun.php +++ b/src/CloudRun.php @@ -18,6 +18,8 @@ class CloudRun private const int MAX_RETRIES = 3; + private const int POLL_TIMEOUT = 600; + /** * @param array $arguments */ @@ -69,9 +71,17 @@ public function handle(array $arguments): int echo "Run created successfully.\n"; echo "ID: {$response['id']}\n"; - echo "Status: {$response['status']}\n"; - return 0; + $result = $this->poll($response['url'], $apiToken); + + if ($result['output'] !== null && $result['output'] !== '') { + echo $result['output']; + } + + return match ($result['status']) { + 'passed' => 0, + default => 1, + }; } finally { if (file_exists($tarballPath)) { unlink($tarballPath); @@ -207,7 +217,59 @@ private function createTarball(string $projectPath, array $files): string } /** - * @return array{id: string, status: string} + * @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; + + while (time() < $deadline) { + sleep(2); + + $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 poll run status (HTTP {$httpCode}). Retrying...\n"); + + continue; + } + + $body = json_decode((string) $response, true); + + if (! is_array($body) || ! isset($body['status'])) { + fwrite(STDERR, "Warning: Unexpected poll response. 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 */ + echo "Status: {$body['status']}\n"; + + if (in_array($body['status'], $terminalStatuses, true)) { + return $body; + } + } + + 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 { @@ -234,7 +296,7 @@ private function upload(string $apiUrl, string $apiToken, string $tarballPath, s } /** - * @return array{id: string, status: string} + * @return array{id: string, url: string, status: string} */ private function doUpload(string $url, string $apiToken, string $tarballPath, string $pestArguments): array { @@ -268,7 +330,7 @@ private function doUpload(string $url, string $apiToken, string $tarballPath, st throw new RuntimeException('Network error: '.$error); } - /** @var array{id?: string, status?: string, message?: string}|null $body */ + /** @var array{id?: string, url?: string, status?: string, message?: string}|null $body */ $body = json_decode((string) $response, true); if ($httpCode === 401) { @@ -285,10 +347,10 @@ private function doUpload(string $url, string $apiToken, string $tarballPath, st throw new RuntimeException("Server error ({$httpCode}): The service may be temporarily unavailable."); } - if ($httpCode !== 201 || ! is_array($body) || ! isset($body['id'], $body['status'])) { + 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'], 'status' => $body['status']]; + return ['id' => $body['id'], 'url' => $body['url'], 'status' => $body['status']]; } } 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 b3ec2ca..0000000 --- a/tests/Example.php +++ /dev/null @@ -1,5 +0,0 @@ -toBeTrue(); -}); From a3c1549063f0f237155b3278335118523c2d7f0e Mon Sep 17 00:00:00 2001 From: Patricio Date: Fri, 13 Mar 2026 12:43:04 +0000 Subject: [PATCH 4/6] wip --- src/CloudRun.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/CloudRun.php b/src/CloudRun.php index fe5390e..fb3164f 100644 --- a/src/CloudRun.php +++ b/src/CloudRun.php @@ -330,9 +330,13 @@ private function doUpload(string $url, string $apiToken, string $tarballPath, st throw new RuntimeException('Network error: '.$error); } - /** @var array{id?: string, url?: string, status?: string, message?: string}|null $body */ + /** @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']) && is_array($body['data'])) { + $body = $body['data']; + } + if ($httpCode === 401) { throw new RuntimeException('Authentication failed (401): Check your API token.'); } From b8f8b2655a916f397d022adb55c76ef91cb57aac Mon Sep 17 00:00:00 2001 From: Patricio Date: Fri, 13 Mar 2026 13:49:37 +0000 Subject: [PATCH 5/6] wip --- src/CloudRun.php | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/src/CloudRun.php b/src/CloudRun.php index fb3164f..775e7c6 100644 --- a/src/CloudRun.php +++ b/src/CloudRun.php @@ -69,15 +69,10 @@ public function handle(array $arguments): int $response = $this->upload($apiUrl, $apiToken, $tarballPath, $pestArguments); - echo "Run created successfully.\n"; - echo "ID: {$response['id']}\n"; + fwrite(STDOUT, "Run started, with ID: {$response['id']}\n"); $result = $this->poll($response['url'], $apiToken); - if ($result['output'] !== null && $result['output'] !== '') { - echo $result['output']; - } - return match ($result['status']) { 'passed' => 0, default => 1, @@ -223,9 +218,18 @@ private function poll(string $url, string $apiToken): array { $terminalStatuses = ['passed', 'failed', 'errored', 'cancelled']; $deadline = time() + self::POLL_TIMEOUT; + $outputOffset = 0; while (time() < $deadline) { - sleep(2); + sleep(1); + + fwrite(STDOUT, '.'); + + while (ob_get_level() > 0) { + ob_end_flush(); + } + + flush(); $ch = curl_init($url); @@ -251,6 +255,10 @@ private function poll(string $url, string $apiToken): array $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 poll response. Retrying...\n"); @@ -258,11 +266,16 @@ private function poll(string $url, string $apiToken): array } /** @var array{id: string, url: string, status: string, exit_code: int|null, output: string|null, started_at: string|null, finished_at: string|null} $body */ - echo "Status: {$body['status']}\n"; + 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.'); @@ -333,7 +346,7 @@ private function doUpload(string $url, string $apiToken, string $tarballPath, st /** @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']) && is_array($body['data'])) { + if (is_array($body) && isset($body['data'])) { $body = $body['data']; } From af770490f44726fe4334c4d6134ce23514b221dc Mon Sep 17 00:00:00 2001 From: Patricio Date: Fri, 13 Mar 2026 13:53:13 +0000 Subject: [PATCH 6/6] wip --- src/CloudRun.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CloudRun.php b/src/CloudRun.php index 775e7c6..0cb20f9 100644 --- a/src/CloudRun.php +++ b/src/CloudRun.php @@ -248,7 +248,7 @@ private function poll(string $url, string $apiToken): array curl_close($ch); if ($response === false || $httpCode !== 200) { - fwrite(STDERR, "Warning: Failed to poll run status (HTTP {$httpCode}). Retrying...\n"); + fwrite(STDERR, "Warning: Failed to get the run status (HTTP {$httpCode}). Retrying...\n"); continue; } @@ -260,7 +260,7 @@ private function poll(string $url, string $apiToken): array } if (! is_array($body) || ! isset($body['status'])) { - fwrite(STDERR, "Warning: Unexpected poll response. Retrying...\n"); + fwrite(STDERR, "Warning: Unexpected response while updating status. Retrying...\n"); continue; }