From 15921dfe4795df786ad6a77a07c388192fc8128c Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 22 Jan 2026 09:44:50 +0100 Subject: [PATCH 01/18] fixed tests --- .github/workflows/static-analysis.yml | 2 +- .github/workflows/tests.yml | 1 + tests/Framework/Dumper.toLine.phpt | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index d061616e..36cd4b85 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -1,4 +1,4 @@ -name: Static Analysis (only informative) +name: Static Analysis on: [push, pull_request] diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c754daf8..013fb177 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -50,6 +50,7 @@ jobs: code_coverage: name: Code Coverage runs-on: ubuntu-latest + continue-on-error: true steps: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 diff --git a/tests/Framework/Dumper.toLine.phpt b/tests/Framework/Dumper.toLine.phpt index 42d599e2..6da6998e 100644 --- a/tests/Framework/Dumper.toLine.phpt +++ b/tests/Framework/Dumper.toLine.phpt @@ -14,7 +14,7 @@ Assert::match('false', Dumper::toLine(false)); Assert::match('0', Dumper::toLine(0)); Assert::match('1', Dumper::toLine(1)); Assert::match('0.0', Dumper::toLine(0.0)); -Assert::match('0.1', Dumper::toLine(0.1)); +Assert::match('%f%', Dumper::toLine(0.1)); Assert::match('INF', Dumper::toLine(INF)); Assert::match('-INF', Dumper::toLine(-INF)); Assert::match('NAN', Dumper::toLine(NAN)); From 4cb0904d2a5e98519b0be6d9276cd3cd300c95b2 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sat, 7 Feb 2026 23:24:35 +0100 Subject: [PATCH 02/18] cs --- src/CodeCoverage/Collector.php | 2 +- src/Framework/Environment.php | 4 ++-- src/Framework/TestCase.php | 2 +- src/Runner/PhpInterpreter.php | 4 ++-- src/Runner/TestHandler.php | 1 - 5 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/CodeCoverage/Collector.php b/src/CodeCoverage/Collector.php index 974b1941..4114143f 100644 --- a/src/CodeCoverage/Collector.php +++ b/src/CodeCoverage/Collector.php @@ -67,7 +67,7 @@ public static function start(string $file, string $engine): void self::{'start' . $engine}(); register_shutdown_function(function (): void { - register_shutdown_function([self::class, 'save']); + register_shutdown_function(self::save(...)); }); } diff --git a/src/Framework/Environment.php b/src/Framework/Environment.php index abe93bb3..395930f3 100644 --- a/src/Framework/Environment.php +++ b/src/Framework/Environment.php @@ -112,7 +112,7 @@ public static function setupErrors(): void ini_set('html_errors', '0'); ini_set('log_errors', '0'); - set_exception_handler([self::class, 'handleException']); + set_exception_handler(self::handleException(...)); set_error_handler(function (int $severity, string $message, string $file, int $line): bool { if ( @@ -126,7 +126,7 @@ public static function setupErrors(): void }); register_shutdown_function(function (): void { - Assert::$onFailure = [self::class, 'handleException']; + Assert::$onFailure = self::handleException(...); $error = error_get_last(); register_shutdown_function(function () use ($error): void { diff --git a/src/Framework/TestCase.php b/src/Framework/TestCase.php index d052aac1..08f90b70 100644 --- a/src/Framework/TestCase.php +++ b/src/Framework/TestCase.php @@ -102,7 +102,7 @@ public function runTest(string $method, ?array $args = null): void : [$args]; if ($this->prevErrorHandler === false) { - $this->prevErrorHandler = set_error_handler(function (int $severity): ?bool { + $this->prevErrorHandler = set_error_handler(function (int $severity): bool { if ($this->handleErrors && ($severity & error_reporting()) === $severity) { $this->handleErrors = false; $this->silentTearDown(); diff --git a/src/Runner/PhpInterpreter.php b/src/Runner/PhpInterpreter.php index 2cb1fc0b..8b8629d7 100644 --- a/src/Runner/PhpInterpreter.php +++ b/src/Runner/PhpInterpreter.php @@ -44,7 +44,7 @@ public function __construct(string $path, array $args = []) $output = stream_get_contents($pipes[1]); proc_close($proc); - $args = ' ' . implode(' ', array_map([Helpers::class, 'escapeArg'], $args)); + $args = ' ' . implode(' ', array_map(Helpers::escapeArg(...), $args)); if (str_contains($output, 'phpdbg')) { $args = ' -qrrb -S cli' . $args; } @@ -87,7 +87,7 @@ public function __construct(string $path, array $args = []) public function withArguments(array $args): static { $me = clone $this; - $me->commandLine .= ' ' . implode(' ', array_map([Helpers::class, 'escapeArg'], $args)); + $me->commandLine .= ' ' . implode(' ', array_map(Helpers::escapeArg(...), $args)); return $me; } diff --git a/src/Runner/TestHandler.php b/src/Runner/TestHandler.php index 74bc35d5..113ca29f 100644 --- a/src/Runner/TestHandler.php +++ b/src/Runner/TestHandler.php @@ -96,7 +96,6 @@ public function assess(Job $job): void } foreach ((array) $annotations[$m[1]] as $arg) { - /** @var Test|null $res */ if ($res = $this->$method($job, $arg)) { $this->runner->finishTest($res); return; From aa8bf3f72700ba56d967d31b3f9c57c78e757edf Mon Sep 17 00:00:00 2001 From: David Grudl Date: Wed, 11 Feb 2026 02:44:02 +0100 Subject: [PATCH 03/18] improved phpDoc --- .../Generators/AbstractGenerator.php | 2 +- src/Framework/Assert.php | 21 ++++++++++++++----- src/Framework/DomQuery.php | 2 +- src/Framework/Environment.php | 2 +- src/Framework/Expect.php | 6 +++--- src/Framework/FileMock.php | 2 +- src/Framework/Helpers.php | 2 +- src/Framework/TestCase.php | 4 ++-- src/Framework/functions.php | 7 ++++++- src/Runner/CommandLine.php | 2 +- src/Runner/Job.php | 4 ++-- src/Runner/Output/ConsolePrinter.php | 2 +- src/Runner/Runner.php | 6 +++--- src/Runner/Test.php | 6 +++--- src/Runner/TestHandler.php | 8 +++---- 15 files changed, 46 insertions(+), 30 deletions(-) diff --git a/src/CodeCoverage/Generators/AbstractGenerator.php b/src/CodeCoverage/Generators/AbstractGenerator.php index 5eea9d2a..010c7969 100644 --- a/src/CodeCoverage/Generators/AbstractGenerator.php +++ b/src/CodeCoverage/Generators/AbstractGenerator.php @@ -24,7 +24,7 @@ abstract class AbstractGenerator LineTested = 1, LineUntested = -1; - /** @var string[] file extensions to accept */ + /** @var list file extensions to accept */ public array $acceptFiles = ['php', 'phpt', 'phtml']; /** @var array> file path => line number => coverage count */ diff --git a/src/Framework/Assert.php b/src/Framework/Assert.php index ae95d89d..121d4c66 100644 --- a/src/Framework/Assert.php +++ b/src/Framework/Assert.php @@ -265,7 +265,7 @@ public static function falsey(mixed $actual, ?string $description = null): void /** * Asserts the number of items in an array or Countable. - * @param mixed[] $value + * @param mixed[]|\Countable $value */ public static function count(int $count, array|\Countable $value, ?string $description = null): void { @@ -302,7 +302,10 @@ public static function type(string|object $type, mixed $value, ?string $descript /** * Asserts that a function throws exception of given type and its message matches given pattern. - * @param class-string<\Throwable> $class + * @template T of \Throwable + * @param callable(): void $function + * @param class-string $class + * @return T|null */ public static function exception( callable $function, @@ -337,7 +340,10 @@ public static function exception( /** * Asserts that a function throws exception of given type and its message matches given pattern. Alias for exception(). - * @param class-string<\Throwable> $class + * @template T of \Throwable + * @param callable(): void $function + * @param class-string $class + * @return T|null */ public static function throws( callable $function, @@ -352,7 +358,8 @@ public static function throws( /** * Asserts that a function generates one or more PHP errors or throws exceptions. - * @param int|string|mixed[] $expectedType + * @param callable(): void $function + * @param int|string|mixed[] $expectedType * @throws \Exception */ public static function error( @@ -419,6 +426,7 @@ public static function error( /** * Asserts that a function does not generate PHP errors and does not throw exceptions. + * @param callable(): void $function */ public static function noError(callable $function): void { @@ -520,7 +528,10 @@ private static function describe(string $reason, ?string $description = null): s /** * Executes function that can access private and protected members of given object via $this. - * @param object|class-string $objectOrClass + * @template TReturn + * @param object|class-string $objectOrClass + * @param \Closure(): TReturn $closure + * @return TReturn */ public static function with(object|string $objectOrClass, \Closure $closure): mixed { diff --git a/src/Framework/DomQuery.php b/src/Framework/DomQuery.php index a438f640..f171846f 100644 --- a/src/Framework/DomQuery.php +++ b/src/Framework/DomQuery.php @@ -76,7 +76,7 @@ public static function fromXml(string $xml): self /** * Returns array of elements matching CSS selector. - * @return DomQuery[] + * @return list */ public function find(string $selector): array { diff --git a/src/Framework/Environment.php b/src/Framework/Environment.php index 395930f3..838ff6d4 100644 --- a/src/Framework/Environment.php +++ b/src/Framework/Environment.php @@ -193,7 +193,7 @@ public static function lock(string $name = '', string $path = ''): void /** * Returns current test annotations. - * @return array + * @return array */ public static function getTestAnnotations(): array { diff --git a/src/Framework/Expect.php b/src/Framework/Expect.php index b1272fe9..d568cfaf 100644 --- a/src/Framework/Expect.php +++ b/src/Framework/Expect.php @@ -51,7 +51,7 @@ */ class Expect { - /** @var array */ + /** @var list */ private array $constraints = []; @@ -83,8 +83,8 @@ public function __call(string $method, array $args): self } - /** @param callable(mixed): bool $constraint returns false to indicate failure */ - public function and(callable $constraint): self + /** @param (callable(mixed): bool)|self $constraint returns false to indicate failure */ + public function and(callable|self $constraint): self { $this->constraints[] = $constraint; return $this; diff --git a/src/Framework/FileMock.php b/src/Framework/FileMock.php index 93b8a3ae..6a8ad1e0 100644 --- a/src/Framework/FileMock.php +++ b/src/Framework/FileMock.php @@ -20,7 +20,7 @@ class FileMock { private const Protocol = 'mock'; - /** @var string[] */ + /** @var array */ public static array $files = []; /** @var resource used by PHP itself */ diff --git a/src/Framework/Helpers.php b/src/Framework/Helpers.php index dffc3f52..da7b8f9c 100644 --- a/src/Framework/Helpers.php +++ b/src/Framework/Helpers.php @@ -91,7 +91,7 @@ public static function findCommonDirectory(array $paths): string /** * Parse the first docblock encountered in the provided string. - * @return mixed[] annotation name => value(s) + * @return array annotation name => value(s) * @internal */ public static function parseDocComment(string $s): array diff --git a/src/Framework/TestCase.php b/src/Framework/TestCase.php index 08f90b70..1de1e275 100644 --- a/src/Framework/TestCase.php +++ b/src/Framework/TestCase.php @@ -24,7 +24,7 @@ class TestCase private bool $handleErrors = false; - /** @var callable|false|null */ + /** @var (callable(int, string, string, int): bool)|false|null */ private $prevErrorHandler = false; @@ -242,7 +242,7 @@ private function sendMethodList(array $methods): void /** * Prepares test data from specified data providers or default method parameters if no provider is specified. * @param string[] $dataprovider - * @return array> + * @return array> */ private function prepareTestData(\ReflectionMethod $method, array $dataprovider): array { diff --git a/src/Framework/functions.php b/src/Framework/functions.php index 64f6beb3..31fd351c 100644 --- a/src/Framework/functions.php +++ b/src/Framework/functions.php @@ -9,6 +9,7 @@ /** * Executes a provided test closure, handling setup and teardown operations. + * @param \Closure(): mixed $closure */ function test(string $description, Closure $closure): void { @@ -42,7 +43,8 @@ function test(string $description, Closure $closure): void /** * Tests for exceptions thrown by a provided closure matching specific criteria. - * @param class-string<\Throwable> $class + * @param \Closure(): void $function + * @param class-string<\Throwable> $class */ function testException( string $description, @@ -58,6 +60,7 @@ function testException( /** * Tests that a provided closure does not generate any errors or exceptions. + * @param \Closure(): void $function */ function testNoError(string $description, Closure $function): void { @@ -71,6 +74,7 @@ function testNoError(string $description, Closure $function): void /** * Registers a function to be called before each test execution. + * @param (\Closure(): void)|null $closure */ function setUp(?Closure $closure): void { @@ -81,6 +85,7 @@ function setUp(?Closure $closure): void /** * Registers a function to be called after each test execution. + * @param (\Closure(): void)|null $closure */ function tearDown(?Closure $closure): void { diff --git a/src/Runner/CommandLine.php b/src/Runner/CommandLine.php index 86d779a9..8572ea32 100644 --- a/src/Runner/CommandLine.php +++ b/src/Runner/CommandLine.php @@ -30,7 +30,7 @@ class CommandLine /** @var array> */ private array $options = []; - /** @var string[] */ + /** @var array */ private array $aliases = []; /** @var string[] */ diff --git a/src/Runner/Job.php b/src/Runner/Job.php index 257446f1..ba69a122 100644 --- a/src/Runner/Job.php +++ b/src/Runner/Job.php @@ -43,7 +43,7 @@ class Job private ?string $stderrFile; private int $exitCode = self::CodeNone; - /** @var string[] output headers */ + /** @var array output headers */ private array $headers = []; private float $duration; @@ -210,7 +210,7 @@ public function getExitCode(): int /** * Returns output headers. - * @return string[] + * @return array */ public function getHeaders(): array { diff --git a/src/Runner/Output/ConsolePrinter.php b/src/Runner/Output/ConsolePrinter.php index 7907b9cd..d62228b6 100644 --- a/src/Runner/Output/ConsolePrinter.php +++ b/src/Runner/Output/ConsolePrinter.php @@ -32,7 +32,7 @@ class ConsolePrinter implements Tester\Runner\OutputHandler private float $time; private int $count; - /** @var array result type (Test::*) => count */ + /** @var array result type => count */ private array $results; private ?string $baseDir; diff --git a/src/Runner/Runner.php b/src/Runner/Runner.php index 85fa0307..e6f2b29a 100644 --- a/src/Runner/Runner.php +++ b/src/Runner/Runner.php @@ -20,15 +20,15 @@ */ class Runner { - /** @var string[] paths to test files/directories */ + /** @var list paths to test files/directories */ public array $paths = []; - /** @var string[] */ + /** @var list */ public array $ignoreDirs = ['vendor']; public int $threadCount = 1; public TestHandler $testHandler; - /** @var OutputHandler[] */ + /** @var list */ public array $outputHandlers = []; public bool $stopOnFail = false; private PhpInterpreter $interpreter; diff --git a/src/Runner/Test.php b/src/Runner/Test.php index f6d430ce..d661b7a5 100644 --- a/src/Runner/Test.php +++ b/src/Runner/Test.php @@ -40,7 +40,7 @@ class Test private int $result = self::Prepared; private ?float $duration = null; - /** @var string[]|string[][] */ + /** @var list */ private $args = []; @@ -58,7 +58,7 @@ public function getFile(): string /** - * @return string[]|string[][] + * @return list */ public function getArguments(): array { @@ -117,7 +117,7 @@ public function withTitle(string $title): self } - /** @param array $args */ + /** @param array> $args */ public function withArguments(array $args): static { if ($this->hasResult()) { diff --git a/src/Runner/TestHandler.php b/src/Runner/TestHandler.php index 113ca29f..42f9c00e 100644 --- a/src/Runner/TestHandler.php +++ b/src/Runner/TestHandler.php @@ -143,7 +143,7 @@ private function initiatePhpIni(Test $test, string $pair, PhpInterpreter &$inter } - /** @return Test[]|Test */ + /** @return list|Test */ private function initiateDataProvider(Test $test, string $provider): array|Test { try { @@ -163,7 +163,7 @@ private function initiateDataProvider(Test $test, string $provider): array|Test } - /** @return Test[] */ + /** @return list */ private function initiateMultiple(Test $test, string $count): array { return array_map( @@ -173,7 +173,7 @@ private function initiateMultiple(Test $test, string $count): array } - /** @return Test|Test[] */ + /** @return Test|list */ private function initiateTestCase(Test $test, mixed $value, PhpInterpreter $interpreter): Test|array { $methods = null; @@ -299,7 +299,7 @@ private function assessOutputMatch(Job $job, string $content): ?Test } - /** @return array{array, ?string} [annotations, test title] */ + /** @return array{array, ?string} [annotations, test title] */ private function getAnnotations(string $file): array { $annotations = Helpers::parseDocComment(Helpers::readFile($file)); From 5271113db0357836c0e4241a087f870afab56ab9 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 22 Jan 2026 09:39:27 +0100 Subject: [PATCH 04/18] throw exception in Test::getResult() when result not yet set --- src/Runner/Test.php | 2 +- tests/Runner/Test.phpt | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Runner/Test.php b/src/Runner/Test.php index d661b7a5..492148e4 100644 --- a/src/Runner/Test.php +++ b/src/Runner/Test.php @@ -77,7 +77,7 @@ public function getSignature(): string /** @return self::Failed|self::Passed|self::Skipped */ public function getResult(): int { - return $this->result; + return $this->result ?: throw new \LogicException('Result is not set yet.'); } diff --git a/tests/Runner/Test.phpt b/tests/Runner/Test.phpt index c20da9e7..02a44ada 100644 --- a/tests/Runner/Test.phpt +++ b/tests/Runner/Test.phpt @@ -25,7 +25,11 @@ test('', function () { Assert::same([], $test->getArguments()); Assert::same('some/Test.phpt', $test->getSignature()); Assert::false($test->hasResult()); - Assert::same(Test::Prepared, $test->getResult()); + Assert::exception( + fn() => $test->getResult(), + LogicException::class, + 'Result is not set yet.', + ); Assert::null($test->getDuration()); }); From da321a2583a08901879fddd01eb605289376012a Mon Sep 17 00:00:00 2001 From: David Grudl Date: Tue, 10 Feb 2026 18:41:03 +0100 Subject: [PATCH 05/18] uses nette/phpstan-rules --- composer.json | 9 +++- phpstan.neon | 1 - tests/types/tester-types.php | 80 +++++++++++++++++++++++++++++++++++ tests/types/tester-types.phpt | 10 +++++ 4 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 tests/types/tester-types.php create mode 100644 tests/types/tester-types.phpt diff --git a/composer.json b/composer.json index d81e4a35..5f7235a4 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,9 @@ }, "require-dev": { "ext-simplexml": "*", - "phpstan/phpstan": "^2.0@stable" + "phpstan/phpstan": "^2.1@stable", + "phpstan/extension-installer": "^1.4@stable", + "nette/phpstan-rules": "^1.0" }, "autoload": { "classmap": ["src/"], @@ -38,5 +40,10 @@ }, "extra": { "branch-alias": { "dev-master": "2.6-dev" } + }, + "config": { + "allow-plugins": { + "phpstan/extension-installer": true + } } } diff --git a/phpstan.neon b/phpstan.neon index 261116b7..c29db2b2 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,6 +1,5 @@ parameters: level: 6 - errorFormat: raw paths: - src diff --git a/tests/types/tester-types.php b/tests/types/tester-types.php new file mode 100644 index 00000000..343842ed --- /dev/null +++ b/tests/types/tester-types.php @@ -0,0 +1,80 @@ + null, RuntimeException::class); + assertType('RuntimeException|null', $result); +} + + +function testAssertThrows(): void +{ + $result = Assert::throws(fn() => null, InvalidArgumentException::class); + assertType('InvalidArgumentException|null', $result); +} + + +function testDomQueryFind(): void +{ + $dom = DomQuery::fromHtml('
test
'); + $result = $dom->find('div'); + assertType('list', $result); +} + + +function testDataProviderParseAnnotation(): void +{ + $result = DataProvider::parseAnnotation('file.ini, query', '/path/to/test.phpt'); + assertType('array{string, string, bool}', $result); +} + + +function testAssertExpandMatchingPatterns(): void +{ + $result = Assert::expandMatchingPatterns('pattern', 'actual'); + assertType('array{string, string}', $result); +} + + +function testHelpersParseDocComment(): void +{ + $result = Helpers::parseDocComment('/** @param string $x */'); + assertType('array|string>', $result); +} + + +function testJobGetHeaders(Job $job): void +{ + $result = $job->getHeaders(); + assertType('array', $result); +} diff --git a/tests/types/tester-types.phpt b/tests/types/tester-types.phpt new file mode 100644 index 00000000..cb9f9b0f --- /dev/null +++ b/tests/types/tester-types.phpt @@ -0,0 +1,10 @@ + Date: Thu, 12 Feb 2026 11:20:54 +0100 Subject: [PATCH 06/18] fixed PHPStan errors --- phpstan-baseline.neon | 72 +++++++------------ phpstan.neon | 19 ++++- src/CodeCoverage/Collector.php | 8 ++- .../Generators/AbstractGenerator.php | 2 +- .../Generators/CloverXMLGenerator.php | 4 +- src/CodeCoverage/Generators/HtmlGenerator.php | 4 +- src/CodeCoverage/PhpParser.php | 3 +- src/Framework/Assert.php | 5 +- src/Framework/DomQuery.php | 4 +- src/Framework/Dumper.php | 6 +- src/Framework/Environment.php | 10 ++- src/Framework/FileMock.php | 2 +- src/Framework/HttpAssert.php | 13 ++-- src/Framework/TestCase.php | 10 +-- src/Runner/CliTester.php | 22 +++--- src/Runner/CommandLine.php | 5 +- src/Runner/Job.php | 1 + src/Runner/Output/ConsolePrinter.php | 4 +- src/Runner/Output/JUnitPrinter.php | 2 +- src/Runner/TestHandler.php | 12 ++-- 20 files changed, 108 insertions(+), 100 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 1811062b..333f09b0 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -7,16 +7,10 @@ parameters: path: src/CodeCoverage/Generators/CloverXMLGenerator.php - - message: '#^Call to an undefined method \(\(TNode of DOMNode\)\|false\)\:\:setAttribute\(\)\.$#' - identifier: method.notFound - count: 9 - path: src/CodeCoverage/Generators/CloverXMLGenerator.php - - - - message: '#^Parameter \#1 \$element of static method Tester\\CodeCoverage\\Generators\\CloverXMLGenerator\:\:setMetricAttributes\(\) expects DOMElement, \(\(TNode of DOMNode\)\|false\) given\.$#' - identifier: argument.type - count: 3 - path: src/CodeCoverage/Generators/CloverXMLGenerator.php + message: '#^Method Tester\\Assert\:\:exception\(\) should return \(T of Throwable\)\|null but returns Throwable\|null\.$#' + identifier: return.type + count: 1 + path: src/Framework/Assert.php - message: '#^Closure invoked with 1 parameter, 0 required\.$#' @@ -25,46 +19,10 @@ parameters: path: src/Framework/DataProvider.php - - message: '#^Method Tester\\DomQuery\:\:closest\(\) should return Tester\\DomQuery\|null but returns SimpleXMLElement\|null\.$#' - identifier: return.type - count: 1 - path: src/Framework/DomQuery.php - - - - message: '#^Method Tester\\DomQuery\:\:find\(\) should return array\ but returns array\\.$#' - identifier: return.type - count: 1 - path: src/Framework/DomQuery.php - - - - message: '#^Method Tester\\DomQuery\:\:fromHtml\(\) should return Tester\\DomQuery but returns SimpleXMLElement\|null\.$#' - identifier: return.type - count: 1 - path: src/Framework/DomQuery.php - - - - message: '#^Method Tester\\DomQuery\:\:fromXml\(\) should return Tester\\DomQuery but returns SimpleXMLElement\|false\.$#' - identifier: return.type - count: 1 - path: src/Framework/DomQuery.php - - - - message: '#^Offset 7 does not exist on array\{0\: string, 1\: non\-empty\-string, 2\: ''''\|''0'', 3\: ''0'', 4\?\: non\-empty\-string, 5\?\: non\-empty\-string, 6\: non\-empty\-string\}\.$#' - identifier: offsetAccess.notFound - count: 1 - path: src/Framework/DomQuery.php - - - - message: '#^Parameter \#1 \$node of function simplexml_import_dom expects DOMNode, Dom\\Element given\.$#' + message: '#^Parameter \#2 \$file of static method Tester\\DataProvider\:\:parseAnnotation\(\) expects string, array\\|string given\.$#' identifier: argument.type - count: 2 - path: src/Framework/DomQuery.php - - - - message: '#^Strict comparison using \=\=\= between non\-empty\-string and '''' will always evaluate to false\.$#' - identifier: identical.alwaysFalse count: 1 - path: src/Framework/DomQuery.php + path: src/Framework/Environment.php - message: '#^Variable \$ref in isset\(\) always exists and is not nullable\.$#' @@ -90,6 +48,24 @@ parameters: count: 1 path: src/Runner/CliTester.php + - + message: '#^Method Tester\\Runner\\CommandLine\:\:parse\(\) should return array\ but returns array\\.$#' + identifier: return.type + count: 1 + path: src/Runner/CommandLine.php + + - + message: '#^Offset string\|true might not exist on array\\>\.$#' + identifier: offsetAccess.notFound + count: 1 + path: src/Runner/CommandLine.php + + - + message: '#^Parameter \#3 \$length of function substr expects int\|null, int\<0, max\>\|false given\.$#' + identifier: argument.type + count: 1 + path: src/Runner/PhpInterpreter.php + - message: '#^If condition is always false\.$#' identifier: if.alwaysFalse diff --git a/phpstan.neon b/phpstan.neon index c29db2b2..13a9d75b 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,5 +1,5 @@ parameters: - level: 6 + level: 8 paths: - src @@ -7,5 +7,22 @@ parameters: excludePaths: - src/Framework/FileMutator.php + ignoreErrors: + - # DomQuery: PHP 8.4 Dom\Element API not in PHPStan stubs + identifier: method.notFound + path: src/Framework/DomQuery.php + - # DomQuery: SimpleXMLElement subclass return types + identifier: return.type + path: src/Framework/DomQuery.php + - # DomQuery: regex match group offsets + identifier: offsetAccess.notFound + path: src/Framework/DomQuery.php + - # DomQuery: simplexml_import_dom accepts Dom\Node in PHP 8.4 + identifier: argument.type + path: src/Framework/DomQuery.php + - # DomQuery: regex analysis false positive + identifier: identical.alwaysFalse + path: src/Framework/DomQuery.php + includes: - phpstan-baseline.neon diff --git a/src/CodeCoverage/Collector.php b/src/CodeCoverage/Collector.php index 4114143f..5461e1fd 100644 --- a/src/CodeCoverage/Collector.php +++ b/src/CodeCoverage/Collector.php @@ -32,9 +32,9 @@ class Collector public static function detectEngines(): array { return array_filter([ - extension_loaded('pcov') ? [self::EnginePcov, phpversion('pcov')] : null, - defined('PHPDBG_VERSION') ? [self::EnginePhpdbg, PHPDBG_VERSION] : null, - extension_loaded('xdebug') ? [self::EngineXdebug, phpversion('xdebug')] : null, + extension_loaded('pcov') ? [self::EnginePcov, (string) phpversion('pcov')] : null, + defined('PHPDBG_VERSION') ? [self::EnginePhpdbg, (string) PHPDBG_VERSION] : null, + extension_loaded('xdebug') ? [self::EngineXdebug, (string) phpversion('xdebug')] : null, ]); } @@ -186,7 +186,9 @@ private static function startPhpDbg(): void */ private static function collectPhpDbg(): array { + /** @var array> $positive */ $positive = phpdbg_end_oplog(); + /** @var array> $negative */ $negative = phpdbg_get_executable(); foreach ($positive as $file => &$lines) { diff --git a/src/CodeCoverage/Generators/AbstractGenerator.php b/src/CodeCoverage/Generators/AbstractGenerator.php index 010c7969..72bcc36e 100644 --- a/src/CodeCoverage/Generators/AbstractGenerator.php +++ b/src/CodeCoverage/Generators/AbstractGenerator.php @@ -60,7 +60,7 @@ public function __construct(string $file, array $sources = []) } } - $this->sources = array_map('realpath', $sources); + $this->sources = array_map(fn($s) => (string) realpath($s), $sources); } diff --git a/src/CodeCoverage/Generators/CloverXMLGenerator.php b/src/CodeCoverage/Generators/CloverXMLGenerator.php index 7e7fd9b6..43de06b0 100644 --- a/src/CodeCoverage/Generators/CloverXMLGenerator.php +++ b/src/CodeCoverage/Generators/CloverXMLGenerator.php @@ -86,7 +86,7 @@ protected function renderSelf(): void if (empty($this->data[$file])) { $coverageData = null; - $this->totalSum += count(file($file, FILE_SKIP_EMPTY_LINES)); + $this->totalSum += count(file($file, FILE_SKIP_EMPTY_LINES) ?: []); } else { $coverageData = $this->data[$file]; } @@ -201,7 +201,7 @@ private static function analyzeMethod(\stdClass $info, ?array $coverageData = nu $coveredCount = 0; if ($coverageData === null) { // Never loaded file - $count = max(1, $info->end - $info->start - 2); + $count = (int) max(1, $info->end - $info->start - 2); } else { for ($i = $info->start; $i <= $info->end; $i++) { if (isset($coverageData[$i]) && $coverageData[$i] !== self::LineDead) { diff --git a/src/CodeCoverage/Generators/HtmlGenerator.php b/src/CodeCoverage/Generators/HtmlGenerator.php index 0c9eb5d2..3cb9f486 100644 --- a/src/CodeCoverage/Generators/HtmlGenerator.php +++ b/src/CodeCoverage/Generators/HtmlGenerator.php @@ -95,10 +95,10 @@ private function parse(): void $this->totalSum += $total; $this->coveredSum += $covered; } else { - $this->totalSum += count(file($entry, FILE_SKIP_EMPTY_LINES)); + $this->totalSum += count(file($entry, FILE_SKIP_EMPTY_LINES) ?: []); } - $light = $total ? $total < 5 : count(file($entry)) < 50; + $light = $total ? $total < 5 : count(file($entry) ?: []) < 50; $this->files[] = (object) [ 'name' => str_replace($commonSourcesPath, '', $entry), 'file' => $entry, diff --git a/src/CodeCoverage/PhpParser.php b/src/CodeCoverage/PhpParser.php index 31abd60b..987f3847 100644 --- a/src/CodeCoverage/PhpParser.php +++ b/src/CodeCoverage/PhpParser.php @@ -58,7 +58,8 @@ public function parse(string $code): \stdClass { $tokens = \PhpToken::tokenize($code, TOKEN_PARSE); - $level = $classLevel = $functionLevel = null; + $level = 0; + $classLevel = $functionLevel = null; $namespace = ''; $line = 1; diff --git a/src/Framework/Assert.php b/src/Framework/Assert.php index 121d4c66..1b853ceb 100644 --- a/src/Framework/Assert.php +++ b/src/Framework/Assert.php @@ -558,13 +558,14 @@ public static function isMatching(string $pattern, string $actual, bool $strict '\x00' => '\x00', '[\t ]*\r?\n' => '[\t ]*\r?\n', // right trim ]; - $pattern = '#^' . preg_replace_callback('#' . implode('|', array_keys($patterns)) . '#U' . $utf8, function ($m) use ($patterns) { + $pattern = '#^' . preg_replace_callback('#' . implode('|', array_keys($patterns)) . '#U' . $utf8, function ($m) use ($patterns): string { foreach ($patterns as $re => $replacement) { $s = preg_replace("#^$re$#D", str_replace('\\', '\\\\', $replacement), $m[0], 1, $count); if ($count) { - return $s; + return $s ?? $m[0]; } } + return $m[0]; }, rtrim($pattern, " \t\n\r")) . $suffix; } diff --git a/src/Framework/DomQuery.php b/src/Framework/DomQuery.php index f171846f..44e1c5c8 100644 --- a/src/Framework/DomQuery.php +++ b/src/Framework/DomQuery.php @@ -81,9 +81,9 @@ public static function fromXml(string $xml): self public function find(string $selector): array { if (PHP_VERSION_ID < 80400) { - return str_starts_with($selector, ':scope') + return (str_starts_with($selector, ':scope') ? $this->xpath('self::' . self::css2xpath(substr($selector, 6))) - : $this->xpath('descendant::' . self::css2xpath($selector)); + : $this->xpath('descendant::' . self::css2xpath($selector))) ?: []; } return array_map( diff --git a/src/Framework/Dumper.php b/src/Framework/Dumper.php index e5132e4c..e1a6250c 100644 --- a/src/Framework/Dumper.php +++ b/src/Framework/Dumper.php @@ -299,7 +299,7 @@ public static function dumpException(\Throwable $e): string $trace = $e->getTrace(); array_splice($trace, 0, $e instanceof \ErrorException ? 1 : 0, [['file' => $e->getFile(), 'line' => $e->getLine()]]); - $testFile = null; + $testFile = $e->getFile(); foreach (array_reverse($trace) as $item) { if (isset($item['file'])) { // in case of shutdown handler, we want to skip inner-code blocks and debugging calls $testFile = $item['file']; @@ -364,8 +364,8 @@ public static function dumpException(\Throwable $e): string continue; } - $line = $item['class'] === Assert::class && method_exists($item['class'], $item['function']) - && strpos($tmp = file($item['file'])[$item['line'] - 1], "::$item[function](") ? $tmp : null; + $line = $item['class'] === Assert::class && isset($item['function'], $item['file']) && method_exists($item['class'], $item['function']) + && strpos($tmp = (file($item['file']) ?: [])[$item['line'] - 1] ?? '', "::$item[function](") ? $tmp : null; $s .= 'in ' . ($item['file'] diff --git a/src/Framework/Environment.php b/src/Framework/Environment.php index 838ff6d4..2be444d4 100644 --- a/src/Framework/Environment.php +++ b/src/Framework/Environment.php @@ -68,8 +68,10 @@ class_exists(Assert::class); $annotations = self::getTestAnnotations(); self::$checkAssertions = !isset($annotations['outputmatch']) && !isset($annotations['outputmatchfile']); - if (getenv(self::VariableCoverage) && getenv(self::VariableCoverageEngine)) { - CodeCoverage\Collector::start(getenv(self::VariableCoverage), getenv(self::VariableCoverageEngine)); + $coverageFile = getenv(self::VariableCoverage); + $coverageEngine = getenv(self::VariableCoverageEngine); + if ($coverageFile && $coverageEngine) { + CodeCoverage\Collector::start($coverageFile, $coverageEngine); } if (getenv('TERMINAL_EMULATOR') === 'JetBrains-JediTerm') { @@ -229,7 +231,9 @@ public static function bypassFinals(): void */ public static function loadData(): array { - if (isset($_SERVER['argv']) && ($tmp = preg_filter('#--dataprovider=(.*)#Ai', '$1', $_SERVER['argv']))) { + /** @var list $argv */ + $argv = $_SERVER['argv'] ?? []; + if ($argv && ($tmp = preg_filter('#--dataprovider=(.*)#Ai', '$1', $argv))) { [$key, $file] = explode('|', reset($tmp), 2); $data = DataProvider::load($file); if (!array_key_exists($key, $data)) { diff --git a/src/Framework/FileMock.php b/src/Framework/FileMock.php index 6a8ad1e0..8b139e82 100644 --- a/src/Framework/FileMock.php +++ b/src/Framework/FileMock.php @@ -212,7 +212,7 @@ private function warning(string $message): void { $bt = debug_backtrace(0, 3); if (isset($bt[2]['function'])) { - $message = $bt[2]['function'] . '(' . @$bt[2]['args'][0] . '): ' . $message; + $message = $bt[2]['function'] . '(' . ($bt[2]['args'][0] ?? '') . '): ' . $message; } trigger_error($message, E_USER_WARNING); diff --git a/src/Framework/HttpAssert.php b/src/Framework/HttpAssert.php index 4c65ccc5..6f8e2694 100644 --- a/src/Framework/HttpAssert.php +++ b/src/Framework/HttpAssert.php @@ -40,12 +40,11 @@ public static function fetch( ?string $body = null, ): self { - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $url); + $ch = curl_init($url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_HEADER, true); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, $follow); - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, strtoupper($method)); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, strtoupper($method) ?: 'GET'); if ($headers) { $headerList = []; @@ -64,15 +63,15 @@ public static function fetch( } if ($cookies) { - $cookieString = ''; + $pairs = []; foreach ($cookies as $name => $value) { - $cookieString .= "$name=$value; "; + $pairs[] = "$name=$value"; } - curl_setopt($ch, CURLOPT_COOKIE, rtrim($cookieString, '; ')); + curl_setopt($ch, CURLOPT_COOKIE, implode('; ', $pairs)); } $response = curl_exec($ch); - if ($response === false) { + if (!is_string($response)) { throw new \Exception('HTTP request failed: ' . curl_error($ch)); } diff --git a/src/Framework/TestCase.php b/src/Framework/TestCase.php index 1de1e275..86c3a1bf 100644 --- a/src/Framework/TestCase.php +++ b/src/Framework/TestCase.php @@ -42,7 +42,9 @@ public function run(): void array_map(fn(\ReflectionMethod $rm): string => $rm->getName(), (new \ReflectionObject($this))->getMethods()), )); - if (isset($_SERVER['argv']) && ($tmp = preg_filter('#--method=([\w-]+)$#Ai', '$1', $_SERVER['argv']))) { + /** @var list $argv */ + $argv = $_SERVER['argv'] ?? []; + if ($argv && ($tmp = preg_filter('#--method=([\w-]+)$#Ai', '$1', $argv))) { $method = reset($tmp); if ($method === self::ListMethods) { $this->sendMethodList($methods); @@ -123,13 +125,13 @@ public function runTest(string $method, ?array $args = null): void try { if ($info['throws']) { $e = Assert::error(function () use ($method, $params): void { - [$this, $method->getName()](...$params); + $this->{$method->getName()}(...$params); }, ...$throws); if ($e instanceof AssertException) { throw $e; } } else { - [$this, $method->getName()](...$params); + $this->{$method->getName()}(...$params); } } catch (\Throwable $e) { $this->handleErrors = false; @@ -160,7 +162,7 @@ protected function getData(string $provider): mixed return $this->$provider(); } else { $rc = new \ReflectionClass($this); - [$file, $query] = DataProvider::parseAnnotation($provider, $rc->getFileName()); + [$file, $query] = DataProvider::parseAnnotation($provider, (string) $rc->getFileName()); return DataProvider::load($file, $query); } } diff --git a/src/Runner/CliTester.php b/src/Runner/CliTester.php index eed32743..b5b70593 100644 --- a/src/Runner/CliTester.php +++ b/src/Runner/CliTester.php @@ -160,19 +160,23 @@ private function loadOptions(): CommandLine ], ); - if (isset($_SERVER['argv'])) { - if (($tmp = array_search('-l', $_SERVER['argv'], strict: true)) - || ($tmp = array_search('-log', $_SERVER['argv'], strict: true)) - || ($tmp = array_search('--log', $_SERVER['argv'], strict: true)) + /** @var list $argv */ + $argv = $_SERVER['argv'] ?? []; + if ($argv) { + if (($tmp = array_search('-l', $argv, strict: true)) + || ($tmp = array_search('-log', $argv, strict: true)) + || ($tmp = array_search('--log', $argv, strict: true)) ) { - $_SERVER['argv'][$tmp] = '-o'; - $_SERVER['argv'][$tmp + 1] = 'log:' . $_SERVER['argv'][$tmp + 1]; + $argv[$tmp] = '-o'; + $argv[$tmp + 1] = 'log:' . $argv[$tmp + 1]; } - if ($tmp = array_search('--tap', $_SERVER['argv'], strict: true)) { - unset($_SERVER['argv'][$tmp]); - $_SERVER['argv'] = array_merge($_SERVER['argv'], ['-o', 'tap']); + if ($tmp = array_search('--tap', $argv, strict: true)) { + unset($argv[$tmp]); + $argv = array_merge($argv, ['-o', 'tap']); } + + $_SERVER['argv'] = $argv; } $this->options = $cmd->parse(); diff --git a/src/Runner/CommandLine.php b/src/Runner/CommandLine.php index 8572ea32..11d969b4 100644 --- a/src/Runner/CommandLine.php +++ b/src/Runner/CommandLine.php @@ -51,7 +51,7 @@ public function __construct(string $help, array $defaults = []) throw new \InvalidArgumentException("Unable to parse '$line[1]'."); } - $name = end($m[1]); + $name = (string) end($m[1]); $opts = $this->options[$name] ?? []; $arg = (string) end($m[2]); $this->options[$name] = $opts + [ @@ -81,6 +81,7 @@ public function __construct(string $help, array $defaults = []) public function parse(?array $args = null): array { if ($args === null) { + /** @var list $args */ $args = isset($_SERVER['argv']) ? array_slice($_SERVER['argv'], 1) : []; } @@ -106,7 +107,7 @@ public function parse(?array $args = null): array continue; } - [$name, $arg] = strpos($arg, '=') ? explode('=', $arg, 2) : [$arg, true]; + [$name, $arg] = strpos($arg, '=') !== false ? explode('=', $arg, 2) : [$arg, true]; if (isset($this->aliases[$name])) { $name = $this->aliases[$name]; diff --git a/src/Runner/Job.php b/src/Runner/Job.php index ba69a122..7de100ea 100644 --- a/src/Runner/Job.php +++ b/src/Runner/Job.php @@ -158,6 +158,7 @@ public function isRunning(): bool $this->test->stdout .= stream_get_contents($this->stdout); } + assert($this->proc !== null); $status = proc_get_status($this->proc); if ($status['running']) { return true; diff --git a/src/Runner/Output/ConsolePrinter.php b/src/Runner/Output/ConsolePrinter.php index d62228b6..a7d15e41 100644 --- a/src/Runner/Output/ConsolePrinter.php +++ b/src/Runner/Output/ConsolePrinter.php @@ -95,7 +95,7 @@ public function finish(Test $test): void self::ModeLines => $this->generateFinishLine($test), }); - $title = ($test->title ? "$test->title | " : '') . substr($test->getSignature(), strlen($this->baseDir)); + $title = ($test->title ? "$test->title | " : '') . substr($test->getSignature(), strlen((string) $this->baseDir)); $message = ' ' . str_replace("\n", "\n ", trim((string) $test->message)) . "\n\n"; $message = preg_replace('/^ $/m', '', $message); if ($result === Test::Failed) { @@ -125,7 +125,7 @@ public function end(): void private function generateFinishLine(Test $test): string { $result = $test->getResult(); - $shortFilePath = str_replace($this->baseDir, '', $test->getFile()); + $shortFilePath = str_replace((string) $this->baseDir, '', $test->getFile()); $shortDirPath = dirname($shortFilePath) . DIRECTORY_SEPARATOR; $basename = basename($shortFilePath); $fileText = $result === Test::Failed diff --git a/src/Runner/Output/JUnitPrinter.php b/src/Runner/Output/JUnitPrinter.php index 0f0f247b..f08cb34a 100644 --- a/src/Runner/Output/JUnitPrinter.php +++ b/src/Runner/Output/JUnitPrinter.php @@ -54,7 +54,7 @@ public function finish(Test $test): void $this->results[$test->getResult()]++; $this->buffer .= "\t\tgetSignature()) . '" name="' . htmlspecialchars($test->getSignature()) . '"'; $this->buffer .= match ($test->getResult()) { - Test::Failed => ">\n\t\t\tmessage, ENT_COMPAT | ENT_HTML5) . "\"/>\n\t\t\n", + Test::Failed => ">\n\t\t\tmessage ?? '', ENT_COMPAT | ENT_HTML5) . "\"/>\n\t\t\n", Test::Skipped => ">\n\t\t\t\n\t\t\n", Test::Passed => "/>\n", }; diff --git a/src/Runner/TestHandler.php b/src/Runner/TestHandler.php index 42f9c00e..a0d14379 100644 --- a/src/Runner/TestHandler.php +++ b/src/Runner/TestHandler.php @@ -13,7 +13,7 @@ use Tester\Dumper; use Tester\Helpers; use Tester\TestCase; -use function count, in_array, is_array; +use function count, in_array, is_array, is_string; use const DIRECTORY_SEPARATOR; @@ -138,8 +138,8 @@ private function initiatePhpExtension(Test $test, string $value, PhpInterpreter private function initiatePhpIni(Test $test, string $pair, PhpInterpreter &$interpreter): void { - [$name, $value] = explode('=', $pair, 2) + [1 => null]; - $interpreter = $interpreter->withPhpIniOption($name, $value); + $parts = explode('=', $pair, 2); + $interpreter = $interpreter->withPhpIniOption($parts[0], $parts[1] ?? null); } @@ -230,12 +230,12 @@ private function initiateTestCase(Test $test, mixed $value, PhpInterpreter $inte } } - return array_map( + return array_values(array_map( fn(string $method): Test => $test ->withTitle(trim("$test->title $method")) ->withArguments(['method' => $method]), $methods, - ); + )); } @@ -303,7 +303,7 @@ private function assessOutputMatch(Job $job, string $content): ?Test private function getAnnotations(string $file): array { $annotations = Helpers::parseDocComment(Helpers::readFile($file)); - $testTitle = isset($annotations[0]) + $testTitle = is_string($annotations[0] ?? null) ? preg_replace('#^TEST:\s*#i', '', $annotations[0]) : null; return [$annotations, $testTitle]; From fa5e4a6bc26cd7a848dd350546df68a53aed21c9 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sun, 28 Dec 2025 02:23:58 +0100 Subject: [PATCH 07/18] added CLAUDE.md --- .gitattributes | 1 + CLAUDE.md | 870 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 871 insertions(+) create mode 100644 CLAUDE.md diff --git a/.gitattributes b/.gitattributes index e8e40fb9..97e415cd 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,7 @@ .gitattributes export-ignore .github/ export-ignore .gitignore export-ignore +CLAUDE.md export-ignore ncs.* export-ignore phpstan*.neon export-ignore tests/ export-ignore diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..79cc30ff --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,870 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Nette Tester is a lightweight, standalone PHP testing framework designed for simplicity, speed, and process isolation. It runs tests in parallel by default (8 threads) and supports code coverage through Xdebug, PCOV, or PHPDBG. + +**Key characteristics:** +- Zero external dependencies (pure PHP 8.0+) +- Each test runs in a completely isolated PHP process +- Annotation-driven test configuration +- Self-hosting (uses itself for testing) + +## Essential Commands + +```bash +# or directly: +src/tester tests -s + +# Run specific test file +src/tester tests/Framework/Assert.phpt -s + +# or simply +php tests/Framework/Assert.phpt + +# Static analysis +composer run phpstan +# or directly: +vendor/bin/phpstan analyse +``` + +**Common test runner options:** +- `-s` - Show information about skipped tests +- `-C` - Use system-wide php.ini +- `-c ` - Use specific php.ini file +- `-d key=value` - Set PHP INI directive +- `-j ` - Number of parallel jobs (default: 8, use 1 for serial) +- `-p ` - Specify PHP interpreter to use +- `--stop-on-fail` - Stop execution on first failure +- `-o ` - Output format (can specify multiple): + - `console` - Default format without ASCII logo + - `console-lines` - One test per line with details + - `tap` - Test Anything Protocol format + - `junit` - JUnit XML format (e.g., `-o junit:output.xml`) + - `log` - All tests including successful ones + - `none` - No output +- `-i, --info` - Show test environment information and exit +- `--setup ` - Script to run at startup (has access to `$runner`) +- `--temp ` - Custom temporary directory +- `--colors [1|0]` - Force enable/disable colors + +## Architecture + +### Three-Layer Design + +**Runner Layer** (`src/Runner/`) +- Test orchestration and parallel execution management +- `Runner` - Main test orchestrator, discovers tests, creates jobs +- `Job` - Wraps each test, spawns isolated PHP process via `proc_open()` +- `Test` - Immutable value object representing test state +- `TestHandler` - Processes test annotations and determines test variants +- `PhpInterpreter` - Encapsulates PHP binary configuration +- `Output/` - Multiple output formats (Console, TAP, JUnit, Logger) + +**Framework Layer** (`src/Framework/`) +- Core testing utilities and assertions +- `Assert` - 25+ assertion methods (same, equal, exception, match, etc.) +- `TestCase` - xUnit-style base class with setUp/tearDown hooks +- `Environment` - Test environment initialization and configuration +- `DataProvider` - External test data loading (INI, PHP files) +- `Helpers` - Utility functions including annotation parsing + +**Code Coverage Layer** (`src/CodeCoverage/`) +- Multi-engine coverage support (PCOV, Xdebug, PHPDBG) +- `Collector` - Aggregates coverage data from parallel test processes +- `Generators/` - HTML and CloverXML report generators + +### Process Isolation Architecture + +The most distinctive feature is **true process isolation**: + +``` +Runner (main process) + ├── Job #1 → proc_open() → Isolated PHP process + ├── Job #2 → proc_open() → Isolated PHP process + └── Job #N → proc_open() → Isolated PHP process +``` + +**Why this matters:** +- Tests cannot interfere with each other (no shared state) +- Memory leaks don't accumulate across tests +- Fatal errors in one test don't crash the entire suite +- Parallel execution is straightforward and reliable + +### Test Lifecycle State Machine + +``` +PREPARED (0) → [execution] → PASSED (2) | FAILED (1) | SKIPPED (3) +``` + +**Three phases:** +1. **Initiate** - Process annotations, create test variants (data providers, TestCase methods) +2. **Execute** - Run test in isolated process, capture output/exit code +3. **Assess** - Evaluate results against expectations (exit code, output patterns) + +### Annotation-Driven Configuration + +Tests use PHPDoc annotations for declarative configuration: + +```php +/** + * @phpVersion >= 8.1 + * @phpExtension json, mbstring + * @dataProvider data.ini + * @outputMatch %A%test passed%A% + * @testCase + */ +``` + +The `TestHandler` class has dedicated `initiate*` and `assess*` methods for each annotation type. + +## Testing Patterns + +### Three Ways to Organize Tests + +**Style 1: Simple assertion tests** (`.phpt` files) +```php + $obj->method(), + InvalidArgumentException::class, + 'Error message' +); +``` +- Direct execution of assertions +- Good for unit tests and edge cases +- Fast and simple + +**Style 2: Using test() function** (requires `Environment::setupFunctions()`) +```php +getArea()); +}); + +test('dimensions must not be negative', function () { + Assert::exception( + fn() => new Rectangle(-1, 20), + InvalidArgumentException::class, + ); +}); +``` +- Named test blocks with labels +- Good for grouping related assertions +- Supports global `setUp()` and `tearDown()` functions +- Labels are printed during execution + +**Style 3: TestCase classes** (`.phpt` files with `@testCase` annotation) +```php +run(); +``` +- xUnit-style structure with setup/teardown +- Better for integration tests +- Each `test*` method runs as separate test +- Supports `@throws` and `@dataProvider` method annotations + +### TestCase Method Discovery + +When using `@testCase`: +1. **List Mode** - Runner calls test with `--method=nette-tester-list-methods` to discover all `test*` methods +2. **Execute Mode** - Runner calls test with `--method=testFoo` for each individual method +3. This two-phase approach enables efficient parallel execution of TestCase methods + +### Data Provider Support + +Data providers enable parameterized testing: + +**INI format:** +```ini +[dataset1] +input = "test" +expected = "TEST" + +[dataset2] +input = "foo" +expected = "FOO" +``` + +**PHP format:** +```php +return [ + 'dataset1' => ['input' => 'test', 'expected' => 'TEST'], + 'dataset2' => ['input' => 'foo', 'expected' => 'FOO'], +]; +``` + +**Query syntax for filtering:** +```php +/** + * @dataProvider data.ini, >= 8.1 + */ +``` + +## Test Annotations + +Annotations control how tests are handled by the test runner. Written in PHPDoc at the beginning of test files. **Note:** Annotations are ignored when tests are run manually as PHP scripts. + +### File-Level Annotations + +**@skip** - Skip the test entirely +```php +/** + * @skip Temporarily disabled + */ +``` + +**@phpVersion** - Skip if PHP version doesn't match +```php +/** + * @phpVersion >= 8.1 + * @phpVersion < 8.4 + * @phpVersion != 8.2.5 + */ +``` + +**@phpExtension** - Skip if required extensions not loaded +```php +/** + * @phpExtension pdo, pdo_mysql + * @phpExtension json + */ +``` + +**@dataProvider** - Run test multiple times with different data +```php +/** + * @dataProvider databases.ini + * @dataProvider? optional-file.ini # Skip if file doesn't exist + * @dataProvider data.ini, postgresql, >=9.0 # With filter condition + */ + +// Access data in test +$args = Tester\Environment::loadData(); +// Returns array with section data from INI/PHP file +``` + +**@multiple** - Run test N times +```php +/** + * @multiple 10 + */ +``` + +**@testCase** - Treat file as TestCase class (enables parallel method execution) +```php +/** + * @testCase + */ +``` + +**@exitCode** - Expected exit code (default: 0) +```php +/** + * @exitCode 56 + */ +``` + +**@httpCode** - Expected HTTP code when running under CGI (default: 200) +```php +/** + * @httpCode 500 + * @httpCode any # Don't check HTTP code + */ +``` + +**@outputMatch** / **@outputMatchFile** - Verify test output matches pattern +```php +/** + * @outputMatch %A%Fatal error%A% + * @outputMatchFile expected-output.txt + */ +``` + +**@phpIni** - Set INI values for test +```php +/** + * @phpIni precision=20 + * @phpIni memory_limit=256M + */ +``` + +### TestCase Method Annotations + +**@throws** - Expect exception (alternative to Assert::exception) +```php +/** + * @throws RuntimeException + * @throws LogicException Wrong argument order + */ +public function testMethod() { } +``` + +**@dataProvider** - Run method multiple times with different parameters +```php +// From method +/** + * @dataProvider getLoopArgs + */ +public function testLoop($a, $b, $c) { } + +public function getLoopArgs() { + return [[1, 2, 3], [4, 5, 6]]; +} + +// From file +/** + * @dataProvider loop-args.ini + * @dataProvider loop-args.php + */ +public function testLoop($a, $b, $c) { } +``` + +## Assertions + +### Core Assertion Methods + +**Identity and equality:** +- `Assert::same($expected, $actual)` - Strict comparison (===) +- `Assert::notSame($expected, $actual)` - Strict inequality (!==) +- `Assert::equal($expected, $actual)` - Loose comparison (ignores object identity, array order) +- `Assert::notEqual($expected, $actual)` - Loose inequality + +**Containment:** +- `Assert::contains($needle, array|string $haystack)` - Substring or array element +- `Assert::notContains($needle, array|string $haystack)` - Must not contain +- `Assert::hasKey(string|int $key, array $actual)` - Array must have key +- `Assert::notHasKey(string|int $key, array $actual)` - Array must not have key + +**Boolean checks:** +- `Assert::true($value)` - Strict true (=== true) +- `Assert::false($value)` - Strict false (=== false) +- `Assert::truthy($value)` - Truthy value +- `Assert::falsey($value)` - Falsey value +- `Assert::null($value)` - Strict null (=== null) +- `Assert::notNull($value)` - Not null (!== null) + +**Special values:** +- `Assert::nan($value)` - Must be NAN (use only this for NAN testing) +- `Assert::count($count, Countable|array $value)` - Element count + +**Type checking:** +- `Assert::type(string|object $type, $value)` - Type validation + - Supports: array, list, bool, callable, float, int, null, object, resource, scalar, string + - Supports class names and instanceof checks + +**Pattern matching:** +- `Assert::match($pattern, $actual)` - Regex or wildcard matching +- `Assert::notMatch($pattern, $actual)` - Must not match pattern +- `Assert::matchFile($file, $actual)` - Pattern loaded from file + +**Exceptions and errors:** +- `Assert::exception(callable $fn, string $class, ?string $message, $code)` - Expect exception +- `Assert::error(callable $fn, int|string|array $type, ?string $message)` - Expect PHP error/warning +- `Assert::noError(callable $fn)` - Must not generate any error or exception + +**Other:** +- `Assert::fail(string $message)` - Force test failure +- `Assert::with($object, callable $fn)` - Access private/protected members + +### Expect Pattern for Complex Assertions + +Use `Tester\Expect` inside `Assert::equal()` for complex structure validation: + +```php +use Tester\Expect; + +Assert::equal([ + 'id' => Expect::type('int'), + 'username' => 'milo', + 'password' => Expect::match('%h%'), // hex string + 'created_at' => Expect::type(DateTime::class), + 'items' => Expect::type('array')->andCount(5), +], $result); +``` + +**Available Expect methods:** +- `Expect::type($type)` - Type expectation +- `Expect::match($pattern)` - Pattern expectation +- `Expect::count($count)` - Count expectation +- `Expect::that(callable $fn)` - Custom validator +- Chain with `->andCount()`, etc. + +### Failed Assertion Output + +When assertions fail with complex structures, Tester saves dumps to `output/` directory: +``` +tests/ +├── output/ +│ ├── MyTest.actual # Actual value +│ └── MyTest.expected # Expected value +└── MyTest.phpt # Failing test +``` + +Change output directory: `Tester\Dumper::$dumpDir = __DIR__ . '/custom-output';` + +## Helper Classes and Functions + +### HttpAssert (version 2.5.6+) + +Testing HTTP servers with fluent interface: + +```php +use Tester\HttpAssert; + +// Basic request +$response = HttpAssert::fetch('https://api.example.com/users'); +$response + ->expectCode(200) + ->expectHeader('Content-Type', contains: 'json') + ->expectBody(contains: 'users'); + +// Custom request +HttpAssert::fetch( + 'https://api.example.com/users', + method: 'POST', + headers: [ + 'Authorization' => 'Bearer token123', + 'Accept: application/json', // String format also supported + ], + cookies: ['session' => 'abc123'], + follow: false, // Don't follow redirects + body: '{"name": "John"}' +) + ->expectCode(201); + +// Status code validation +$response + ->expectCode(200) // Exact code + ->expectCode(fn($code) => $code < 400) // Custom validation + ->denyCode(404) // Must not be 404 + ->denyCode(fn($code) => $code >= 500); // Must not be server error + +// Header validation +$response + ->expectHeader('Content-Type') // Header exists + ->expectHeader('Content-Type', 'application/json') // Exact value + ->expectHeader('Content-Type', contains: 'json') // Contains text + ->expectHeader('Server', matches: 'nginx %a%') // Matches pattern + ->denyHeader('X-Debug') // Must not exist + ->denyHeader('X-Debug', contains: 'error'); // Must not contain + +// Body validation +$response + ->expectBody('OK') // Exact match + ->expectBody(contains: 'success') // Contains text + ->expectBody(matches: '%A%hello%A%') // Matches pattern + ->expectBody(fn($body) => json_decode($body)) // Custom validator + ->denyBody('Error') // Must not match + ->denyBody(contains: 'exception'); // Must not contain +``` + +### DomQuery + +CSS selector-based HTML/XML querying (extends SimpleXMLElement): + +```php +use Tester\DomQuery; + +$dom = DomQuery::fromHtml('
+

Title

+
Text
+
'); + +// Check element existence +Assert::true($dom->has('article.post')); +Assert::true($dom->has('h1')); + +// Find elements (returns array of DomQuery objects) +$headings = $dom->find('h1'); +Assert::same('Title', (string) $headings[0]); + +// Check if element matches selector +$content = $dom->find('.content')[0]; +Assert::true($content->matches('div')); +Assert::false($content->matches('p')); + +// Find closest ancestor +$article = $content->closest('.post'); +Assert::true($article->matches('article')); +``` + +### FileMock + +Emulate files in memory for testing file operations: + +```php +use Tester\FileMock; + +// Create virtual file +$file = FileMock::create('initial content'); + +// Use with file functions +file_put_contents($file, "Line 1\n", FILE_APPEND); +file_put_contents($file, "Line 2\n", FILE_APPEND); + +// Verify content +Assert::same("initial contentLine 1\nLine 2\n", file_get_contents($file)); + +// Works with parse_ini_file, fopen, etc. +``` + +### Environment Helpers + +**Environment::setup()** - Must be called in bootstrap +- Improves error dump readability with coloring +- Enables assertion tracking (tests without assertions fail) +- Starts code coverage collection (when --coverage used) +- Prints OK/FAILURE status at end + +**Environment::setupFunctions()** - Creates global test functions +```php +// In bootstrap.php +Tester\Environment::setup(); +Tester\Environment::setupFunctions(); + +// In tests +test('description', function () { /* ... */ }); +setUp(function () { /* runs before each test() */ }); +tearDown(function () { /* runs after each test() */ }); +``` + +**Environment::skip($message)** - Skip test with reason +```php +if (!extension_loaded('redis')) { + Tester\Environment::skip('Redis extension required'); +} +``` + +**Environment::lock($name, $dir)** - Prevent parallel execution +```php +// For tests that need exclusive database access +Tester\Environment::lock('database', __DIR__ . '/tmp'); +``` + +**Environment::bypassFinals()** - Remove final keywords during loading +```php +Tester\Environment::bypassFinals(); + +class MyTestClass extends NormallyFinalClass { } +``` + +**Environment variables:** +- `Environment::VariableRunner` - Detect if running under test runner +- `Environment::VariableThread` - Get thread number in parallel execution + +### Helpers::purge($dir) + +Create directory and delete all content (useful for temp directories): +```php +Tester\Helpers::purge(__DIR__ . '/temp'); +``` + +## Code Coverage + +### Multi-Engine Support + +The framework supports three coverage engines (auto-detected): +- **PCOV** - Fastest, modern, recommended +- **Xdebug** - Most common, slower +- **PHPDBG** - Built into PHP, no extension needed + +### Coverage Data Aggregation + +Coverage collection uses file-based aggregation with locking for parallel test execution: +1. Each test process collects coverage data +2. At shutdown, writes to shared file (with `flock()`) +3. Merges with existing data using `array_replace_recursive()` +4. Distinguishes positive (executed) vs negative (not executed) lines + +**Engine priority:** PCOV → PHPDBG → Xdebug + +**Memory management for large tests:** +```php +// In tests that consume lots of memory +Tester\CodeCoverage\Collector::flush(); +// Writes collected data to file and frees memory +// No effect if coverage not running or using Xdebug +``` + +**Generate coverage reports:** +```bash +# HTML report +src/tester tests --coverage coverage.html --coverage-src src + +# Clover XML report (for CI) +src/tester tests --coverage coverage.xml --coverage-src src + +# Multiple source paths +src/tester tests --coverage coverage.html \ + --coverage-src src \ + --coverage-src app +``` + +## Communication Patterns + +### Environment Variables + +Parent-child process communication uses environment variables: +- `NETTE_TESTER_RUNNER` - Indicates test is running under runner +- `NETTE_TESTER_THREAD` - Thread number for parallel execution +- `NETTE_TESTER_COVERAGE` - Coverage file path +- `NETTE_TESTER_COVERAGE_ENGINE` - Which coverage engine to use + +### Pattern Matching + +The `Assert::match()` method supports powerful pattern matching with wildcards and regex: + +**Wildcard patterns:** +- `%a%` - One or more of anything except line ending characters +- `%a?%` - Zero or more of anything except line ending characters +- `%A%` - One or more of anything including line ending characters (multiline) +- `%A?%` - Zero or more of anything including line ending characters +- `%s%` - One or more whitespace characters except line ending +- `%s?%` - Zero or more whitespace characters except line ending +- `%S%` - One or more characters except whitespace +- `%S?%` - Zero or more characters except whitespace +- `%c%` - A single character of any sort (except line ending) +- `%d%` - One or more digits +- `%d?%` - Zero or more digits +- `%i%` - Signed integer value +- `%f%` - Floating-point number +- `%h%` - One or more hexadecimal digits +- `%w%` - One or more alphanumeric characters +- `%%` - Literal % character + +**Regular expressions:** +Must be delimited with `~` or `#`: +```php +Assert::match('#^[0-9a-f]+$#i', $hexValue); +Assert::match('~Error in file .+ on line \d+~', $errorMessage); +``` + +**Examples:** +```php +// Wildcard patterns +Assert::match('%h%', 'a1b2c3'); // Hex string +Assert::match('Error in file %a% on line %i%', $error); // Dynamic parts +Assert::match('%A%hello%A%world%A%', $multiline); // Multiline matching + +// Regular expression +Assert::match('#^\d{4}-\d{2}-\d{2}$#', $date); // Date format +``` + +## Coding Conventions + +- All source files must include `declare(strict_types=1)` +- Use tabs for indentation +- Follow Nette Coding Standard (based on PSR-12) +- File extension `.phpt` for test files +- Place return type and opening brace on same line (PSR-12 style) +- Document PHPDoc annotations when they control test behavior + +## File Organization + +``` +src/ +├── Runner/ # Test execution orchestration +│ ├── Runner.php # Main orchestrator +│ ├── Job.php # Process wrapper +│ ├── Test.php # Test state/data +│ ├── TestHandler.php # Annotation processing +│ └── Output/ # Multiple output formats +├── Framework/ # Testing utilities +│ ├── Assert.php # 25+ assertion methods +│ ├── TestCase.php # xUnit-style base class +│ ├── Environment.php # Test context setup +│ └── DataProvider.php # External test data +├── CodeCoverage/ # Coverage collection +│ ├── Collector.php # Multi-engine collector +│ └── Generators/ # HTML & XML reports +├── bootstrap.php # Framework initialization +├── tester.php # CLI entry point (manual class loading) +└── tester # Executable wrapper + +tests/ +├── bootstrap.php # Test suite initialization +├── Framework/ # Framework layer tests +├── Runner/ # Runner layer tests +├── CodeCoverage/ # Coverage layer tests +└── RunnerOutput/ # Output format tests +``` + +## Key Design Principles + +1. **Immutability** - `Test` class uses clone-and-modify pattern to prevent accidental state mutations +2. **Strategy Pattern** - `OutputHandler` interface enables multiple output formats simultaneously +3. **No Autoloading** - Manual class loading in `tester.php` ensures no autoloader conflicts +4. **Self-Testing** - The framework uses itself for testing (83 test files) +5. **Smart Result Caching** - Failed tests run first on next execution to speed up development workflow + - Caches test results in temp directory + - Uses MD5 hash of test signature for cache filename + - Prioritizes previously failed tests for faster feedback + +### Test Runner Behavior + +**First run:** +``` +src/tester tests +# Runs all tests in discovery order +``` + +**Subsequent runs:** +- Failed tests from previous run execute first +- Helps quickly verify if bugs are fixed +- Non-zero exit code if any test fails + +**Parallel execution (default):** +- 8 threads by default +- Tests run in separate PHP processes +- Results aggregated as they complete +- Use `-j 1` for serial execution when debugging + +**Watch mode:** +```bash +src/tester --watch src tests +# Auto-reruns tests when files change +# Great for TDD workflow +``` + +## Common Development Tasks + +When adding new assertions to `Assert` class: +- Add method to `src/Framework/Assert.php` +- Add corresponding test to `tests/Framework/Assert.*.phpt` +- Document in readme.md assertion table + +When modifying test execution flow: +- Consider impact on both simple tests and TestCase-based tests +- Test with both serial (`-j 1`) and parallel execution +- Verify annotation processing in `TestHandler` + +When working with output formats: +- Implement `OutputHandler` interface +- Add tests in `tests/RunnerOutput/` +- Update CLI help text in `CliTester.php` + +## Testing the Tester + +The project uses itself for testing. The test bootstrap (`tests/bootstrap.php`) creates a `PhpInterpreter` that mirrors the current PHP environment. + +**Important when running tests:** +- Tests run in isolated processes (like production usage) +- Coverage requires Xdebug/PCOV/PHPDBG +- Some tests verify specific output formats and patterns +- Test files use `.phptx` extension when they shouldn't be automatically discovered + +## Important Notes and Edge Cases + +### Tests Must Execute Assertions + +A test without any assertion calls is considered **suspicious** and will fail: +``` +Error: This test forgets to execute an assertion. +``` + +If a test intentionally has no assertions, explicitly mark it: +```php +Assert::true(true); // Mark test as intentionally assertion-free +``` + +### Proper Test Termination + +**Don't use exit() or die()** to signal test failure: +```php +// Wrong - exit code 0 signals success +exit('Error in connection'); + +// Correct - use Assert::fail() +Assert::fail('Error in connection'); +``` + +### PHP INI Handling + +Tester runs PHP processes with the system php.ini: +- Use `-c path/to/php.ini` for custom php.ini +- Use `-d key=value` for individual INI settings + +### Test File Naming + +Test runner discovers tests by file pattern: +- `*.phpt` - Standard test files +- `*Test.php` - Alternative test file pattern +- `.phptx` extension - Tests that shouldn't be auto-discovered (used in Tester's own test suite) + +### Bootstrap Pattern + +Typical test bootstrap structure: +```php +// tests/bootstrap.php +require __DIR__ . '/../vendor/autoload.php'; + +Tester\Environment::setup(); +Tester\Environment::setupFunctions(); // Optional, for test() function + +date_default_timezone_set('Europe/Prague'); +define('TempDir', __DIR__ . '/tmp/' . getmypid()); +Tester\Helpers::purge(TempDir); +``` + +### Directory Structure for Tests + +Organize tests by namespace: +``` +tests/ +├── NamespaceOne/ +│ ├── MyClass.getUsers.phpt +│ ├── MyClass.setUsers.phpt +│ └── ... +├── NamespaceTwo/ +│ └── ... +├── bootstrap.php +└── ... +``` + +Run tests from specific folder: +```bash +src/tester tests/NamespaceOne +``` + +### Unique Philosophy + +**Each test is a runnable PHP script:** +- Can be executed directly: `php tests/MyTest.phpt` +- Can be debugged in IDE with breakpoints +- Can be opened in browser (for CGI tests) +- Makes test development fast and interactive + +## CI/CD + +GitHub Actions workflow tests across: +- 3 operating systems (Ubuntu, Windows, macOS) +- 6 PHP versions (8.0 - 8.5) +- 18 total combinations + +This extensive matrix ensures compatibility across all supported environments. From 29a5403e06286479f1b0c5b92d7499449093c731 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 12 Feb 2026 11:21:41 +0100 Subject: [PATCH 08/18] improved --cider mode (thx Claude) --- phpstan-baseline.neon | 12 ++++ src/Framework/Ansi.php | 64 +++++++++++++++++++ src/Runner/Output/ConsolePrinter.php | 96 +++++++++++++++++++++++++++- src/Runner/OutputHandler.php | 2 + src/Runner/Runner.php | 29 ++++++++- tests/Framework/Ansi.pad.phpt | 37 +++++++++++ tests/Framework/Ansi.truncate.phpt | 37 +++++++++++ 7 files changed, 273 insertions(+), 4 deletions(-) create mode 100644 tests/Framework/Ansi.pad.phpt create mode 100644 tests/Framework/Ansi.truncate.phpt diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 333f09b0..b2055c78 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -66,6 +66,18 @@ parameters: count: 1 path: src/Runner/PhpInterpreter.php + - + message: '#^Call to function method_exists\(\) with Tester\\Runner\\OutputHandler and ''jobStarted'' will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: src/Runner/Runner.php + + - + message: '#^Call to function method_exists\(\) with Tester\\Runner\\OutputHandler and ''tick'' will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: src/Runner/Runner.php + - message: '#^If condition is always false\.$#' identifier: if.alwaysFalse diff --git a/src/Framework/Ansi.php b/src/Framework/Ansi.php index 08b3c3f4..e4b63ad8 100644 --- a/src/Framework/Ansi.php +++ b/src/Framework/Ansi.php @@ -78,6 +78,20 @@ public static function hideCursor(): string } + public static function cursorMove(int $x = 0, int $y = 0): string + { + return match (true) { + $x < 0 => "\e[" . (-$x) . 'D', + $x > 0 => "\e[{$x}C", + default => '', + } . match (true) { + $y < 0 => "\e[" . (-$y) . 'A', + $y > 0 => "\e[{$y}B", + default => '', + }; + } + + /** * Returns ANSI sequence to clear from cursor to end of line. */ @@ -110,7 +124,57 @@ public static function reset(): string */ public static function textWidth(string $text): int { + $text = self::stripAnsi($text); return preg_match_all('/./su', $text) + preg_match_all('/[\x{1F300}-\x{1F9FF}]/u', $text); // emoji are 2-wide } + + + /** + * Pads text to specified display width. + * @param STR_PAD_LEFT|STR_PAD_RIGHT|STR_PAD_BOTH $type + */ + public static function pad( + string $text, + int $width, + string $char = ' ', + int $type = STR_PAD_RIGHT, + ): string + { + $padding = $width - self::textWidth($text); + if ($padding <= 0) { + return $text; + } + + return match ($type) { + STR_PAD_LEFT => str_repeat($char, $padding) . $text, + STR_PAD_RIGHT => $text . str_repeat($char, $padding), + STR_PAD_BOTH => str_repeat($char, intdiv($padding, 2)) . $text . str_repeat($char, $padding - intdiv($padding, 2)), + }; + } + + + /** + * Truncates text to max display width, adding ellipsis if needed. + */ + public static function truncate(string $text, int $maxWidth, string $ellipsis = '…'): string + { + if (self::textWidth($text) <= $maxWidth) { + return $text; + } + + $maxWidth -= self::textWidth($ellipsis); + $res = ''; + $width = 0; + foreach (preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY) as $char) { + $charWidth = preg_match('/[\x{1F300}-\x{1F9FF}]/u', $char) ? 2 : 1; + if ($width + $charWidth > $maxWidth) { + break; + } + $res .= $char; + $width += $charWidth; + } + + return $res . $ellipsis; + } } diff --git a/src/Runner/Output/ConsolePrinter.php b/src/Runner/Output/ConsolePrinter.php index a7d15e41..bccc6246 100644 --- a/src/Runner/Output/ConsolePrinter.php +++ b/src/Runner/Output/ConsolePrinter.php @@ -11,10 +11,11 @@ use Tester; use Tester\Ansi; +use Tester\Environment; +use Tester\Runner\Job; use Tester\Runner\Runner; use Tester\Runner\Test; -use function sprintf, strlen; -use const DIRECTORY_SEPARATOR; +use function count, fwrite, sprintf, str_repeat, strlen; /** @@ -26,6 +27,8 @@ class ConsolePrinter implements Tester\Runner\OutputHandler public const ModeCider = 2; public const ModeLines = 3; + private const MaxDisplayedThreads = 20; + /** @var resource */ private $file; private string $buffer; @@ -35,6 +38,11 @@ class ConsolePrinter implements Tester\Runner\OutputHandler /** @var array result type => count */ private array $results; private ?string $baseDir; + private int $panelWidth = 60; + private int $panelHeight = 0; + + /** @var \WeakMap */ + private \WeakMap $startTimes; public function __construct( @@ -45,6 +53,7 @@ public function __construct( private int $mode = self::ModeDots, ) { $this->file = fopen($file ?? 'php://output', 'w') ?: throw new \RuntimeException("Cannot open file '$file' for writing."); + $this->startTimes = new \WeakMap; } @@ -55,6 +64,9 @@ public function begin(): void $this->baseDir = null; $this->results = [Test::Passed => 0, Test::Skipped => 0, Test::Failed => 0]; $this->time = -microtime(as_float: true); + if ($this->mode === self::ModeCider && $this->runner->threadCount < 2) { + $this->mode = self::ModeLines; + } fwrite($this->file, $this->runner->getInterpreter()->getShortInfo() . ' | ' . $this->runner->getInterpreter()->getCommandLine() . " | {$this->runner->threadCount} thread" . ($this->runner->threadCount > 1 ? 's' : '') . "\n\n"); @@ -91,7 +103,7 @@ public function finish(Test $test): void $this->results[$result]++; fwrite($this->file, match ($this->mode) { self::ModeDots => [Test::Passed => '.', Test::Skipped => 's', Test::Failed => Ansi::colorize('F', 'white/red')][$result], - self::ModeCider => [Test::Passed => '🍏', Test::Skipped => 's', Test::Failed => '🍎'][$result], + self::ModeCider => '', self::ModeLines => $this->generateFinishLine($test), }); @@ -108,6 +120,12 @@ public function finish(Test $test): void public function end(): void { + if ($this->panelHeight) { + fwrite($this->file, Ansi::cursorMove(y: -$this->panelHeight) + . str_repeat(Ansi::clearLine() . "\n", $this->panelHeight) + . Ansi::cursorMove(y: -$this->panelHeight)); + } + $run = array_sum($this->results); fwrite($this->file, !$this->count ? "No tests found\n" : "\n\n" . $this->buffer . "\n" @@ -160,4 +178,76 @@ private function generateFinishLine(Test $test): string $message, ); } + + + public function jobStarted(Job $job): void + { + $this->startTimes[$job] = microtime(true); + } + + + /** + * @param Job[] $running + */ + public function tick(array $running): void + { + if ($this->mode !== self::ModeCider) { + return; + } + + // Move cursor up to overwrite previous output + if ($this->panelHeight) { + fwrite($this->file, Ansi::cursorMove(y: -$this->panelHeight)); + } + + $lines = []; + + // Header with progress bar + $barWidth = $this->panelWidth - 12; + $filled = (int) round($barWidth * ($this->runner->getFinishedCount() / $this->runner->getJobCount())); + $lines[] = '╭' . Ansi::pad(' ' . str_repeat('█', $filled) . str_repeat('░', $barWidth - $filled) . ' ', $this->panelWidth - 2, '─', STR_PAD_BOTH) . '╮'; + + $threadJobs = []; + foreach ($running as $job) { + $threadJobs[(int) $job->getEnvironmentVariable(Environment::VariableThread)] = $job; + } + + // Thread lines + $numWidth = strlen((string) $this->runner->threadCount); + $displayCount = min($this->runner->threadCount, self::MaxDisplayedThreads); + + for ($t = 1; $t <= $displayCount; $t++) { + if (isset($threadJobs[$t])) { + $job = $threadJobs[$t]; + $name = basename($job->getTest()->getFile()); + $time = sprintf('%0.1fs', microtime(true) - ($this->startTimes[$job] ?? microtime(true))); + $nameWidth = $this->panelWidth - $numWidth - strlen($time) - 7; + $name = Ansi::pad(Ansi::truncate($name, $nameWidth), $nameWidth); + $line = Ansi::colorize(sprintf("%{$numWidth}d:", $t), 'lime') . " $name " . Ansi::colorize($time, 'yellow'); + } else { + $line = Ansi::pad(Ansi::colorize(sprintf("%{$numWidth}d: -", $t), 'gray'), $this->panelWidth - 4); + } + $lines[] = '│ ' . $line . ' │'; + } + + if ($this->runner->threadCount > self::MaxDisplayedThreads) { + $more = $this->runner->threadCount - self::MaxDisplayedThreads; + $ellipsis = Ansi::colorize("… and $more more", 'gray'); + $lines[] = '│' . Ansi::pad($ellipsis, $this->panelWidth - 2) . '│'; + } + + // Footer: (85 tests, 🍏×74 🍎×2, 9.0s) + $summary = "($this->count tests, " + . ($this->results[Test::Passed] ? "🍏×{$this->results[Test::Passed]}" : '') + . ($this->results[Test::Failed] ? " 🍎×{$this->results[Test::Failed]}" : '') + . ', ' . sprintf('%0.1fs', $this->time + microtime(true)) . ')'; + $lines[] = '╰' . Ansi::pad($summary, $this->panelWidth - 2, '─', STR_PAD_BOTH) . '╯'; + + foreach ($lines as $line) { + fwrite($this->file, "\r" . $line . Ansi::clearLine() . "\n"); + } + fflush($this->file); + + $this->panelHeight = count($lines); + } } diff --git a/src/Runner/OutputHandler.php b/src/Runner/OutputHandler.php index 3aef8365..c45de0c4 100644 --- a/src/Runner/OutputHandler.php +++ b/src/Runner/OutputHandler.php @@ -12,6 +12,8 @@ /** * Runner output. + * @method void jobStarted(Job $job) called when a job starts running + * @method void tick(Job[] $running) called periodically during test execution */ interface OutputHandler { diff --git a/src/Runner/Runner.php b/src/Runner/Runner.php index e6f2b29a..00c08148 100644 --- a/src/Runner/Runner.php +++ b/src/Runner/Runner.php @@ -44,6 +44,8 @@ class Runner /** @var array test signature => result (Test::Prepared|Passed|Failed|Skipped) */ private array $lastResults = []; + private int $jobCount = 0; + private int $finishedCount = 0; public function __construct(PhpInterpreter $interpreter) @@ -95,6 +97,8 @@ public function run(): bool foreach ($this->paths as $path) { $this->findTests($path); } + $this->finishedCount = 0; + $this->jobCount = count($this->jobs); if ($this->tempDir) { usort( @@ -104,7 +108,6 @@ public function run(): bool } $threads = range(1, $this->threadCount); - $async = $this->threadCount > 1 && count($this->jobs) > 1; try { @@ -112,10 +115,21 @@ public function run(): bool while ($threads && $this->jobs) { $running[] = $job = array_shift($this->jobs); $job->setEnvironmentVariable(Environment::VariableThread, (string) array_shift($threads)); + foreach ($this->outputHandlers as $handler) { + if (method_exists($handler, 'jobStarted')) { + $handler->jobStarted($job); + } + } $job->run(async: $async); } if ($async) { + foreach ($this->outputHandlers as $handler) { + if (method_exists($handler, 'tick')) { + $handler->tick($running); + } + } + Job::waitForActivity($running); } @@ -126,6 +140,7 @@ public function run(): bool if (!$job->isRunning()) { $threads[] = $job->getEnvironmentVariable(Environment::VariableThread); + $this->finishedCount++; $this->testHandler->assess($job); unset($running[$key]); } @@ -216,6 +231,18 @@ public function getInterpreter(): PhpInterpreter } + public function getJobCount(): int + { + return $this->jobCount; + } + + + public function getFinishedCount(): int + { + return $this->finishedCount; + } + + private function getLastResult(Test $test): int { $signature = $test->getSignature(); diff --git a/tests/Framework/Ansi.pad.phpt b/tests/Framework/Ansi.pad.phpt new file mode 100644 index 00000000..99f3dee9 --- /dev/null +++ b/tests/Framework/Ansi.pad.phpt @@ -0,0 +1,37 @@ + Date: Fri, 20 Feb 2026 16:24:30 +0100 Subject: [PATCH 09/18] Add working with metrics for lines outside class/struct definitions. --- .../Generators/CloverXMLGenerator.php | 43 ++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/src/CodeCoverage/Generators/CloverXMLGenerator.php b/src/CodeCoverage/Generators/CloverXMLGenerator.php index 43de06b0..815a2c4f 100644 --- a/src/CodeCoverage/Generators/CloverXMLGenerator.php +++ b/src/CodeCoverage/Generators/CloverXMLGenerator.php @@ -79,6 +79,9 @@ protected function renderSelf(): void 'coveredConditionalCount' => 0, ]; + // Prepare metrics for lines outside class/struct definitions + $structuralLines = array_fill(1, $code->linesOfCode + 1, true); + foreach ($this->getSourceIterator() as $file) { $file = (string) $file; @@ -127,10 +130,20 @@ protected function renderSelf(): void $elClassMetrics = $elClass->appendChild($doc->createElement('metrics')); $classMetrics = $this->calculateClassMetrics($info, $coverageData); + + // mark all lines inside iterated class as non-structurals + for ($index = $info->start + 1; $index <= $info->end; $index++) { // + 1 to skip function name + unset($structuralLines[$index]); + } + self::setMetricAttributes($elClassMetrics, $classMetrics); self::appendMetrics($fileMetrics, $classMetrics); } + //plain metrics - procedural style + $structMetrics = $this->calculateStructuralMetrics($structuralLines, $coverageData); + self::appendMetrics($fileMetrics, $structMetrics); + self::setMetricAttributes($elFileMetrics, $fileMetrics); @@ -152,7 +165,6 @@ protected function renderSelf(): void self::appendMetrics($projectMetrics, $fileMetrics); } - // TODO: What about reported (covered) lines outside of class/trait definition? self::setMetricAttributes($elProjectMetrics, $projectMetrics); echo $doc->saveXML(); @@ -191,6 +203,35 @@ private function calculateClassMetrics(\stdClass $info, ?array $coverageData = n } + private function calculateStructuralMetrics(array $structuralLines, ?array $coverageData = null): \stdClass + { + $stats = (object) [ + 'statementCount' => 0, + 'coveredStatementCount' => 0, + 'elementCount' => null, + 'coveredElementCount' => null, + ]; + + if ($coverageData === null) { // Never loaded file should return empty stats + return $stats; + } + + foreach ($structuralLines as $line => $val) { + if (isset($coverageData[$line]) && $coverageData[$line] !== self::LineDead) { + $stats->statementCount++; + if ($coverageData[$line] > 0) { + $stats->coveredStatementCount++; + } + } + } + + $stats->elementCount = $stats->statementCount; + $stats->coveredElementCount = $stats->coveredStatementCount; + + return $stats; + } + + /** * @param ?array $coverageData line number => coverage count * @return array{int, int} [line count, covered line count] From b207045aaea5288be609d8f57596bcf42b65ff7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Km=C3=ADnek?= Date: Fri, 20 Feb 2026 16:24:44 +0100 Subject: [PATCH 10/18] Update expected test results. Tested file contain 2 outside lines which affects project metrics --- tests/CodeCoverage/CloverXMLGenerator.expected.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/CodeCoverage/CloverXMLGenerator.expected.xml b/tests/CodeCoverage/CloverXMLGenerator.expected.xml index 6186653b..64bfb1c6 100644 --- a/tests/CodeCoverage/CloverXMLGenerator.expected.xml +++ b/tests/CodeCoverage/CloverXMLGenerator.expected.xml @@ -1,9 +1,9 @@ - + - + From f11b21c18ecb90c5ea17e64e17680801ec901fc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Km=C3=ADnek?= Date: Fri, 20 Feb 2026 16:37:45 +0100 Subject: [PATCH 11/18] Enable dev stability of nette/phpstan-rules --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 5f7235a4..340338a5 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ "ext-simplexml": "*", "phpstan/phpstan": "^2.1@stable", "phpstan/extension-installer": "^1.4@stable", - "nette/phpstan-rules": "^1.0" + "nette/phpstan-rules": "^1.0@dev" }, "autoload": { "classmap": ["src/"], From c367ca2d3754e113ebe0dc9de855ad4fa436aac9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Km=C3=ADnek?= Date: Fri, 20 Feb 2026 16:38:04 +0100 Subject: [PATCH 12/18] Fix wrong placement + phpDoc --- src/CodeCoverage/Generators/CloverXMLGenerator.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/CodeCoverage/Generators/CloverXMLGenerator.php b/src/CodeCoverage/Generators/CloverXMLGenerator.php index 815a2c4f..20106f72 100644 --- a/src/CodeCoverage/Generators/CloverXMLGenerator.php +++ b/src/CodeCoverage/Generators/CloverXMLGenerator.php @@ -79,9 +79,6 @@ protected function renderSelf(): void 'coveredConditionalCount' => 0, ]; - // Prepare metrics for lines outside class/struct definitions - $structuralLines = array_fill(1, $code->linesOfCode + 1, true); - foreach ($this->getSourceIterator() as $file) { $file = (string) $file; @@ -119,6 +116,9 @@ protected function renderSelf(): void 'coveredConditionalCount' => 0, ]; + // Prepare metrics for lines outside class/struct definitions + $structuralLines = array_fill(1, $code->linesOfCode + 1, true); + foreach (array_merge($code->classes, $code->traits) as $name => $info) { // TODO: interfaces? $elClass = $elFile->appendChild($doc->createElement('class')); if (($tmp = strrpos($name, '\\')) === false) { @@ -203,6 +203,10 @@ private function calculateClassMetrics(\stdClass $info, ?array $coverageData = n } + /** + * @param array $structuralLines line number => covered flag + * @param ?array $coverageData line number => coverage count + */ private function calculateStructuralMetrics(array $structuralLines, ?array $coverageData = null): \stdClass { $stats = (object) [ From 2b1f916ad2c6f1915a1ceb352301e20e82844b12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Km=C3=ADnek?= Date: Fri, 20 Feb 2026 16:40:09 +0100 Subject: [PATCH 13/18] Fix coding style --- tests/Framework/functions.setUp.tearDown.phpt | 98 +++++++++---------- 1 file changed, 49 insertions(+), 49 deletions(-) diff --git a/tests/Framework/functions.setUp.tearDown.phpt b/tests/Framework/functions.setUp.tearDown.phpt index 692d30db..68daee44 100644 --- a/tests/Framework/functions.setUp.tearDown.phpt +++ b/tests/Framework/functions.setUp.tearDown.phpt @@ -1,49 +1,49 @@ - Assert::true(true)); -Assert::same(1, $setUpCalled); -Assert::same(1, $tearDownCalled); - - -// Test that both setUp and tearDown are called even when test fails -Assert::exception( - function () use (&$setUpCalled, &$tearDownCalled) { - test('', fn() => throw new RuntimeException('Test failed')); - }, - RuntimeException::class, - 'Test failed', -); -Assert::same(2, $setUpCalled); // setUp should have been called -Assert::same(2, $tearDownCalled); // tearDown should have been called despite the failure - - -// Test that setUp and tearDown are called after testException() -testException('testException with setUp/tearDown', fn() => throw new Exception('Expected'), Exception::class); -Assert::same(3, $setUpCalled); -Assert::same(3, $tearDownCalled); - - -// Test that setUp and tearDown are called after testNoError() -testNoError('testNoError with setUp/tearDown', fn() => Assert::true(true)); -Assert::same(4, $setUpCalled); -Assert::same(4, $tearDownCalled); + Assert::true(true)); +Assert::same(1, $setUpCalled); +Assert::same(1, $tearDownCalled); + + +// Test that both setUp and tearDown are called even when test fails +Assert::exception( + function () use (&$setUpCalled, &$tearDownCalled) { + test('', fn() => throw new RuntimeException('Test failed')); + }, + RuntimeException::class, + 'Test failed', +); +Assert::same(2, $setUpCalled); // setUp should have been called +Assert::same(2, $tearDownCalled); // tearDown should have been called despite the failure + + +// Test that setUp and tearDown are called after testException() +testException('testException with setUp/tearDown', fn() => throw new Exception('Expected'), Exception::class); +Assert::same(3, $setUpCalled); +Assert::same(3, $tearDownCalled); + + +// Test that setUp and tearDown are called after testNoError() +testNoError('testNoError with setUp/tearDown', fn() => Assert::true(true)); +Assert::same(4, $setUpCalled); +Assert::same(4, $tearDownCalled); From a5baf2002b6050f59f2c56aab3934bda10c40898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Km=C3=ADnek?= Date: Fri, 20 Feb 2026 17:08:06 +0100 Subject: [PATCH 14/18] Treat PHPDBG differently as it does not support the -r parameter - only possible repacement is -s parameter --- tests/Runner/PhpInterpreter.phpt | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/Runner/PhpInterpreter.phpt b/tests/Runner/PhpInterpreter.phpt index fb108a83..68eb9c40 100644 --- a/tests/Runner/PhpInterpreter.phpt +++ b/tests/Runner/PhpInterpreter.phpt @@ -37,6 +37,22 @@ Assert::count($count, $engines); // createInterpreter() uses same php.ini as parent if (!$interpreter->isCgi()) { - $output = shell_exec($interpreter->withArguments(['-r echo php_ini_loaded_file();'])->getCommandLine()); + if (defined('PHPDBG_VERSION')) { + // PHPDBG does not support -r for inline code; use -s '' to read from stdin instead + $proc = proc_open( + $interpreter->withArguments(['-s', ''])->getCommandLine(), + [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']], + $pipes, + null, + null, + ['bypass_shell' => true], + ); + fwrite($pipes[0], 'withArguments(['-r echo php_ini_loaded_file();'])->getCommandLine()); + } Assert::same(php_ini_loaded_file(), $output); } From 6cdf72ce6a2156db921f27f9919eb3f84410c7a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Km=C3=ADnek?= Date: Sun, 22 Feb 2026 15:06:18 +0100 Subject: [PATCH 15/18] PHPDBG outputs stderro to stdout - fix error in PHPDBG source code --- tests/Runner/Job.phpt | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/Runner/Job.phpt b/tests/Runner/Job.phpt index 7010ad47..3625b7ce 100644 --- a/tests/Runner/Job.phpt +++ b/tests/Runner/Job.phpt @@ -20,8 +20,15 @@ test('appending arguments to Test', function () { Assert::same($test, $job->getTest()); Assert::same(231, $job->getExitCode()); - Assert::same('Args: one, --two=1, three, --two=2+stdout', $job->getTest()->stdout); - Assert::same('+stderr1+stderr2', $job->getTest()->stderr); + // PHPDBG has a bug where php://stderr writes are always routed to stdout (fd 1), + // so stderr content appears interleaved in stdout in the order it was written + if (defined('PHPDBG_VERSION')) { + Assert::same('Args: one, --two=1, three, --two=2+stderr1+stdout+stderr2', $job->getTest()->stdout); + Assert::same('', $job->getTest()->stderr); + } else { + Assert::same('Args: one, --two=1, three, --two=2+stdout', $job->getTest()->stdout); + Assert::same('+stderr1+stderr2', $job->getTest()->stderr); + } Assert::type('float', $job->getDuration()); if (PHP_SAPI !== 'cli') { From b74a1f5366bae3cbf1eeeb7199d1d79b0f5166b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Km=C3=ADnek?= Date: Sun, 22 Feb 2026 15:09:16 +0100 Subject: [PATCH 16/18] Skip following tests due to bugs in PHPDBG --- tests/Runner/Runner.edge.phpt | 5 +++++ tests/RunnerOutput/OutputHandlers.phpt | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/tests/Runner/Runner.edge.phpt b/tests/Runner/Runner.edge.phpt index b2840cdc..7e059bc3 100644 --- a/tests/Runner/Runner.edge.phpt +++ b/tests/Runner/Runner.edge.phpt @@ -44,6 +44,11 @@ $runner->paths[] = __DIR__ . '/edge/*.phptx'; $runner->outputHandlers[] = $logger = new Logger; $runner->run(); +// PHPDBG redirects stderr to stdout, making output format verification unreliable +if (defined('PHPDBG_VERSION')) { + Tester\Environment::skip('Not compatible with PHPDBG (does not process register_shutdown_function at all, so the exit code always stays 0).'); +} + Assert::same([Test::Failed, 'Exited with error code 231 (expected 0)'], $logger->results['shutdown.exitCode.a.phptx']); Assert::same([Test::Passed, null], $logger->results['shutdown.exitCode.b.phptx']); diff --git a/tests/RunnerOutput/OutputHandlers.phpt b/tests/RunnerOutput/OutputHandlers.phpt index 16990d34..daf4b843 100644 --- a/tests/RunnerOutput/OutputHandlers.phpt +++ b/tests/RunnerOutput/OutputHandlers.phpt @@ -13,6 +13,12 @@ use Tester\Runner\Output; use Tester\Runner\Runner; require __DIR__ . '/../bootstrap.php'; + +// PHPDBG redirects stderr to stdout, making output format verification unreliable +if (defined('PHPDBG_VERSION')) { + Tester\Environment::skip('Not compatible with PHPDBG (stderr is redirected to stdout).'); +} + require __DIR__ . '/../../src/Runner/Test.php'; require __DIR__ . '/../../src/Runner/TestHandler.php'; require __DIR__ . '/../../src/Runner/Runner.php'; From 3344ae0487cd2da796a526c1782da1fefe1b6f92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Km=C3=ADnek?= Date: Sun, 22 Feb 2026 15:15:13 +0100 Subject: [PATCH 17/18] Drop support for PHP8.0, due to dropped support in nette/phpstan-rules --- .github/workflows/tests.yml | 2 +- CLAUDE.md | 4 ++-- composer.json | 2 +- readme.md | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 013fb177..14ebf54a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,7 +8,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest, macOS-latest] - php: ['8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] + php: ['8.1', '8.2', '8.3', '8.4', '8.5'] fail-fast: false diff --git a/CLAUDE.md b/CLAUDE.md index 79cc30ff..5a9da8f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Nette Tester is a lightweight, standalone PHP testing framework designed for simplicity, speed, and process isolation. It runs tests in parallel by default (8 threads) and supports code coverage through Xdebug, PCOV, or PHPDBG. **Key characteristics:** -- Zero external dependencies (pure PHP 8.0+) +- Zero external dependencies (pure PHP 8.1+) - Each test runs in a completely isolated PHP process - Annotation-driven test configuration - Self-hosting (uses itself for testing) @@ -864,7 +864,7 @@ src/tester tests/NamespaceOne GitHub Actions workflow tests across: - 3 operating systems (Ubuntu, Windows, macOS) -- 6 PHP versions (8.0 - 8.5) +- 5 PHP versions (8.1 - 8.5) - 18 total combinations This extensive matrix ensures compatibility across all supported environments. diff --git a/composer.json b/composer.json index 340338a5..8ed0d4cd 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,7 @@ } ], "require": { - "php": "8.0 - 8.5" + "php": "8.1 - 8.5" }, "require-dev": { "ext-simplexml": "*", diff --git a/readme.md b/readme.md index 678ed2f7..312256d6 100644 --- a/readme.md +++ b/readme.md @@ -40,7 +40,7 @@ composer require nette/tester --dev Alternatively, you can download the [tester.phar](https://github.com/nette/tester/releases) file. -Nette Tester 2.6 is compatible with PHP 8.0 to 8.5. Collecting and processing code coverage information depends on Xdebug or PCOV extension, or PHPDBG SAPI. +Nette Tester 2.6 is compatible with PHP 8.1 to 8.5. Collecting and processing code coverage information depends on Xdebug or PCOV extension, or PHPDBG SAPI.   From 8859839ce1ff49853027f5eaeb789e2fbb7f3426 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Km=C3=ADnek?= Date: Sun, 22 Feb 2026 15:19:16 +0100 Subject: [PATCH 18/18] Fix coding style --- src/Framework/FileMock.php | 4 ++-- tests/Framework/FileMutator.errors.phpt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Framework/FileMock.php b/src/Framework/FileMock.php index 8b139e82..d106ba91 100644 --- a/src/Framework/FileMock.php +++ b/src/Framework/FileMock.php @@ -168,7 +168,7 @@ public function stream_set_option(int $option, int $arg1, int $arg2): bool /** @return array */ public function stream_stat(): array { - return ['mode' => 0100666, 'size' => strlen($this->content)]; + return ['mode' => 0o100666, 'size' => strlen($this->content)]; } @@ -176,7 +176,7 @@ public function stream_stat(): array public function url_stat(string $path, int $flags): array|false { return isset(self::$files[$path]) - ? ['mode' => 0100666, 'size' => strlen(self::$files[$path])] + ? ['mode' => 0o100666, 'size' => strlen(self::$files[$path])] : false; } diff --git a/tests/Framework/FileMutator.errors.phpt b/tests/Framework/FileMutator.errors.phpt index e70ed858..45a8f059 100644 --- a/tests/Framework/FileMutator.errors.phpt +++ b/tests/Framework/FileMutator.errors.phpt @@ -10,7 +10,7 @@ require __DIR__ . '/../bootstrap.php'; Tester\Environment::bypassFinals(); Assert::error( - fn() => chmod('unknown', 0777), + fn() => chmod('unknown', 0o777), E_WARNING, );