First Commit
This commit is contained in:
+21
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) Taylor Otwell
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
Vendored
+98
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"name": "laravel/pao",
|
||||
"description": "Agent-optimized output for PHP testing tools",
|
||||
"keywords": [
|
||||
"php",
|
||||
"ai",
|
||||
"phpunit",
|
||||
"pest",
|
||||
"paratest",
|
||||
"phpstan",
|
||||
"rector",
|
||||
"agent",
|
||||
"testing",
|
||||
"dev"
|
||||
],
|
||||
"license": "MIT",
|
||||
"support": {
|
||||
"issues": "https://github.com/laravel/pao/issues",
|
||||
"source": "https://github.com/laravel/pao"
|
||||
},
|
||||
"authors": [
|
||||
{
|
||||
"name": "Taylor Otwell",
|
||||
"email": "taylor@laravel.com"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"laravel/agent-detector": "^2.0.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"brianium/paratest": "^7.20.0",
|
||||
"laravel/pint": "^1.29.1",
|
||||
"orchestra/testbench": "^10.11.0 || ^11.1.0",
|
||||
"pestphp/pest": "^4.7.2 || ^5.0.0",
|
||||
"pestphp/pest-plugin-type-coverage": "^4.0.4 || ^5.0.0",
|
||||
"phpstan/phpstan": "^2.2.2",
|
||||
"rector/rector": "^2.4.5",
|
||||
"symfony/process": "^7.4.8 || ^8.1.0",
|
||||
"symfony/var-dumper": "^7.4.8 || ^8.1.0"
|
||||
},
|
||||
"conflict": {
|
||||
"laravel/framework": "<12.0.0",
|
||||
"nunomaduro/collision": "<8.9.3",
|
||||
"phpunit/phpunit": "<12.5.23 || >=13.0.0 <13.1.7 || >=14.0.0",
|
||||
"pestphp/pest": "<4.6.3 || >=6.0.0"
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/Autoload.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Laravel\\Pao\\": "src/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Laravel\\Pao\\Laravel\\ServiceProvider"
|
||||
]
|
||||
},
|
||||
"pest": {
|
||||
"plugins": [
|
||||
"Laravel\\Pao\\Drivers\\Pest\\Plugin"
|
||||
]
|
||||
}
|
||||
},
|
||||
"minimum-stability": "dev",
|
||||
"prefer-stable": true,
|
||||
"config": {
|
||||
"sort-packages": true,
|
||||
"preferred-install": "dist",
|
||||
"allow-plugins": {
|
||||
"pestphp/pest-plugin": true
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "pint",
|
||||
"refactor": "rector",
|
||||
"test:type-coverage": "pest --type-coverage --min=100",
|
||||
"test:lint": "pint --test",
|
||||
"test:unit": "pest --parallel --coverage --exactly=100",
|
||||
"test:types": "phpstan",
|
||||
"test:refactor": "rector --dry-run",
|
||||
"test": [
|
||||
"@test:refactor",
|
||||
"@test:lint",
|
||||
"@test:type-coverage",
|
||||
"@test:types",
|
||||
"@test:unit"
|
||||
]
|
||||
}
|
||||
}
|
||||
Vendored
+74
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/** @codeCoverageIgnoreStart */
|
||||
|
||||
namespace Laravel\Pao;
|
||||
|
||||
use Laravel\AgentDetector\AgentDetector;
|
||||
|
||||
/** @var array<int, string>|null $argv */
|
||||
$argv = $_SERVER['argv'] ?? null;
|
||||
|
||||
if (! is_array($argv) || $argv === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (filter_var($_SERVER['PAO_DISABLE'] ?? false, FILTER_VALIDATE_BOOLEAN)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$agent = AgentDetector::detect();
|
||||
|
||||
if (! $agent->isAgent && ! filter_var($_SERVER['PAO_FORCE'] ?? false, FILTER_VALIDATE_BOOLEAN)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (array_intersect($argv, ['--version', '--help', '-h', 'worker'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
unset($_SERVER['COLLISION_PRINTER']);
|
||||
$_SERVER['PEST_PARALLEL_NO_OUTPUT'] = '1';
|
||||
|
||||
register_shutdown_function(function (): void {
|
||||
if (! Execution::running()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$execution = Execution::current();
|
||||
|
||||
$result = $execution->driver->parse() ?: [];
|
||||
|
||||
$captured = trim(UserFilters\CaptureFilter::output());
|
||||
|
||||
$execution->restoreStdout();
|
||||
|
||||
if ($captured !== '') {
|
||||
$captured = OutputCleaner::clean($captured);
|
||||
|
||||
$lines = array_values(array_filter(
|
||||
array_map(trim(...), explode("\n", $captured)),
|
||||
fn (string $line): bool => $line !== ''
|
||||
&& ! preg_match('/^[.st!]+$/', $line)
|
||||
&& ! preg_match('/^(Tests:|Duration:|Parallel:|Time:|Generating code coverage)\s/', $line)
|
||||
&& ! preg_match('/^(INFO\s+)?No tests found\.?$/i', $line)
|
||||
&& ! str_ends_with($line, 'by Sebastian Bergmann and contributors.'),
|
||||
));
|
||||
|
||||
if ($lines !== []) {
|
||||
$existing = is_array($result['raw'] ?? null) ? array_values($result['raw']) : [];
|
||||
|
||||
$result['raw'] = [...$existing, ...$lines];
|
||||
}
|
||||
}
|
||||
|
||||
if ($result !== []) {
|
||||
$result = ['tool' => $execution->driver->name()] + $result;
|
||||
|
||||
fwrite(STDOUT, json_encode($result, JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR).PHP_EOL);
|
||||
}
|
||||
});
|
||||
|
||||
Execution::start($agent, $argv);
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Laravel\Pao\Contracts;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
interface Driver
|
||||
{
|
||||
public function start(): void;
|
||||
|
||||
public function name(): string;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function parse(): ?array;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Laravel\Pao\Drivers\Concerns;
|
||||
|
||||
use PHPUnit\Event\Code\TestMethod;
|
||||
use PHPUnit\Event\Telemetry\HRTime;
|
||||
use PHPUnit\Event\Test\Finished;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
final class ProfileCollector
|
||||
{
|
||||
private static bool $executionStarted = false;
|
||||
|
||||
private static ?HRTime $startTime = null;
|
||||
|
||||
private static float $preparedAt = 0.0;
|
||||
|
||||
/** @var list<array{test: string, file: string, duration_ms: int}> */
|
||||
private static array $entries = [];
|
||||
|
||||
public static function executionStarted(): void
|
||||
{
|
||||
self::$executionStarted = true;
|
||||
}
|
||||
|
||||
public static function hasExecutionStarted(): bool
|
||||
{
|
||||
return self::$executionStarted;
|
||||
}
|
||||
|
||||
public static function startTimer(HRTime $time): void
|
||||
{
|
||||
self::$startTime = $time;
|
||||
}
|
||||
|
||||
public static function startTimerFromNanoseconds(float $nanoseconds): void
|
||||
{
|
||||
$seconds = (int) ($nanoseconds / 1_000_000_000);
|
||||
$nanos = (int) ($nanoseconds - ($seconds * 1_000_000_000));
|
||||
|
||||
self::$startTime = HRTime::fromSecondsAndNanoseconds($seconds, $nanos);
|
||||
}
|
||||
|
||||
public static function durationMs(): int
|
||||
{
|
||||
if (! self::$startTime instanceof HRTime) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$startNs = (self::$startTime->seconds() * 1_000_000_000) + self::$startTime->nanoseconds();
|
||||
|
||||
return (int) round((hrtime(true) - $startNs) / 1_000_000);
|
||||
}
|
||||
|
||||
public static function prepared(): void
|
||||
{
|
||||
self::$preparedAt = hrtime(true);
|
||||
}
|
||||
|
||||
public static function finished(Finished $event): void
|
||||
{
|
||||
$test = $event->test();
|
||||
|
||||
$file = $test->file();
|
||||
$doubleColonPos = strpos($file, '::');
|
||||
if ($doubleColonPos !== false) {
|
||||
$file = substr($file, 0, $doubleColonPos);
|
||||
}
|
||||
|
||||
self::$entries[] = [
|
||||
'test' => $test instanceof TestMethod ? $test->nameWithClass() : $test->id(),
|
||||
'file' => $file,
|
||||
'duration_ms' => self::$preparedAt > 0
|
||||
? (int) round((hrtime(true) - self::$preparedAt) / 1_000_000)
|
||||
: (int) round($event->telemetryInfo()->durationSincePrevious()->asFloat() * 1000),
|
||||
];
|
||||
|
||||
self::$preparedAt = 0.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{test: string, file: string, duration_ms: int}>
|
||||
*/
|
||||
public static function entries(): array
|
||||
{
|
||||
return self::$entries;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Laravel\Pao\Drivers\Concerns;
|
||||
|
||||
use Pest\Plugins\Parallel\Paratest\WrapperRunner;
|
||||
use PHPUnit\Event\Code\TestMethod;
|
||||
use PHPUnit\Event\Code\Throwable;
|
||||
use PHPUnit\Event\Facade as EventFacade;
|
||||
use PHPUnit\Event\Test\Errored;
|
||||
use PHPUnit\Event\Test\Failed;
|
||||
use PHPUnit\Event\Test\Finished;
|
||||
use PHPUnit\Event\Test\FinishedSubscriber;
|
||||
use PHPUnit\Event\Test\Prepared;
|
||||
use PHPUnit\Event\Test\PreparedSubscriber;
|
||||
use PHPUnit\Event\TestRunner\ExecutionFinished;
|
||||
use PHPUnit\Event\TestRunner\ExecutionFinishedSubscriber;
|
||||
use PHPUnit\Event\TestRunner\ExecutionStarted;
|
||||
use PHPUnit\Event\TestRunner\ExecutionStartedSubscriber;
|
||||
use PHPUnit\TestRunner\TestResult\Facade as TestResultFacade;
|
||||
use PHPUnit\TestRunner\TestResult\Issues\Issue;
|
||||
use PHPUnit\TestRunner\TestResult\TestResult;
|
||||
use PHPUnit\TextUI\Configuration\Registry as ConfigurationRegistry;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
trait TestResultParsable
|
||||
{
|
||||
public ?TestResult $testResult = null;
|
||||
|
||||
private bool $executionFinished = false;
|
||||
|
||||
protected function registerExecutionFinishedSubscriber(): void
|
||||
{
|
||||
try {
|
||||
$markFinished = function (): void {
|
||||
$this->executionFinished = true;
|
||||
};
|
||||
|
||||
EventFacade::instance()->registerSubscriber(
|
||||
new readonly class($markFinished) implements ExecutionFinishedSubscriber
|
||||
{
|
||||
/**
|
||||
* @param \Closure(): void $markFinished
|
||||
*/
|
||||
public function __construct(private \Closure $markFinished) {}
|
||||
|
||||
public function notify(ExecutionFinished $event): void
|
||||
{
|
||||
($this->markFinished)();
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
protected function startTimer(): void
|
||||
{
|
||||
try {
|
||||
EventFacade::instance()->registerSubscriber(
|
||||
new class implements ExecutionStartedSubscriber
|
||||
{
|
||||
public function notify(ExecutionStarted $event): void
|
||||
{
|
||||
ProfileCollector::executionStarted();
|
||||
ProfileCollector::startTimer($event->telemetryInfo()->time());
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
protected function registerProfileSubscriber(): void
|
||||
{
|
||||
/** @var list<string> $argv */
|
||||
$argv = $_SERVER['argv'] ?? [];
|
||||
|
||||
if (! in_array('--profile', $argv, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
EventFacade::instance()->registerSubscribers(
|
||||
new class implements PreparedSubscriber
|
||||
{
|
||||
public function notify(Prepared $event): void
|
||||
{
|
||||
ProfileCollector::prepared();
|
||||
}
|
||||
},
|
||||
new class implements FinishedSubscriber
|
||||
{
|
||||
public function notify(Finished $event): void
|
||||
{
|
||||
ProfileCollector::finished($event);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function parse(): ?array
|
||||
{
|
||||
$testResult = $this->resolveTestResult();
|
||||
|
||||
if (! $testResult instanceof TestResult) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($testResult->numberOfTestsRun() > 0 || ProfileCollector::hasExecutionStarted()) {
|
||||
return $this->parseTestResult($testResult);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function resolveTestResult(): ?TestResult
|
||||
{
|
||||
if ($this->testResult instanceof TestResult) {
|
||||
return $this->testResult;
|
||||
}
|
||||
|
||||
if (class_exists(WrapperRunner::class, false)
|
||||
&& WrapperRunner::$result instanceof TestResult) {
|
||||
return WrapperRunner::$result;
|
||||
}
|
||||
|
||||
if (! $this->executionFinished) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return TestResultFacade::result();
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function parseTestResult(TestResult $testResult): array
|
||||
{
|
||||
$failedCount = $testResult->numberOfTestFailedEvents();
|
||||
$erroredCount = $testResult->numberOfTestErroredEvents();
|
||||
$skipped = $testResult->numberOfTestSkippedEvents() + $testResult->numberOfTestSkippedByTestSuiteSkippedEvents();
|
||||
$incomplete = $testResult->numberOfTestMarkedIncompleteEvents();
|
||||
$tests = $testResult->numberOfTestsRun();
|
||||
$assertions = $testResult->numberOfAssertions();
|
||||
$deprecations = $testResult->numberOfPhpOrUserDeprecations();
|
||||
$warnings = $testResult->numberOfWarnings();
|
||||
$notices = $testResult->numberOfNotices();
|
||||
$risky = $testResult->numberOfTestsWithTestConsideredRiskyEvents();
|
||||
$ignoredByBaseline = $testResult->numberOfIssuesIgnoredByBaseline();
|
||||
$hasNoTests = $tests === 0;
|
||||
$noTestsFoundAndFailsOnEmpty = $hasNoTests && $this->failsOnEmptyTestSuite();
|
||||
|
||||
$durationMs = ProfileCollector::durationMs();
|
||||
|
||||
/** @var list<array{test: string, file: string, line: int, message: string}> $failureDetails */
|
||||
$failureDetails = [];
|
||||
|
||||
foreach ($testResult->testFailedEvents() as $event) {
|
||||
$throwable = $event->throwable();
|
||||
$message = trim($throwable->description());
|
||||
|
||||
if ($event instanceof Failed) {
|
||||
$test = $event->test();
|
||||
$file = $test->file();
|
||||
$line = $test instanceof TestMethod ? $test->line() : 0;
|
||||
|
||||
[$file, $line] = $this->resolveTestLocation($file, $line, $throwable);
|
||||
|
||||
$failureDetails[] = [
|
||||
'test' => $test instanceof TestMethod ? $test->nameWithClass() : $test->id(),
|
||||
'file' => $file,
|
||||
'line' => $line,
|
||||
'message' => $message,
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
[$file, $line] = $this->resolveTestLocation('', 0, $throwable);
|
||||
|
||||
$failureDetails[] = [
|
||||
'test' => $event->testClassName().'::'.$event->calledMethod()->methodName(),
|
||||
'file' => $file,
|
||||
'line' => $line,
|
||||
'message' => $message,
|
||||
];
|
||||
}
|
||||
|
||||
/** @var list<array{test: string, file: string, line: int, message: string}> $errorDetails */
|
||||
$errorDetails = [];
|
||||
|
||||
foreach ($testResult->testErroredEvents() as $event) {
|
||||
if ($event instanceof Errored) {
|
||||
$test = $event->test();
|
||||
$throwable = $event->throwable();
|
||||
$message = trim($throwable->message());
|
||||
$file = $test->file();
|
||||
$line = $test instanceof TestMethod ? $test->line() : 0;
|
||||
|
||||
[$file, $line] = $this->resolveTestLocation($file, $line, $throwable);
|
||||
|
||||
$errorDetails[] = [
|
||||
'test' => $test instanceof TestMethod ? $test->nameWithClass() : $test->id(),
|
||||
'file' => $file,
|
||||
'line' => $line,
|
||||
'message' => $message,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/** @var array<string, mixed> $result */
|
||||
$result = [
|
||||
'result' => $testResult->wasSuccessful() && ! $noTestsFoundAndFailsOnEmpty ? 'passed' : 'failed',
|
||||
'tests' => $tests,
|
||||
'passed' => $tests - $failedCount - $erroredCount - $skipped,
|
||||
'assertions' => $assertions,
|
||||
'duration_ms' => $durationMs,
|
||||
];
|
||||
|
||||
if ($hasNoTests) {
|
||||
$result['raw'] = ['No tests found.'];
|
||||
}
|
||||
|
||||
if ($failedCount > 0) {
|
||||
$result['failed'] = $failedCount;
|
||||
$result['failures'] = $failureDetails;
|
||||
}
|
||||
|
||||
if ($erroredCount > 0) {
|
||||
$result['errors'] = $erroredCount;
|
||||
$result['error_details'] = $errorDetails;
|
||||
}
|
||||
|
||||
if ($skipped > 0) {
|
||||
$result['skipped'] = $skipped;
|
||||
}
|
||||
|
||||
if ($incomplete > 0) {
|
||||
$result['incomplete'] = $incomplete;
|
||||
}
|
||||
|
||||
if ($deprecations > 0) {
|
||||
$result['deprecations'] = $deprecations;
|
||||
$result['deprecation_details'] = $this->extractIssueDetails(
|
||||
[...$testResult->deprecations(), ...$testResult->phpDeprecations()],
|
||||
);
|
||||
}
|
||||
|
||||
if ($warnings > 0) {
|
||||
$result['warnings'] = $warnings;
|
||||
$result['warning_details'] = $this->extractIssueDetails(
|
||||
[...$testResult->warnings(), ...$testResult->phpWarnings()],
|
||||
);
|
||||
}
|
||||
|
||||
if ($notices > 0) {
|
||||
$result['notices'] = $notices;
|
||||
$result['notice_details'] = $this->extractIssueDetails(
|
||||
[...$testResult->notices(), ...$testResult->phpNotices()],
|
||||
);
|
||||
}
|
||||
|
||||
$phpErrors = $testResult->errors();
|
||||
|
||||
if ($phpErrors !== []) {
|
||||
$result['php_errors'] = count($phpErrors);
|
||||
$result['php_error_details'] = $this->extractIssueDetails($phpErrors);
|
||||
}
|
||||
|
||||
if ($risky > 0) {
|
||||
$result['risky'] = $risky;
|
||||
}
|
||||
|
||||
if ($ignoredByBaseline > 0) {
|
||||
$result['ignored_by_baseline'] = $ignoredByBaseline;
|
||||
}
|
||||
|
||||
$profileEntries = ProfileCollector::entries();
|
||||
|
||||
if ($profileEntries !== []) {
|
||||
usort($profileEntries, fn (array $a, array $b): int => $b['duration_ms'] <=> $a['duration_ms']);
|
||||
$result['profile'] = array_slice($profileEntries, 0, 10);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function failsOnEmptyTestSuite(): bool
|
||||
{
|
||||
try {
|
||||
return ConfigurationRegistry::get()->failOnEmptyTestSuite();
|
||||
} catch (\Throwable) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Issue> $issues
|
||||
* @return list<array{file: string, line: int, message: string}>
|
||||
*/
|
||||
private function extractIssueDetails(array $issues): array
|
||||
{
|
||||
$details = [];
|
||||
|
||||
foreach ($issues as $issue) {
|
||||
$details[] = [
|
||||
'file' => $issue->file(),
|
||||
'line' => $issue->line(),
|
||||
'message' => $issue->description(),
|
||||
];
|
||||
}
|
||||
|
||||
return $details;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{string, int}
|
||||
*/
|
||||
private function resolveTestLocation(string $file, int $line, Throwable $throwable): array
|
||||
{
|
||||
$isReal = $line > 0 && ! str_contains($file, "eval()'d code");
|
||||
|
||||
if ($isReal) {
|
||||
return [$file, $line];
|
||||
}
|
||||
|
||||
$text = $throwable->description()."\n".$throwable->stackTrace();
|
||||
|
||||
if (preg_match('/\bat\s+(.+\.php):(\d+)/', $text, $matches) === 1) {
|
||||
return [$matches[1], (int) $matches[2]];
|
||||
}
|
||||
|
||||
if (preg_match('#([\w/\\\\._-]+\.php):(\d+)#', $throwable->stackTrace(), $matches) === 1) {
|
||||
return [$matches[1], (int) $matches[2]];
|
||||
}
|
||||
|
||||
return [$file, $line];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Laravel\Pao\Drivers\Paratest;
|
||||
|
||||
use Laravel\Pao\Drivers\Concerns\TestResultParsable;
|
||||
use Laravel\Pao\Drivers\Starter as BaseStarter;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
final class Starter extends BaseStarter
|
||||
{
|
||||
use TestResultParsable;
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'paratest';
|
||||
}
|
||||
|
||||
public function start(): void
|
||||
{
|
||||
$this->registerNullFilter();
|
||||
$this->startTimer();
|
||||
$this->registerExecutionFinishedSubscriber();
|
||||
$this->silenceStdout();
|
||||
|
||||
/** @var list<string> $serverArgv */
|
||||
$serverArgv = $_SERVER['argv'];
|
||||
|
||||
$argv = $serverArgv;
|
||||
|
||||
$argv[] = '--runner';
|
||||
$argv[] = WrapperRunner::class;
|
||||
|
||||
$_SERVER['argv'] = $argv;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Laravel\Pao\Drivers\Paratest;
|
||||
|
||||
use Laravel\Pao\Drivers\Concerns\ProfileCollector;
|
||||
use Laravel\Pao\Execution;
|
||||
use ParaTest\Options;
|
||||
use ParaTest\RunnerInterface;
|
||||
use ParaTest\WrapperRunner\ResultPrinter;
|
||||
use ParaTest\WrapperRunner\SuiteLoader;
|
||||
use ParaTest\WrapperRunner\WrapperRunner as ParatestWrapperRunner;
|
||||
use PHPUnit\TestRunner\TestResult\Facade as TestResultFacade;
|
||||
use PHPUnit\TestRunner\TestResult\TestResult;
|
||||
use PHPUnit\TextUI\Configuration\CodeCoverageFilterRegistry;
|
||||
use PHPUnit\Util\ExcludeList;
|
||||
use ReflectionObject;
|
||||
use SplFileInfo;
|
||||
use Symfony\Component\Console\Output\NullOutput;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
final readonly class WrapperRunner implements RunnerInterface
|
||||
{
|
||||
private ParatestWrapperRunner $runner;
|
||||
|
||||
public function __construct(
|
||||
Options $options,
|
||||
) {
|
||||
$this->runner = new ParatestWrapperRunner($options, new NullOutput);
|
||||
}
|
||||
|
||||
public function run(): int
|
||||
{
|
||||
$runner = $this->runner;
|
||||
$r = new ReflectionObject($runner);
|
||||
|
||||
/** @var non-empty-string $directory */
|
||||
$directory = dirname((string) $r->getFileName(), 2);
|
||||
ExcludeList::addDirectory($directory);
|
||||
|
||||
/** @var Options $options */
|
||||
$options = $r->getProperty('options')->getValue($runner);
|
||||
|
||||
/** @var OutputInterface $output */
|
||||
$output = $r->getProperty('output')->getValue($runner);
|
||||
|
||||
/** @var CodeCoverageFilterRegistry $filterRegistry */
|
||||
$filterRegistry = $r->getProperty('codeCoverageFilterRegistry')->getValue($runner);
|
||||
|
||||
$suiteLoader = new SuiteLoader($options, $output, $filterRegistry);
|
||||
|
||||
$result = TestResultFacade::result();
|
||||
|
||||
$r->getProperty('pending')->setValue($runner, $suiteLoader->tests);
|
||||
|
||||
/** @var ResultPrinter $printer */
|
||||
$printer = $r->getProperty('printer')->getValue($runner);
|
||||
$printer->setTestCount($suiteLoader->testCount);
|
||||
$printer->start();
|
||||
|
||||
$startTime = hrtime(true);
|
||||
|
||||
$r->getMethod('startWorkers')->invoke($runner);
|
||||
$r->getMethod('assignAllPendingTests')->invoke($runner);
|
||||
$r->getMethod('waitForAllToFinish')->invoke($runner);
|
||||
|
||||
ProfileCollector::startTimerFromNanoseconds($startTime);
|
||||
ProfileCollector::executionStarted();
|
||||
|
||||
/** @var list<SplFileInfo> $testResultFiles */
|
||||
$testResultFiles = $r->getProperty('testResultFiles')->getValue($runner);
|
||||
|
||||
$mergedResult = $this->mergeTestResults($result, $testResultFiles);
|
||||
|
||||
if (Execution::running()) {
|
||||
$driver = Execution::current()->driver;
|
||||
|
||||
if ($driver instanceof Starter) {
|
||||
$driver->testResult = $mergedResult;
|
||||
}
|
||||
}
|
||||
|
||||
/** @var int $exitCode */
|
||||
$exitCode = $r->getMethod('complete')->invoke($runner, $result);
|
||||
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<SplFileInfo> $testResultFiles
|
||||
*/
|
||||
private function mergeTestResults(TestResult $sum, array $testResultFiles): TestResult
|
||||
{
|
||||
foreach ($testResultFiles as $testResultFile) {
|
||||
if (! $testResultFile->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$contents = file_get_contents($testResultFile->getPathname());
|
||||
|
||||
if ($contents === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$testResult = unserialize($contents);
|
||||
|
||||
if (! $testResult instanceof TestResult) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$arguments = [
|
||||
(int) $sum->hasTests() + (int) $testResult->hasTests(),
|
||||
$sum->numberOfTestsRun() + $testResult->numberOfTestsRun(),
|
||||
$sum->numberOfAssertions() + $testResult->numberOfAssertions(),
|
||||
[...$sum->testErroredEvents(), ...$testResult->testErroredEvents()],
|
||||
[...$sum->testFailedEvents(), ...$testResult->testFailedEvents()],
|
||||
array_merge_recursive($sum->testConsideredRiskyEvents(), $testResult->testConsideredRiskyEvents()),
|
||||
[...$sum->testSuiteSkippedEvents(), ...$testResult->testSuiteSkippedEvents()],
|
||||
[...$sum->testSkippedEvents(), ...$testResult->testSkippedEvents()],
|
||||
[...$sum->testMarkedIncompleteEvents(), ...$testResult->testMarkedIncompleteEvents()],
|
||||
array_merge_recursive($sum->testTriggeredPhpunitDeprecationEvents(), $testResult->testTriggeredPhpunitDeprecationEvents()),
|
||||
array_merge_recursive($sum->testTriggeredPhpunitErrorEvents(), $testResult->testTriggeredPhpunitErrorEvents()),
|
||||
array_merge_recursive($sum->testTriggeredPhpunitNoticeEvents(), $testResult->testTriggeredPhpunitNoticeEvents()),
|
||||
array_merge_recursive($sum->testTriggeredPhpunitWarningEvents(), $testResult->testTriggeredPhpunitWarningEvents()),
|
||||
[...$sum->testRunnerTriggeredDeprecationEvents(), ...$testResult->testRunnerTriggeredDeprecationEvents()],
|
||||
[...$sum->testRunnerTriggeredNoticeEvents(), ...$testResult->testRunnerTriggeredNoticeEvents()],
|
||||
[...$sum->testRunnerTriggeredWarningEvents(), ...$testResult->testRunnerTriggeredWarningEvents()],
|
||||
];
|
||||
|
||||
$arguments = [
|
||||
...$arguments,
|
||||
...$this->mergeEventLists($sum, $testResult, [
|
||||
'testRunnerTriggeredIssueDeprecationEvents',
|
||||
'testRunnerTriggeredIssueErrorEvents',
|
||||
'testRunnerTriggeredIssueNoticeEvents',
|
||||
'testRunnerTriggeredIssuePhpDeprecationEvents',
|
||||
'testRunnerTriggeredIssuePhpNoticeEvents',
|
||||
'testRunnerTriggeredIssuePhpWarningEvents',
|
||||
'testRunnerTriggeredIssueWarningEvents',
|
||||
]),
|
||||
[...$sum->errors(), ...$testResult->errors()],
|
||||
[...$sum->deprecations(), ...$testResult->deprecations()],
|
||||
[...$sum->notices(), ...$testResult->notices()],
|
||||
[...$sum->warnings(), ...$testResult->warnings()],
|
||||
[...$sum->phpDeprecations(), ...$testResult->phpDeprecations()],
|
||||
[...$sum->phpNotices(), ...$testResult->phpNotices()],
|
||||
[...$sum->phpWarnings(), ...$testResult->phpWarnings()],
|
||||
$sum->numberOfIssuesIgnoredByBaseline() + $testResult->numberOfIssuesIgnoredByBaseline(),
|
||||
];
|
||||
|
||||
/** @phpstan-ignore-next-line argument.type */
|
||||
$sum = new TestResult(...$arguments);
|
||||
}
|
||||
|
||||
return $sum;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<non-empty-string> $methods
|
||||
* @return list<array<mixed>>
|
||||
*/
|
||||
private function mergeEventLists(object $sum, object $testResult, array $methods): array
|
||||
{
|
||||
$merged = [];
|
||||
|
||||
foreach ($methods as $method) {
|
||||
if (! method_exists($sum, $method)) {
|
||||
return $merged;
|
||||
}
|
||||
|
||||
$first = $sum->{$method}();
|
||||
$second = $testResult->{$method}();
|
||||
|
||||
$merged[] = array_merge(
|
||||
is_array($first) ? $first : [],
|
||||
is_array($second) ? $second : [],
|
||||
);
|
||||
}
|
||||
|
||||
return $merged;
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Laravel\Pao\Drivers\Pest;
|
||||
|
||||
use Laravel\Pao\Execution;
|
||||
use Pest\Contracts\Plugins\HandlesArguments;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
final class Plugin implements HandlesArguments
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $arguments
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function handleArguments(array $arguments): array
|
||||
{
|
||||
if (! Execution::running()) {
|
||||
return $arguments;
|
||||
}
|
||||
|
||||
$arguments[] = '--no-output';
|
||||
$arguments[] = '--no-progress';
|
||||
|
||||
return $arguments;
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Laravel\Pao\Drivers\Pest;
|
||||
|
||||
use Laravel\Pao\Drivers\Concerns\ProfileCollector;
|
||||
use Laravel\Pao\Drivers\Concerns\TestResultParsable;
|
||||
use Laravel\Pao\Drivers\Starter as BaseStarter;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
final class Starter extends BaseStarter
|
||||
{
|
||||
use TestResultParsable;
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'pest';
|
||||
}
|
||||
|
||||
public function start(): void
|
||||
{
|
||||
$this->registerNullFilter();
|
||||
$this->startTimer();
|
||||
$this->registerExecutionFinishedSubscriber();
|
||||
$this->saveStdout();
|
||||
$this->silenceStdout();
|
||||
|
||||
/** @var list<string> $argv */
|
||||
$argv = $_SERVER['argv'] ?? [];
|
||||
|
||||
if (in_array('--parallel', $argv, true)) {
|
||||
ProfileCollector::startTimerFromNanoseconds(hrtime(true));
|
||||
ProfileCollector::executionStarted();
|
||||
} else {
|
||||
$this->registerProfileSubscriber();
|
||||
}
|
||||
}
|
||||
}
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Laravel\Pao\Drivers\Phpstan;
|
||||
|
||||
use Laravel\Pao\Drivers\Starter as BaseStarter;
|
||||
use Laravel\Pao\UserFilters\CaptureFilter;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
final class Starter extends BaseStarter
|
||||
{
|
||||
public function name(): string
|
||||
{
|
||||
return 'phpstan';
|
||||
}
|
||||
|
||||
public function start(): void
|
||||
{
|
||||
$this->registerNullFilter();
|
||||
$this->silenceStderr();
|
||||
|
||||
/** @var array<int, string> $argv */
|
||||
$argv = $_SERVER['argv'];
|
||||
$argv = $this->ensureErrorFormatJson($argv);
|
||||
$argv = $this->ensureNoProgress($argv);
|
||||
$_SERVER['argv'] = $argv;
|
||||
|
||||
$this->silenceStdout();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function parse(): ?array
|
||||
{
|
||||
$captured = trim(CaptureFilter::output());
|
||||
|
||||
CaptureFilter::reset();
|
||||
|
||||
if ($captured === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$start = strpos($captured, '{');
|
||||
|
||||
if ($start !== false && $start > 0) {
|
||||
$captured = substr($captured, $start);
|
||||
}
|
||||
|
||||
/** @var array<string, mixed>|null $data */
|
||||
$data = json_decode($captured, associative: true);
|
||||
|
||||
if (! is_array($data) || ! isset($data['totals'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var array<string, list<array{line: int, message: string, identifier: string, ignorable?: bool, tip?: string}>> $errorDetails */
|
||||
$errorDetails = [];
|
||||
$totalFileErrors = 0;
|
||||
|
||||
/** @var array<string, array{errors: int, messages: list<array{message: string, line: int, identifier?: string, ignorable?: bool, tip?: string}>}> $files */
|
||||
$files = is_array($data['files'] ?? null) ? $data['files'] : [];
|
||||
|
||||
foreach ($files as $file => $fileData) {
|
||||
foreach ($fileData['messages'] as $message) {
|
||||
$totalFileErrors++;
|
||||
|
||||
$detail = [
|
||||
'line' => $message['line'],
|
||||
'message' => $message['message'],
|
||||
'identifier' => $message['identifier'] ?? 'unknown',
|
||||
];
|
||||
|
||||
if (isset($message['ignorable']) && $message['ignorable'] === false) {
|
||||
$detail['ignorable'] = false;
|
||||
}
|
||||
|
||||
if (isset($message['tip']) && $message['tip'] !== '') {
|
||||
$detail['tip'] = $message['tip'];
|
||||
}
|
||||
|
||||
$errorDetails[$file][] = $detail;
|
||||
}
|
||||
}
|
||||
|
||||
/** @var list<string> $errors */
|
||||
$errors = is_array($data['errors'] ?? null) ? $data['errors'] : [];
|
||||
|
||||
/** @var list<string> $generalErrors */
|
||||
$generalErrors = array_values(array_filter($errors, static fn (string $error): bool => $error !== ''));
|
||||
|
||||
$totalErrors = $totalFileErrors + count($generalErrors);
|
||||
|
||||
/** @var array<string, mixed> $result */
|
||||
$result = [
|
||||
'result' => $totalErrors > 0 ? 'failed' : 'passed',
|
||||
'errors' => $totalErrors,
|
||||
];
|
||||
|
||||
if ($errorDetails !== []) {
|
||||
$verbose = $this->isVerbose();
|
||||
$limit = 30;
|
||||
|
||||
if (! $verbose && $totalFileErrors > $limit) {
|
||||
$result['error_details'] = $this->truncateGrouped($errorDetails, $limit);
|
||||
$result['truncated'] = true;
|
||||
$result['hint'] = 'Pass -v to see all errors.';
|
||||
} else {
|
||||
$result['error_details'] = $errorDetails;
|
||||
}
|
||||
|
||||
$result['instructions'] = $this->agentInstructions();
|
||||
}
|
||||
|
||||
if ($generalErrors !== []) {
|
||||
$result['general_errors'] = $generalErrors;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function agentInstructions(): string
|
||||
{
|
||||
return <<<'TXT'
|
||||
Each error has an associated identifier, like `argument.type` or `return.missing`. Each error identifier has documentation at URL https://phpstan.org/error-identifiers/<identifier>, which contains a code example, explanation why it is an error, and instructions on how to fix it. Before fixing the error, fetch the documentation page for its identifier. The error usually indicates a real bug or incorrect type in the code. Fix the underlying cause, do not just make the error go away. Do not add `@phpstan-ignore` comments, `@phpstan-ignore-next-line` comments, or baseline entries to suppress the error. Do not use assert() or inline @var PHPDoc tag to override PHPStan's inferred type. Do not add type casts just to silence errors. Do not widen parameter or return types just to make the error go away.
|
||||
TXT;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, list<array{line: int, message: string, identifier: string, ignorable?: bool, tip?: string}>> $grouped
|
||||
* @return array<string, list<array{line: int, message: string, identifier: string, ignorable?: bool, tip?: string}>>
|
||||
*/
|
||||
private function truncateGrouped(array $grouped, int $limit): array
|
||||
{
|
||||
$result = [];
|
||||
$count = 0;
|
||||
|
||||
foreach ($grouped as $file => $errors) {
|
||||
foreach ($errors as $error) {
|
||||
if ($count >= $limit) {
|
||||
break 2;
|
||||
}
|
||||
|
||||
$result[$file][] = $error;
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function isVerbose(): bool
|
||||
{
|
||||
/** @var array<int, string> $argv */
|
||||
$argv = $_SERVER['argv'] ?? [];
|
||||
|
||||
foreach ($argv as $arg) {
|
||||
if (in_array($arg, ['-v', '-vv', '-vvv', '--verbose'], true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $argv
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function ensureErrorFormatJson(array $argv): array
|
||||
{
|
||||
$filtered = [];
|
||||
$skipNext = false;
|
||||
|
||||
foreach ($argv as $arg) {
|
||||
if ($skipNext) {
|
||||
$skipNext = false;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_starts_with($arg, '--error-format=')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($arg === '--error-format') {
|
||||
$skipNext = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$filtered[] = $arg;
|
||||
}
|
||||
|
||||
$filtered[] = '--error-format=json';
|
||||
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $argv
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function ensureNoProgress(array $argv): array
|
||||
{
|
||||
if (! in_array('--no-progress', $argv, true)) {
|
||||
$argv[] = '--no-progress';
|
||||
}
|
||||
|
||||
return $argv;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Laravel\Pao\Drivers\Phpunit;
|
||||
|
||||
use Laravel\Pao\Drivers\Concerns\TestResultParsable;
|
||||
use Laravel\Pao\Drivers\Starter as BaseStarter;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
final class Starter extends BaseStarter
|
||||
{
|
||||
use TestResultParsable;
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'phpunit';
|
||||
}
|
||||
|
||||
public function start(): void
|
||||
{
|
||||
$this->registerNullFilter();
|
||||
$this->startTimer();
|
||||
$this->registerExecutionFinishedSubscriber();
|
||||
$this->registerProfileSubscriber();
|
||||
|
||||
/** @var list<string> $serverArgv */
|
||||
$serverArgv = $_SERVER['argv'];
|
||||
|
||||
$argv = $serverArgv;
|
||||
|
||||
if (! in_array('--no-output', $argv, true)) {
|
||||
$argv[] = '--no-output';
|
||||
}
|
||||
|
||||
$_SERVER['argv'] = $argv;
|
||||
}
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Laravel\Pao\Drivers\Rector;
|
||||
|
||||
use Laravel\Pao\Drivers\Starter as BaseStarter;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
final class Starter extends BaseStarter
|
||||
{
|
||||
private ?int $outputBufferLevel = null;
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'rector';
|
||||
}
|
||||
|
||||
public function start(): void
|
||||
{
|
||||
/** @var array<int, string> $argv */
|
||||
$argv = $_SERVER['argv'];
|
||||
$argv = $this->ensureOutputFormatJson($argv);
|
||||
$_SERVER['argv'] = $argv;
|
||||
$GLOBALS['argv'] = $argv;
|
||||
|
||||
$this->outputBufferLevel = ob_get_level();
|
||||
ob_start();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function parse(): ?array
|
||||
{
|
||||
$captured = trim($this->bufferedOutput());
|
||||
|
||||
if ($captured === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$start = strpos($captured, '{');
|
||||
|
||||
if ($start !== false && $start > 0) {
|
||||
$captured = substr($captured, $start);
|
||||
}
|
||||
|
||||
/** @var array<string, mixed>|null $data */
|
||||
$data = json_decode($captured, associative: true);
|
||||
|
||||
if (! is_array($data) || ! is_array($data['totals'] ?? null)) {
|
||||
return [
|
||||
'result' => 'failed',
|
||||
'raw' => [$captured],
|
||||
];
|
||||
}
|
||||
|
||||
$changedFiles = $data['totals']['changed_files'] ?? 0;
|
||||
$errors = $data['totals']['errors'] ?? 0;
|
||||
|
||||
if (! is_int($changedFiles) || ! is_int($errors)) {
|
||||
return [
|
||||
'result' => 'failed',
|
||||
'raw' => [$captured],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'result' => $errors > 0 || ($changedFiles > 0 && $this->isDryRun()) ? 'failed' : 'passed',
|
||||
] + $data;
|
||||
}
|
||||
|
||||
private function bufferedOutput(): string
|
||||
{
|
||||
if ($this->outputBufferLevel === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$buffered = '';
|
||||
|
||||
while (ob_get_level() > $this->outputBufferLevel) {
|
||||
$buffer = ob_get_clean();
|
||||
|
||||
if ($buffer === false) {
|
||||
break;
|
||||
}
|
||||
|
||||
$buffered = $buffer.$buffered;
|
||||
}
|
||||
|
||||
return $buffered;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $argv
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function ensureOutputFormatJson(array $argv): array
|
||||
{
|
||||
$filtered = [];
|
||||
$skipNext = false;
|
||||
|
||||
foreach ($argv as $arg) {
|
||||
if ($skipNext) {
|
||||
$skipNext = false;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_starts_with($arg, '--output-format=')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($arg === '--output-format') {
|
||||
$skipNext = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$filtered[] = $arg;
|
||||
}
|
||||
|
||||
$filtered[] = '--output-format=json';
|
||||
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
private function isDryRun(): bool
|
||||
{
|
||||
/** @var array<int, string> $argv */
|
||||
$argv = $_SERVER['argv'] ?? [];
|
||||
|
||||
foreach ($argv as $arg) {
|
||||
if (in_array($arg, ['--dry-run', '-n'], true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Laravel\Pao\Drivers;
|
||||
|
||||
use Laravel\Pao\Contracts\Driver;
|
||||
use Laravel\Pao\Execution;
|
||||
use Laravel\Pao\UserFilters\CaptureFilter;
|
||||
use Laravel\Pao\UserFilters\NullFilter;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
abstract class Starter implements Driver
|
||||
{
|
||||
protected function registerNullFilter(): void
|
||||
{
|
||||
if (! in_array('agent_output_null', stream_get_filters(), true)) {
|
||||
stream_filter_register('agent_output_null', NullFilter::class);
|
||||
}
|
||||
}
|
||||
|
||||
protected function silenceStdout(): void
|
||||
{
|
||||
if (! in_array('agent_output_capture', stream_get_filters(), true)) {
|
||||
stream_filter_register('agent_output_capture', CaptureFilter::class);
|
||||
}
|
||||
|
||||
CaptureFilter::reset();
|
||||
|
||||
$execution = Execution::current();
|
||||
|
||||
$execution->filter = stream_filter_append(STDOUT, 'agent_output_capture', STREAM_FILTER_WRITE) ?: null;
|
||||
}
|
||||
|
||||
protected function silenceStderr(): void
|
||||
{
|
||||
stream_filter_append(STDERR, 'agent_output_null', STREAM_FILTER_WRITE);
|
||||
}
|
||||
|
||||
protected function saveStdout(): void
|
||||
{
|
||||
$execution = Execution::current();
|
||||
|
||||
$execution->stdout = fopen('php://stdout', 'w') ?: STDOUT;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Laravel\Pao\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
final class ShouldNotHappenException extends RuntimeException
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('This should not have happened. Please report this issue at [https://github.com/laravel/pao/issues/new].');
|
||||
}
|
||||
}
|
||||
Vendored
+101
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Laravel\Pao;
|
||||
|
||||
use Laravel\AgentDetector\AgentResult;
|
||||
use Laravel\Pao\Contracts\Driver;
|
||||
use Laravel\Pao\Exceptions\ShouldNotHappenException;
|
||||
use Laravel\Pao\UserFilters\CaptureFilter;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*
|
||||
* @phpstan-type TestDetail array{test: string, file: string, line: int, message: string}
|
||||
* @phpstan-type ProfileEntry array{test: string, file: string, duration_ms: int}
|
||||
* @phpstan-type Result array{result: 'passed'|'failed', tests: int, passed: int, duration_ms: int, failed?: int, failures?: list<TestDetail>, errors?: int, error_details?: list<TestDetail>, skipped?: int, profile?: list<ProfileEntry>, raw?: list<string>}
|
||||
*/
|
||||
final class Execution
|
||||
{
|
||||
private static ?self $instance = null;
|
||||
|
||||
/**
|
||||
* @param resource|null $stdout
|
||||
* @param resource|null $filter
|
||||
*/
|
||||
private function __construct(
|
||||
public readonly AgentResult $agent,
|
||||
public readonly Driver $driver,
|
||||
public mixed $stdout = null,
|
||||
public mixed $filter = null,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $argv
|
||||
*/
|
||||
public static function start(AgentResult $agent, array $argv): void
|
||||
{
|
||||
if (self::running()) {
|
||||
throw new ShouldNotHappenException;
|
||||
}
|
||||
|
||||
$binary = basename($argv[0] ?? '');
|
||||
|
||||
$starter = match ($binary) {
|
||||
'paratest' => new Drivers\Paratest\Starter,
|
||||
'pest' => new Drivers\Pest\Starter,
|
||||
'phpstan', 'phpstan.phar' => new Drivers\Phpstan\Starter,
|
||||
'phpunit' => new Drivers\Phpunit\Starter,
|
||||
'rector' => new Drivers\Rector\Starter,
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($starter instanceof Driver) {
|
||||
self::$instance = new self(
|
||||
$agent,
|
||||
$starter,
|
||||
);
|
||||
|
||||
$starter->start();
|
||||
}
|
||||
}
|
||||
|
||||
public static function running(): bool
|
||||
{
|
||||
return self::$instance instanceof Execution;
|
||||
}
|
||||
|
||||
public static function current(): self
|
||||
{
|
||||
return self::$instance ?? throw new ShouldNotHappenException;
|
||||
}
|
||||
|
||||
public function restoreStdout(): void
|
||||
{
|
||||
if (is_resource($this->filter)) {
|
||||
stream_filter_remove($this->filter);
|
||||
|
||||
$this->filter = null;
|
||||
}
|
||||
}
|
||||
|
||||
public function flushStdout(): void
|
||||
{
|
||||
if (! is_resource($this->filter)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$captured = CaptureFilter::output();
|
||||
|
||||
$this->restoreStdout();
|
||||
|
||||
if ($captured !== '') {
|
||||
fwrite(STDOUT, $captured);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Laravel\Pao\Laravel;
|
||||
|
||||
use Illuminate\Console\OutputStyle;
|
||||
use Laravel\Pao\OutputCleaner;
|
||||
use Symfony\Component\Console\Formatter\OutputFormatter;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
final class PaoOutputStyle extends OutputStyle
|
||||
{
|
||||
private static ?OutputFormatter $formatter = null;
|
||||
|
||||
public function __construct(InputInterface $input, OutputInterface $output)
|
||||
{
|
||||
$output->setDecorated(false);
|
||||
|
||||
parent::__construct($input, $output);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|iterable<string> $messages
|
||||
*/
|
||||
#[\Override]
|
||||
public function write(string|iterable $messages, bool $newline = false, int $options = 0): void
|
||||
{
|
||||
parent::write($this->clean($messages), $newline, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|iterable<string> $messages
|
||||
*/
|
||||
#[\Override]
|
||||
public function writeln(string|iterable $messages, int $type = self::OUTPUT_NORMAL): void
|
||||
{
|
||||
parent::writeln($this->clean($messages), $type);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|iterable<string> $messages
|
||||
* @return string|list<string>
|
||||
*/
|
||||
private function clean(string|iterable $messages): string|array
|
||||
{
|
||||
$formatter = self::$formatter ??= new OutputFormatter(false);
|
||||
$strip = fn (string $m): string => OutputCleaner::clean((string) $formatter->format($m));
|
||||
|
||||
if (is_string($messages)) {
|
||||
return $strip($messages);
|
||||
}
|
||||
|
||||
return array_values(array_map($strip, [...$messages]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Laravel\Pao\Laravel;
|
||||
|
||||
use Illuminate\Console\Events\CommandStarting;
|
||||
use Illuminate\Console\OutputStyle;
|
||||
use Illuminate\Contracts\Events\Dispatcher;
|
||||
use Illuminate\Support\ServiceProvider as LaravelServiceProvider;
|
||||
use Laravel\AgentDetector\AgentDetector;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
final class ServiceProvider extends LaravelServiceProvider
|
||||
{
|
||||
public function boot(): void
|
||||
{
|
||||
if (filter_var($_SERVER['PAO_DISABLE'] ?? false, FILTER_VALIDATE_BOOLEAN)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->app->runningInConsole()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->app->runningUnitTests()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! AgentDetector::detect()->isAgent && ! filter_var($_SERVER['PAO_FORCE'] ?? false, FILTER_VALIDATE_BOOLEAN)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->app->bind(OutputStyle::class, PaoOutputStyle::class);
|
||||
|
||||
/** @var Dispatcher $events */
|
||||
$events = $this->app->make(Dispatcher::class);
|
||||
$events->listen(CommandStarting::class, function (CommandStarting $event): void {
|
||||
$event->output->setDecorated(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Laravel\Pao;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
final class OutputCleaner
|
||||
{
|
||||
public static function clean(string $output): string
|
||||
{
|
||||
$output = (string) preg_replace('/\e\[[0-9;]*[A-Za-z]/', '', $output);
|
||||
$output = (string) preg_replace('/[\x00-\x09\x0B\x0C\x0E-\x1F\x7F]/', '', $output);
|
||||
$output = (string) preg_replace('/\x{FFFD}/u', '', $output);
|
||||
$output = (string) preg_replace('/[─━│┌┐└┘├┤┬┴┼▓░▒═║╔╗╚╝╠╣╦╩╬➜▶►⚠✖✔●◆■▪→←↑↓▕⨯✕]+/u', '', $output);
|
||||
$output = (string) preg_replace('/\.{3,}/', '..', $output);
|
||||
$output = (string) preg_replace('/[ \t]+/', ' ', $output);
|
||||
|
||||
return (string) preg_replace('/\n\s*\n/', "\n", $output);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Laravel\Pao\UserFilters;
|
||||
|
||||
use php_user_filter;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
final class CaptureFilter extends php_user_filter
|
||||
{
|
||||
private static string $captured = '';
|
||||
|
||||
/**
|
||||
* @param resource $in
|
||||
* @param resource $out
|
||||
* @param int $consumed
|
||||
*/
|
||||
public function filter($in, $out, &$consumed, bool $closing): int // @pest-ignore-type
|
||||
{
|
||||
while ($bucket = stream_bucket_make_writeable($in)) {
|
||||
/** @var int $datalen */
|
||||
$datalen = $bucket->datalen;
|
||||
$consumed += $datalen;
|
||||
|
||||
/** @var string $data */
|
||||
$data = $bucket->data;
|
||||
self::$captured .= $data;
|
||||
|
||||
$bucket->data = '';
|
||||
stream_bucket_append($out, $bucket);
|
||||
}
|
||||
|
||||
return PSFS_PASS_ON;
|
||||
}
|
||||
|
||||
public static function output(): string
|
||||
{
|
||||
return self::$captured;
|
||||
}
|
||||
|
||||
public static function reset(): void
|
||||
{
|
||||
self::$captured = '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Laravel\Pao\UserFilters;
|
||||
|
||||
use php_user_filter;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
final class NullFilter extends php_user_filter
|
||||
{
|
||||
public function filter($in, $out, &$consumed, bool $closing): int // @pest-ignore-type
|
||||
{
|
||||
while ($bucket = stream_bucket_make_writeable($in)) {
|
||||
/** @var int $datalen */
|
||||
$datalen = $bucket->datalen;
|
||||
$consumed += $datalen;
|
||||
$bucket->data = '';
|
||||
stream_bucket_append($out, $bucket);
|
||||
}
|
||||
|
||||
return PSFS_PASS_ON;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user