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: diff --git a/composer.json b/composer.json index 912849e..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.1.1", + "version": "1.2.0", "keywords": [ "volt-test", "php-sdk", diff --git a/src/CloudRun.php b/src/CloudRun.php new file mode 100644 index 0000000..244f729 --- /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/Configuration.php b/src/Configuration.php index 9e5dffd..d5d3df2 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -18,8 +18,16 @@ class Configuration private array $target; + private string $httpTimeout = ''; + private bool $httpDebug = false; + /** @var Stage[] */ + private array $stages = []; + + /** @var array */ + private array $regionConfig = []; + public function __construct(string $name, string $description = '') { $this->name = $name; @@ -38,15 +46,34 @@ 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 (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->duration) !== '') { - $array['duration'] = $this->duration; + + if (trim($this->httpTimeout) !== '') { + $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; @@ -82,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]'); @@ -92,10 +133,111 @@ 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)) { + 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; 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) !== ''; + } + + 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]) + * + * @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; + } + + 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/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 @@ + 'volt-test-linux-amd64', diff --git a/src/ProcessManager.php b/src/ProcessManager.php index 6a1c63c..eaac1a0 100644 --- a/src/ProcessManager.php +++ b/src/ProcessManager.php @@ -9,6 +9,7 @@ class ProcessManager private string $binaryPath; private $currentProcess = null; + private mixed $pipes; public function __construct(string $binaryPath) @@ -43,7 +44,7 @@ public function handleSignal(int $signal): void $this->currentProcess = null; // Print the final output - if (!empty($output)) { + if (! empty($output)) { echo "\n$output\n"; } } @@ -51,7 +52,7 @@ public function handleSignal(int $signal): void exit(0); } - public function execute(array $config, bool $streamOutput): string + public function executeCloud(array $config): string { [$success, $process, $pipes] = $this->openProcess(); $this->currentProcess = $process; @@ -65,14 +66,48 @@ 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); + $output = $this->handleCloudProcess($pipes); + + foreach ($pipes as $pipe) { + if (is_resource($pipe)) { + fclose($pipe); + } + } + + if (is_resource($process)) { + $this->closeProcess($process); + $this->currentProcess = null; + } - // Store stderr content before closing - $stderrContent = ''; - if (isset($pipes[2]) && is_resource($pipes[2])) { - rewind($pipes[2]); - $stderrContent = stream_get_contents($pipes[2]); + 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(); + $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, $stderrContent] = $this->handleProcess($pipes, $streamOutput); // Clean up pipes foreach ($pipes as $pipe) { @@ -84,10 +119,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 ''; } } @@ -128,10 +161,55 @@ protected function openProcess(): array return [true, $process, $pipes]; } - private function handleProcess(array $pipes, bool $streamOutput): string + 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): array + { + $output = ''; + $stderr = ''; + // Set non-blocking mode for stdout and stderr stream_set_blocking($pipes[1], false); stream_set_blocking($pipes[2], false); @@ -157,6 +235,7 @@ private function handleProcess(array $pipes, bool $streamOutput): string if (feof($pipe)) { fclose($pipe); unset($pipes[$type]); + continue; } } @@ -166,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/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/Step.php b/src/Step.php index 009bf55..9c5a151 100644 --- a/src/Step.php +++ b/src/Step.php @@ -186,17 +186,19 @@ 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; } - 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/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 51e0711..1c3c4ad 100644 --- a/src/VoltTest.php +++ b/src/VoltTest.php @@ -2,7 +2,13 @@ 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; class VoltTest @@ -13,6 +19,13 @@ class VoltTest private ProcessManager $processManager; + private ?string $cloudApiKey = null; + + private int $cloudTimeout = 1800; + + /** @var callable|null */ + private $onConflictPrompt = null; + public function __construct(string $name, string $description = '') { ErrorHandler::register(); @@ -31,6 +44,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 +63,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 +82,55 @@ 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() && ! $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 + * @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); @@ -76,16 +139,111 @@ public function setHttpDebug(bool $httpDebug): 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 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 setTarget(string $idleTimeout): self + public function regions(array $regions): self + { + $this->config->setRegions($regions); + + 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 $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 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. + * + * @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 setOnConflictPrompt(callable $callback): self + { + $this->onConflictPrompt = $callback; return $this; } @@ -98,15 +256,147 @@ 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|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) { + $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); + } + $output = $this->processManager->execute($config, $streamOutput); 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); + + if (! is_array($data)) { + throw new CloudException('Failed to parse cloud result: ' . $output); + } + + if (isset($data['error']) && $data['error'] === true) { + $this->throwCloudError($data['error_type'] ?? 'cloud_error', $data['message'] ?? 'Unknown error'); + } + + $status = $data['status'] ?? 'unknown'; + $runId = $data['run_id'] ?? ''; + $testId = $data['test_id'] ?? ''; + $errorMessage = $data['error_message'] ?? ''; + + if ($status === 'failed') { + $msg = 'Cloud run failed'; + if ($errorMessage !== '') { + $msg .= ": {$errorMessage}"; + } + + throw new RunFailedException("{$msg}. Run ID: {$runId}"); + } + + if ($status === 'stopped') { + throw new RunFailedException("Cloud run was stopped. Run ID: {$runId}"); + } + + return new CloudRun($runId, $testId, $status); + } + + private function throwCloudError(string $errorType, string $message): void + { + 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 { $config = $this->config->toArray(); @@ -118,6 +408,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/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..c060bd9 --- /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://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://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/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/ConfigurationRegionsTest.php b/tests/Units/ConfigurationRegionsTest.php new file mode 100644 index 0000000..b784940 --- /dev/null +++ b/tests/Units/ConfigurationRegionsTest.php @@ -0,0 +1,143 @@ +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'); + $this->config->setRegions(['us-east-1' => 60.5, 'eu-west-1' => 39.5]); + } +} 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/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/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); + } } 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..a1b508a --- /dev/null +++ b/tests/Units/VoltTestCloudTest.php @@ -0,0 +1,606 @@ +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 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 testRunCloudAddsCloudFieldsToConfig(): void + { + $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'); + + $this->voltTest->scenario('Test') + ->step('Step') + ->get('http://example.com') + ->validateStatus('success', 200); + + $this->voltTest->run(false); + + $this->assertTrue($capturedConfig['cloud']); + $this->assertEquals('vt_test_key_123', $capturedConfig['api_key']); + $this->assertEquals(120, $capturedConfig['cloud_timeout']); + } + + public function testRunCloudParsesSuccessJson(): void + { + $mockProcessManager = $this->createMock(ProcessManager::class); + $mockProcessManager->method('executeCloud') + ->willReturn(json_encode(['run_id' => 'run-abc', 'test_id' => 'test-456', 'status' => 'completed'])); + + $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); + + $result = $this->voltTest->run(false); + + $this->assertInstanceOf(CloudRun::class, $result); + $this->assertEquals('run-abc', $result->getRunId()); + $this->assertEquals('test-456', $result->getTestId()); + $this->assertEquals('completed', $result->getStatus()); + $this->assertTrue($result->isSuccessful()); + } + + public function testRunCloudParsesFailedStatus(): void + { + $mockProcessManager = $this->createMock(ProcessManager::class); + $mockProcessManager->method('executeCloud') + ->willReturn(json_encode(['run_id' => 'run-1', 'test_id' => 'test-1', 'status' => 'failed'])); + + $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->expectException(RunFailedException::class); + $this->expectExceptionMessage('Cloud run failed'); + + $this->voltTest->run(false); + } + + public function testRunCloudParsesFailedStatusWithErrorMessage(): void + { + $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', + ])); + + $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->expectException(RunFailedException::class); + $this->expectExceptionMessage('Cloud run failed: Target unreachable'); + + $this->voltTest->run(false); + } + + public function testRunCloudParsesStoppedStatus(): void + { + $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); + + $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(RunFailedException::class); + $this->expectExceptionMessage('Cloud run was stopped'); + + $this->voltTest->run(false); + } + + public function testRunCloudAuthenticationError(): void + { + $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'); + + $this->voltTest->scenario('Test') + ->step('Step') + ->get('http://example.com') + ->validateStatus('success', 200); + + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage('Invalid API key'); + + $this->voltTest->run(false); + } + + 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'])); + + $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->expectException(PlanLimitException::class); + + $this->voltTest->run(false); + } + + public function testRunCloudConnectionError(): void + { + $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); + + $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(CloudConnectionException::class); + + $this->voltTest->run(false); + } + + 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'])); + + $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->expectException(CloudTimeoutException::class); + + $this->voltTest->run(false); + } + + public function testRunCloudMalformedOutput(): void + { + $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'); + + $this->voltTest->scenario('Test') + ->step('Step') + ->get('http://example.com') + ->validateStatus('success', 200); + + $this->expectException(CloudException::class); + $this->expectExceptionMessage('Failed to parse cloud result'); + + $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' +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/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 new file mode 100644 index 0000000..11539d9 --- /dev/null +++ b/tests/Units/VoltTestRegionsTest.php @@ -0,0 +1,100 @@ +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]); + $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(); + } +} diff --git a/tests/Units/VoltTestStagesTest.php b/tests/Units/VoltTestStagesTest.php new file mode 100644 index 0000000..0d82adf --- /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); + } + + // --- Stages auto-clear constant load when adding first stage --- + + public function testStageClearsConstantLoadAfterSetVirtualUsers(): void + { + $this->voltTest->setVirtualUsers(10); + + $result = $this->voltTest->stage('5m', 100); + + $this->assertSame($this->voltTest, $result); + $this->assertTrue($this->voltTest->hasStages()); + } + + public function testStageClearsConstantLoadAfterSetDuration(): void + { + $this->voltTest->setDuration('5m'); + + $result = $this->voltTest->stage('5m', 100); + + $this->assertSame($this->voltTest, $result); + $this->assertTrue($this->voltTest->hasStages()); + } + + public function testStageClearsConstantLoadAfterSetRampUp(): void + { + $this->voltTest->setRampUp('10s'); + + $result = $this->voltTest->stage('5m', 100); + + $this->assertSame($this->voltTest, $result); + $this->assertTrue($this->voltTest->hasStages()); + } + + // --- 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); + } +} 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'); 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'"); } - } }