Skip to content

Commit 67507c3

Browse files
committed
Create a Run
1 parent d9cda6b commit 67507c3

3 files changed

Lines changed: 294 additions & 2 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,6 @@ coverage.xml
1010
*.swp
1111
*.swo
1212
.phpunit.cache
13+
14+
# For now
15+
.claude

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
6+
7+
## [Unreleased]
8+
9+
### Added
10+
11+
- Implemented `CloudRun::handle()` — packages project into `project.tar.gz` and uploads to Pest Cloud API
12+
- Config file support via `pest.cloud.json` for `respectGitignore` and `exclude` patterns
13+
- `.gitignore`-aware file collection using `git ls-files`
14+
- Tarball size validation (50 MB limit)
15+
- Upload retry with exponential backoff (3 attempts)
16+
- Error handling for 401, 422, and 5xx API responses

src/CloudRun.php

Lines changed: 275 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,291 @@
44

55
namespace Pest\PestCloud;
66

7+
use CURLFile;
8+
use Phar;
9+
use PharData;
10+
use RuntimeException;
11+
712
/**
813
* @internal
914
*/
1015
class CloudRun
1116
{
17+
private const int MAX_TARBALL_SIZE = 50 * 1024 * 1024;
18+
19+
private const int MAX_RETRIES = 3;
20+
1221
/**
1322
* @param array<int, string> $arguments
1423
*/
1524
public function handle(array $arguments): int
1625
{
17-
dd($arguments);
26+
$projectPath = (string) getcwd();
27+
$config = $this->loadConfig($projectPath);
28+
29+
$apiUrl = is_string($_SERVER['PEST_CLOUD_URL'] ?? null)
30+
? $_SERVER['PEST_CLOUD_URL']
31+
: 'https://cloud.pestphp.com';
32+
33+
$apiToken = is_string($_SERVER['PEST_CLOUD_TOKEN'] ?? null)
34+
? $_SERVER['PEST_CLOUD_TOKEN']
35+
: '';
36+
37+
if ($apiToken === '') {
38+
fwrite(STDERR, "Error: No API token configured. Set the PEST_CLOUD_TOKEN environment variable.\n");
39+
40+
return 1;
41+
}
42+
43+
$pestArguments = implode(' ', $arguments);
44+
45+
$files = $config['respectGitignore']
46+
? $this->getGitTrackedFiles($projectPath)
47+
: $this->getAllFiles($projectPath);
48+
49+
$files = $this->applyExclusions($files, $config['exclude']);
50+
51+
if ($files === []) {
52+
fwrite(STDERR, "Error: No files to include in the tarball.\n");
53+
54+
return 1;
55+
}
56+
57+
$tarballPath = $this->createTarball($projectPath, $files);
58+
59+
try {
60+
$size = filesize($tarballPath);
61+
62+
if ($size === false || $size > self::MAX_TARBALL_SIZE) {
63+
fwrite(STDERR, "Error: Project tarball exceeds the 50 MB limit. Add more exclusions to pest.cloud.json.\n");
64+
65+
return 1;
66+
}
67+
68+
$response = $this->upload($apiUrl, $apiToken, $tarballPath, $pestArguments);
69+
70+
echo "Run created successfully.\n";
71+
echo "ID: {$response['id']}\n";
72+
echo "Status: {$response['status']}\n";
73+
74+
return 0;
75+
} finally {
76+
if (file_exists($tarballPath)) {
77+
unlink($tarballPath);
78+
}
79+
}
80+
}
81+
82+
/**
83+
* @return array{respectGitignore: bool, exclude: list<string>}
84+
*/
85+
private function loadConfig(string $projectPath): array
86+
{
87+
$defaults = [
88+
'respectGitignore' => true,
89+
'exclude' => [],
90+
];
91+
92+
$configPath = $projectPath.'/pest.cloud.json';
93+
94+
if (! file_exists($configPath)) {
95+
return $defaults;
96+
}
97+
98+
$content = file_get_contents($configPath);
99+
100+
if ($content === false) {
101+
return $defaults;
102+
}
103+
104+
$config = json_decode($content, true);
105+
106+
if (! is_array($config)) {
107+
return $defaults;
108+
}
109+
110+
/** @var array{respectGitignore?: bool, exclude?: list<string>} $config */
111+
112+
return [
113+
'respectGitignore' => $config['respectGitignore'] ?? true,
114+
'exclude' => $config['exclude'] ?? [],
115+
];
116+
}
117+
118+
/**
119+
* @return list<string>
120+
*/
121+
private function getGitTrackedFiles(string $projectPath): array
122+
{
123+
$command = 'cd '.escapeshellarg($projectPath).' && git ls-files --cached --others --exclude-standard';
124+
$output = [];
125+
$resultCode = 0;
126+
127+
exec($command, $output, $resultCode);
128+
129+
if ($resultCode !== 0) {
130+
return $this->getAllFiles($projectPath);
131+
}
132+
133+
return array_values(array_filter(
134+
$output,
135+
fn (string $file): bool => $file !== '' && is_file($projectPath.'/'.$file),
136+
));
137+
}
138+
139+
/**
140+
* @return list<string>
141+
*/
142+
private function getAllFiles(string $projectPath): array
143+
{
144+
$files = [];
145+
$iterator = new \RecursiveIteratorIterator(
146+
new \RecursiveDirectoryIterator($projectPath, \FilesystemIterator::SKIP_DOTS),
147+
\RecursiveIteratorIterator::LEAVES_ONLY,
148+
);
149+
150+
/** @var \SplFileInfo $file */
151+
foreach ($iterator as $file) {
152+
if ($file->isFile()) {
153+
$files[] = substr($file->getPathname(), strlen($projectPath) + 1);
154+
}
155+
}
156+
157+
return $files;
158+
}
159+
160+
/**
161+
* @param list<string> $files
162+
* @param list<string> $exclusions
163+
* @return list<string>
164+
*/
165+
private function applyExclusions(array $files, array $exclusions): array
166+
{
167+
if ($exclusions === []) {
168+
return $files;
169+
}
170+
171+
return array_values(array_filter($files, function (string $file) use ($exclusions): bool {
172+
foreach ($exclusions as $pattern) {
173+
if (fnmatch($pattern, $file) || fnmatch($pattern, basename($file)) || str_starts_with($file, rtrim($pattern, '/').'/')) {
174+
return false;
175+
}
176+
}
177+
178+
return true;
179+
}));
180+
}
181+
182+
/**
183+
* @param list<string> $files
184+
*/
185+
private function createTarball(string $projectPath, array $files): string
186+
{
187+
$tarPath = sys_get_temp_dir().'/pest_cloud_'.bin2hex(random_bytes(8)).'.tar';
188+
$tarballPath = $tarPath.'.gz';
189+
190+
$phar = new PharData($tarPath);
191+
192+
foreach ($files as $file) {
193+
$fullPath = $projectPath.'/'.$file;
194+
195+
if (is_file($fullPath)) {
196+
$phar->addFile($fullPath, $file);
197+
}
198+
}
199+
200+
$phar->compress(Phar::GZ);
201+
202+
if (file_exists($tarPath)) {
203+
unlink($tarPath);
204+
}
205+
206+
return $tarballPath;
207+
}
208+
209+
/**
210+
* @return array{id: string, status: string}
211+
*/
212+
private function upload(string $apiUrl, string $apiToken, string $tarballPath, string $pestArguments): array
213+
{
214+
$url = rtrim($apiUrl, '/').'/api/run';
215+
$lastException = null;
216+
217+
for ($attempt = 1; $attempt <= self::MAX_RETRIES; $attempt++) {
218+
try {
219+
return $this->doUpload($url, $apiToken, $tarballPath, $pestArguments);
220+
} catch (RuntimeException $e) {
221+
$lastException = $e;
222+
223+
if (str_contains($e->getMessage(), '401') || str_contains($e->getMessage(), '422')) {
224+
throw $e;
225+
}
226+
227+
if ($attempt < self::MAX_RETRIES) {
228+
sleep(2 ** ($attempt - 1));
229+
}
230+
}
231+
}
232+
233+
throw $lastException;
234+
}
235+
236+
/**
237+
* @return array{id: string, status: string}
238+
*/
239+
private function doUpload(string $url, string $apiToken, string $tarballPath, string $pestArguments): array
240+
{
241+
$ch = curl_init($url);
242+
243+
$postFields = [
244+
'tarball' => new CURLFile($tarballPath, 'application/gzip', 'project.tar.gz'),
245+
];
246+
247+
if ($pestArguments !== '') {
248+
$postFields['pest_arguments'] = $pestArguments;
249+
}
250+
251+
curl_setopt_array($ch, [
252+
CURLOPT_POSTFIELDS => $postFields,
253+
CURLOPT_HTTPHEADER => [
254+
'Authorization: Bearer '.$apiToken,
255+
'Accept: application/json',
256+
],
257+
CURLOPT_RETURNTRANSFER => true,
258+
CURLOPT_TIMEOUT => 120,
259+
]);
260+
261+
$response = curl_exec($ch);
262+
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
263+
$error = curl_error($ch);
264+
265+
curl_close($ch);
266+
267+
if ($response === false) {
268+
throw new RuntimeException('Network error: '.$error);
269+
}
270+
271+
/** @var array{id?: string, status?: string, message?: string}|null $body */
272+
$body = json_decode((string) $response, true);
273+
274+
if ($httpCode === 401) {
275+
throw new RuntimeException('Authentication failed (401): Check your API token.');
276+
}
277+
278+
if ($httpCode === 422) {
279+
$message = is_array($body) ? ($body['message'] ?? 'Validation error') : 'Validation error';
280+
281+
throw new RuntimeException('Validation error (422): '.$message);
282+
}
283+
284+
if ($httpCode >= 500) {
285+
throw new RuntimeException("Server error ({$httpCode}): The service may be temporarily unavailable.");
286+
}
287+
288+
if ($httpCode !== 201 || ! is_array($body) || ! isset($body['id'], $body['status'])) {
289+
throw new RuntimeException("Unexpected response (HTTP {$httpCode}): ".$response);
290+
}
18291

19-
return 0;
292+
return ['id' => $body['id'], 'status' => $body['status']];
20293
}
21294
}

0 commit comments

Comments
 (0)