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/.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..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 @@ -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/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..5a9da8f1 --- /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.1+) +- 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) +- 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 d81e4a35..8ed0d4cd 100644 --- a/composer.json +++ b/composer.json @@ -19,11 +19,13 @@ } ], "require": { - "php": "8.0 - 8.5" + "php": "8.1 - 8.5" }, "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@dev" }, "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-baseline.neon b/phpstan-baseline.neon index 1811062b..b2055c78 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,70 +19,64 @@ parameters: path: src/Framework/DataProvider.php - - message: '#^Method Tester\\DomQuery\:\:closest\(\) should return Tester\\DomQuery\|null but returns SimpleXMLElement\|null\.$#' - identifier: return.type + message: '#^Parameter \#2 \$file of static method Tester\\DataProvider\:\:parseAnnotation\(\) expects string, array\\|string given\.$#' + identifier: argument.type count: 1 - path: src/Framework/DomQuery.php + path: src/Framework/Environment.php - - message: '#^Method Tester\\DomQuery\:\:find\(\) should return array\ but returns array\\.$#' - identifier: return.type + message: '#^Variable \$ref in isset\(\) always exists and is not nullable\.$#' + identifier: isset.variable count: 1 - path: src/Framework/DomQuery.php + path: src/Framework/Helpers.php - - message: '#^Method Tester\\DomQuery\:\:fromHtml\(\) should return Tester\\DomQuery but returns SimpleXMLElement\|null\.$#' - identifier: return.type + message: '#^Anonymous function has an unused use \$runner\.$#' + identifier: closure.unusedUse count: 1 - path: src/Framework/DomQuery.php + path: src/Runner/CliTester.php - - message: '#^Method Tester\\DomQuery\:\:fromXml\(\) should return Tester\\DomQuery but returns SimpleXMLElement\|false\.$#' - identifier: return.type + message: '#^Closure invoked with 1 parameter, 0 required\.$#' + identifier: arguments.count count: 1 - path: src/Framework/DomQuery.php + path: src/Runner/CliTester.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 + message: '#^While loop condition is always true\.$#' + identifier: while.alwaysTrue count: 1 - path: src/Framework/DomQuery.php - - - - message: '#^Parameter \#1 \$node of function simplexml_import_dom expects DOMNode, Dom\\Element given\.$#' - identifier: argument.type - count: 2 - path: src/Framework/DomQuery.php + path: src/Runner/CliTester.php - - message: '#^Strict comparison using \=\=\= between non\-empty\-string and '''' will always evaluate to false\.$#' - identifier: identical.alwaysFalse + message: '#^Method Tester\\Runner\\CommandLine\:\:parse\(\) should return array\ but returns array\\.$#' + identifier: return.type count: 1 - path: src/Framework/DomQuery.php + path: src/Runner/CommandLine.php - - message: '#^Variable \$ref in isset\(\) always exists and is not nullable\.$#' - identifier: isset.variable + message: '#^Offset string\|true might not exist on array\\>\.$#' + identifier: offsetAccess.notFound count: 1 - path: src/Framework/Helpers.php + path: src/Runner/CommandLine.php - - message: '#^Anonymous function has an unused use \$runner\.$#' - identifier: closure.unusedUse + message: '#^Parameter \#3 \$length of function substr expects int\|null, int\<0, max\>\|false given\.$#' + identifier: argument.type count: 1 - path: src/Runner/CliTester.php + path: src/Runner/PhpInterpreter.php - - message: '#^Closure invoked with 1 parameter, 0 required\.$#' - identifier: arguments.count + 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/CliTester.php + path: src/Runner/Runner.php - - message: '#^While loop condition is always true\.$#' - identifier: while.alwaysTrue + 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/CliTester.php + path: src/Runner/Runner.php - message: '#^If condition is always false\.$#' diff --git a/phpstan.neon b/phpstan.neon index 261116b7..13a9d75b 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,6 +1,5 @@ parameters: - level: 6 - errorFormat: raw + level: 8 paths: - src @@ -8,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/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.   diff --git a/src/CodeCoverage/Collector.php b/src/CodeCoverage/Collector.php index 974b1941..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, ]); } @@ -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(...)); }); } @@ -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 5eea9d2a..72bcc36e 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 */ @@ -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..20106f72 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]; } @@ -116,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) { @@ -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,39 @@ 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) [ + '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] @@ -201,7 +246,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/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/Framework/Assert.php b/src/Framework/Assert.php index ae95d89d..1b853ceb 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 { @@ -547,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 a438f640..44e1c5c8 100644 --- a/src/Framework/DomQuery.php +++ b/src/Framework/DomQuery.php @@ -76,14 +76,14 @@ public static function fromXml(string $xml): self /** * Returns array of elements matching CSS selector. - * @return DomQuery[] + * @return list */ 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 abe93bb3..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') { @@ -112,7 +114,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 +128,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 { @@ -193,7 +195,7 @@ public static function lock(string $name = '', string $path = ''): void /** * Returns current test annotations. - * @return array + * @return array */ public static function getTestAnnotations(): array { @@ -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/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..d106ba91 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 */ @@ -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; } @@ -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/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/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 d052aac1..86c3a1bf 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; @@ -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); @@ -102,7 +104,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(); @@ -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); } } @@ -242,7 +244,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/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 86d779a9..11d969b4 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[] */ @@ -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 257446f1..7de100ea 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; @@ -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; @@ -210,7 +211,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..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,15 +27,22 @@ 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; private float $time; private int $count; - /** @var array result type (Test::*) => count */ + /** @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,11 +103,11 @@ 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), }); - $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) { @@ -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" @@ -125,7 +143,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 @@ -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/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/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/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/Runner.php b/src/Runner/Runner.php index 85fa0307..00c08148 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; @@ -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/src/Runner/Test.php b/src/Runner/Test.php index f6d430ce..492148e4 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 { @@ -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.'); } @@ -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 74bc35d5..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; @@ -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; @@ -139,12 +138,12 @@ 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); } - /** @return Test[]|Test */ + /** @return list|Test */ private function initiateDataProvider(Test $test, string $provider): array|Test { try { @@ -164,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( @@ -174,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; @@ -231,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, - ); + )); } @@ -300,11 +299,11 @@ 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)); - $testTitle = isset($annotations[0]) + $testTitle = is_string($annotations[0] ?? null) ? preg_replace('#^TEST:\s*#i', '', $annotations[0]) : null; return [$annotations, $testTitle]; 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 @@ - + - + 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 @@ + chmod('unknown', 0777), + fn() => chmod('unknown', 0o777), E_WARNING, ); 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); 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') { 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); } 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/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()); }); 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'; 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 @@ +