From 0af69a748b7a5296cd308ee7e34d2b469cb5c41f Mon Sep 17 00:00:00 2001 From: elwafa Date: Sat, 11 Apr 2026 10:19:02 +0200 Subject: [PATCH 01/23] feat: add HTTP timeout configuration for requests --- src/Configuration.php | 15 +++++++++++++++ src/VoltTest.php | 16 ++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/Configuration.php b/src/Configuration.php index 9e5dffd..ca05cd6 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -18,6 +18,8 @@ class Configuration private array $target; + private string $httpTimeout = ''; + private bool $httpDebug = false; public function __construct(string $name, string $description = '') @@ -48,6 +50,9 @@ public function toArray(): array if (trim($this->duration) !== '') { $array['duration'] = $this->duration; } + if (trim($this->httpTimeout) !== '') { + $array['http_timeout'] = $this->httpTimeout; + } return $array; } @@ -92,6 +97,16 @@ public function setTarget(string $idleTimeout = '30s'): self return $this; } + public function setHttpTimeout(string $httpTimeout): self + { + if (! preg_match('/^\d+[smh]$/', $httpTimeout)) { + throw new VoltTestException('Invalid HTTP timeout format. Use [s|m|h]'); + } + $this->httpTimeout = $httpTimeout; + + return $this; + } + public function setHttpDebug(bool $httpDebug): self { $this->httpDebug = $httpDebug; diff --git a/src/VoltTest.php b/src/VoltTest.php index 51e0711..5c71b65 100644 --- a/src/VoltTest.php +++ b/src/VoltTest.php @@ -68,6 +68,22 @@ public function setRampUp(string $rampUp): self return $this; } + /** + * Set the HTTP request timeout (per-request) + * @param string $timeout e.g. "60s", "2m" — default is 30s + * @return $this + * @throws VoltTestException + */ + public function setHttpTimeout(string $timeout): self + { + if (! preg_match('/^\d+[smh]$/', $timeout)) { + throw new VoltTestException('Invalid HTTP timeout format. Use [s|m|h]'); + } + $this->config->setHttpTimeout($timeout); + + return $this; + } + public function setHttpDebug(bool $httpDebug): self { $this->config->setHttpDebug($httpDebug); From 957ead00034133901191fb8f914d73b68da07e32 Mon Sep 17 00:00:00 2001 From: Islam A-Elwafa Date: Fri, 17 Apr 2026 14:35:20 +0200 Subject: [PATCH 02/23] version to 1.2.0-dev in composer.json --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 912849e..911e29f 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "volt-test/php-sdk", "description": "Volt Test PHP SDK - A performance testing tool for PHP Developers", "type": "library", - "version": "1.1.1", + "version": "1.2.0-dev", "keywords": [ "volt-test", "php-sdk", From a53ec7abdd3e74a0adf7e7999c7f012849e23060 Mon Sep 17 00:00:00 2001 From: elwafa Date: Fri, 17 Apr 2026 14:38:11 +0200 Subject: [PATCH 03/23] feat: implement stages for load profile configuration and validation --- src/Configuration.php | 43 +++++++++++++++++++++++++++++++++++------ src/Stage.php | 45 +++++++++++++++++++++++++++++++++++++++++++ src/VoltTest.php | 29 ++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 6 deletions(-) create mode 100644 src/Stage.php diff --git a/src/Configuration.php b/src/Configuration.php index ca05cd6..78cc159 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -22,6 +22,9 @@ class Configuration private bool $httpDebug = false; + /** @var Stage[] */ + private array $stages = []; + public function __construct(string $name, string $description = '') { $this->name = $name; @@ -40,16 +43,24 @@ public function toArray(): array $array = [ 'name' => $this->name, 'description' => $this->description, - 'virtual_users' => $this->virtualUsers, 'target' => $this->target, 'http_debug' => $this->httpDebug, ]; - if (trim($this->rampUp) !== '') { - $array['ramp_up'] = $this->rampUp; - } - if (trim($this->duration) !== '') { - $array['duration'] = $this->duration; + + if (count($this->stages) > 0) { + // Stages mode: omit virtual_users, duration, ramp_up + $array['stages'] = array_map(fn (Stage $s) => $s->toArray(), $this->stages); + } else { + // Constant mode + $array['virtual_users'] = $this->virtualUsers; + if (trim($this->rampUp) !== '') { + $array['ramp_up'] = $this->rampUp; + } + if (trim($this->duration) !== '') { + $array['duration'] = $this->duration; + } } + if (trim($this->httpTimeout) !== '') { $array['http_timeout'] = $this->httpTimeout; } @@ -113,4 +124,24 @@ public function setHttpDebug(bool $httpDebug): self return $this; } + + /** + * @throws VoltTestException + */ + public function addStage(string $duration, int $target): self + { + $this->stages[] = new Stage($duration, $target); + + return $this; + } + + public function hasStages(): bool + { + return count($this->stages) > 0; + } + + public function hasConstantLoad(): bool + { + return $this->virtualUsers > 1 || trim($this->duration) !== '' || trim($this->rampUp) !== ''; + } } diff --git a/src/Stage.php b/src/Stage.php new file mode 100644 index 0000000..e8177f2 --- /dev/null +++ b/src/Stage.php @@ -0,0 +1,45 @@ +[s|m|h]'); + } + if ($target < 0) { + throw new VoltTestException('Stage target must be non-negative'); + } + $this->duration = $duration; + $this->target = $target; + } + + public function getDuration(): string + { + return $this->duration; + } + + public function getTarget(): int + { + return $this->target; + } + + public function toArray(): array + { + return [ + 'duration' => $this->duration, + 'target' => $this->target, + ]; + } +} diff --git a/src/VoltTest.php b/src/VoltTest.php index 5c71b65..9fb155b 100644 --- a/src/VoltTest.php +++ b/src/VoltTest.php @@ -31,6 +31,9 @@ public function setVirtualUsers(int $count): self if ($count < 1) { throw new VoltTestException('Virtual users count must be at least 1'); } + if ($this->config->hasStages()) { + throw new VoltTestException('Cannot use setVirtualUsers with stages. Stages define the full load profile.'); + } $this->config->setVirtualUsers($count); return $this; @@ -47,6 +50,9 @@ public function setDuration(string $duration): self if (! preg_match('/^\d+[smh]$/', $duration)) { throw new VoltTestException('Invalid duration format. Use [s|m|h]'); } + if ($this->config->hasStages()) { + throw new VoltTestException('Cannot use setDuration with stages. Stages define the full load profile.'); + } $this->config->setDuration($duration); return $this; @@ -63,11 +69,34 @@ public function setRampUp(string $rampUp): self if (! preg_match('/^\d+[smh]$/', $rampUp)) { throw new VoltTestException('Invalid ramp-up format. Use [s|m|h]'); } + if ($this->config->hasStages()) { + throw new VoltTestException('Cannot use setRampUp with stages. Stages define the full load profile.'); + } $this->config->setRampUp($rampUp); return $this; } + /** + * Add a stage to the load profile. + * Each stage linearly ramps from the previous target to this target over the given duration. + * Stages are mutually exclusive with setVirtualUsers/setDuration/setRampUp. + * + * @param string $duration Duration of this stage (e.g. "5m", "30s", "1h") + * @param int $target Target VU count at the end of this stage + * @return $this + * @throws VoltTestException + */ + public function stage(string $duration, int $target): self + { + if ($this->config->hasConstantLoad()) { + throw new VoltTestException('Cannot use stages with setVirtualUsers/setDuration/setRampUp. Use stages to define the full load profile.'); + } + $this->config->addStage($duration, $target); + + return $this; + } + /** * Set the HTTP request timeout (per-request) * @param string $timeout e.g. "60s", "2m" — default is 30s From 4089eb780d51b0101dbc5fad7275b6d7c9e4c4af Mon Sep 17 00:00:00 2001 From: elwafa Date: Fri, 17 Apr 2026 14:40:36 +0200 Subject: [PATCH 04/23] feat: update CI configuration to include release branches for push and pull request triggers --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf8989d..42a6558 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [ main, develop ] + branches: [ main, develop, release/* ] pull_request: - branches: [ main, develop ] + branches: [ main, develop , release/* ] jobs: test: From 70389b3ca5239cadc788faf2c38ddc5527c11b0a Mon Sep 17 00:00:00 2001 From: elwafa Date: Sat, 18 Apr 2026 13:39:27 +0200 Subject: [PATCH 05/23] feat: implement cloud execution features with stages and error handling --- src/CloudClient.php | 116 +++++ src/CloudRun.php | 46 ++ src/Exceptions/AuthenticationException.php | 7 + src/Exceptions/CloudConnectionException.php | 7 + src/Exceptions/CloudException.php | 7 + src/Exceptions/CloudTimeoutException.php | 7 + src/Exceptions/PlanLimitException.php | 7 + src/Exceptions/RunFailedException.php | 7 + src/Extractors/HtmlExtractor.php | 1 - src/ProcessManager.php | 4 +- src/Step.php | 2 +- src/VoltTest.php | 194 ++++++++- tests/Units/CloudClientTest.php | 67 +++ tests/Units/CloudExceptionTest.php | 80 ++++ tests/Units/CloudRunTest.php | 67 +++ tests/Units/ConfigurationStagesTest.php | 213 +++++++++ tests/Units/StageTest.php | 104 +++++ tests/Units/StepTest.php | 1 - tests/Units/VoltTestCloudTest.php | 452 ++++++++++++++++++++ tests/Units/VoltTestStagesTest.php | 293 +++++++++++++ 20 files changed, 1677 insertions(+), 5 deletions(-) create mode 100644 src/CloudClient.php create mode 100644 src/CloudRun.php create mode 100644 src/Exceptions/AuthenticationException.php create mode 100644 src/Exceptions/CloudConnectionException.php create mode 100644 src/Exceptions/CloudException.php create mode 100644 src/Exceptions/CloudTimeoutException.php create mode 100644 src/Exceptions/PlanLimitException.php create mode 100644 src/Exceptions/RunFailedException.php create mode 100644 tests/Units/CloudClientTest.php create mode 100644 tests/Units/CloudExceptionTest.php create mode 100644 tests/Units/CloudRunTest.php create mode 100644 tests/Units/ConfigurationStagesTest.php create mode 100644 tests/Units/StageTest.php create mode 100644 tests/Units/VoltTestCloudTest.php create mode 100644 tests/Units/VoltTestStagesTest.php diff --git a/src/CloudClient.php b/src/CloudClient.php new file mode 100644 index 0000000..b393b20 --- /dev/null +++ b/src/CloudClient.php @@ -0,0 +1,116 @@ +apiKey = $apiKey; + $this->baseUrl = $baseUrl ?? self::BASE_URL; + } + + public function createTest(array $data): array + { + return $this->request('POST', '/api/v1/tests', $data); + } + + public function startRun(string $testId): array + { + return $this->request('POST', '/api/v1/runs', ['test_id' => $testId]); + } + + public function getRunStatus(string $runId): array + { + return $this->request('GET', '/api/v1/runs/' . $runId); + } + + public function stopRun(string $runId): array + { + return $this->request('DELETE', '/api/v1/runs/' . $runId); + } + + private function request(string $method, string $endpoint, ?array $body = null): array + { + $url = rtrim($this->baseUrl, '/') . $endpoint; + + $ch = curl_init(); + + $headers = [ + 'Authorization: Bearer ' . $this->apiKey, + 'Content-Type: application/json', + 'Accept: application/json', + 'User-Agent: ' . self::USER_AGENT, + ]; + + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_TIMEOUT => 30, + CURLOPT_CONNECTTIMEOUT => 10, + CURLOPT_SSL_VERIFYPEER => true, + ]); + + if ($method === 'POST') { + curl_setopt($ch, CURLOPT_POST, true); + if ($body !== null) { + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body)); + } + } elseif ($method === 'DELETE') { + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); + } + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlError = curl_error($ch); + curl_close($ch); + + if ($response === false) { + throw new CloudConnectionException('Failed to connect to VoltTest Cloud: ' . $curlError); + } + + $decoded = json_decode($response, true) ?? []; + + if ($httpCode === 401) { + $message = $decoded['error']['message'] ?? 'Invalid or expired API key'; + + throw new AuthenticationException($message); + } + + if ($httpCode === 403) { + $message = $decoded['error']['message'] ?? 'Plan limit exceeded'; + + throw new PlanLimitException($message); + } + + if ($httpCode >= 400) { + $message = $decoded['error']['message'] ?? "API request failed with status {$httpCode}"; + + throw new CloudException($message); + } + + return $decoded; + } +} diff --git a/src/CloudRun.php b/src/CloudRun.php new file mode 100644 index 0000000..4257d16 --- /dev/null +++ b/src/CloudRun.php @@ -0,0 +1,46 @@ +runId = $runId; + $this->testId = $testId; + $this->status = $status; + } + + public function getRunId(): string + { + return $this->runId; + } + + public function getTestId(): string + { + return $this->testId; + } + + public function getStatus(): string + { + return $this->status; + } + + public function getDashboardUrl(): string + { + return self::DASHBOARD_BASE_URL . '/runs/' . $this->runId; + } + + public function isSuccessful(): bool + { + return $this->status === 'completed'; + } +} diff --git a/src/Exceptions/AuthenticationException.php b/src/Exceptions/AuthenticationException.php new file mode 100644 index 0000000..eb59075 --- /dev/null +++ b/src/Exceptions/AuthenticationException.php @@ -0,0 +1,7 @@ +currentProcess = null; // Print the final output - if (!empty($output)) { + if (! empty($output)) { echo "\n$output\n"; } } @@ -157,6 +158,7 @@ private function handleProcess(array $pipes, bool $streamOutput): string if (feof($pipe)) { fclose($pipe); unset($pipes[$type]); + continue; } } diff --git a/src/Step.php b/src/Step.php index 009bf55..e3d5284 100644 --- a/src/Step.php +++ b/src/Step.php @@ -192,11 +192,11 @@ public function extractFromRegex(string $variableName, string $selector): self return $this; } - public function extractFromHtml(string $variableName, string $selector, ?string $attribute = null): self { $htmlExtractor = new HtmlExtractor($variableName, $selector, $attribute); $this->extracts[] = $htmlExtractor; + return $this; } diff --git a/src/VoltTest.php b/src/VoltTest.php index 9fb155b..15bde1e 100644 --- a/src/VoltTest.php +++ b/src/VoltTest.php @@ -2,7 +2,9 @@ namespace VoltTest; +use VoltTest\Exceptions\CloudTimeoutException; use VoltTest\Exceptions\ErrorHandler; +use VoltTest\Exceptions\RunFailedException; use VoltTest\Exceptions\VoltTestException; class VoltTest @@ -13,6 +15,12 @@ class VoltTest private ProcessManager $processManager; + private ?string $cloudApiKey = null; + + private int $cloudTimeout = 1800; + + protected int $pollInterval = 3; + public function __construct(string $name, string $description = '') { ErrorHandler::register(); @@ -135,6 +143,40 @@ public function setTarget(string $idleTimeout): self return $this; } + /** + * Enable cloud execution mode. + * + * @param string $apiKey Your VoltTest API key (starts with "vt_") + * @return $this + * @throws VoltTestException + */ + public function cloud(string $apiKey): self + { + if (empty($apiKey)) { + throw new VoltTestException('API key is required. Create one at https://app.volt-test.com/settings'); + } + + if (! str_starts_with($apiKey, 'vt_')) { + throw new VoltTestException('API key must start with "vt_"'); + } + + $this->cloudApiKey = $apiKey; + + return $this; + } + + /** + * Set the cloud execution timeout in seconds (default: 1800 = 30 minutes). + * + * @return $this + */ + public function setCloudTimeout(int $seconds): self + { + $this->cloudTimeout = max(60, $seconds); + + return $this; + } + public function scenario(string $name, string $description = ''): Scenario { $scenario = new Scenario($name, $description); @@ -143,15 +185,165 @@ public function scenario(string $name, string $description = ''): Scenario return $scenario; } - public function run(bool $streamOutput = false): TestResult + public function run(bool $streamOutput = false): TestResult|CloudRun { $config = $this->prepareConfig(); + if ($this->cloudApiKey !== null) { + return $this->runCloud($config); + } + $output = $this->processManager->execute($config, $streamOutput); return new TestResult($output); } + protected function createCloudClient(): CloudClient + { + return new CloudClient($this->cloudApiKey); + } + + private function runCloud(array $config): CloudRun + { + $client = $this->createCloudClient(); + + /** @var string|null $runId */ + $runId = null; + if (function_exists('pcntl_async_signals')) { + pcntl_async_signals(true); + pcntl_signal(SIGINT, function () use ($client, &$runId) { + if ($runId !== null) { + echo "\n Stopping cloud run...\n"; + + try { + $client->stopRun($runId); + } catch (\Exception $e) { + } + } + exit(130); + }); + } + + $targetUrl = $config['target']['url'] ?? ''; + $virtualUsers = $config['virtual_users'] ?? 1; + $durationSeconds = 0; + + if (isset($config['stages']) && is_array($config['stages'])) { + foreach ($config['stages'] as $stage) { + $durationSeconds += $this->parseDurationToSeconds($stage['duration'] ?? '0s'); + } + $targets = array_column($config['stages'], 'target'); + if (! empty($targets)) { + $virtualUsers = max($targets); + } + } elseif (isset($config['duration'])) { + $durationSeconds = $this->parseDurationToSeconds($config['duration']); + } + + $testConfig = $config; + unset($testConfig['weights']); + + $testData = [ + 'name' => $config['name'] ?? 'Unnamed Test', + 'description' => $config['description'] ?? '', + 'target_url' => $targetUrl, + 'virtual_users' => $virtualUsers, + 'duration_seconds' => $durationSeconds, + 'test_config' => json_encode($testConfig), + ]; + + $test = $client->createTest($testData); + $run = $client->startRun($test['id']); + $runId = $run['id']; + + echo "\n"; + echo " Cloud test submitted (run: {$runId})\n"; + echo " Waiting for cloud infrastructure...\n"; + echo "\n"; + + $elapsed = 0; + $interval = $this->pollInterval; + $spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + $frame = 0; + $lastStatus = ''; + + while ($elapsed < $this->cloudTimeout) { + sleep($interval); + $elapsed += $interval; + + $status = $client->getRunStatus($runId); + $currentStatus = $status['status']; + + if (in_array($currentStatus, ['completed', 'failed', 'stopped'])) { + echo "\r\033[K"; + + break; + } + + $spinner = $spinnerFrames[$frame % count($spinnerFrames)]; + $frame++; + + if (in_array($currentStatus, ['pending', 'provisioning', 'starting'])) { + $label = ucfirst($currentStatus) . '...'; + echo "\r\033[K {$spinner} {$label}"; + $lastStatus = $currentStatus; + } elseif ($currentStatus === 'running' && isset($status['progress'])) { + $pct = $status['progress']['percentage'] ?? 0; + $elapsedSec = $status['progress']['elapsed_seconds'] ?? 0; + $totalSec = $status['progress']['total_seconds'] ?? $durationSeconds; + + $barWidth = 20; + $filled = (int) round($barWidth * $pct / 100); + $bar = str_repeat('▓', $filled) . str_repeat('░', $barWidth - $filled); + + echo "\r\033[K {$bar} {$pct}% ({$elapsedSec}s / {$totalSec}s)"; + } + } + + echo "\n"; + + if ($elapsed >= $this->cloudTimeout) { + throw new CloudTimeoutException( + "Cloud run timed out after {$this->cloudTimeout} seconds. Run ID: {$runId}" + ); + } + + $cloudRun = new CloudRun($runId, $test['id'], $currentStatus); + + if ($currentStatus === 'failed') { + $errorMsg = $status['error_message'] ?? 'Unknown error'; + echo " ✗ Test failed: {$errorMsg}\n\n"; + echo " View details → {$cloudRun->getDashboardUrl()}\n\n"; + + throw new RunFailedException("Cloud run failed: {$errorMsg}. Run ID: {$runId}"); + } + + if ($currentStatus === 'stopped') { + echo " ⊘ Test was stopped\n\n"; + echo " View details → {$cloudRun->getDashboardUrl()}\n\n"; + + throw new RunFailedException("Cloud run was stopped. Run ID: {$runId}"); + } + + echo " ✓ Test completed\n\n"; + echo " View results → {$cloudRun->getDashboardUrl()}\n\n"; + + return $cloudRun; + } + + private function parseDurationToSeconds(string $duration): int + { + if (preg_match('/^(\d+)(s|m|h)$/', $duration, $matches)) { + return match ($matches[2]) { + 's' => (int) $matches[1], + 'm' => (int) $matches[1] * 60, + 'h' => (int) $matches[1] * 3600, + }; + } + + return 0; + } + private function prepareConfig(): array { $config = $this->config->toArray(); diff --git a/tests/Units/CloudClientTest.php b/tests/Units/CloudClientTest.php new file mode 100644 index 0000000..0c8d9fc --- /dev/null +++ b/tests/Units/CloudClientTest.php @@ -0,0 +1,67 @@ +assertEquals('vt_test_key_123', $this->getPrivateProperty($client, 'apiKey')); + } + + public function testConstructorWithCustomBaseUrl(): void + { + $client = new CloudClient('vt_test_key_123', 'https://custom.api.com/v1'); + + $this->assertEquals('https://custom.api.com/v1', $this->getPrivateProperty($client, 'baseUrl')); + } + + public function testConstructorDefaultBaseUrl(): void + { + $client = new CloudClient('vt_test_key_123'); + + $reflection = new \ReflectionClass(CloudClient::class); + $constant = $reflection->getReflectionConstant('BASE_URL'); + $expectedUrl = $constant->getValue(); + + $this->assertEquals($expectedUrl, $this->getPrivateProperty($client, 'baseUrl')); + } + + public function testConstructorThrowsOnEmptyApiKey(): void + { + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage('API key is required'); + + new CloudClient(''); + } + + public function testConstructorThrowsOnInvalidPrefix(): void + { + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage('API key must start with "vt_"'); + + new CloudClient('invalid_key_123'); + } + + public function testConstructorThrowsOnWhitespaceKey(): void + { + $this->expectException(AuthenticationException::class); + + new CloudClient(' '); + } + + private function getPrivateProperty(object $object, string $propertyName): mixed + { + $reflection = new \ReflectionClass(get_class($object)); + $property = $reflection->getProperty($propertyName); + $property->setAccessible(true); + + return $property->getValue($object); + } +} diff --git a/tests/Units/CloudExceptionTest.php b/tests/Units/CloudExceptionTest.php new file mode 100644 index 0000000..d44b1c2 --- /dev/null +++ b/tests/Units/CloudExceptionTest.php @@ -0,0 +1,80 @@ +assertInstanceOf(VoltTestException::class, $e); + } + + public function testAuthenticationExceptionExtendsCloudException(): void + { + $e = new AuthenticationException('auth failed'); + + $this->assertInstanceOf(CloudException::class, $e); + $this->assertInstanceOf(VoltTestException::class, $e); + } + + public function testPlanLimitExceptionExtendsCloudException(): void + { + $e = new PlanLimitException('plan limit'); + + $this->assertInstanceOf(CloudException::class, $e); + $this->assertInstanceOf(VoltTestException::class, $e); + } + + public function testCloudTimeoutExceptionExtendsCloudException(): void + { + $e = new CloudTimeoutException('timed out'); + + $this->assertInstanceOf(CloudException::class, $e); + $this->assertInstanceOf(VoltTestException::class, $e); + } + + public function testCloudConnectionExceptionExtendsCloudException(): void + { + $e = new CloudConnectionException('connection failed'); + + $this->assertInstanceOf(CloudException::class, $e); + $this->assertInstanceOf(VoltTestException::class, $e); + } + + public function testRunFailedExceptionExtendsCloudException(): void + { + $e = new RunFailedException('run failed'); + + $this->assertInstanceOf(CloudException::class, $e); + $this->assertInstanceOf(VoltTestException::class, $e); + } + + public function testExceptionMessagesArePreserved(): void + { + $exceptions = [ + new CloudException('cloud msg'), + new AuthenticationException('auth msg'), + new PlanLimitException('plan msg'), + new CloudTimeoutException('timeout msg'), + new CloudConnectionException('conn msg'), + new RunFailedException('run msg'), + ]; + + $expectedMessages = ['cloud msg', 'auth msg', 'plan msg', 'timeout msg', 'conn msg', 'run msg']; + + foreach ($exceptions as $i => $exception) { + $this->assertEquals($expectedMessages[$i], $exception->getMessage()); + } + } +} diff --git a/tests/Units/CloudRunTest.php b/tests/Units/CloudRunTest.php new file mode 100644 index 0000000..8b0f86c --- /dev/null +++ b/tests/Units/CloudRunTest.php @@ -0,0 +1,67 @@ +assertEquals('run-123', $run->getRunId()); + $this->assertEquals('test-456', $run->getTestId()); + $this->assertEquals('completed', $run->getStatus()); + } + + public function testGetDashboardUrl(): void + { + $run = new CloudRun('run-abc-123', 'test-456', 'running'); + + $this->assertEquals('https://app.volt-test.com/runs/run-abc-123', $run->getDashboardUrl()); + } + + public function testGetDashboardUrlWithDifferentIds(): void + { + $run = new CloudRun('abc-def-ghi', 'test-1', 'pending'); + + $this->assertEquals('https://app.volt-test.com/runs/abc-def-ghi', $run->getDashboardUrl()); + } + + public function testIsSuccessfulWhenCompleted(): void + { + $run = new CloudRun('run-1', 'test-1', 'completed'); + + $this->assertTrue($run->isSuccessful()); + } + + public function testIsSuccessfulWhenFailed(): void + { + $run = new CloudRun('run-1', 'test-1', 'failed'); + + $this->assertFalse($run->isSuccessful()); + } + + public function testIsSuccessfulWhenRunning(): void + { + $run = new CloudRun('run-1', 'test-1', 'running'); + + $this->assertFalse($run->isSuccessful()); + } + + public function testIsSuccessfulWhenStopped(): void + { + $run = new CloudRun('run-1', 'test-1', 'stopped'); + + $this->assertFalse($run->isSuccessful()); + } + + public function testIsSuccessfulWhenPending(): void + { + $run = new CloudRun('run-1', 'test-1', 'pending'); + + $this->assertFalse($run->isSuccessful()); + } +} diff --git a/tests/Units/ConfigurationStagesTest.php b/tests/Units/ConfigurationStagesTest.php new file mode 100644 index 0000000..3f648e1 --- /dev/null +++ b/tests/Units/ConfigurationStagesTest.php @@ -0,0 +1,213 @@ +config = new Configuration('Test', 'Test Description'); + } + + public function testHasStagesReturnsFalseByDefault(): void + { + $this->assertFalse($this->config->hasStages()); + } + + public function testHasStagesReturnsTrueAfterAddingStage(): void + { + $this->config->addStage('5m', 100); + + $this->assertTrue($this->config->hasStages()); + } + + public function testHasConstantLoadReturnsFalseByDefault(): void + { + $this->assertFalse($this->config->hasConstantLoad()); + } + + public function testHasConstantLoadReturnsTrueWithVirtualUsers(): void + { + $this->config->setVirtualUsers(10); + + $this->assertTrue($this->config->hasConstantLoad()); + } + + public function testHasConstantLoadReturnsTrueWithDuration(): void + { + $this->config->setDuration('5m'); + + $this->assertTrue($this->config->hasConstantLoad()); + } + + public function testHasConstantLoadReturnsTrueWithRampUp(): void + { + $this->config->setRampUp('10s'); + + $this->assertTrue($this->config->hasConstantLoad()); + } + + public function testHasConstantLoadFalseWithOneVirtualUser(): void + { + $this->config->setVirtualUsers(1); + + $this->assertFalse($this->config->hasConstantLoad()); + } + + public function testAddStageReturnsSelf(): void + { + $result = $this->config->addStage('5m', 100); + + $this->assertSame($this->config, $result); + } + + public function testAddMultipleStages(): void + { + $this->config + ->addStage('2m', 10) + ->addStage('5m', 50) + ->addStage('2m', 0); + + $this->assertTrue($this->config->hasStages()); + + $array = $this->config->toArray(); + $this->assertCount(3, $array['stages']); + } + + public function testToArrayWithStagesOmitsConstantLoadFields(): void + { + $this->config->addStage('5m', 100); + + $array = $this->config->toArray(); + + $this->assertArrayHasKey('stages', $array); + $this->assertArrayNotHasKey('virtual_users', $array); + $this->assertArrayNotHasKey('duration', $array); + $this->assertArrayNotHasKey('ramp_up', $array); + } + + public function testToArrayWithStagesSerializesCorrectly(): void + { + $this->config + ->addStage('2m', 10) + ->addStage('5m', 50) + ->addStage('2m', 0); + + $array = $this->config->toArray(); + + $this->assertEquals([ + ['duration' => '2m', 'target' => 10], + ['duration' => '5m', 'target' => 50], + ['duration' => '2m', 'target' => 0], + ], $array['stages']); + } + + public function testToArrayWithoutStagesIncludesConstantLoadFields(): void + { + $this->config + ->setVirtualUsers(10) + ->setDuration('5m') + ->setRampUp('30s'); + + $array = $this->config->toArray(); + + $this->assertArrayNotHasKey('stages', $array); + $this->assertEquals(10, $array['virtual_users']); + $this->assertEquals('5m', $array['duration']); + $this->assertEquals('30s', $array['ramp_up']); + } + + public function testToArrayWithoutDurationOmitsDurationField(): void + { + $array = $this->config->toArray(); + + $this->assertArrayNotHasKey('duration', $array); + } + + public function testToArrayWithoutRampUpOmitsRampUpField(): void + { + $array = $this->config->toArray(); + + $this->assertArrayNotHasKey('ramp_up', $array); + } + + public function testAddStageWithInvalidDurationThrows(): void + { + $this->expectException(VoltTestException::class); + + $this->config->addStage('invalid', 10); + } + + public function testAddStageWithNegativeTargetThrows(): void + { + $this->expectException(VoltTestException::class); + + $this->config->addStage('5m', -1); + } + + #[DataProvider('validHttpTimeoutProvider')] + public function testSetHttpTimeout(string $timeout): void + { + $this->config->setHttpTimeout($timeout); + + $array = $this->config->toArray(); + $this->assertEquals($timeout, $array['http_timeout']); + } + + public static function validHttpTimeoutProvider(): array + { + return [ + ['30s'], + ['1m'], + ['60s'], + ['2m'], + ['1h'], + ]; + } + + #[DataProvider('invalidHttpTimeoutProvider')] + public function testSetInvalidHttpTimeoutThrows(string $timeout): void + { + $this->expectException(VoltTestException::class); + $this->expectExceptionMessage('Invalid HTTP timeout format. Use [s|m|h]'); + + $this->config->setHttpTimeout($timeout); + } + + public static function invalidHttpTimeoutProvider(): array + { + return [ + [''], + ['10'], + ['s'], + ['1x'], + ['-1s'], + ]; + } + + public function testHttpTimeoutOmittedWhenNotSet(): void + { + $array = $this->config->toArray(); + + $this->assertArrayNotHasKey('http_timeout', $array); + } + + public function testHttpTimeoutIncludedWithStages(): void + { + $this->config + ->addStage('5m', 100) + ->setHttpTimeout('60s'); + + $array = $this->config->toArray(); + + $this->assertArrayHasKey('stages', $array); + $this->assertEquals('60s', $array['http_timeout']); + } +} diff --git a/tests/Units/StageTest.php b/tests/Units/StageTest.php new file mode 100644 index 0000000..e70b3cf --- /dev/null +++ b/tests/Units/StageTest.php @@ -0,0 +1,104 @@ +assertEquals('5m', $stage->getDuration()); + $this->assertEquals(100, $stage->getTarget()); + } + + public function testZeroTargetIsAllowed(): void + { + $stage = new Stage('1m', 0); + + $this->assertEquals(0, $stage->getTarget()); + } + + #[DataProvider('validDurationProvider')] + public function testValidDurations(string $duration): void + { + $stage = new Stage($duration, 10); + + $this->assertEquals($duration, $stage->getDuration()); + } + + public static function validDurationProvider(): array + { + return [ + 'seconds' => ['30s'], + 'minutes' => ['5m'], + 'hours' => ['1h'], + 'zero seconds' => ['0s'], + 'large number' => ['999m'], + ]; + } + + #[DataProvider('invalidDurationProvider')] + public function testInvalidDurationThrows(string $duration): void + { + $this->expectException(VoltTestException::class); + $this->expectExceptionMessage('Invalid stage duration format. Use [s|m|h]'); + + new Stage($duration, 10); + } + + public static function invalidDurationProvider(): array + { + return [ + 'empty' => [''], + 'no unit' => ['10'], + 'only unit' => ['s'], + 'invalid unit' => ['5x'], + 'word format' => ['30min'], + 'negative' => ['-1s'], + 'decimal' => ['1.5m'], + 'space' => ['5 m'], + ]; + } + + public function testNegativeTargetThrows(): void + { + $this->expectException(VoltTestException::class); + $this->expectExceptionMessage('Stage target must be non-negative'); + + new Stage('5m', -1); + } + + public function testNegativeLargeTargetThrows(): void + { + $this->expectException(VoltTestException::class); + $this->expectExceptionMessage('Stage target must be non-negative'); + + new Stage('5m', -100); + } + + public function testToArray(): void + { + $stage = new Stage('5m', 100); + + $this->assertEquals([ + 'duration' => '5m', + 'target' => 100, + ], $stage->toArray()); + } + + public function testToArrayWithZeroTarget(): void + { + $stage = new Stage('2m', 0); + + $this->assertEquals([ + 'duration' => '2m', + 'target' => 0, + ], $stage->toArray()); + } +} diff --git a/tests/Units/StepTest.php b/tests/Units/StepTest.php index 8bb9fb4..1d68590 100644 --- a/tests/Units/StepTest.php +++ b/tests/Units/StepTest.php @@ -128,7 +128,6 @@ public function testExtractFromHtml(): void $this->assertEquals('html', $extract['type']); } - public function testExtractFromHtmlWithAttribute(): void { $this->step->get(self::TEST_URL) diff --git a/tests/Units/VoltTestCloudTest.php b/tests/Units/VoltTestCloudTest.php new file mode 100644 index 0000000..ed96b85 --- /dev/null +++ b/tests/Units/VoltTestCloudTest.php @@ -0,0 +1,452 @@ +getProperty('cloudTimeout'); + $property->setAccessible(true); + $property->setValue($this, $seconds); + } + + protected function createCloudClient(): CloudClient + { + return $this->mockClient; + } +} + +class VoltTestCloudTest extends TestCase +{ + private VoltTest $voltTest; + + protected function setUp(): void + { + $this->voltTest = new VoltTest('Cloud Test Suite', 'Testing cloud features'); + } + + protected function tearDown(): void + { + ErrorHandler::unregister(); + parent::tearDown(); + } + + public function testCloudSetsApiKey(): void + { + $this->voltTest->cloud('vt_test_key_123'); + + $this->assertEquals('vt_test_key_123', $this->getPrivateProperty($this->voltTest, 'cloudApiKey')); + } + + public function testCloudReturnsSelf(): void + { + $result = $this->voltTest->cloud('vt_test_key_123'); + + $this->assertSame($this->voltTest, $result); + } + + public function testCloudThrowsOnEmptyKey(): void + { + $this->expectException(VoltTestException::class); + $this->expectExceptionMessage('API key is required'); + + $this->voltTest->cloud(''); + } + + public function testCloudThrowsOnInvalidPrefix(): void + { + $this->expectException(VoltTestException::class); + $this->expectExceptionMessage('API key must start with "vt_"'); + + $this->voltTest->cloud('invalid_key'); + } + + public function testSetCloudTimeoutSetsValue(): void + { + $this->voltTest->setCloudTimeout(120); + + $this->assertEquals(120, $this->getPrivateProperty($this->voltTest, 'cloudTimeout')); + } + + public function testSetCloudTimeoutReturnsSelf(): void + { + $result = $this->voltTest->setCloudTimeout(120); + + $this->assertSame($this->voltTest, $result); + } + + public function testSetCloudTimeoutClampsToMinimum60(): void + { + $this->voltTest->setCloudTimeout(30); + + $this->assertEquals(60, $this->getPrivateProperty($this->voltTest, 'cloudTimeout')); + } + + public function testSetCloudTimeoutAccepts60(): void + { + $this->voltTest->setCloudTimeout(60); + + $this->assertEquals(60, $this->getPrivateProperty($this->voltTest, 'cloudTimeout')); + } + + public function testDefaultCloudTimeoutIs1800(): void + { + $this->assertEquals(1800, $this->getPrivateProperty($this->voltTest, 'cloudTimeout')); + } + + public function testParseDurationSeconds(): void + { + $method = new \ReflectionMethod(VoltTest::class, 'parseDurationToSeconds'); + $method->setAccessible(true); + + $this->assertEquals(30, $method->invoke($this->voltTest, '30s')); + } + + public function testParseDurationMinutes(): void + { + $method = new \ReflectionMethod(VoltTest::class, 'parseDurationToSeconds'); + $method->setAccessible(true); + + $this->assertEquals(300, $method->invoke($this->voltTest, '5m')); + } + + public function testParseDurationHours(): void + { + $method = new \ReflectionMethod(VoltTest::class, 'parseDurationToSeconds'); + $method->setAccessible(true); + + $this->assertEquals(3600, $method->invoke($this->voltTest, '1h')); + } + + public function testParseDurationInvalid(): void + { + $method = new \ReflectionMethod(VoltTest::class, 'parseDurationToSeconds'); + $method->setAccessible(true); + + $this->assertEquals(0, $method->invoke($this->voltTest, 'invalid')); + } + + public function testParseDurationEmpty(): void + { + $method = new \ReflectionMethod(VoltTest::class, 'parseDurationToSeconds'); + $method->setAccessible(true); + + $this->assertEquals(0, $method->invoke($this->voltTest, '')); + } + + public function testParseDurationMissingUnit(): void + { + $method = new \ReflectionMethod(VoltTest::class, 'parseDurationToSeconds'); + $method->setAccessible(true); + + $this->assertEquals(0, $method->invoke($this->voltTest, '10')); + } + + public function testParseDurationZero(): void + { + $method = new \ReflectionMethod(VoltTest::class, 'parseDurationToSeconds'); + $method->setAccessible(true); + + $this->assertEquals(0, $method->invoke($this->voltTest, '0s')); + } + + public function testRunRoutesToLocalWhenNoCloudKey(): void + { + $mockProcessManager = $this->createMock(ProcessManager::class); + $mockProcessManager->expects($this->once()) + ->method('execute') + ->willReturn($this->getSampleOutput()); + + $this->setPrivateProperty($this->voltTest, 'processManager', $mockProcessManager); + + $this->voltTest + ->setVirtualUsers(1) + ->setDuration('1s') + ->setTarget('40s'); + + $this->voltTest->scenario('Simple Test') + ->step('Homepage') + ->get('http://example.com') + ->validateStatus('success', 200); + + $result = $this->voltTest->run(false); + + $this->assertInstanceOf(TestResult::class, $result); + } + + public function testRunRoutesToCloudWhenKeySet(): void + { + $testable = new TestableVoltTest('Cloud Route Test'); + $testable->setCloudTimeout(60); + + $mockClient = $this->createMock(CloudClient::class); + $mockClient->expects($this->once()) + ->method('createTest') + ->willReturn(['id' => 'test-1']); + $mockClient->expects($this->once()) + ->method('startRun') + ->with('test-1') + ->willReturn(['id' => 'run-1']); + $mockClient->expects($this->once()) + ->method('getRunStatus') + ->with('run-1') + ->willReturn(['status' => 'completed']); + + $testable->mockClient = $mockClient; + + $testable->cloud('vt_test_key_123') + ->setVirtualUsers(1) + ->setDuration('1s'); + + $testable->scenario('Test') + ->step('Step') + ->get('http://example.com') + ->validateStatus('success', 200); + + ob_start(); + $result = $testable->run(false); + ob_end_clean(); + + $this->assertInstanceOf(CloudRun::class, $result); + } + + public function testRunCloudCompletedSuccessfully(): void + { + $testable = $this->createTestableVoltTest(); + + $mockClient = $this->createMock(CloudClient::class); + $mockClient->method('createTest')->willReturn(['id' => 'test-1']); + $mockClient->method('startRun')->willReturn(['id' => 'run-1']); + $mockClient->method('getRunStatus')->willReturn(['status' => 'completed']); + + $testable->mockClient = $mockClient; + + ob_start(); + $result = $testable->run(false); + ob_end_clean(); + + $this->assertInstanceOf(CloudRun::class, $result); + $this->assertTrue($result->isSuccessful()); + $this->assertEquals('run-1', $result->getRunId()); + $this->assertEquals('test-1', $result->getTestId()); + $this->assertEquals('completed', $result->getStatus()); + } + + public function testRunCloudOutputContainsDashboardUrl(): void + { + $testable = $this->createTestableVoltTest(); + + $mockClient = $this->createMock(CloudClient::class); + $mockClient->method('createTest')->willReturn(['id' => 'test-1']); + $mockClient->method('startRun')->willReturn(['id' => 'run-abc']); + $mockClient->method('getRunStatus')->willReturn(['status' => 'completed']); + + $testable->mockClient = $mockClient; + + ob_start(); + $testable->run(false); + $output = ob_get_clean(); + + $this->assertStringContainsString('https://app.volt-test.com/runs/run-abc', $output); + $this->assertStringContainsString('Test completed', $output); + } + + public function testRunCloudFailedThrowsRunFailedException(): void + { + $testable = $this->createTestableVoltTest(); + + $mockClient = $this->createMock(CloudClient::class); + $mockClient->method('createTest')->willReturn(['id' => 'test-1']); + $mockClient->method('startRun')->willReturn(['id' => 'run-1']); + $mockClient->method('getRunStatus')->willReturn([ + 'status' => 'failed', + 'error_message' => 'Out of memory', + ]); + + $testable->mockClient = $mockClient; + + $this->expectException(RunFailedException::class); + $this->expectExceptionMessage('Cloud run failed: Out of memory'); + + ob_start(); + + try { + $testable->run(false); + } finally { + ob_end_clean(); + } + } + + public function testRunCloudStoppedThrowsRunFailedException(): void + { + $testable = $this->createTestableVoltTest(); + + $mockClient = $this->createMock(CloudClient::class); + $mockClient->method('createTest')->willReturn(['id' => 'test-1']); + $mockClient->method('startRun')->willReturn(['id' => 'run-1']); + $mockClient->method('getRunStatus')->willReturn(['status' => 'stopped']); + + $testable->mockClient = $mockClient; + + $this->expectException(RunFailedException::class); + $this->expectExceptionMessage('Cloud run was stopped'); + + ob_start(); + + try { + $testable->run(false); + } finally { + ob_end_clean(); + } + } + + public function testRunCloudTimeoutThrowsCloudTimeoutException(): void + { + $testable = new TestableVoltTest('Timeout Test'); + $testable->pollInterval = 1; + $testable->setTestCloudTimeout(2); + $testable->cloud('vt_test_key_123') + ->setVirtualUsers(1) + ->setDuration('1s'); + + $testable->scenario('Test') + ->step('Step') + ->get('http://example.com') + ->validateStatus('success', 200); + + $mockClient = $this->createMock(CloudClient::class); + $mockClient->method('createTest')->willReturn(['id' => 'test-1']); + $mockClient->method('startRun')->willReturn(['id' => 'run-1']); + $mockClient->method('getRunStatus')->willReturn([ + 'status' => 'running', + 'progress' => ['percentage' => 50, 'elapsed_seconds' => 15, 'total_seconds' => 30], + ]); + + $testable->mockClient = $mockClient; + + $this->expectException(CloudTimeoutException::class); + $this->expectExceptionMessage('Cloud run timed out'); + + ob_start(); + + try { + $testable->run(false); + } finally { + ob_end_clean(); + } + } + + public function testRunCloudBuildsCorrectTestData(): void + { + $testable = new TestableVoltTest('My Load Test', 'Testing the app'); + $testable->pollInterval = 0; + $testable->setCloudTimeout(60); + + $testable->cloud('vt_test_key_123') + ->setVirtualUsers(50) + ->setDuration('5m'); + + $testable->scenario('Homepage') + ->step('Load page') + ->get('http://example.com') + ->validateStatus('success', 200); + + $capturedData = null; + $mockClient = $this->createMock(CloudClient::class); + $mockClient->expects($this->once()) + ->method('createTest') + ->willReturnCallback(function (array $data) use (&$capturedData) { + $capturedData = $data; + + return ['id' => 'test-1']; + }); + $mockClient->method('startRun')->willReturn(['id' => 'run-1']); + $mockClient->method('getRunStatus')->willReturn(['status' => 'completed']); + + $testable->mockClient = $mockClient; + + ob_start(); + $testable->run(false); + ob_end_clean(); + + $this->assertEquals('My Load Test', $capturedData['name']); + $this->assertEquals('Testing the app', $capturedData['description']); + $this->assertEquals(50, $capturedData['virtual_users']); + $this->assertEquals(300, $capturedData['duration_seconds']); + $this->assertArrayHasKey('test_config', $capturedData); + $this->assertIsString($capturedData['test_config']); + } + + private function createTestableVoltTest(): TestableVoltTest + { + $testable = new TestableVoltTest('Cloud Test'); + $testable->setCloudTimeout(60); + $testable->cloud('vt_test_key_123') + ->setVirtualUsers(1) + ->setDuration('1s'); + + $testable->scenario('Test') + ->step('Step') + ->get('http://example.com') + ->validateStatus('success', 200); + + return $testable; + } + + private function getSampleOutput(): string + { + return <<<'EOT' +Test Metrics Summary: +=================== +Duration: 1.5s +Total Reqs: 10 +Success Rate: 100.00% +Req/sec: 6.67 +Success Requests: 10 +Failed Requests: 0 + +Response Time: +------------ +Min: 50ms +Max: 200ms +Avg: 100ms +Median: 95ms +P95: 180ms +P99: 195ms +EOT; + } + + private function setPrivateProperty(object $object, string $propertyName, mixed $value): void + { + $reflection = new \ReflectionClass(get_class($object)); + $property = $reflection->getProperty($propertyName); + $property->setAccessible(true); + $property->setValue($object, $value); + } + + private function getPrivateProperty(object $object, string $propertyName): mixed + { + $reflection = new \ReflectionClass(get_class($object)); + $property = $reflection->getProperty($propertyName); + $property->setAccessible(true); + + return $property->getValue($object); + } +} diff --git a/tests/Units/VoltTestStagesTest.php b/tests/Units/VoltTestStagesTest.php new file mode 100644 index 0000000..a7e2c5e --- /dev/null +++ b/tests/Units/VoltTestStagesTest.php @@ -0,0 +1,293 @@ +voltTest = new VoltTest('Stages Test', 'Testing stages'); + } + + protected function tearDown(): void + { + ErrorHandler::unregister(); + parent::tearDown(); + } + + public function testStageReturnsSelf(): void + { + $result = $this->voltTest->stage('5m', 100); + + $this->assertSame($this->voltTest, $result); + } + + public function testSingleStage(): void + { + $this->voltTest->stage('5m', 100); + + $config = $this->getPrivateProperty($this->voltTest, 'config'); + + $this->assertTrue($config->hasStages()); + } + + public function testMultipleStagesChaining(): void + { + $result = $this->voltTest + ->stage('2m', 10) + ->stage('5m', 50) + ->stage('3m', 100) + ->stage('2m', 0); + + $this->assertSame($this->voltTest, $result); + + $config = $this->getPrivateProperty($this->voltTest, 'config'); + $array = $config->toArray(); + $this->assertCount(4, $array['stages']); + } + + public function testStageWithZeroTarget(): void + { + $this->voltTest->stage('2m', 0); + + $config = $this->getPrivateProperty($this->voltTest, 'config'); + $array = $config->toArray(); + $this->assertEquals(0, $array['stages'][0]['target']); + } + + public function testStageThrowsOnInvalidDuration(): void + { + $this->expectException(VoltTestException::class); + $this->expectExceptionMessage('Invalid stage duration format'); + + $this->voltTest->stage('invalid', 10); + } + + public function testStageThrowsOnNegativeTarget(): void + { + $this->expectException(VoltTestException::class); + $this->expectExceptionMessage('Stage target must be non-negative'); + + $this->voltTest->stage('5m', -1); + } + + // --- Mutual exclusivity: stages after constant load --- + + public function testStageThrowsAfterSetVirtualUsers(): void + { + $this->voltTest->setVirtualUsers(10); + + $this->expectException(VoltTestException::class); + $this->expectExceptionMessage('Cannot use stages with setVirtualUsers/setDuration/setRampUp'); + + $this->voltTest->stage('5m', 100); + } + + public function testStageThrowsAfterSetDuration(): void + { + $this->voltTest->setDuration('5m'); + + $this->expectException(VoltTestException::class); + $this->expectExceptionMessage('Cannot use stages with setVirtualUsers/setDuration/setRampUp'); + + $this->voltTest->stage('5m', 100); + } + + public function testStageThrowsAfterSetRampUp(): void + { + $this->voltTest->setRampUp('10s'); + + $this->expectException(VoltTestException::class); + $this->expectExceptionMessage('Cannot use stages with setVirtualUsers/setDuration/setRampUp'); + + $this->voltTest->stage('5m', 100); + } + + // --- Mutual exclusivity: constant load after stages --- + + public function testSetVirtualUsersThrowsAfterStage(): void + { + $this->voltTest->stage('5m', 100); + + $this->expectException(VoltTestException::class); + $this->expectExceptionMessage('Cannot use setVirtualUsers with stages'); + + $this->voltTest->setVirtualUsers(10); + } + + public function testSetDurationThrowsAfterStage(): void + { + $this->voltTest->stage('5m', 100); + + $this->expectException(VoltTestException::class); + $this->expectExceptionMessage('Cannot use setDuration with stages'); + + $this->voltTest->setDuration('5m'); + } + + public function testSetRampUpThrowsAfterStage(): void + { + $this->voltTest->stage('5m', 100); + + $this->expectException(VoltTestException::class); + $this->expectExceptionMessage('Cannot use setRampUp with stages'); + + $this->voltTest->setRampUp('10s'); + } + + // --- setHttpTimeout --- + + public function testSetHttpTimeoutReturnsSelf(): void + { + $result = $this->voltTest->setHttpTimeout('60s'); + + $this->assertSame($this->voltTest, $result); + } + + #[DataProvider('validHttpTimeoutProvider')] + public function testSetHttpTimeoutWithValidValues(string $timeout): void + { + $this->voltTest->setHttpTimeout($timeout); + + $config = $this->getPrivateProperty($this->voltTest, 'config'); + $this->assertEquals($timeout, $config->toArray()['http_timeout']); + } + + public static function validHttpTimeoutProvider(): array + { + return [ + ['30s'], + ['60s'], + ['1m'], + ['5m'], + ['1h'], + ]; + } + + #[DataProvider('invalidHttpTimeoutProvider')] + public function testSetHttpTimeoutThrowsOnInvalidValues(string $timeout): void + { + $this->expectException(VoltTestException::class); + $this->expectExceptionMessage('Invalid HTTP timeout format'); + + $this->voltTest->setHttpTimeout($timeout); + } + + public static function invalidHttpTimeoutProvider(): array + { + return [ + [''], + ['10'], + ['s'], + ['1x'], + ['30min'], + ['-1s'], + ['1.5h'], + ]; + } + + // --- setTarget (idle timeout) --- + + public function testSetTargetReturnsSelf(): void + { + $result = $this->voltTest->setTarget('60s'); + + $this->assertSame($this->voltTest, $result); + } + + public function testSetTargetUpdatesIdleTimeout(): void + { + $this->voltTest->setTarget('2m'); + + $config = $this->getPrivateProperty($this->voltTest, 'config'); + $array = $config->toArray(); + $this->assertEquals('2m', $array['target']['idle_timeout']); + } + + public function testSetTargetThrowsOnInvalidFormat(): void + { + $this->expectException(VoltTestException::class); + $this->expectExceptionMessage('Invalid idle timeout format'); + + $this->voltTest->setTarget('invalid'); + } + + // --- Stages combined with non-exclusive settings --- + + public function testStagesWithHttpTimeout(): void + { + $this->voltTest + ->stage('2m', 10) + ->stage('5m', 50) + ->setHttpTimeout('60s'); + + $config = $this->getPrivateProperty($this->voltTest, 'config'); + $array = $config->toArray(); + + $this->assertCount(2, $array['stages']); + $this->assertEquals('60s', $array['http_timeout']); + } + + public function testStagesWithTarget(): void + { + $this->voltTest + ->stage('5m', 100) + ->setTarget('2m'); + + $config = $this->getPrivateProperty($this->voltTest, 'config'); + $array = $config->toArray(); + + $this->assertCount(1, $array['stages']); + $this->assertEquals('2m', $array['target']['idle_timeout']); + } + + public function testStagesWithHttpDebug(): void + { + $this->voltTest + ->stage('5m', 100) + ->setHttpDebug(true); + + $config = $this->getPrivateProperty($this->voltTest, 'config'); + $array = $config->toArray(); + + $this->assertCount(1, $array['stages']); + $this->assertTrue($array['http_debug']); + } + + // --- Typical ramp-up / ramp-down pattern --- + + public function testTypicalRampUpRampDownPattern(): void + { + $this->voltTest + ->stage('2m', 10) + ->stage('5m', 50) + ->stage('10m', 100) + ->stage('5m', 50) + ->stage('2m', 0); + + $config = $this->getPrivateProperty($this->voltTest, 'config'); + $array = $config->toArray(); + + $this->assertCount(5, $array['stages']); + $this->assertEquals(10, $array['stages'][0]['target']); + $this->assertEquals(100, $array['stages'][2]['target']); + $this->assertEquals(0, $array['stages'][4]['target']); + } + + private function getPrivateProperty(object $object, string $propertyName): mixed + { + $reflection = new \ReflectionClass(get_class($object)); + $property = $reflection->getProperty($propertyName); + $property->setAccessible(true); + + return $property->getValue($object); + } +} From fbef1122ce8fcfc6a9e94e4764738b430fef4987 Mon Sep 17 00:00:00 2001 From: elwafa Date: Sat, 18 Apr 2026 13:42:03 +0200 Subject: [PATCH 06/23] feat: update base URL for CloudClient to use production endpoint --- src/CloudClient.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CloudClient.php b/src/CloudClient.php index b393b20..26bc0d4 100644 --- a/src/CloudClient.php +++ b/src/CloudClient.php @@ -9,7 +9,7 @@ class CloudClient { - private const BASE_URL = 'http://localhost:8080/'; + private const BASE_URL = 'https://cloud.volt-test.com'; private const USER_AGENT = 'volt-test-php-sdk'; From 76e6a2082eb864f38ac0f53fb0aca123a31b28ab Mon Sep 17 00:00:00 2001 From: elwafa Date: Sat, 18 Apr 2026 13:43:34 +0200 Subject: [PATCH 07/23] feat: update DASHBOARD_BASE_URL to use the correct production endpoint --- src/CloudRun.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CloudRun.php b/src/CloudRun.php index 4257d16..244f729 100644 --- a/src/CloudRun.php +++ b/src/CloudRun.php @@ -4,7 +4,7 @@ class CloudRun { - private const DASHBOARD_BASE_URL = 'https://app.volt-test.com'; + private const DASHBOARD_BASE_URL = 'https://volt-test.com'; private string $runId; From 0cca212cae01a79be16350cbfbc4c4b8a5bd7941 Mon Sep 17 00:00:00 2001 From: elwafa Date: Sat, 18 Apr 2026 13:56:44 +0200 Subject: [PATCH 08/23] feat: update dashboard URL to use the correct production endpoint --- src/VoltTest.php | 2 ++ tests/Units/CloudRunTest.php | 4 ++-- tests/Units/VoltTestCloudTest.php | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/VoltTest.php b/src/VoltTest.php index 15bde1e..2fffa94 100644 --- a/src/VoltTest.php +++ b/src/VoltTest.php @@ -263,6 +263,8 @@ private function runCloud(array $config): CloudRun $elapsed = 0; $interval = $this->pollInterval; + $currentStatus = 'pending'; + $status = []; $spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; $frame = 0; $lastStatus = ''; diff --git a/tests/Units/CloudRunTest.php b/tests/Units/CloudRunTest.php index 8b0f86c..c060bd9 100644 --- a/tests/Units/CloudRunTest.php +++ b/tests/Units/CloudRunTest.php @@ -20,14 +20,14 @@ public function testGetDashboardUrl(): void { $run = new CloudRun('run-abc-123', 'test-456', 'running'); - $this->assertEquals('https://app.volt-test.com/runs/run-abc-123', $run->getDashboardUrl()); + $this->assertEquals('https://volt-test.com/runs/run-abc-123', $run->getDashboardUrl()); } public function testGetDashboardUrlWithDifferentIds(): void { $run = new CloudRun('abc-def-ghi', 'test-1', 'pending'); - $this->assertEquals('https://app.volt-test.com/runs/abc-def-ghi', $run->getDashboardUrl()); + $this->assertEquals('https://volt-test.com/runs/abc-def-ghi', $run->getDashboardUrl()); } public function testIsSuccessfulWhenCompleted(): void diff --git a/tests/Units/VoltTestCloudTest.php b/tests/Units/VoltTestCloudTest.php index ed96b85..2dd39ee 100644 --- a/tests/Units/VoltTestCloudTest.php +++ b/tests/Units/VoltTestCloudTest.php @@ -264,7 +264,7 @@ public function testRunCloudOutputContainsDashboardUrl(): void $testable->run(false); $output = ob_get_clean(); - $this->assertStringContainsString('https://app.volt-test.com/runs/run-abc', $output); + $this->assertStringContainsString('https://volt-test.com/runs/run-abc', $output); $this->assertStringContainsString('Test completed', $output); } From 572a3eb6d714dfa7c67fb3b0365782e32183dafb Mon Sep 17 00:00:00 2001 From: elwafa Date: Sat, 18 Apr 2026 14:35:51 +0200 Subject: [PATCH 09/23] feat: update engine version to v1.2.0-dev --- src/Platform.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Platform.php b/src/Platform.php index 452c55e..bf1093f 100644 --- a/src/Platform.php +++ b/src/Platform.php @@ -6,7 +6,7 @@ class Platform { private const BINARY_NAME = 'volt-test'; - private const ENGINE_CURRENT_VERSION = 'v1.1.0'; + private const ENGINE_CURRENT_VERSION = 'v1.2.0-dev'; private const BASE_DOWNLOAD_URL = 'https://github.com/volt-test/binaries/releases/download'; private const SUPPORTED_PLATFORMS = [ 'linux-amd64' => 'volt-test-linux-amd64', From 9ad03b42a1a742257cf90a5ab784f03405affd8e Mon Sep 17 00:00:00 2001 From: elwafa Date: Fri, 24 Apr 2026 20:20:01 +0300 Subject: [PATCH 10/23] feat: implement cloud execution features with error handling and response parsing --- src/CloudClient.php | 116 --------- src/ProcessManager.php | 85 +++++++ src/TestableProcessManager.php | 14 +- src/VoltTest.php | 170 +++---------- tests/Units/CloudClientTest.php | 67 ------ tests/Units/VoltTestCloudTest.php | 383 +++++++++++++----------------- 6 files changed, 300 insertions(+), 535 deletions(-) delete mode 100644 src/CloudClient.php delete mode 100644 tests/Units/CloudClientTest.php diff --git a/src/CloudClient.php b/src/CloudClient.php deleted file mode 100644 index 26bc0d4..0000000 --- a/src/CloudClient.php +++ /dev/null @@ -1,116 +0,0 @@ -apiKey = $apiKey; - $this->baseUrl = $baseUrl ?? self::BASE_URL; - } - - public function createTest(array $data): array - { - return $this->request('POST', '/api/v1/tests', $data); - } - - public function startRun(string $testId): array - { - return $this->request('POST', '/api/v1/runs', ['test_id' => $testId]); - } - - public function getRunStatus(string $runId): array - { - return $this->request('GET', '/api/v1/runs/' . $runId); - } - - public function stopRun(string $runId): array - { - return $this->request('DELETE', '/api/v1/runs/' . $runId); - } - - private function request(string $method, string $endpoint, ?array $body = null): array - { - $url = rtrim($this->baseUrl, '/') . $endpoint; - - $ch = curl_init(); - - $headers = [ - 'Authorization: Bearer ' . $this->apiKey, - 'Content-Type: application/json', - 'Accept: application/json', - 'User-Agent: ' . self::USER_AGENT, - ]; - - curl_setopt_array($ch, [ - CURLOPT_URL => $url, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HTTPHEADER => $headers, - CURLOPT_TIMEOUT => 30, - CURLOPT_CONNECTTIMEOUT => 10, - CURLOPT_SSL_VERIFYPEER => true, - ]); - - if ($method === 'POST') { - curl_setopt($ch, CURLOPT_POST, true); - if ($body !== null) { - curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body)); - } - } elseif ($method === 'DELETE') { - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); - } - - $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - $curlError = curl_error($ch); - curl_close($ch); - - if ($response === false) { - throw new CloudConnectionException('Failed to connect to VoltTest Cloud: ' . $curlError); - } - - $decoded = json_decode($response, true) ?? []; - - if ($httpCode === 401) { - $message = $decoded['error']['message'] ?? 'Invalid or expired API key'; - - throw new AuthenticationException($message); - } - - if ($httpCode === 403) { - $message = $decoded['error']['message'] ?? 'Plan limit exceeded'; - - throw new PlanLimitException($message); - } - - if ($httpCode >= 400) { - $message = $decoded['error']['message'] ?? "API request failed with status {$httpCode}"; - - throw new CloudException($message); - } - - return $decoded; - } -} diff --git a/src/ProcessManager.php b/src/ProcessManager.php index e8f1ada..d4b0f18 100644 --- a/src/ProcessManager.php +++ b/src/ProcessManager.php @@ -52,6 +52,47 @@ public function handleSignal(int $signal): void exit(0); } + public function executeCloud(array $config): string + { + [$success, $process, $pipes] = $this->openProcess(); + $this->currentProcess = $process; + $this->pipes = $pipes; + + if (! $success || ! is_array($pipes)) { + throw new RuntimeException('Failed to start process of volt test'); + } + + try { + $this->writeInput($pipes[0], json_encode($config, JSON_PRETTY_PRINT)); + fclose($pipes[0]); + + $output = $this->handleCloudProcess($pipes); + + foreach ($pipes as $pipe) { + if (is_resource($pipe)) { + fclose($pipe); + } + } + + if (is_resource($process)) { + $this->closeProcess($process); + $this->currentProcess = null; + } + + return $output; + } finally { + foreach ($pipes as $pipe) { + if (is_resource($pipe)) { + fclose($pipe); + } + } + if (is_resource($process)) { + $this->closeProcess($process); + $this->currentProcess = null; + } + } + } + public function execute(array $config, bool $streamOutput): string { [$success, $process, $pipes] = $this->openProcess(); @@ -129,6 +170,50 @@ protected function openProcess(): array return [true, $process, $pipes]; } + private function handleCloudProcess(array $pipes): string + { + $output = ''; + + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + + while (true) { + $read = array_filter($pipes, 'is_resource'); + if (empty($read)) { + break; + } + + $write = null; + $except = null; + + if (stream_select($read, $write, $except, 1) === false) { + break; + } + + foreach ($read as $pipe) { + $type = array_search($pipe, $pipes, true); + $data = fread($pipe, 4096); + + if ($data === false || $data === '') { + if (feof($pipe)) { + fclose($pipe); + unset($pipes[$type]); + + continue; + } + } + + if ($type === 1) { + $output .= $data; + } elseif ($type === 2) { + fwrite(STDERR, $data); + } + } + } + + return $output; + } + private function handleProcess(array $pipes, bool $streamOutput): string { $output = ''; diff --git a/src/TestableProcessManager.php b/src/TestableProcessManager.php index 185e0c8..cece937 100644 --- a/src/TestableProcessManager.php +++ b/src/TestableProcessManager.php @@ -59,14 +59,20 @@ public function wasProcessCompleted(): bool return $this->processCompleted; } - public function getWritternInput(): string + public function wereResourcesCleaned(): bool { - return $this->writtenInput; + return $this->resourcedCleaned; } - public function wereResourcesCleaned(): bool + public function executeCloud(array $config): string { - return $this->resourcedCleaned; + $this->writtenInput = json_encode($config, JSON_PRETTY_PRINT); + $this->processStarted = true; + $this->processClosed = true; + $this->processCompleted = true; + $this->resourcedCleaned = true; + + return $this->mockOutput; } protected function openProcess(): array diff --git a/src/VoltTest.php b/src/VoltTest.php index 2fffa94..9064d7a 100644 --- a/src/VoltTest.php +++ b/src/VoltTest.php @@ -2,8 +2,12 @@ namespace VoltTest; +use VoltTest\Exceptions\AuthenticationException; +use VoltTest\Exceptions\CloudConnectionException; +use VoltTest\Exceptions\CloudException; use VoltTest\Exceptions\CloudTimeoutException; use VoltTest\Exceptions\ErrorHandler; +use VoltTest\Exceptions\PlanLimitException; use VoltTest\Exceptions\RunFailedException; use VoltTest\Exceptions\VoltTestException; @@ -19,8 +23,6 @@ class VoltTest private int $cloudTimeout = 1800; - protected int $pollInterval = 3; - public function __construct(string $name, string $description = '') { ErrorHandler::register(); @@ -190,7 +192,9 @@ public function run(bool $streamOutput = false): TestResult|CloudRun $config = $this->prepareConfig(); if ($this->cloudApiKey !== null) { - return $this->runCloud($config); + $output = $this->processManager->executeCloud($config); + + return $this->parseCloudResult($output); } $output = $this->processManager->execute($config, $streamOutput); @@ -198,152 +202,48 @@ public function run(bool $streamOutput = false): TestResult|CloudRun return new TestResult($output); } - protected function createCloudClient(): CloudClient + private function parseCloudResult(string $output): CloudRun { - return new CloudClient($this->cloudApiKey); - } + $data = json_decode($output, true); - private function runCloud(array $config): CloudRun - { - $client = $this->createCloudClient(); - - /** @var string|null $runId */ - $runId = null; - if (function_exists('pcntl_async_signals')) { - pcntl_async_signals(true); - pcntl_signal(SIGINT, function () use ($client, &$runId) { - if ($runId !== null) { - echo "\n Stopping cloud run...\n"; - - try { - $client->stopRun($runId); - } catch (\Exception $e) { - } - } - exit(130); - }); + if (! is_array($data)) { + throw new CloudException('Failed to parse cloud result: ' . $output); } - $targetUrl = $config['target']['url'] ?? ''; - $virtualUsers = $config['virtual_users'] ?? 1; - $durationSeconds = 0; - - if (isset($config['stages']) && is_array($config['stages'])) { - foreach ($config['stages'] as $stage) { - $durationSeconds += $this->parseDurationToSeconds($stage['duration'] ?? '0s'); - } - $targets = array_column($config['stages'], 'target'); - if (! empty($targets)) { - $virtualUsers = max($targets); - } - } elseif (isset($config['duration'])) { - $durationSeconds = $this->parseDurationToSeconds($config['duration']); + if (isset($data['error']) && $data['error'] === true) { + $this->throwCloudError($data['error_type'] ?? 'cloud_error', $data['message'] ?? 'Unknown error'); } - $testConfig = $config; - unset($testConfig['weights']); - - $testData = [ - 'name' => $config['name'] ?? 'Unnamed Test', - 'description' => $config['description'] ?? '', - 'target_url' => $targetUrl, - 'virtual_users' => $virtualUsers, - 'duration_seconds' => $durationSeconds, - 'test_config' => json_encode($testConfig), - ]; - - $test = $client->createTest($testData); - $run = $client->startRun($test['id']); - $runId = $run['id']; - - echo "\n"; - echo " Cloud test submitted (run: {$runId})\n"; - echo " Waiting for cloud infrastructure...\n"; - echo "\n"; - - $elapsed = 0; - $interval = $this->pollInterval; - $currentStatus = 'pending'; - $status = []; - $spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; - $frame = 0; - $lastStatus = ''; - - while ($elapsed < $this->cloudTimeout) { - sleep($interval); - $elapsed += $interval; - - $status = $client->getRunStatus($runId); - $currentStatus = $status['status']; - - if (in_array($currentStatus, ['completed', 'failed', 'stopped'])) { - echo "\r\033[K"; - - break; - } - - $spinner = $spinnerFrames[$frame % count($spinnerFrames)]; - $frame++; + $status = $data['status'] ?? 'unknown'; + $runId = $data['run_id'] ?? ''; + $testId = $data['test_id'] ?? ''; + $errorMessage = $data['error_message'] ?? ''; - if (in_array($currentStatus, ['pending', 'provisioning', 'starting'])) { - $label = ucfirst($currentStatus) . '...'; - echo "\r\033[K {$spinner} {$label}"; - $lastStatus = $currentStatus; - } elseif ($currentStatus === 'running' && isset($status['progress'])) { - $pct = $status['progress']['percentage'] ?? 0; - $elapsedSec = $status['progress']['elapsed_seconds'] ?? 0; - $totalSec = $status['progress']['total_seconds'] ?? $durationSeconds; - - $barWidth = 20; - $filled = (int) round($barWidth * $pct / 100); - $bar = str_repeat('▓', $filled) . str_repeat('░', $barWidth - $filled); - - echo "\r\033[K {$bar} {$pct}% ({$elapsedSec}s / {$totalSec}s)"; + if ($status === 'failed') { + $msg = 'Cloud run failed'; + if ($errorMessage !== '') { + $msg .= ": {$errorMessage}"; } - } - echo "\n"; - - if ($elapsed >= $this->cloudTimeout) { - throw new CloudTimeoutException( - "Cloud run timed out after {$this->cloudTimeout} seconds. Run ID: {$runId}" - ); - } - - $cloudRun = new CloudRun($runId, $test['id'], $currentStatus); - - if ($currentStatus === 'failed') { - $errorMsg = $status['error_message'] ?? 'Unknown error'; - echo " ✗ Test failed: {$errorMsg}\n\n"; - echo " View details → {$cloudRun->getDashboardUrl()}\n\n"; - - throw new RunFailedException("Cloud run failed: {$errorMsg}. Run ID: {$runId}"); + throw new RunFailedException("{$msg}. Run ID: {$runId}"); } - if ($currentStatus === 'stopped') { - echo " ⊘ Test was stopped\n\n"; - echo " View details → {$cloudRun->getDashboardUrl()}\n\n"; - + if ($status === 'stopped') { throw new RunFailedException("Cloud run was stopped. Run ID: {$runId}"); } - echo " ✓ Test completed\n\n"; - echo " View results → {$cloudRun->getDashboardUrl()}\n\n"; - - return $cloudRun; + return new CloudRun($runId, $testId, $status); } - private function parseDurationToSeconds(string $duration): int + private function throwCloudError(string $errorType, string $message): void { - if (preg_match('/^(\d+)(s|m|h)$/', $duration, $matches)) { - return match ($matches[2]) { - 's' => (int) $matches[1], - 'm' => (int) $matches[1] * 60, - 'h' => (int) $matches[1] * 3600, - }; - } - - return 0; + match ($errorType) { + 'authentication' => throw new AuthenticationException($message), + 'plan_limit' => throw new PlanLimitException($message), + 'connection' => throw new CloudConnectionException($message), + 'timeout' => throw new CloudTimeoutException($message), + default => throw new CloudException($message), + }; } private function prepareConfig(): array @@ -357,6 +257,12 @@ private function prepareConfig(): array return $scenario->getWeight(); }, $this->scenarios); + if ($this->cloudApiKey !== null) { + $config['cloud'] = true; + $config['api_key'] = $this->cloudApiKey; + $config['cloud_timeout'] = $this->cloudTimeout; + } + return $config; } } diff --git a/tests/Units/CloudClientTest.php b/tests/Units/CloudClientTest.php deleted file mode 100644 index 0c8d9fc..0000000 --- a/tests/Units/CloudClientTest.php +++ /dev/null @@ -1,67 +0,0 @@ -assertEquals('vt_test_key_123', $this->getPrivateProperty($client, 'apiKey')); - } - - public function testConstructorWithCustomBaseUrl(): void - { - $client = new CloudClient('vt_test_key_123', 'https://custom.api.com/v1'); - - $this->assertEquals('https://custom.api.com/v1', $this->getPrivateProperty($client, 'baseUrl')); - } - - public function testConstructorDefaultBaseUrl(): void - { - $client = new CloudClient('vt_test_key_123'); - - $reflection = new \ReflectionClass(CloudClient::class); - $constant = $reflection->getReflectionConstant('BASE_URL'); - $expectedUrl = $constant->getValue(); - - $this->assertEquals($expectedUrl, $this->getPrivateProperty($client, 'baseUrl')); - } - - public function testConstructorThrowsOnEmptyApiKey(): void - { - $this->expectException(AuthenticationException::class); - $this->expectExceptionMessage('API key is required'); - - new CloudClient(''); - } - - public function testConstructorThrowsOnInvalidPrefix(): void - { - $this->expectException(AuthenticationException::class); - $this->expectExceptionMessage('API key must start with "vt_"'); - - new CloudClient('invalid_key_123'); - } - - public function testConstructorThrowsOnWhitespaceKey(): void - { - $this->expectException(AuthenticationException::class); - - new CloudClient(' '); - } - - private function getPrivateProperty(object $object, string $propertyName): mixed - { - $reflection = new \ReflectionClass(get_class($object)); - $property = $reflection->getProperty($propertyName); - $property->setAccessible(true); - - return $property->getValue($object); - } -} diff --git a/tests/Units/VoltTestCloudTest.php b/tests/Units/VoltTestCloudTest.php index 2dd39ee..4d06233 100644 --- a/tests/Units/VoltTestCloudTest.php +++ b/tests/Units/VoltTestCloudTest.php @@ -3,36 +3,19 @@ namespace Tests\Units; use PHPUnit\Framework\TestCase; -use VoltTest\CloudClient; use VoltTest\CloudRun; +use VoltTest\Exceptions\AuthenticationException; +use VoltTest\Exceptions\CloudConnectionException; +use VoltTest\Exceptions\CloudException; use VoltTest\Exceptions\CloudTimeoutException; use VoltTest\Exceptions\ErrorHandler; +use VoltTest\Exceptions\PlanLimitException; use VoltTest\Exceptions\RunFailedException; use VoltTest\Exceptions\VoltTestException; use VoltTest\ProcessManager; use VoltTest\TestResult; use VoltTest\VoltTest; -class TestableVoltTest extends VoltTest -{ - public ?CloudClient $mockClient = null; - - public int $pollInterval = 0; - - public function setTestCloudTimeout(int $seconds): void - { - $reflection = new \ReflectionClass(VoltTest::class); - $property = $reflection->getProperty('cloudTimeout'); - $property->setAccessible(true); - $property->setValue($this, $seconds); - } - - protected function createCloudClient(): CloudClient - { - return $this->mockClient; - } -} - class VoltTestCloudTest extends TestCase { private VoltTest $voltTest; @@ -111,62 +94,6 @@ public function testDefaultCloudTimeoutIs1800(): void $this->assertEquals(1800, $this->getPrivateProperty($this->voltTest, 'cloudTimeout')); } - public function testParseDurationSeconds(): void - { - $method = new \ReflectionMethod(VoltTest::class, 'parseDurationToSeconds'); - $method->setAccessible(true); - - $this->assertEquals(30, $method->invoke($this->voltTest, '30s')); - } - - public function testParseDurationMinutes(): void - { - $method = new \ReflectionMethod(VoltTest::class, 'parseDurationToSeconds'); - $method->setAccessible(true); - - $this->assertEquals(300, $method->invoke($this->voltTest, '5m')); - } - - public function testParseDurationHours(): void - { - $method = new \ReflectionMethod(VoltTest::class, 'parseDurationToSeconds'); - $method->setAccessible(true); - - $this->assertEquals(3600, $method->invoke($this->voltTest, '1h')); - } - - public function testParseDurationInvalid(): void - { - $method = new \ReflectionMethod(VoltTest::class, 'parseDurationToSeconds'); - $method->setAccessible(true); - - $this->assertEquals(0, $method->invoke($this->voltTest, 'invalid')); - } - - public function testParseDurationEmpty(): void - { - $method = new \ReflectionMethod(VoltTest::class, 'parseDurationToSeconds'); - $method->setAccessible(true); - - $this->assertEquals(0, $method->invoke($this->voltTest, '')); - } - - public function testParseDurationMissingUnit(): void - { - $method = new \ReflectionMethod(VoltTest::class, 'parseDurationToSeconds'); - $method->setAccessible(true); - - $this->assertEquals(0, $method->invoke($this->voltTest, '10')); - } - - public function testParseDurationZero(): void - { - $method = new \ReflectionMethod(VoltTest::class, 'parseDurationToSeconds'); - $method->setAccessible(true); - - $this->assertEquals(0, $method->invoke($this->voltTest, '0s')); - } - public function testRunRoutesToLocalWhenNoCloudKey(): void { $mockProcessManager = $this->createMock(ProcessManager::class); @@ -191,223 +118,247 @@ public function testRunRoutesToLocalWhenNoCloudKey(): void $this->assertInstanceOf(TestResult::class, $result); } - public function testRunRoutesToCloudWhenKeySet(): void + public function testRunCloudAddsCloudFieldsToConfig(): void { - $testable = new TestableVoltTest('Cloud Route Test'); - $testable->setCloudTimeout(60); - - $mockClient = $this->createMock(CloudClient::class); - $mockClient->expects($this->once()) - ->method('createTest') - ->willReturn(['id' => 'test-1']); - $mockClient->expects($this->once()) - ->method('startRun') - ->with('test-1') - ->willReturn(['id' => 'run-1']); - $mockClient->expects($this->once()) - ->method('getRunStatus') - ->with('run-1') - ->willReturn(['status' => 'completed']); - - $testable->mockClient = $mockClient; - - $testable->cloud('vt_test_key_123') + $mockProcessManager = $this->createMock(ProcessManager::class); + $capturedConfig = null; + $mockProcessManager->expects($this->once()) + ->method('executeCloud') + ->willReturnCallback(function (array $config) use (&$capturedConfig) { + $capturedConfig = $config; + + return json_encode(['run_id' => 'run-1', 'test_id' => 'test-1', 'status' => 'completed']); + }); + + $this->setPrivateProperty($this->voltTest, 'processManager', $mockProcessManager); + + $this->voltTest->cloud('vt_test_key_123') + ->setCloudTimeout(120) ->setVirtualUsers(1) ->setDuration('1s'); - $testable->scenario('Test') + $this->voltTest->scenario('Test') ->step('Step') ->get('http://example.com') ->validateStatus('success', 200); - ob_start(); - $result = $testable->run(false); - ob_end_clean(); + $this->voltTest->run(false); - $this->assertInstanceOf(CloudRun::class, $result); + $this->assertTrue($capturedConfig['cloud']); + $this->assertEquals('vt_test_key_123', $capturedConfig['api_key']); + $this->assertEquals(120, $capturedConfig['cloud_timeout']); } - public function testRunCloudCompletedSuccessfully(): void + public function testRunCloudParsesSuccessJson(): void { - $testable = $this->createTestableVoltTest(); + $mockProcessManager = $this->createMock(ProcessManager::class); + $mockProcessManager->method('executeCloud') + ->willReturn(json_encode(['run_id' => 'run-abc', 'test_id' => 'test-456', 'status' => 'completed'])); - $mockClient = $this->createMock(CloudClient::class); - $mockClient->method('createTest')->willReturn(['id' => 'test-1']); - $mockClient->method('startRun')->willReturn(['id' => 'run-1']); - $mockClient->method('getRunStatus')->willReturn(['status' => 'completed']); + $this->setPrivateProperty($this->voltTest, 'processManager', $mockProcessManager); + + $this->voltTest->cloud('vt_test_key_123') + ->setVirtualUsers(1) + ->setDuration('1s'); - $testable->mockClient = $mockClient; + $this->voltTest->scenario('Test') + ->step('Step') + ->get('http://example.com') + ->validateStatus('success', 200); - ob_start(); - $result = $testable->run(false); - ob_end_clean(); + $result = $this->voltTest->run(false); $this->assertInstanceOf(CloudRun::class, $result); - $this->assertTrue($result->isSuccessful()); - $this->assertEquals('run-1', $result->getRunId()); - $this->assertEquals('test-1', $result->getTestId()); + $this->assertEquals('run-abc', $result->getRunId()); + $this->assertEquals('test-456', $result->getTestId()); $this->assertEquals('completed', $result->getStatus()); + $this->assertTrue($result->isSuccessful()); } - public function testRunCloudOutputContainsDashboardUrl(): void + public function testRunCloudParsesFailedStatus(): void { - $testable = $this->createTestableVoltTest(); + $mockProcessManager = $this->createMock(ProcessManager::class); + $mockProcessManager->method('executeCloud') + ->willReturn(json_encode(['run_id' => 'run-1', 'test_id' => 'test-1', 'status' => 'failed'])); - $mockClient = $this->createMock(CloudClient::class); - $mockClient->method('createTest')->willReturn(['id' => 'test-1']); - $mockClient->method('startRun')->willReturn(['id' => 'run-abc']); - $mockClient->method('getRunStatus')->willReturn(['status' => 'completed']); + $this->setPrivateProperty($this->voltTest, 'processManager', $mockProcessManager); - $testable->mockClient = $mockClient; + $this->voltTest->cloud('vt_test_key_123') + ->setVirtualUsers(1) + ->setDuration('1s'); - ob_start(); - $testable->run(false); - $output = ob_get_clean(); + $this->voltTest->scenario('Test') + ->step('Step') + ->get('http://example.com') + ->validateStatus('success', 200); + + $this->expectException(RunFailedException::class); + $this->expectExceptionMessage('Cloud run failed'); - $this->assertStringContainsString('https://volt-test.com/runs/run-abc', $output); - $this->assertStringContainsString('Test completed', $output); + $this->voltTest->run(false); } - public function testRunCloudFailedThrowsRunFailedException(): void + public function testRunCloudParsesFailedStatusWithErrorMessage(): void { - $testable = $this->createTestableVoltTest(); + $mockProcessManager = $this->createMock(ProcessManager::class); + $mockProcessManager->method('executeCloud') + ->willReturn(json_encode([ + 'run_id' => 'run-1', + 'test_id' => 'test-1', + 'status' => 'failed', + 'error_message' => 'Target unreachable', + ])); - $mockClient = $this->createMock(CloudClient::class); - $mockClient->method('createTest')->willReturn(['id' => 'test-1']); - $mockClient->method('startRun')->willReturn(['id' => 'run-1']); - $mockClient->method('getRunStatus')->willReturn([ - 'status' => 'failed', - 'error_message' => 'Out of memory', - ]); + $this->setPrivateProperty($this->voltTest, 'processManager', $mockProcessManager); - $testable->mockClient = $mockClient; + $this->voltTest->cloud('vt_test_key_123') + ->setVirtualUsers(1) + ->setDuration('1s'); - $this->expectException(RunFailedException::class); - $this->expectExceptionMessage('Cloud run failed: Out of memory'); + $this->voltTest->scenario('Test') + ->step('Step') + ->get('http://example.com') + ->validateStatus('success', 200); - ob_start(); + $this->expectException(RunFailedException::class); + $this->expectExceptionMessage('Cloud run failed: Target unreachable'); - try { - $testable->run(false); - } finally { - ob_end_clean(); - } + $this->voltTest->run(false); } - public function testRunCloudStoppedThrowsRunFailedException(): void + public function testRunCloudParsesStoppedStatus(): void { - $testable = $this->createTestableVoltTest(); + $mockProcessManager = $this->createMock(ProcessManager::class); + $mockProcessManager->method('executeCloud') + ->willReturn(json_encode(['run_id' => 'run-1', 'test_id' => 'test-1', 'status' => 'stopped'])); + + $this->setPrivateProperty($this->voltTest, 'processManager', $mockProcessManager); - $mockClient = $this->createMock(CloudClient::class); - $mockClient->method('createTest')->willReturn(['id' => 'test-1']); - $mockClient->method('startRun')->willReturn(['id' => 'run-1']); - $mockClient->method('getRunStatus')->willReturn(['status' => 'stopped']); + $this->voltTest->cloud('vt_test_key_123') + ->setVirtualUsers(1) + ->setDuration('1s'); - $testable->mockClient = $mockClient; + $this->voltTest->scenario('Test') + ->step('Step') + ->get('http://example.com') + ->validateStatus('success', 200); $this->expectException(RunFailedException::class); $this->expectExceptionMessage('Cloud run was stopped'); - ob_start(); - - try { - $testable->run(false); - } finally { - ob_end_clean(); - } + $this->voltTest->run(false); } - public function testRunCloudTimeoutThrowsCloudTimeoutException(): void + public function testRunCloudAuthenticationError(): void { - $testable = new TestableVoltTest('Timeout Test'); - $testable->pollInterval = 1; - $testable->setTestCloudTimeout(2); - $testable->cloud('vt_test_key_123') + $mockProcessManager = $this->createMock(ProcessManager::class); + $mockProcessManager->method('executeCloud') + ->willReturn(json_encode(['error' => true, 'error_type' => 'authentication', 'message' => 'Invalid API key'])); + + $this->setPrivateProperty($this->voltTest, 'processManager', $mockProcessManager); + + $this->voltTest->cloud('vt_test_key_123') ->setVirtualUsers(1) ->setDuration('1s'); - $testable->scenario('Test') + $this->voltTest->scenario('Test') ->step('Step') ->get('http://example.com') ->validateStatus('success', 200); - $mockClient = $this->createMock(CloudClient::class); - $mockClient->method('createTest')->willReturn(['id' => 'test-1']); - $mockClient->method('startRun')->willReturn(['id' => 'run-1']); - $mockClient->method('getRunStatus')->willReturn([ - 'status' => 'running', - 'progress' => ['percentage' => 50, 'elapsed_seconds' => 15, 'total_seconds' => 30], - ]); + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage('Invalid API key'); - $testable->mockClient = $mockClient; + $this->voltTest->run(false); + } - $this->expectException(CloudTimeoutException::class); - $this->expectExceptionMessage('Cloud run timed out'); + public function testRunCloudPlanLimitError(): void + { + $mockProcessManager = $this->createMock(ProcessManager::class); + $mockProcessManager->method('executeCloud') + ->willReturn(json_encode(['error' => true, 'error_type' => 'plan_limit', 'message' => 'Plan limit exceeded'])); - ob_start(); + $this->setPrivateProperty($this->voltTest, 'processManager', $mockProcessManager); - try { - $testable->run(false); - } finally { - ob_end_clean(); - } + $this->voltTest->cloud('vt_test_key_123') + ->setVirtualUsers(1) + ->setDuration('1s'); + + $this->voltTest->scenario('Test') + ->step('Step') + ->get('http://example.com') + ->validateStatus('success', 200); + + $this->expectException(PlanLimitException::class); + + $this->voltTest->run(false); } - public function testRunCloudBuildsCorrectTestData(): void + public function testRunCloudConnectionError(): void { - $testable = new TestableVoltTest('My Load Test', 'Testing the app'); - $testable->pollInterval = 0; - $testable->setCloudTimeout(60); + $mockProcessManager = $this->createMock(ProcessManager::class); + $mockProcessManager->method('executeCloud') + ->willReturn(json_encode(['error' => true, 'error_type' => 'connection', 'message' => 'Connection failed'])); + + $this->setPrivateProperty($this->voltTest, 'processManager', $mockProcessManager); - $testable->cloud('vt_test_key_123') - ->setVirtualUsers(50) - ->setDuration('5m'); + $this->voltTest->cloud('vt_test_key_123') + ->setVirtualUsers(1) + ->setDuration('1s'); - $testable->scenario('Homepage') - ->step('Load page') + $this->voltTest->scenario('Test') + ->step('Step') ->get('http://example.com') ->validateStatus('success', 200); - $capturedData = null; - $mockClient = $this->createMock(CloudClient::class); - $mockClient->expects($this->once()) - ->method('createTest') - ->willReturnCallback(function (array $data) use (&$capturedData) { - $capturedData = $data; + $this->expectException(CloudConnectionException::class); - return ['id' => 'test-1']; - }); - $mockClient->method('startRun')->willReturn(['id' => 'run-1']); - $mockClient->method('getRunStatus')->willReturn(['status' => 'completed']); + $this->voltTest->run(false); + } - $testable->mockClient = $mockClient; + public function testRunCloudTimeoutError(): void + { + $mockProcessManager = $this->createMock(ProcessManager::class); + $mockProcessManager->method('executeCloud') + ->willReturn(json_encode(['error' => true, 'error_type' => 'timeout', 'message' => 'Cloud run timed out'])); - ob_start(); - $testable->run(false); - ob_end_clean(); + $this->setPrivateProperty($this->voltTest, 'processManager', $mockProcessManager); + + $this->voltTest->cloud('vt_test_key_123') + ->setVirtualUsers(1) + ->setDuration('1s'); + + $this->voltTest->scenario('Test') + ->step('Step') + ->get('http://example.com') + ->validateStatus('success', 200); - $this->assertEquals('My Load Test', $capturedData['name']); - $this->assertEquals('Testing the app', $capturedData['description']); - $this->assertEquals(50, $capturedData['virtual_users']); - $this->assertEquals(300, $capturedData['duration_seconds']); - $this->assertArrayHasKey('test_config', $capturedData); - $this->assertIsString($capturedData['test_config']); + $this->expectException(CloudTimeoutException::class); + + $this->voltTest->run(false); } - private function createTestableVoltTest(): TestableVoltTest + public function testRunCloudMalformedOutput(): void { - $testable = new TestableVoltTest('Cloud Test'); - $testable->setCloudTimeout(60); - $testable->cloud('vt_test_key_123') + $mockProcessManager = $this->createMock(ProcessManager::class); + $mockProcessManager->method('executeCloud') + ->willReturn('not json'); + + $this->setPrivateProperty($this->voltTest, 'processManager', $mockProcessManager); + + $this->voltTest->cloud('vt_test_key_123') ->setVirtualUsers(1) ->setDuration('1s'); - $testable->scenario('Test') + $this->voltTest->scenario('Test') ->step('Step') ->get('http://example.com') ->validateStatus('success', 200); - return $testable; + $this->expectException(CloudException::class); + $this->expectExceptionMessage('Failed to parse cloud result'); + + $this->voltTest->run(false); } private function getSampleOutput(): string From f811ae2e455bab0a4f3c9d83ac4605dcd1b0ea0b Mon Sep 17 00:00:00 2001 From: elwafa Date: Fri, 24 Apr 2026 20:21:23 +0300 Subject: [PATCH 11/23] feat: bump version to 1.2.1-dev in composer.json and Platform.php --- composer.json | 2 +- src/Platform.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 911e29f..a0f2074 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "volt-test/php-sdk", "description": "Volt Test PHP SDK - A performance testing tool for PHP Developers", "type": "library", - "version": "1.2.0-dev", + "version": "1.2.1-dev", "keywords": [ "volt-test", "php-sdk", diff --git a/src/Platform.php b/src/Platform.php index bf1093f..90d0eeb 100644 --- a/src/Platform.php +++ b/src/Platform.php @@ -6,7 +6,7 @@ class Platform { private const BINARY_NAME = 'volt-test'; - private const ENGINE_CURRENT_VERSION = 'v1.2.0-dev'; + private const ENGINE_CURRENT_VERSION = 'v1.2.1-dev'; private const BASE_DOWNLOAD_URL = 'https://github.com/volt-test/binaries/releases/download'; private const SUPPORTED_PLATFORMS = [ 'linux-amd64' => 'volt-test-linux-amd64', From f21a70a81782e854344238e2ab81a98b2327d957 Mon Sep 17 00:00:00 2001 From: elwafa Date: Fri, 8 May 2026 18:35:35 +0300 Subject: [PATCH 12/23] feat: add conflict handling for cloud test execution with user prompt --- src/VoltTest.php | 90 ++++++++++++- tests/Units/VoltTestCloudTest.php | 203 ++++++++++++++++++++++++++++++ 2 files changed, 292 insertions(+), 1 deletion(-) diff --git a/src/VoltTest.php b/src/VoltTest.php index 9064d7a..1075a5c 100644 --- a/src/VoltTest.php +++ b/src/VoltTest.php @@ -23,6 +23,9 @@ class VoltTest private int $cloudTimeout = 1800; + /** @var callable|null */ + private $onConflictPrompt = null; + public function __construct(string $name, string $description = '') { ErrorHandler::register(); @@ -179,6 +182,13 @@ public function setCloudTimeout(int $seconds): self return $this; } + public function setOnConflictPrompt(callable $callback): self + { + $this->onConflictPrompt = $callback; + + return $this; + } + public function scenario(string $name, string $description = ''): Scenario { $scenario = new Scenario($name, $description); @@ -187,13 +197,31 @@ public function scenario(string $name, string $description = ''): Scenario return $scenario; } - public function run(bool $streamOutput = false): TestResult|CloudRun + public function run(bool $streamOutput = false): TestResult|CloudRun|null { $config = $this->prepareConfig(); if ($this->cloudApiKey !== null) { $output = $this->processManager->executeCloud($config); + $data = json_decode($output, true); + if (is_array($data) && isset($data['conflict']) && $data['conflict'] === true) { + $existingTests = $data['existing_tests'] ?? []; + $decision = $this->promptForConflict($existingTests); + + if ($decision === 'cancel') { + return null; + } + + if ($decision !== null) { + $config['existing_test_id'] = $decision; + } else { + $config['skip_lookup'] = true; + } + + $output = $this->processManager->executeCloud($config); + } + return $this->parseCloudResult($output); } @@ -202,6 +230,66 @@ public function run(bool $streamOutput = false): TestResult|CloudRun return new TestResult($output); } + /** + * @param array[] $existingTests + * @return string|null Test ID to update, or null to create new + */ + private function promptForConflict(array $existingTests): ?string + { + if (empty($existingTests)) { + return null; + } + + if ($this->onConflictPrompt !== null) { + return ($this->onConflictPrompt)($existingTests); + } + + if (function_exists('posix_isatty') && posix_isatty(STDIN)) { + $count = count($existingTests); + $name = $existingTests[0]['name'] ?? 'Unknown'; + echo "\n{$count} test(s) named '{$name}' already exist:\n"; + + foreach ($existingTests as $i => $test) { + $num = $i + 1; + $id = substr($test['id'] ?? '', 0, 8); + $url = $test['target_url'] ?? 'N/A'; + $vus = $test['virtual_users'] ?? '?'; + $updated = $test['updated_at'] ?? ''; + echo " [{$num}] ID: {$id}... Target: {$url} VUs: {$vus} Updated: {$updated}\n"; + } + + $createOption = $count + 1; + $cancelOption = $count + 2; + echo " [{$createOption}] Create new test\n"; + echo " [{$cancelOption}] Cancel\n"; + + while (true) { + echo "Choice [1]: "; + $input = trim((string) fgets(STDIN)); + + if ($input === '') { + return $existingTests[0]['id'] ?? null; + } + + $choice = (int) $input; + if ($choice >= 1 && $choice <= $count) { + return $existingTests[$choice - 1]['id'] ?? null; + } + if ($choice === $createOption) { + return null; + } + if ($choice === $cancelOption) { + return 'cancel'; + } + + echo " Invalid choice. Please enter a number between 1 and {$cancelOption}.\n"; + } + } + + // Non-interactive: default to updating the most recent + return $existingTests[0]['id'] ?? null; + } + private function parseCloudResult(string $output): CloudRun { $data = json_decode($output, true); diff --git a/tests/Units/VoltTestCloudTest.php b/tests/Units/VoltTestCloudTest.php index 4d06233..a1b508a 100644 --- a/tests/Units/VoltTestCloudTest.php +++ b/tests/Units/VoltTestCloudTest.php @@ -361,6 +361,209 @@ public function testRunCloudMalformedOutput(): void $this->voltTest->run(false); } + public function testRunCloudConflictUpdateReInvokesWithExistingTestId(): void + { + $callCount = 0; + $capturedConfigs = []; + + $mockProcessManager = $this->createMock(ProcessManager::class); + $mockProcessManager->expects($this->exactly(2)) + ->method('executeCloud') + ->willReturnCallback(function (array $config) use (&$callCount, &$capturedConfigs) { + $capturedConfigs[] = $config; + $callCount++; + + if ($callCount === 1) { + return json_encode([ + 'conflict' => true, + 'existing_tests' => [ + ['id' => 'test-aaa', 'name' => 'Cloud Test Suite', 'target_url' => 'https://example.com', 'virtual_users' => 100, 'updated_at' => '2026-05-08'], + ['id' => 'test-bbb', 'name' => 'Cloud Test Suite', 'target_url' => 'https://other.com', 'virtual_users' => 50, 'updated_at' => '2026-05-07'], + ], + ]); + } + + return json_encode(['run_id' => 'run-1', 'test_id' => 'test-aaa', 'status' => 'completed']); + }); + + $this->setPrivateProperty($this->voltTest, 'processManager', $mockProcessManager); + + $this->voltTest->cloud('vt_test_key_123') + ->setVirtualUsers(1) + ->setDuration('1s'); + + // Callback returns the ID of the first test (update it) + $this->voltTest->setOnConflictPrompt(function (array $existingTests) { + return $existingTests[0]['id']; + }); + + $this->voltTest->scenario('Test') + ->step('Step') + ->get('http://example.com') + ->validateStatus('success', 200); + + $result = $this->voltTest->run(false); + + $this->assertInstanceOf(\VoltTest\CloudRun::class, $result); + $this->assertArrayNotHasKey('existing_test_id', $capturedConfigs[0]); + $this->assertEquals('test-aaa', $capturedConfigs[1]['existing_test_id']); + } + + public function testRunCloudConflictCreateReInvokesWithSkipLookup(): void + { + $callCount = 0; + $capturedConfigs = []; + + $mockProcessManager = $this->createMock(ProcessManager::class); + $mockProcessManager->expects($this->exactly(2)) + ->method('executeCloud') + ->willReturnCallback(function (array $config) use (&$callCount, &$capturedConfigs) { + $capturedConfigs[] = $config; + $callCount++; + + if ($callCount === 1) { + return json_encode([ + 'conflict' => true, + 'existing_tests' => [ + ['id' => 'test-aaa', 'name' => 'Cloud Test Suite'], + ], + ]); + } + + return json_encode(['run_id' => 'run-2', 'test_id' => 'new-test-id', 'status' => 'completed']); + }); + + $this->setPrivateProperty($this->voltTest, 'processManager', $mockProcessManager); + + $this->voltTest->cloud('vt_test_key_123') + ->setVirtualUsers(1) + ->setDuration('1s'); + + // Callback returns null = create new + $this->voltTest->setOnConflictPrompt(function (array $existingTests) { + return null; + }); + + $this->voltTest->scenario('Test') + ->step('Step') + ->get('http://example.com') + ->validateStatus('success', 200); + + $result = $this->voltTest->run(false); + + $this->assertInstanceOf(\VoltTest\CloudRun::class, $result); + $this->assertArrayNotHasKey('existing_test_id', $capturedConfigs[1]); + $this->assertTrue($capturedConfigs[1]['skip_lookup']); + } + + public function testRunCloudSkipsLookupWhenNotCloud(): void + { + $mockProcessManager = $this->createMock(ProcessManager::class); + $mockProcessManager->expects($this->once()) + ->method('execute') + ->willReturn($this->getSampleOutput()); + + $this->setPrivateProperty($this->voltTest, 'processManager', $mockProcessManager); + + $this->voltTest + ->setVirtualUsers(1) + ->setDuration('1s') + ->setTarget('40s'); + + $this->voltTest->scenario('Simple Test') + ->step('Homepage') + ->get('http://example.com') + ->validateStatus('success', 200); + + $result = $this->voltTest->run(false); + + $this->assertInstanceOf(\VoltTest\TestResult::class, $result); + } + + public function testSetOnConflictPromptReturnsSelf(): void + { + $result = $this->voltTest->setOnConflictPrompt(function (array $test) { + return 'update'; + }); + + $this->assertSame($this->voltTest, $result); + } + + public function testOnConflictPromptCallbackIsStored(): void + { + $callback = function (array $test) { + return 'create'; + }; + $this->voltTest->setOnConflictPrompt($callback); + + $this->assertSame($callback, $this->getPrivateProperty($this->voltTest, 'onConflictPrompt')); + } + + public function testRunCloudConflictCancelReturnsNull(): void + { + $mockProcessManager = $this->createMock(ProcessManager::class); + $mockProcessManager->expects($this->once()) + ->method('executeCloud') + ->willReturn(json_encode([ + 'conflict' => true, + 'existing_tests' => [ + ['id' => 'test-aaa', 'name' => 'Cloud Test Suite'], + ], + ])); + + $this->setPrivateProperty($this->voltTest, 'processManager', $mockProcessManager); + + $this->voltTest->cloud('vt_test_key_123') + ->setVirtualUsers(1) + ->setDuration('1s'); + + $this->voltTest->setOnConflictPrompt(function (array $existingTests) { + return 'cancel'; + }); + + $this->voltTest->scenario('Test') + ->step('Step') + ->get('http://example.com') + ->validateStatus('success', 200); + + $result = $this->voltTest->run(false); + + $this->assertNull($result); + } + + public function testRunCloudConflictCancelDoesNotInvokeEngineAgain(): void + { + $mockProcessManager = $this->createMock(ProcessManager::class); + $mockProcessManager->expects($this->once()) + ->method('executeCloud') + ->willReturn(json_encode([ + 'conflict' => true, + 'existing_tests' => [ + ['id' => 'test-aaa', 'name' => 'Cloud Test Suite'], + ['id' => 'test-bbb', 'name' => 'Cloud Test Suite'], + ], + ])); + + $this->setPrivateProperty($this->voltTest, 'processManager', $mockProcessManager); + + $this->voltTest->cloud('vt_test_key_123') + ->setVirtualUsers(1) + ->setDuration('1s'); + + $this->voltTest->setOnConflictPrompt(function (array $existingTests) { + return 'cancel'; + }); + + $this->voltTest->scenario('Test') + ->step('Step') + ->get('http://example.com') + ->validateStatus('success', 200); + + $result = $this->voltTest->run(false); + + $this->assertNull($result); + } + private function getSampleOutput(): string { return <<<'EOT' From 6953039fd31df9c2d6ffec9b173f30d4487d5870 Mon Sep 17 00:00:00 2001 From: elwafa Date: Fri, 8 May 2026 18:38:55 +0300 Subject: [PATCH 13/23] feat: bump version to 1.2.2-dev in composer.json --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index a0f2074..d04f45d 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "volt-test/php-sdk", "description": "Volt Test PHP SDK - A performance testing tool for PHP Developers", "type": "library", - "version": "1.2.1-dev", + "version": "1.2.2-dev", "keywords": [ "volt-test", "php-sdk", From 96fbe6a48934e1070f650e283626a2d6c3d62add Mon Sep 17 00:00:00 2001 From: elwafa Date: Wed, 20 May 2026 07:23:12 +0300 Subject: [PATCH 14/23] feat: add region distribution configuration for cloud execution --- src/Configuration.php | 51 ++++++++ src/VoltTest.php | 18 +++ tests/Units/ConfigurationRegionsTest.php | 144 +++++++++++++++++++++++ tests/Units/VoltTestRegionsTest.php | 93 +++++++++++++++ 4 files changed, 306 insertions(+) create mode 100644 tests/Units/ConfigurationRegionsTest.php create mode 100644 tests/Units/VoltTestRegionsTest.php diff --git a/src/Configuration.php b/src/Configuration.php index 78cc159..8acc249 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -25,6 +25,9 @@ class Configuration /** @var Stage[] */ private array $stages = []; + /** @var array */ + private array $regionConfig = []; + public function __construct(string $name, string $description = '') { $this->name = $name; @@ -65,6 +68,14 @@ public function toArray(): array $array['http_timeout'] = $this->httpTimeout; } + if (count($this->regionConfig) > 0) { + $array['region_config'] = array_map( + fn (string $region, int $weight) => ['region' => $region, 'weight' => $weight], + array_keys($this->regionConfig), + array_values($this->regionConfig) + ); + } + return $array; } @@ -144,4 +155,44 @@ public function hasConstantLoad(): bool { return $this->virtualUsers > 1 || trim($this->duration) !== '' || trim($this->rampUp) !== ''; } + + /** + * @param array $regions Region code => weight (e.g., ['us-east-1' => 60, 'eu-west-1' => 40]) + * + * @throws VoltTestException + */ + public function setRegions(array $regions): self + { + if (empty($regions)) { + throw new VoltTestException('Region distribution cannot be empty'); + } + + foreach ($regions as $region => $weight) { + if (! is_string($region) || trim($region) === '') { + throw new VoltTestException('Region code must be a non-empty string'); + } + + if (! is_int($weight)) { + throw new VoltTestException('Region weight must be an integer'); + } + + if ($weight <= 0) { + throw new VoltTestException('Region weight must be greater than 0'); + } + } + + $sum = array_sum($regions); + if ($sum !== 100) { + throw new VoltTestException("Region weights must sum to 100, got {$sum}"); + } + + $this->regionConfig = $regions; + + return $this; + } + + public function hasRegions(): bool + { + return count($this->regionConfig) > 0; + } } diff --git a/src/VoltTest.php b/src/VoltTest.php index 1075a5c..371dad2 100644 --- a/src/VoltTest.php +++ b/src/VoltTest.php @@ -133,6 +133,20 @@ public function setHttpDebug(bool $httpDebug): self return $this; } + /** + * Set region distribution for cloud execution. + * + * @param array $regions Region code => weight (e.g., ['us-east-1' => 60, 'eu-west-1' => 40]) + * @return $this + * @throws VoltTestException + */ + public function regions(array $regions): self + { + $this->config->setRegions($regions); + + return $this; + } + /** * Set the target URL and idle timeout * @param string $idleTimeout Default is 30s (30 seconds) example: 1s (1 second), 1m (1 minute), 1h (1 hour) @@ -199,6 +213,10 @@ public function scenario(string $name, string $description = ''): Scenario public function run(bool $streamOutput = false): TestResult|CloudRun|null { + if ($this->config->hasRegions() && $this->cloudApiKey === null) { + throw new VoltTestException('Region distribution requires cloud execution mode. Call cloud() before run().'); + } + $config = $this->prepareConfig(); if ($this->cloudApiKey !== null) { diff --git a/tests/Units/ConfigurationRegionsTest.php b/tests/Units/ConfigurationRegionsTest.php new file mode 100644 index 0000000..dbdb1f1 --- /dev/null +++ b/tests/Units/ConfigurationRegionsTest.php @@ -0,0 +1,144 @@ +config = new Configuration('Test', 'Test Description'); + } + + public function testHasRegionsReturnsFalseByDefault(): void + { + $this->assertFalse($this->config->hasRegions()); + } + + public function testSetRegionsStoresRegions(): void + { + $this->config->setRegions(['us-east-1' => 60, 'eu-west-1' => 40]); + $this->assertTrue($this->config->hasRegions()); + } + + public function testSetRegionsReturnsSelf(): void + { + $result = $this->config->setRegions(['us-east-1' => 100]); + $this->assertInstanceOf(Configuration::class, $result); + } + + public function testToArrayOmitsRegionConfigWhenNotSet(): void + { + $array = $this->config->toArray(); + $this->assertArrayNotHasKey('region_config', $array); + } + + public function testToArrayIncludesRegionConfigWhenSet(): void + { + $this->config->setRegions(['us-east-1' => 100]); + $array = $this->config->toArray(); + + $this->assertArrayHasKey('region_config', $array); + $this->assertEquals([ + ['region' => 'us-east-1', 'weight' => 100], + ], $array['region_config']); + } + + public function testToArraySerializesMultipleRegions(): void + { + $this->config->setRegions(['us-east-1' => 60, 'eu-west-1' => 40]); + $array = $this->config->toArray(); + + $this->assertEquals([ + ['region' => 'us-east-1', 'weight' => 60], + ['region' => 'eu-west-1', 'weight' => 40], + ], $array['region_config']); + } + + public function testSetRegionsReplacesOnSecondCall(): void + { + $this->config->setRegions(['us-east-1' => 100]); + $this->config->setRegions(['eu-west-1' => 70, 'ap-southeast-1' => 30]); + + $array = $this->config->toArray(); + $this->assertEquals([ + ['region' => 'eu-west-1', 'weight' => 70], + ['region' => 'ap-southeast-1', 'weight' => 30], + ], $array['region_config']); + } + + public function testRegionsWorkWithStages(): void + { + $this->config->addStage('5m', 100); + $this->config->setRegions(['us-east-1' => 60, 'eu-west-1' => 40]); + + $array = $this->config->toArray(); + $this->assertArrayHasKey('stages', $array); + $this->assertArrayHasKey('region_config', $array); + } + + public function testRegionsWorkWithConstantLoad(): void + { + $this->config->setVirtualUsers(50); + $this->config->setRegions(['us-east-1' => 60, 'eu-west-1' => 40]); + + $array = $this->config->toArray(); + $this->assertEquals(50, $array['virtual_users']); + $this->assertArrayHasKey('region_config', $array); + } + + public function testSetRegionsThrowsOnEmptyArray(): void + { + $this->expectException(VoltTestException::class); + $this->expectExceptionMessage('Region distribution cannot be empty'); + $this->config->setRegions([]); + } + + public function testSetRegionsThrowsWhenWeightsSumNot100(): void + { + $this->expectException(VoltTestException::class); + $this->expectExceptionMessage('Region weights must sum to 100, got 80'); + $this->config->setRegions(['us-east-1' => 50, 'eu-west-1' => 30]); + } + + public function testSetRegionsThrowsOnZeroWeight(): void + { + $this->expectException(VoltTestException::class); + $this->expectExceptionMessage('Region weight must be greater than 0'); + $this->config->setRegions(['us-east-1' => 0, 'eu-west-1' => 100]); + } + + public function testSetRegionsThrowsOnNegativeWeight(): void + { + $this->expectException(VoltTestException::class); + $this->expectExceptionMessage('Region weight must be greater than 0'); + $this->config->setRegions(['us-east-1' => -10, 'eu-west-1' => 110]); + } + + public function testSetRegionsThrowsOnEmptyRegionCode(): void + { + $this->expectException(VoltTestException::class); + $this->expectExceptionMessage('Region code must be a non-empty string'); + $this->config->setRegions(['' => 100]); + } + + public function testSetRegionsThrowsOnWhitespaceOnlyRegionCode(): void + { + $this->expectException(VoltTestException::class); + $this->expectExceptionMessage('Region code must be a non-empty string'); + $this->config->setRegions([' ' => 100]); + } + + public function testSetRegionsThrowsOnNonIntegerWeight(): void + { + $this->expectException(VoltTestException::class); + $this->expectExceptionMessage('Region weight must be an integer'); + /** @phpstan-ignore argument.type */ + $this->config->setRegions(['us-east-1' => 60.5, 'eu-west-1' => 39.5]); + } +} diff --git a/tests/Units/VoltTestRegionsTest.php b/tests/Units/VoltTestRegionsTest.php new file mode 100644 index 0000000..70840f3 --- /dev/null +++ b/tests/Units/VoltTestRegionsTest.php @@ -0,0 +1,93 @@ +test = new VoltTest('Region Test', 'Testing region distribution'); + } + + public function testRegionsReturnsSelf(): void + { + $result = $this->test->regions(['us-east-1' => 100]); + $this->assertInstanceOf(VoltTest::class, $result); + } + + public function testRegionsWithSingleRegion(): void + { + $this->test->regions(['us-east-1' => 100]); + $this->assertInstanceOf(VoltTest::class, $this->test); + } + + public function testRegionsWithMultipleRegions(): void + { + $this->test->regions(['us-east-1' => 60, 'eu-west-1' => 40]); + $this->assertInstanceOf(VoltTest::class, $this->test); + } + + public function testRegionsThrowsOnEmptyArray(): void + { + $this->expectException(VoltTestException::class); + $this->expectExceptionMessage('Region distribution cannot be empty'); + $this->test->regions([]); + } + + public function testRegionsThrowsWhenWeightsNotSumTo100(): void + { + $this->expectException(VoltTestException::class); + $this->expectExceptionMessage('Region weights must sum to 100'); + $this->test->regions(['us-east-1' => 50, 'eu-west-1' => 30]); + } + + public function testRegionsThrowsOnInvalidWeight(): void + { + $this->expectException(VoltTestException::class); + $this->expectExceptionMessage('Region weight must be greater than 0'); + $this->test->regions(['us-east-1' => 0, 'eu-west-1' => 100]); + } + + public function testRegionsThrowsOnEmptyRegionCode(): void + { + $this->expectException(VoltTestException::class); + $this->expectExceptionMessage('Region code must be a non-empty string'); + $this->test->regions(['' => 100]); + } + + public function testRegionsFluentChaining(): void + { + $result = $this->test + ->setVirtualUsers(100) + ->setDuration('5m') + ->regions(['us-east-1' => 60, 'eu-west-1' => 40]); + + $this->assertInstanceOf(VoltTest::class, $result); + } + + public function testRegionsWithStages(): void + { + $result = $this->test + ->stage('1m', 50) + ->stage('5m', 100) + ->regions(['us-east-1' => 60, 'eu-west-1' => 40]); + + $this->assertInstanceOf(VoltTest::class, $result); + } + + public function testRunThrowsWhenRegionsSetWithoutCloud(): void + { + $this->test->regions(['us-east-1' => 100]); + $this->test->scenario('Test')->step('Step')->get('http://localhost'); + + $this->expectException(VoltTestException::class); + $this->expectExceptionMessage('Region distribution requires cloud execution mode'); + $this->test->run(); + } +} From b67ee81ae91d858f5db7f42f86892788d2d633b7 Mon Sep 17 00:00:00 2001 From: elwafa Date: Wed, 20 May 2026 07:27:00 +0300 Subject: [PATCH 15/23] fix: update parameter type hint for regions in Configuration.php --- src/Configuration.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Configuration.php b/src/Configuration.php index 8acc249..4c1afa1 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -157,7 +157,7 @@ public function hasConstantLoad(): bool } /** - * @param array $regions Region code => weight (e.g., ['us-east-1' => 60, 'eu-west-1' => 40]) + * @param array $regions Region code => weight (e.g., ['us-east-1' => 60, 'eu-west-1' => 40]) * * @throws VoltTestException */ From e9e1719a4674b0c1b8123f78a5affd157300a78e Mon Sep 17 00:00:00 2001 From: elwafa Date: Wed, 20 May 2026 07:28:35 +0300 Subject: [PATCH 16/23] fix: remove phpstan ignore comment for region weight type validation in ConfigurationRegionsTest.php --- tests/Units/ConfigurationRegionsTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/Units/ConfigurationRegionsTest.php b/tests/Units/ConfigurationRegionsTest.php index dbdb1f1..b784940 100644 --- a/tests/Units/ConfigurationRegionsTest.php +++ b/tests/Units/ConfigurationRegionsTest.php @@ -138,7 +138,6 @@ public function testSetRegionsThrowsOnNonIntegerWeight(): void { $this->expectException(VoltTestException::class); $this->expectExceptionMessage('Region weight must be an integer'); - /** @phpstan-ignore argument.type */ $this->config->setRegions(['us-east-1' => 60.5, 'eu-west-1' => 39.5]); } } From e51d984adc103c06b71024b782a7fab6ac9073d4 Mon Sep 17 00:00:00 2001 From: elwafa Date: Fri, 22 May 2026 19:06:14 +0300 Subject: [PATCH 17/23] feat: add setName and setDescription methods to Configuration and VoltTest classes --- src/Configuration.php | 14 ++++ src/VoltTest.php | 14 ++++ .../ConfigurationNameDescriptionTest.php | 82 +++++++++++++++++++ tests/Units/VoltTestNameDescriptionTest.php | 55 +++++++++++++ tests/Units/VoltTestRegionsTest.php | 7 ++ tests/VoltTestTest.php | 5 +- 6 files changed, 174 insertions(+), 3 deletions(-) create mode 100644 tests/Units/ConfigurationNameDescriptionTest.php create mode 100644 tests/Units/VoltTestNameDescriptionTest.php diff --git a/src/Configuration.php b/src/Configuration.php index 4c1afa1..c7b06ce 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -195,4 +195,18 @@ public function hasRegions(): bool { return count($this->regionConfig) > 0; } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + public function setDescription(string $description): self + { + $this->description = $description; + + return $this; + } } diff --git a/src/VoltTest.php b/src/VoltTest.php index 371dad2..883cb2f 100644 --- a/src/VoltTest.php +++ b/src/VoltTest.php @@ -147,6 +147,20 @@ public function regions(array $regions): self return $this; } + public function setName(string $name): self + { + $this->config->setName($name); + + return $this; + } + + public function setDescription(string $description): self + { + $this->config->setDescription($description); + + return $this; + } + /** * Set the target URL and idle timeout * @param string $idleTimeout Default is 30s (30 seconds) example: 1s (1 second), 1m (1 minute), 1h (1 hour) diff --git a/tests/Units/ConfigurationNameDescriptionTest.php b/tests/Units/ConfigurationNameDescriptionTest.php new file mode 100644 index 0000000..05dc2fd --- /dev/null +++ b/tests/Units/ConfigurationNameDescriptionTest.php @@ -0,0 +1,82 @@ +config = new Configuration('Original Name', 'Original Description'); + } + + public function testSetNameUpdatesName(): void + { + $this->config->setName('Updated Name'); + $this->assertEquals('Updated Name', $this->config->toArray()['name']); + } + + public function testSetDescriptionUpdatesDescription(): void + { + $this->config->setDescription('Updated Description'); + $this->assertEquals('Updated Description', $this->config->toArray()['description']); + } + + public function testSetNameReturnsSelf(): void + { + $result = $this->config->setName('New Name'); + $this->assertInstanceOf(Configuration::class, $result); + } + + public function testSetDescriptionReturnsSelf(): void + { + $result = $this->config->setDescription('New Description'); + $this->assertInstanceOf(Configuration::class, $result); + } + + public function testSetNameOverwritesPreviousValue(): void + { + $this->config->setName('First'); + $this->config->setName('Second'); + $this->assertEquals('Second', $this->config->toArray()['name']); + } + + public function testSetDescriptionOverwritesPreviousValue(): void + { + $this->config->setDescription('First'); + $this->config->setDescription('Second'); + $this->assertEquals('Second', $this->config->toArray()['description']); + } + + public function testSetNameAllowsEmptyString(): void + { + $this->config->setName(''); + $this->assertEquals('', $this->config->toArray()['name']); + } + + public function testSetDescriptionAllowsEmptyString(): void + { + $this->config->setDescription(''); + $this->assertEquals('', $this->config->toArray()['description']); + } + + public function testSetNameAndDescriptionTogether(): void + { + $this->config->setName('New Name')->setDescription('New Description'); + + $array = $this->config->toArray(); + $this->assertEquals('New Name', $array['name']); + $this->assertEquals('New Description', $array['description']); + } + + public function testConstructorValuesUnchangedWithoutSetters(): void + { + $array = $this->config->toArray(); + $this->assertEquals('Original Name', $array['name']); + $this->assertEquals('Original Description', $array['description']); + } +} diff --git a/tests/Units/VoltTestNameDescriptionTest.php b/tests/Units/VoltTestNameDescriptionTest.php new file mode 100644 index 0000000..827cf90 --- /dev/null +++ b/tests/Units/VoltTestNameDescriptionTest.php @@ -0,0 +1,55 @@ +test = new VoltTest('Original Name', 'Original Description'); + } + + protected function tearDown(): void + { + ErrorHandler::unregister(); + parent::tearDown(); + } + + public function testSetNameReturnsSelf(): void + { + $result = $this->test->setName('New Name'); + $this->assertInstanceOf(VoltTest::class, $result); + } + + public function testSetDescriptionReturnsSelf(): void + { + $result = $this->test->setDescription('New Description'); + $this->assertInstanceOf(VoltTest::class, $result); + } + + public function testSetNameFluentChaining(): void + { + $result = $this->test + ->setName('New Name') + ->setDescription('New Description') + ->setVirtualUsers(10); + + $this->assertInstanceOf(VoltTest::class, $result); + } + + public function testSetDescriptionFluentChaining(): void + { + $result = $this->test + ->setDescription('New Description') + ->setName('New Name') + ->setDuration('30s'); + + $this->assertInstanceOf(VoltTest::class, $result); + } +} diff --git a/tests/Units/VoltTestRegionsTest.php b/tests/Units/VoltTestRegionsTest.php index 70840f3..11539d9 100644 --- a/tests/Units/VoltTestRegionsTest.php +++ b/tests/Units/VoltTestRegionsTest.php @@ -3,6 +3,7 @@ namespace Tests\Units; use PHPUnit\Framework\TestCase; +use VoltTest\Exceptions\ErrorHandler; use VoltTest\Exceptions\VoltTestException; use VoltTest\VoltTest; @@ -15,6 +16,12 @@ public function setUp(): void $this->test = new VoltTest('Region Test', 'Testing region distribution'); } + protected function tearDown(): void + { + ErrorHandler::unregister(); + parent::tearDown(); + } + public function testRegionsReturnsSelf(): void { $result = $this->test->regions(['us-east-1' => 100]); diff --git a/tests/VoltTestTest.php b/tests/VoltTestTest.php index c660b84..c9f7d9a 100644 --- a/tests/VoltTestTest.php +++ b/tests/VoltTestTest.php @@ -41,12 +41,11 @@ public function testVoltTest() $result = $voltTest->run(true); - // Verify response time metrics + $this->assertNotNull($result); + $avgResponseTime = $result->getAvgResponseTime(); - var_dump($avgResponseTime); if ($avgResponseTime !== null) { $this->assertStringContainsString('ms', $avgResponseTime, "Average response time should contain 'ms'"); } - } } From db5abb0f3808c8004cf852478b466f5e49cd9e24 Mon Sep 17 00:00:00 2001 From: elwafa Date: Fri, 22 May 2026 19:36:02 +0300 Subject: [PATCH 18/23] feat: add clearConstantLoad method and update stage handling in VoltTest --- src/Configuration.php | 9 +++++++++ src/VoltTest.php | 9 +++++++-- tests/Units/VoltTestStagesTest.php | 26 +++++++++++++------------- 3 files changed, 29 insertions(+), 15 deletions(-) diff --git a/src/Configuration.php b/src/Configuration.php index c7b06ce..6498ace 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -156,6 +156,15 @@ public function hasConstantLoad(): bool return $this->virtualUsers > 1 || trim($this->duration) !== '' || trim($this->rampUp) !== ''; } + public function clearConstantLoad(): self + { + $this->virtualUsers = 1; + $this->duration = ''; + $this->rampUp = ''; + + return $this; + } + /** * @param array $regions Region code => weight (e.g., ['us-east-1' => 60, 'eu-west-1' => 40]) * diff --git a/src/VoltTest.php b/src/VoltTest.php index 883cb2f..915615e 100644 --- a/src/VoltTest.php +++ b/src/VoltTest.php @@ -102,14 +102,19 @@ public function setRampUp(string $rampUp): self */ public function stage(string $duration, int $target): self { - if ($this->config->hasConstantLoad()) { - throw new VoltTestException('Cannot use stages with setVirtualUsers/setDuration/setRampUp. Use stages to define the full load profile.'); + if ($this->config->hasConstantLoad() && ! $this->config->hasStages()) { + $this->config->clearConstantLoad(); } $this->config->addStage($duration, $target); return $this; } + public function hasStages(): bool + { + return $this->config->hasStages(); + } + /** * Set the HTTP request timeout (per-request) * @param string $timeout e.g. "60s", "2m" — default is 30s diff --git a/tests/Units/VoltTestStagesTest.php b/tests/Units/VoltTestStagesTest.php index a7e2c5e..0d82adf 100644 --- a/tests/Units/VoltTestStagesTest.php +++ b/tests/Units/VoltTestStagesTest.php @@ -79,36 +79,36 @@ public function testStageThrowsOnNegativeTarget(): void $this->voltTest->stage('5m', -1); } - // --- Mutual exclusivity: stages after constant load --- + // --- Stages auto-clear constant load when adding first stage --- - public function testStageThrowsAfterSetVirtualUsers(): void + public function testStageClearsConstantLoadAfterSetVirtualUsers(): void { $this->voltTest->setVirtualUsers(10); - $this->expectException(VoltTestException::class); - $this->expectExceptionMessage('Cannot use stages with setVirtualUsers/setDuration/setRampUp'); + $result = $this->voltTest->stage('5m', 100); - $this->voltTest->stage('5m', 100); + $this->assertSame($this->voltTest, $result); + $this->assertTrue($this->voltTest->hasStages()); } - public function testStageThrowsAfterSetDuration(): void + public function testStageClearsConstantLoadAfterSetDuration(): void { $this->voltTest->setDuration('5m'); - $this->expectException(VoltTestException::class); - $this->expectExceptionMessage('Cannot use stages with setVirtualUsers/setDuration/setRampUp'); + $result = $this->voltTest->stage('5m', 100); - $this->voltTest->stage('5m', 100); + $this->assertSame($this->voltTest, $result); + $this->assertTrue($this->voltTest->hasStages()); } - public function testStageThrowsAfterSetRampUp(): void + public function testStageClearsConstantLoadAfterSetRampUp(): void { $this->voltTest->setRampUp('10s'); - $this->expectException(VoltTestException::class); - $this->expectExceptionMessage('Cannot use stages with setVirtualUsers/setDuration/setRampUp'); + $result = $this->voltTest->stage('5m', 100); - $this->voltTest->stage('5m', 100); + $this->assertSame($this->voltTest, $result); + $this->assertTrue($this->voltTest->hasStages()); } // --- Mutual exclusivity: constant load after stages --- From e4d889a7ae66cd30f71388e25d71cfb65224b4b6 Mon Sep 17 00:00:00 2001 From: elwafa Date: Thu, 28 May 2026 09:46:11 +0300 Subject: [PATCH 19/23] fix: improve error handling in ProcessManager by checking stderr content --- src/ProcessManager.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/ProcessManager.php b/src/ProcessManager.php index d4b0f18..838116b 100644 --- a/src/ProcessManager.php +++ b/src/ProcessManager.php @@ -126,10 +126,8 @@ public function execute(array $config, bool $streamOutput): string if (is_resource($process)) { $exitCode = $this->closeProcess($process); $this->currentProcess = null; - if ($exitCode !== 0) { + if ($exitCode !== 0 && ! empty(trim($stderrContent))) { echo "\nError: " . trim($stderrContent) . "\n"; - - return ''; } } From f169c6fe8ef0d95f205a9af3ad3beddd92e3d911 Mon Sep 17 00:00:00 2001 From: elwafa Date: Thu, 28 May 2026 09:50:24 +0300 Subject: [PATCH 20/23] fix: enhance stderr handling in ProcessManager and add tests for non-zero exit codes --- src/ProcessManager.php | 21 +++++------ tests/Units/ProcessManagerTest.php | 57 +++++++++++++++++++++++++++--- 2 files changed, 61 insertions(+), 17 deletions(-) diff --git a/src/ProcessManager.php b/src/ProcessManager.php index 838116b..eaac1a0 100644 --- a/src/ProcessManager.php +++ b/src/ProcessManager.php @@ -107,14 +107,7 @@ public function execute(array $config, bool $streamOutput): string $this->writeInput($pipes[0], json_encode($config, JSON_PRETTY_PRINT)); fclose($pipes[0]); - $output = $this->handleProcess($pipes, $streamOutput); - - // Store stderr content before closing - $stderrContent = ''; - if (isset($pipes[2]) && is_resource($pipes[2])) { - rewind($pipes[2]); - $stderrContent = stream_get_contents($pipes[2]); - } + [$output, $stderrContent] = $this->handleProcess($pipes, $streamOutput); // Clean up pipes foreach ($pipes as $pipe) { @@ -212,9 +205,10 @@ private function handleCloudProcess(array $pipes): string return $output; } - private function handleProcess(array $pipes, bool $streamOutput): string + private function handleProcess(array $pipes, bool $streamOutput): array { $output = ''; + $stderr = ''; // Set non-blocking mode for stdout and stderr stream_set_blocking($pipes[1], false); @@ -251,13 +245,16 @@ private function handleProcess(array $pipes, bool $streamOutput): string if ($streamOutput) { echo $data; } - } elseif ($type === 2 && $streamOutput) { // stderr - fwrite(STDERR, $data); + } elseif ($type === 2) { // stderr + $stderr .= $data; + if ($streamOutput) { + fwrite(STDERR, $data); + } } } } - return $output; + return [$output, $stderr]; } protected function writeInput($pipe, string $input): void diff --git a/tests/Units/ProcessManagerTest.php b/tests/Units/ProcessManagerTest.php index e741d86..c415f57 100644 --- a/tests/Units/ProcessManagerTest.php +++ b/tests/Units/ProcessManagerTest.php @@ -140,12 +140,59 @@ public function testCleanupOnError(): void { $this->processManager->setMockExitCode(1); - try { - $this->processManager->execute(['test' => true], false); - } catch (\RuntimeException $e) { - // Expected exception - } + $this->processManager->execute(['test' => true], false); $this->assertTrue($this->processManager->wereResourcesCleaned()); } + + public function testNonZeroExitCodePreservesOutput(): void + { + $metricsOutput = <<processManager->setMockOutput($metricsOutput); + $this->processManager->setMockStderr('some error'); + $this->processManager->setMockExitCode(1); + + ob_start(); + $output = $this->processManager->execute(['test' => true], false); + ob_end_clean(); + + $this->assertEquals($metricsOutput, $output); + } + + public function testNonZeroExitCodePrintsStderr(): void + { + $this->processManager->setMockOutput('some output'); + $this->processManager->setMockStderr('connection refused'); + $this->processManager->setMockExitCode(1); + + ob_start(); + $this->processManager->execute(['test' => true], false); + $printed = ob_get_clean(); + + $this->assertStringContainsString('connection refused', $printed); + } + + public function testNonZeroExitCodeNoStderrSkipsErrorMessage(): void + { + $this->processManager->setMockOutput('some output'); + $this->processManager->setMockStderr(''); + $this->processManager->setMockExitCode(1); + + ob_start(); + $output = $this->processManager->execute(['test' => true], false); + $printed = ob_get_clean(); + + $this->assertEquals('some output', $output); + $this->assertStringNotContainsString('Error:', $printed); + } } From c4600d72e63b81b6ff79f0e2a2576b00307ea26f Mon Sep 17 00:00:00 2001 From: elwafa Date: Fri, 5 Jun 2026 20:14:39 +0300 Subject: [PATCH 21/23] feat: add target URL configuration with validation and idle timeout settings --- src/Configuration.php | 24 ++++++++- src/Platform.php | 2 +- src/VoltTest.php | 34 +++++++++++-- tests/Units/ConfigurationTest.php | 83 +++++++++++++++++++++++++++++++ tests/Units/VoltTestTest.php | 48 ++++++++++++++++++ 5 files changed, 185 insertions(+), 6 deletions(-) diff --git a/src/Configuration.php b/src/Configuration.php index 6498ace..d5d3df2 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -109,7 +109,21 @@ public function setRampUp(string $rampUp): self return $this; } - public function setTarget(string $idleTimeout = '30s'): self + public function setTargetUrl(string $url): self + { + if (! preg_match('/^https?:\/\//', $url)) { + throw new VoltTestException('Target URL must start with http:// or https://'); + } + $parsed = parse_url($url); + if ($parsed === false || ! isset($parsed['host'])) { + throw new VoltTestException('Invalid target URL'); + } + $this->target['url'] = $url; + + return $this; + } + + public function setIdleTimeout(string $idleTimeout = '30s'): self { if (! preg_match('/^\d+[smh]$/', $idleTimeout)) { throw new VoltTestException('Invalid idle timeout format. Use [s|m|h]'); @@ -119,6 +133,14 @@ public function setTarget(string $idleTimeout = '30s'): self return $this; } + /** + * @deprecated Use setIdleTimeout() instead + */ + public function setTarget(string $idleTimeout = '30s'): self + { + return $this->setIdleTimeout($idleTimeout); + } + public function setHttpTimeout(string $httpTimeout): self { if (! preg_match('/^\d+[smh]$/', $httpTimeout)) { diff --git a/src/Platform.php b/src/Platform.php index 90d0eeb..529d2c4 100644 --- a/src/Platform.php +++ b/src/Platform.php @@ -6,7 +6,7 @@ class Platform { private const BINARY_NAME = 'volt-test'; - private const ENGINE_CURRENT_VERSION = 'v1.2.1-dev'; + private const ENGINE_CURRENT_VERSION = 'v1.2.0'; private const BASE_DOWNLOAD_URL = 'https://github.com/volt-test/binaries/releases/download'; private const SUPPORTED_PLATFORMS = [ 'linux-amd64' => 'volt-test-linux-amd64', diff --git a/src/VoltTest.php b/src/VoltTest.php index 915615e..1c3c4ad 100644 --- a/src/VoltTest.php +++ b/src/VoltTest.php @@ -167,20 +167,46 @@ public function setDescription(string $description): self } /** - * Set the target URL and idle timeout - * @param string $idleTimeout Default is 30s (30 seconds) example: 1s (1 second), 1m (1 minute), 1h (1 hour) + * Set the target URL and idle timeout. + * + * @param string $url The base URL of the target (e.g. "https://api.example.com") + * @param string $idleTimeout Default is 30s. Example: 1s, 1m, 1h + * @return $this * @throws VoltTestException */ - public function setTarget(string $idleTimeout): self + public function target(string $url, string $idleTimeout = '30s'): self + { + $this->config->setTargetUrl($url); + $this->config->setIdleTimeout($idleTimeout); + + return $this; + } + + /** + * Set the idle timeout for the target. + * + * @param string $idleTimeout Default is 30s. Example: 1s, 1m, 1h + * @return $this + * @throws VoltTestException + */ + public function setIdleTimeout(string $idleTimeout): self { if (! preg_match('/^\d+[smh]$/', $idleTimeout)) { throw new VoltTestException('Invalid idle timeout format. Use [s|m|h]'); } - $this->config->setTarget($idleTimeout); + $this->config->setIdleTimeout($idleTimeout); return $this; } + /** + * @deprecated Use target() or setIdleTimeout() instead + */ + public function setTarget(string $idleTimeout): self + { + return $this->setIdleTimeout($idleTimeout); + } + /** * Enable cloud execution mode. * diff --git a/tests/Units/ConfigurationTest.php b/tests/Units/ConfigurationTest.php index 9d9723b..51b9b29 100644 --- a/tests/Units/ConfigurationTest.php +++ b/tests/Units/ConfigurationTest.php @@ -218,6 +218,89 @@ public static function invalidTargetTimeoutProvider(): array ]; } + #[DataProvider('validTargetUrlProvider')] + public function testSetValidTargetUrl(string $url): void + { + $this->config->setTargetUrl($url); + + $configArray = $this->config->toArray(); + $this->assertEquals($url, $configArray['target']['url']); + } + + public static function validTargetUrlProvider(): array + { + return [ + ['https://example.com'], + ['http://localhost:8000'], + ['https://api.example.com'], + ['http://192.168.1.1:3000'], + ['https://sub.domain.example.com'], + ]; + } + + #[DataProvider('invalidTargetUrlSetProvider')] + public function testSetInvalidTargetUrlThrowsException(string $url): void + { + $this->expectException(VoltTestException::class); + $this->config->setTargetUrl($url); + } + + public static function invalidTargetUrlSetProvider(): array + { + return [ + [''], + ['not-a-url'], + ['ftp://example.com'], + ['example.com'], + ]; + } + + #[DataProvider('validIdleTimeoutProvider')] + public function testSetValidIdleTimeout(string $timeout): void + { + $this->config->setIdleTimeout($timeout); + + $configArray = $this->config->toArray(); + $this->assertEquals($timeout, $configArray['target']['idle_timeout']); + } + + public static function validIdleTimeoutProvider(): array + { + return [ + ['30s'], + ['1s'], + ['1m'], + ['60s'], + ['2h'], + ]; + } + + #[DataProvider('invalidIdleTimeoutProvider')] + public function testSetInvalidIdleTimeoutThrowsException(string $timeout): void + { + $this->expectException(VoltTestException::class); + $this->config->setIdleTimeout($timeout); + } + + public static function invalidIdleTimeoutProvider(): array + { + return [ + [''], + ['1'], + ['s'], + ['1x'], + ['30min'], + ]; + } + + public function testSetTargetDelegatesToSetIdleTimeout(): void + { + $this->config->setTarget('15s'); + + $configArray = $this->config->toArray(); + $this->assertEquals('15s', $configArray['target']['idle_timeout']); + } + public function testFluentInterface(): void { $result = $this->config diff --git a/tests/Units/VoltTestTest.php b/tests/Units/VoltTestTest.php index 1fefb73..90c609e 100644 --- a/tests/Units/VoltTestTest.php +++ b/tests/Units/VoltTestTest.php @@ -65,6 +65,54 @@ public function testInvalidTarget(): void $this->voltTest->setTarget('invalid-url'); } + public function testTargetSetsUrlAndIdleTimeout(): void + { + $result = $this->voltTest->target('https://api.example.com', '10s'); + + $this->assertInstanceOf(VoltTest::class, $result); + $config = $this->getPrivateProperty($this->voltTest, 'config')->toArray(); + $this->assertEquals('https://api.example.com', $config['target']['url']); + $this->assertEquals('10s', $config['target']['idle_timeout']); + } + + public function testTargetWithDefaultIdleTimeout(): void + { + $this->voltTest->target('https://api.example.com'); + + $config = $this->getPrivateProperty($this->voltTest, 'config')->toArray(); + $this->assertEquals('https://api.example.com', $config['target']['url']); + $this->assertEquals('30s', $config['target']['idle_timeout']); + } + + public function testTargetWithInvalidUrl(): void + { + $this->expectException(VoltTestException::class); + $this->voltTest->target('not-a-url'); + } + + public function testSetIdleTimeout(): void + { + $result = $this->voltTest->setIdleTimeout('15s'); + + $this->assertInstanceOf(VoltTest::class, $result); + $config = $this->getPrivateProperty($this->voltTest, 'config')->toArray(); + $this->assertEquals('15s', $config['target']['idle_timeout']); + } + + public function testSetIdleTimeoutInvalid(): void + { + $this->expectException(VoltTestException::class); + $this->voltTest->setIdleTimeout('invalid'); + } + + public function testSetTargetDelegatesToSetIdleTimeout(): void + { + $this->voltTest->setTarget('20s'); + + $config = $this->getPrivateProperty($this->voltTest, 'config')->toArray(); + $this->assertEquals('20s', $config['target']['idle_timeout']); + } + public function testScenarioCreation(): void { $scenario = $this->voltTest->scenario('Login Flow', 'Test login functionality'); From 48a7da1738ee0d0c5a627a0076df0e955c2cac6b Mon Sep 17 00:00:00 2001 From: elwafa Date: Fri, 5 Jun 2026 20:17:56 +0300 Subject: [PATCH 22/23] fix: add validation and error handling for regex extraction in Step class --- src/Step.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Step.php b/src/Step.php index e3d5284..9c5a151 100644 --- a/src/Step.php +++ b/src/Step.php @@ -186,7 +186,9 @@ public function extractFromJson(string $variableName, string $jsonPath): self public function extractFromRegex(string $variableName, string $selector): self { $regexExtractor = new RegexExtractor($variableName, $selector); - $regexExtractor->validate(); + if (! $regexExtractor->validate()) { + throw new InvalidRegexException('Invalid regex selector or variable name'); + } $this->extracts[] = $regexExtractor; return $this; From b42c0336fd22f5ff3e352f5bc9f5b6294b42d3e9 Mon Sep 17 00:00:00 2001 From: elwafa Date: Fri, 5 Jun 2026 20:46:43 +0300 Subject: [PATCH 23/23] fix: update version number to 1.2.0 in composer.json --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index d04f45d..0ac7151 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "volt-test/php-sdk", "description": "Volt Test PHP SDK - A performance testing tool for PHP Developers", "type": "library", - "version": "1.2.2-dev", + "version": "1.2.0", "keywords": [ "volt-test", "php-sdk",