diff --git a/.gitignore b/.gitignore index bbdcd39..9a8905b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +/bin/summary.json .idea/* .idea/codeStyleSettings.xml composer.lock diff --git a/bin/run.js b/bin/run.js new file mode 100644 index 0000000..d022b90 --- /dev/null +++ b/bin/run.js @@ -0,0 +1,13 @@ +import http from 'k6/http'; + +export const options = JSON.parse(__ENV.PEST_STRESS_TEST_OPTIONS); + +export default () => { + const result = http.get(__ENV.PEST_STRESS_TEST_URL); +} + +export function handleSummary(data) { + return { + 'summary.json': JSON.stringify(data), + }; +} diff --git a/composer.json b/composer.json index c6d1c11..ae7dca0 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { - "name": "pestphp/pest-plugin-template", - "description": "My awesome plugin", + "name": "pestphp/pest-plugin-stressless", + "description": "Stressless plugin for Pest", "keywords": [ "php", "framework", @@ -12,20 +12,27 @@ ], "license": "MIT", "require": { - "php": "^8.1", - "pestphp/pest": "^2.5", - "pestphp/pest-plugin": "^2.0.1" + "php": "^8.2", + "pestphp/pest": "@dev", + "pestphp/pest-plugin": "^2.1.1", + "ext-curl": "*" }, + "repositories": [ + { + "type": "path", + "url": "../pest" + } + ], "autoload": { "psr-4": { - "Pest\\PluginName\\": "src/" + "Pest\\Stressless\\": "src/" }, "files": [ "src/Autoload.php" ] }, "require-dev": { - "pestphp/pest-dev-tools": "^2.9" + "pestphp/pest-dev-tools": "^2.16" }, "minimum-stability": "dev", "prefer-stable": true, diff --git a/phpstan.neon b/phpstan.neon index 92a52bf..33a9a8f 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -7,6 +7,7 @@ parameters: level: max paths: - src + - tests checkMissingIterableValueType: true checkGenericClassInNonGenericObjectType: false diff --git a/src/Autoload.php b/src/Autoload.php index ce516f1..c2cbaf5 100644 --- a/src/Autoload.php +++ b/src/Autoload.php @@ -2,17 +2,9 @@ declare(strict_types=1); -namespace Pest\PluginName; +namespace Pest\Stressless; -use Pest\Plugin; -use PHPUnit\Framework\TestCase; - -Plugin::uses(Example::class); - -/** - * @return TestCase - */ -function example(string $argument) +function stress(string $url): Factory { - return test()->example(...func_get_args()); // @phpstan-ignore-line + return Factory::make($url); } diff --git a/src/Binary.php b/src/Binary.php new file mode 100644 index 0000000..4daea4d --- /dev/null +++ b/src/Binary.php @@ -0,0 +1,52 @@ + sprintf(self::K6, 'macos', $arch), + 'Linux' => sprintf(self::K6, 'linux', $arch), + 'Windows' => sprintf(self::K6, 'windows', $arch), + default => throw new RuntimeException('Unsupported OS.'), + }; + + return new self((string) realpath(__DIR__.'/../bin/'.$path)); + } + + /** + * The string representation of the binary. + */ + public function __toString(): string + { + return $this->path; + } +} diff --git a/src/Example.php b/src/Example.php deleted file mode 100644 index 0e5e480..0000000 --- a/src/Example.php +++ /dev/null @@ -1,23 +0,0 @@ -toBeString(); - - return $this; - } -} diff --git a/src/Expectation.php b/src/Expectation.php new file mode 100644 index 0000000..3edae86 --- /dev/null +++ b/src/Expectation.php @@ -0,0 +1,14 @@ +} $options + */ + private function __construct(private readonly string $url, private array $options) + { + // + } + + /** + * Creates a new instance of the run factory. + */ + public static function make(string $url): self + { + return new self($url, ['stages' => []]); + } + + /** + * Specifies that run should run with the given number of something to be determined. + */ + public function with(int $number): WithOptions + { + return new WithOptions($this, $number); + } + + /** + * Specifies that run should run with the given number of something to be determined. + */ + public function then(int $with): WithOptions + { + return new WithOptions($this, $with); + } + + /** + * Specifies that the stress test should make the given number of requests concurrently for the given duration in seconds. + */ + public function stage(int $requests, int $seconds): self + { + $this->options['stages'][] = [ + 'duration' => "{$seconds}s", + 'target' => $requests, + ]; + + return $this; + } + + /** + * Creates a new run instance. + */ + public function run(): Result + { + return $this->result = (new Run($this->url, $this->options))->start(); + } + + /** + * Forwards calls to the run result. + * + * @param array $arguments + */ + public function __call(string $name, array $arguments): mixed + { + if (! $this->result instanceof \Pest\Stressless\Result) { + $this->run(); + } + + return $this->result->{$name}(...$arguments); // @phpstan-ignore-line + } + + /** + * Forwards property access to the run result. + */ + public function __get(string $name): mixed + { + return $this->{$name}(); // @phpstan-ignore-line + } +} diff --git a/src/Fluent/StageDurationOptions.php b/src/Fluent/StageDurationOptions.php new file mode 100644 index 0000000..62289ab --- /dev/null +++ b/src/Fluent/StageDurationOptions.php @@ -0,0 +1,87 @@ +duration === 1, 'The duration must be 1 second.'); + + return $this->seconds(); + } + + /** + * Specifies that the stage should run for the given number of seconds. + */ + public function seconds(): Factory + { + $this->factory->stage($this->requests, 0); + $this->factory->stage($this->requests, $this->duration); + + return $this->factory; + } + + /** + * Specifies that the stage should run for 1 minute. + */ + public function minute(): Factory + { + assert($this->duration === 1, 'The duration must be 1 minute.'); + + return $this->minutes(); + } + + /** + * Specifies that the stage should run for the given number of minutes. + */ + public function minutes(): Factory + { + $this->factory->stage($this->requests, 0); + $this->factory->stage($this->requests, $this->duration * 60); + + return $this->factory; + } + + /** + * Specifies that the stage should run for 1 hour. + */ + public function hour(): Factory + { + assert($this->duration === 1, 'The duration must be 1 hour.'); + + return $this->hours(); + } + + /** + * Specifies that the stage should run for the given number of hours. + */ + public function hours(): Factory + { + $this->factory->stage($this->requests, 0); + $this->factory->stage($this->requests, $this->duration * 60 * 60); + + return $this->factory; + } +} diff --git a/src/Fluent/StageOptions.php b/src/Fluent/StageOptions.php new file mode 100644 index 0000000..d6e551b --- /dev/null +++ b/src/Fluent/StageOptions.php @@ -0,0 +1,31 @@ +factory, $this->requests, $duration); + } +} diff --git a/src/Fluent/WithOptions.php b/src/Fluent/WithOptions.php new file mode 100644 index 0000000..5883339 --- /dev/null +++ b/src/Fluent/WithOptions.php @@ -0,0 +1,31 @@ +factory, $this->number); + } +} diff --git a/src/Options/Stage.php b/src/Options/Stage.php new file mode 100644 index 0000000..b1ab915 --- /dev/null +++ b/src/Options/Stage.php @@ -0,0 +1,34 @@ + $this->duration, + 'target' => $this->target, + ]; + } +} diff --git a/src/Plugin.php b/src/Plugin.php deleted file mode 100644 index 78afb25..0000000 --- a/src/Plugin.php +++ /dev/null @@ -1,16 +0,0 @@ -array['metrics']['http_req_duration']['values']['avg']; + } + + /** + * Returns the median response time. + */ + public function minResponseTime(): float + { + return $this->array['metrics']['http_req_duration']['values']['min']; + } + + /** + * Returns the median response time. + */ + public function medianResponseTime(): float + { + return $this->array['metrics']['http_req_duration']['values']['med']; + } + + /** + * Returns the 90th percentile response time. + */ + public function percentile90ResponseTime(): float + { + return $this->array['metrics']['http_req_duration']['values']['p(90)']; + } + + /** + * Returns the 95th percentile response time. + */ + public function percentile95ResponseTime(): float + { + return $this->array['metrics']['http_req_duration']['values']['p(95)']; + } + + /** + * Returns the maximum response time. + */ + public function maxResponseTime(): float + { + return $this->array['metrics']['http_req_duration']['values']['max']; + } + + public function failedRequests(): int + { + return $this->array['metrics']['http_req_failed']['values']['passes']; + } + + public function successfulRequests(): int + { + return $this->array['metrics']['http_req_failed']['values']['fails']; + } + + public function totalRequests(): int + { + return $this->array['metrics']['http_reqs']['values']['count']; + } + + /** + * Proxies the properties to methods. + */ + public function __get(string $name): mixed + { + return $this->{$name}(); // @phpstan-ignore-line + } +} diff --git a/src/Run.php b/src/Run.php new file mode 100644 index 0000000..fc00fc6 --- /dev/null +++ b/src/Run.php @@ -0,0 +1,53 @@ + $options + */ + public function __construct(private string $url, private array $options) + { + // + } + + /** + * Processes the run. + */ + public function start(): Result + { + $basePath = dirname(__DIR__); + + $url = is_int(preg_match('/^https?:\/\//', $this->url)) ? $this->url : 'https://'.$this->url; + + $process = new Process([ + 'k6', 'run', 'run.js', + ], $basePath.'/bin', [ + 'PEST_STRESS_TEST_OPTIONS' => json_encode($this->options, JSON_THROW_ON_ERROR), + 'PEST_STRESS_TEST_URL' => $url, + ]); + + $process->run(); + if (! $process->isSuccessful()) { + dd($process->getErrorOutput()); + } + + $summary = file_get_contents($basePath.'/bin/summary.json'); + assert(is_string($summary)); + + $metrics = json_decode($summary, true, 512, JSON_THROW_ON_ERROR); + assert(is_array($metrics)); + + return new Result($metrics); // @phpstan-ignore-line + } +} diff --git a/temp/.gitkeep b/temp/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/Example.php b/tests/Example.php deleted file mode 100644 index 3bf0e1d..0000000 --- a/tests/Example.php +++ /dev/null @@ -1,11 +0,0 @@ -example('foo'); -}); - -it('may be accessed as function', function () { - example('foo'); -}); diff --git a/tests/Feature/Requests.php b/tests/Feature/Requests.php new file mode 100644 index 0000000..e42943e --- /dev/null +++ b/tests/Feature/Requests.php @@ -0,0 +1,17 @@ +with(2)->concurrentRequests() + ->for(2)->seconds(); + + expect($result->totalRequests()) + ->toBeInt() + ->toBeGreaterThan(0) + ->toBe($result->successfulRequests()) + ->and($result->failedRequests)->toBe(0); +}); diff --git a/tests/Feature/ResponseTime.php b/tests/Feature/ResponseTime.php new file mode 100644 index 0000000..8074a4c --- /dev/null +++ b/tests/Feature/ResponseTime.php @@ -0,0 +1,13 @@ +with(10)->concurrentRequests() + ->for(2)->seconds(); + + expect($result->averageResponseTime())->toBeLessThan(1000); +}); diff --git a/tests/Unit/Binary.php b/tests/Unit/Binary.php new file mode 100644 index 0000000..8ef3648 --- /dev/null +++ b/tests/Unit/Binary.php @@ -0,0 +1,9 @@ +toBe(realpath(__DIR__.'/../../bin/k6-macos-arm64')); +});