From 0af69a748b7a5296cd308ee7e34d2b469cb5c41f Mon Sep 17 00:00:00 2001 From: elwafa Date: Sat, 11 Apr 2026 10:19:02 +0200 Subject: [PATCH 1/7] 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 a53ec7abdd3e74a0adf7e7999c7f012849e23060 Mon Sep 17 00:00:00 2001 From: elwafa Date: Fri, 17 Apr 2026 14:38:11 +0200 Subject: [PATCH 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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); }