diff --git a/.gitattributes b/.gitattributes index 9670e954..3ecd16d0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,7 +3,9 @@ .github export-ignore ncs.* export-ignore phpstan.neon export-ignore +CLAUDE.md export-ignore tests/ export-ignore *.sh eol=lf +src/tester eol=lf *.php* diff=php linguist-language=PHP diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..71c1ea32 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,873 @@ +# 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 -C + +# Run specific test file +src/tester tests/Framework/Assert.phpt -s -C + +# 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 `-n` flag (no php.ini loaded): +- Ensures consistent test environment +- System extensions from `/etc/php/conf.d/*.ini` are NOT loaded +- Use `-C` flag to load 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. diff --git a/src/CodeCoverage/Collector.php b/src/CodeCoverage/Collector.php index 20282f0d..eb9de3a3 100644 --- a/src/CodeCoverage/Collector.php +++ b/src/CodeCoverage/Collector.php @@ -56,7 +56,7 @@ public static function start(string $file, string $engine): void } elseif (!in_array( $engine, array_map(fn(array $engineInfo) => $engineInfo[0], self::detectEngines()), - true, + strict: true, )) { throw new \LogicException("Code coverage engine '$engine' is not supported."); } diff --git a/src/CodeCoverage/Generators/AbstractGenerator.php b/src/CodeCoverage/Generators/AbstractGenerator.php index b856e80e..d734f9d4 100644 --- a/src/CodeCoverage/Generators/AbstractGenerator.php +++ b/src/CodeCoverage/Generators/AbstractGenerator.php @@ -112,7 +112,7 @@ protected function getSourceIterator(): \Iterator return new \CallbackFilterIterator( $iterator, fn(\SplFileInfo $file): bool => $file->getBasename()[0] !== '.' // . or .. or .gitignore - && in_array($file->getExtension(), $this->acceptFiles, true), + && in_array($file->getExtension(), $this->acceptFiles, strict: true), ); } diff --git a/src/CodeCoverage/Generators/CloverXMLGenerator.php b/src/CodeCoverage/Generators/CloverXMLGenerator.php index a85038e1..bd88ec88 100644 --- a/src/CodeCoverage/Generators/CloverXMLGenerator.php +++ b/src/CodeCoverage/Generators/CloverXMLGenerator.php @@ -114,6 +114,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) { @@ -125,10 +128,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); @@ -150,7 +163,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(); @@ -188,6 +200,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; + } + + private static function analyzeMethod(\stdClass $info, ?array $coverageData = null): array { $count = 0; diff --git a/src/Framework/Assert.php b/src/Framework/Assert.php index a3289ed9..772e7c5d 100644 --- a/src/Framework/Assert.php +++ b/src/Framework/Assert.php @@ -119,7 +119,7 @@ public static function contains(mixed $needle, array|string $actual, ?string $de { self::$counter++; if (is_array($actual)) { - if (!in_array($needle, $actual, true)) { + if (!in_array($needle, $actual, strict: true)) { self::fail(self::describe('%1 should contain %2', $description), $actual, $needle); } } elseif (!is_string($needle)) { @@ -138,7 +138,7 @@ public static function notContains(mixed $needle, array|string $actual, ?string { self::$counter++; if (is_array($actual)) { - if (in_array($needle, $actual, true)) { + if (in_array($needle, $actual, strict: true)) { self::fail(self::describe('%1 should not contain %2', $description), $actual, $needle); } } elseif (!is_string($needle)) { @@ -281,7 +281,7 @@ public static function type(string|object $type, mixed $value, ?string $descript self::fail(self::describe("%1 should be $type", $description), $value); } } elseif (in_array($type, ['array', 'bool', 'callable', 'float', - 'int', 'integer', 'null', 'object', 'resource', 'scalar', 'string', ], true) + 'int', 'integer', 'null', 'object', 'resource', 'scalar', 'string', ], strict: true) ) { if (!("is_$type")($value)) { self::fail(self::describe(get_debug_type($value) . " should be $type", $description)); @@ -577,7 +577,7 @@ public static function expandMatchingPatterns(string $pattern, string $actual): } foreach (['%A%', '%A?%'] as $greedyPattern) { - if (substr($patternX, -strlen($greedyPattern)) === $greedyPattern) { + if (str_ends_with($patternX, $greedyPattern)) { $patternX = substr($patternX, 0, -strlen($greedyPattern)); $patternY = "$patternX%A?%"; $patternZ = $greedyPattern . $patternZ; diff --git a/src/Framework/DomQuery.php b/src/Framework/DomQuery.php index c1ebd654..a438f640 100644 --- a/src/Framework/DomQuery.php +++ b/src/Framework/DomQuery.php @@ -23,7 +23,7 @@ class DomQuery extends \SimpleXMLElement */ public static function fromHtml(string $html): self { - $old = libxml_use_internal_errors(true); + $old = libxml_use_internal_errors(use_errors: true); libxml_clear_errors(); if (PHP_VERSION_ID < 80400) { diff --git a/src/Framework/Environment.php b/src/Framework/Environment.php index 498fa703..9d20b9fd 100644 --- a/src/Framework/Environment.php +++ b/src/Framework/Environment.php @@ -116,7 +116,7 @@ public static function setupErrors(): void set_error_handler(function (int $severity, string $message, string $file, int $line): ?bool { if ( - in_array($severity, [E_RECOVERABLE_ERROR, E_USER_ERROR], true) + in_array($severity, [E_RECOVERABLE_ERROR, E_USER_ERROR], strict: true) || ($severity & error_reporting()) === $severity ) { self::handleException(new \ErrorException($message, 0, $severity, $file, $line)); @@ -130,7 +130,7 @@ public static function setupErrors(): void $error = error_get_last(); register_shutdown_function(function () use ($error): void { - if (in_array($error['type'] ?? null, [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE], true)) { + if (in_array($error['type'] ?? null, [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE], strict: true)) { if (($error['type'] & error_reporting()) !== $error['type']) { // show fatal errors hidden by @shutup self::print("\n" . Dumper::color('white/red', "Fatal error: $error[message] in $error[file] on line $error[line]")); } diff --git a/src/Framework/FileMock.php b/src/Framework/FileMock.php index 6df2cc6a..2b043466 100644 --- a/src/Framework/FileMock.php +++ b/src/Framework/FileMock.php @@ -50,7 +50,7 @@ public static function create(string $content = '', ?string $extension = null): public static function register(): void { - if (!in_array(self::Protocol, stream_get_wrappers(), true)) { + if (!in_array(self::Protocol, stream_get_wrappers(), strict: true)) { stream_wrapper_register(self::Protocol, self::class); } } @@ -89,7 +89,7 @@ public function stream_open(string $path, string $mode): bool } - public function stream_read(int $length) + public function stream_read(int $length): false|string { if (!$this->isReadable) { return false; @@ -102,7 +102,7 @@ public function stream_read(int $length) } - public function stream_write(string $data) + public function stream_write(string $data): false|int { if (!$this->isWritable) { return false; @@ -171,7 +171,7 @@ public function stream_stat(): array } - public function url_stat(string $path, int $flags) + public function url_stat(string $path, int $flags): array|false { return isset(self::$files[$path]) ? ['mode' => 0100666, 'size' => strlen(self::$files[$path])] @@ -187,12 +187,10 @@ public function stream_lock(int $operation): bool public function stream_metadata(string $path, int $option, $value): bool { - switch ($option) { - case STREAM_META_TOUCH: - return true; - } - - return false; + return match ($option) { + STREAM_META_TOUCH => true, + default => false, + }; } diff --git a/src/Framework/FileMutator.php b/src/Framework/FileMutator.php index 0fa86867..03133d2d 100644 --- a/src/Framework/FileMutator.php +++ b/src/Framework/FileMutator.php @@ -125,20 +125,13 @@ public function stream_lock(int $operation): bool public function stream_metadata(string $path, int $option, $value): bool { - switch ($option) { - case STREAM_META_TOUCH: - return $this->native('touch', $path, $value[0] ?? time(), $value[1] ?? time()); - case STREAM_META_OWNER_NAME: - case STREAM_META_OWNER: - return $this->native('chown', $path, $value); - case STREAM_META_GROUP_NAME: - case STREAM_META_GROUP: - return $this->native('chgrp', $path, $value); - case STREAM_META_ACCESS: - return $this->native('chmod', $path, $value); - } - - return false; + return match ($option) { + STREAM_META_TOUCH => $this->native('touch', $path, $value[0] ?? time(), $value[1] ?? time()), + STREAM_META_OWNER_NAME, STREAM_META_OWNER => $this->native('chown', $path, $value), + STREAM_META_GROUP_NAME, STREAM_META_GROUP => $this->native('chgrp', $path, $value), + STREAM_META_ACCESS => $this->native('chmod', $path, $value), + default => false, + }; } @@ -168,7 +161,7 @@ public function stream_open(string $path, string $mode, int $options, ?string &$ } - public function stream_read(int $count) + public function stream_read(int $count): string|false { return fread($this->handle, $count); } @@ -186,7 +179,7 @@ public function stream_set_option(int $option, int $arg1, int $arg2): bool } - public function stream_stat() + public function stream_stat(): array|false { return fstat($this->handle); } @@ -204,7 +197,7 @@ public function stream_truncate(int $newSize): bool } - public function stream_write(string $data) + public function stream_write(string $data): int|false { return fwrite($this->handle, $data); } diff --git a/src/Framework/Helpers.php b/src/Framework/Helpers.php index 821ff000..143e44f6 100644 --- a/src/Framework/Helpers.php +++ b/src/Framework/Helpers.php @@ -108,9 +108,9 @@ public static function parseDocComment(string $s): array */ public static function errorTypeToString(int $type): string { - $consts = get_defined_constants(true); + $consts = get_defined_constants(categorize: true); foreach ($consts['Core'] as $name => $val) { - if ($type === $val && substr($name, 0, 2) === 'E_') { + if ($type === $val && str_starts_with($name, 'E_')) { return $name; } } diff --git a/src/Framework/TestCase.php b/src/Framework/TestCase.php index fe2d1d8c..5c6a90d9 100644 --- a/src/Framework/TestCase.php +++ b/src/Framework/TestCase.php @@ -192,7 +192,7 @@ private function silentTearDown(): void set_error_handler(fn() => null); try { $this->tearDown(); - } catch (\Throwable $e) { + } catch (\Throwable) { } restore_error_handler(); diff --git a/src/Framework/functions.php b/src/Framework/functions.php index 653f458b..6d038529 100644 --- a/src/Framework/functions.php +++ b/src/Framework/functions.php @@ -31,10 +31,11 @@ function test(string $description, Closure $closure): void Environment::print(Dumper::color('red', '×') . " $description\n\n"); } throw $e; - } - if ($fn = (new ReflectionFunction('tearDown'))->getStaticVariables()['fn']) { - $fn(); + } finally { + if ($fn = (new ReflectionFunction('tearDown'))->getStaticVariables()['fn']) { + $fn(); + } } } @@ -50,18 +51,20 @@ function testException( $code = null, ): void { - try { - Assert::exception($function, $class, $message, $code); - if ($description !== '') { - Environment::print(Dumper::color('lime', '√') . " $description"); - } + test($description, fn() => Assert::exception($function, $class, $message, $code)); +} - } catch (Throwable $e) { - if ($description !== '') { - Environment::print(Dumper::color('red', '×') . " $description\n\n"); - } - throw $e; + +/** + * Tests that a provided closure does not generate any errors or exceptions. + */ +function testNoError(string $description, Closure $function): void +{ + if (($count = func_num_args()) > 2) { + throw new Exception(__FUNCTION__ . "() expects 2 parameters, $count given."); } + + test($description, fn() => Assert::noError($function)); } diff --git a/src/Runner/CliTester.php b/src/Runner/CliTester.php index 0692f4d8..f7c7f7a0 100644 --- a/src/Runner/CliTester.php +++ b/src/Runner/CliTester.php @@ -39,7 +39,7 @@ public function run(): int $this->debugMode = (bool) $this->options['--debug']; if (isset($this->options['--colors'])) { Environment::$useColors = (bool) $this->options['--colors']; - } elseif (in_array($this->stdoutFormat, ['tap', 'junit'], true)) { + } elseif (in_array($this->stdoutFormat, ['tap', 'junit'], strict: true)) { Environment::$useColors = false; } @@ -136,15 +136,15 @@ private function loadOptions(): CommandLine '--cider' => [], '--coverage-src' => [CommandLine::RealPath => true, CommandLine::Repeatable => true], '-o' => [CommandLine::Repeatable => true, CommandLine::Normalizer => function ($arg) use (&$outputFiles) { - [$format, $file] = explode(':', $arg, 2) + [1 => null]; + [$format, $file] = explode(':', $arg, 2) + [1 => '']; if (isset($outputFiles[$file])) { throw new \Exception( - $file === null + $file === '' ? 'Option -o without file name parameter can be used only once.' : "Cannot specify output by -o into file '$file' more then once.", ); - } elseif ($file === null) { + } elseif ($file === '') { $this->stdoutFormat = $format; } @@ -196,7 +196,7 @@ private function createPhpInterpreter(): void echo "Note: No php.ini is used.\n"; } - if (in_array($this->stdoutFormat, ['tap', 'junit'], true)) { + if (in_array($this->stdoutFormat, ['tap', 'junit'], strict: true)) { array_push($args, '-d', 'html_errors=off'); } @@ -287,7 +287,7 @@ private function prepareCodeCoverage(Runner $runner): string private function finishCodeCoverage(string $file): void { - if (!in_array($this->stdoutFormat, ['none', 'tap', 'junit'], true)) { + if (!in_array($this->stdoutFormat, ['none', 'tap', 'junit'], strict: true)) { echo 'Generating code coverage report... '; } @@ -312,7 +312,7 @@ private function watch(Runner $runner): void $state = []; foreach ($this->options['--watch'] as $directory) { foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory)) as $file) { - if (substr($file->getExtension(), 0, 3) === 'php' && substr($file->getBasename(), 0, 1) !== '.') { + if (str_starts_with($file->getExtension(), 'php') && !str_starts_with($file->getBasename(), '.')) { $state[(string) $file] = @filemtime((string) $file); // @ file could be deleted in the meantime } } diff --git a/src/Runner/CommandLine.php b/src/Runner/CommandLine.php index 8a792c12..0e1fe10f 100644 --- a/src/Runner/CommandLine.php +++ b/src/Runner/CommandLine.php @@ -54,7 +54,7 @@ public function __construct(string $help, array $defaults = []) $opts = $this->options[$name] ?? []; $this->options[$name] = $opts + [ self::Argument => (bool) end($m[2]), - self::Optional => isset($line[2]) || (substr(end($m[2]), 0, 1) === '[') || isset($opts[self::Value]), + self::Optional => isset($line[2]) || (str_starts_with(end($m[2]), '[')) || isset($opts[self::Value]), self::Repeatable => (bool) end($m[3]), self::Enum => count($enums = explode('|', trim(end($m[2]), '<[]>'))) > 1 ? $enums : null, self::Value => $line[2] ?? null, @@ -126,7 +126,7 @@ public function parse(?array $args = null): array if ( !empty($opt[self::Enum]) - && !in_array(is_array($arg) ? reset($arg) : $arg, $opt[self::Enum], true) + && !in_array(is_array($arg) ? reset($arg) : $arg, $opt[self::Enum], strict: true) && !( $opt[self::Optional] && $arg === true diff --git a/src/Runner/Job.php b/src/Runner/Job.php index 4faf294b..1f048559 100644 --- a/src/Runner/Job.php +++ b/src/Runner/Job.php @@ -99,7 +99,7 @@ public function run(bool $async = false): void : Helpers::escapeArg($value); } - $this->duration = -microtime(true); + $this->duration = -microtime(as_float: true); $this->proc = proc_open( $this->interpreter->getCommandLine() . ' -d register_argc_argv=on ' . Helpers::escapeArg($this->test->getFile()) . ' ' . implode(' ', $args), @@ -151,7 +151,7 @@ public function isRunning(): bool return true; } - $this->duration += microtime(true); + $this->duration += microtime(as_float: true); fclose($this->stdout); if ($this->stderrFile) { diff --git a/src/Runner/Output/ConsolePrinter.php b/src/Runner/Output/ConsolePrinter.php index f81393aa..ba869d4e 100644 --- a/src/Runner/Output/ConsolePrinter.php +++ b/src/Runner/Output/ConsolePrinter.php @@ -44,7 +44,7 @@ public function __construct( ) { $this->runner = $runner; $this->displaySkipped = $displaySkipped; - $this->file = fopen($file ?: 'php://output', 'w'); + $this->file = fopen($file ?? 'php://output', 'w'); $this->symbols = match (true) { $ciderMode => [Test::Passed => '🍏', Test::Skipped => 's', Test::Failed => '🍎'], $lineMode => [Test::Passed => Dumper::color('lime', 'OK'), Test::Skipped => Dumper::color('yellow', 'SKIP'), Test::Failed => Dumper::color('white/red', 'FAIL')], @@ -63,7 +63,7 @@ public function begin(): void Test::Skipped => 0, Test::Failed => 0, ]; - $this->time = -microtime(true); + $this->time = -microtime(as_float: true); fwrite($this->file, $this->runner->getInterpreter()->getShortInfo() . ' | ' . $this->runner->getInterpreter()->getCommandLine() . " | {$this->runner->threadCount} thread" . ($this->runner->threadCount > 1 ? 's' : '') . "\n\n"); @@ -125,7 +125,7 @@ public function end(): void . ($this->results[Test::Failed] ? $this->results[Test::Failed] . ' failure' . ($this->results[Test::Failed] > 1 ? 's' : '') . ', ' : '') . ($this->results[Test::Skipped] ? $this->results[Test::Skipped] . ' skipped, ' : '') . ($this->count !== $run ? ($this->count - $run) . ' not run, ' : '') - . sprintf('%0.1f', $this->time + microtime(true)) . ' seconds)' . Dumper::color() . "\n"); + . sprintf('%0.1f', $this->time + microtime(as_float: true)) . ' seconds)' . Dumper::color() . "\n"); $this->buffer = ''; } diff --git a/src/Runner/Output/JUnitPrinter.php b/src/Runner/Output/JUnitPrinter.php index a2c9a187..f826fb9b 100644 --- a/src/Runner/Output/JUnitPrinter.php +++ b/src/Runner/Output/JUnitPrinter.php @@ -29,7 +29,7 @@ class JUnitPrinter implements Tester\Runner\OutputHandler public function __construct(?string $file = null) { - $this->file = fopen($file ?: 'php://output', 'w'); + $this->file = fopen($file ?? 'php://output', 'w'); } @@ -41,7 +41,7 @@ public function begin(): void Test::Skipped => 0, Test::Failed => 0, ]; - $this->startTime = microtime(true); + $this->startTime = microtime(as_float: true); fwrite($this->file, "\n\n"); } @@ -65,7 +65,7 @@ public function finish(Test $test): void public function end(): void { - $time = sprintf('%0.1f', microtime(true) - $this->startTime); + $time = sprintf('%0.1f', microtime(as_float: true) - $this->startTime); $output = $this->buffer; $this->buffer = "\tresults[Test::Failed]}\" skipped=\"{$this->results[Test::Skipped]}\" tests=\"" . array_sum($this->results) . "\" time=\"$time\" timestamp=\"" . @date('Y-m-d\TH:i:s') . "\">\n"; $this->buffer .= $output; diff --git a/src/Runner/Output/Logger.php b/src/Runner/Output/Logger.php index 2e4b8dd0..e858cb9a 100644 --- a/src/Runner/Output/Logger.php +++ b/src/Runner/Output/Logger.php @@ -29,7 +29,7 @@ class Logger implements Tester\Runner\OutputHandler public function __construct(Runner $runner, ?string $file = null) { $this->runner = $runner; - $this->file = fopen($file ?: 'php://output', 'w'); + $this->file = fopen($file ?? 'php://output', 'w'); } diff --git a/src/Runner/Output/TapPrinter.php b/src/Runner/Output/TapPrinter.php index aeec3e40..2a96b019 100644 --- a/src/Runner/Output/TapPrinter.php +++ b/src/Runner/Output/TapPrinter.php @@ -25,7 +25,7 @@ class TapPrinter implements Tester\Runner\OutputHandler public function __construct(?string $file = null) { - $this->file = fopen($file ?: 'php://output', 'w'); + $this->file = fopen($file ?? 'php://output', 'w'); } diff --git a/src/Runner/PhpInterpreter.php b/src/Runner/PhpInterpreter.php index 33934cb1..5d0bb0dc 100644 --- a/src/Runner/PhpInterpreter.php +++ b/src/Runner/PhpInterpreter.php @@ -127,6 +127,6 @@ public function getShortInfo(): string public function hasExtension(string $name): bool { - return in_array(strtolower($name), array_map('strtolower', $this->info->extensions), true); + return in_array(strtolower($name), array_map('strtolower', $this->info->extensions), strict: true); } } diff --git a/src/Runner/Runner.php b/src/Runner/Runner.php index 98423951..813bd049 100644 --- a/src/Runner/Runner.php +++ b/src/Runner/Runner.php @@ -143,7 +143,7 @@ private function findTests(string $path): void if (is_dir($path)) { foreach (glob(str_replace('[', '[[]', $path) . '/*', GLOB_ONLYDIR) ?: [] as $dir) { - if (in_array(basename($dir), $this->ignoreDirs, true)) { + if (in_array(basename($dir), $this->ignoreDirs, strict: true)) { continue; } diff --git a/src/Runner/TestHandler.php b/src/Runner/TestHandler.php index 003bb030..c7225833 100644 --- a/src/Runner/TestHandler.php +++ b/src/Runner/TestHandler.php @@ -23,13 +23,12 @@ class TestHandler { private const HttpOk = 200; - private Runner $runner; private ?string $tempDir = null; - public function __construct(Runner $runner) - { - $this->runner = $runner; + public function __construct( + private Runner $runner, + ) { } @@ -173,7 +172,7 @@ private function initiateMultiple(Test $test, string $count): array } - private function initiateTestCase(Test $test, $foo, PhpInterpreter $interpreter) + private function initiateTestCase(Test $test, $foo, PhpInterpreter $interpreter): Test|array { $methods = null; @@ -201,7 +200,7 @@ private function initiateTestCase(Test $test, $foo, PhpInterpreter $interpreter) $job->setTempDirectory($this->tempDir); $job->run(); - if (in_array($job->getExitCode(), [Job::CodeError, Job::CodeFail, Job::CodeSkip], true)) { + if (in_array($job->getExitCode(), [Job::CodeError, Job::CodeFail, Job::CodeSkip], strict: true)) { return $test->withResult($job->getExitCode() === Job::CodeSkip ? Test::Skipped : Test::Failed, $job->getTest()->getOutput()); } diff --git a/src/Runner/info.php b/src/Runner/info.php index bca536e2..380e5d4e 100644 --- a/src/Runner/info.php +++ b/src/Runner/info.php @@ -32,7 +32,7 @@ } foreach ([ - 'PHP binary' => $info->binary ?: '(not available)', + 'PHP binary' => $info->binary ?? '(not available)', 'PHP version' . ($isPhpDbg ? '; PHPDBG version' : '') => "$info->version ($info->sapi)" . ($isPhpDbg ? "; $info->phpDbgVersion" : ''), 'Loaded php.ini files' => count($info->iniFiles) ? implode(', ', $info->iniFiles) : '(none)', 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/DomQuery.fromHtml.84.phpt b/tests/Framework/DomQuery.fromHtml.84.phpt index 674fb25f..9e959e8e 100644 --- a/tests/Framework/DomQuery.fromHtml.84.phpt +++ b/tests/Framework/DomQuery.fromHtml.84.phpt @@ -131,7 +131,7 @@ test('handles malformed HTML gracefully', function () { test('handles HTML entities in attributes', function () { $dom = DomQuery::fromHtml('
Test
'); - Assert::true($dom->find('div')[0]->matches('[data-test="\\"quoted\\""]')); + Assert::true($dom->find('div')[0]->matches('[data-test="\"quoted\""]')); }); test('handles UTF-8', function () { diff --git a/tests/Framework/functions.setUp.tearDown.phpt b/tests/Framework/functions.setUp.tearDown.phpt new file mode 100644 index 00000000..d0d5e356 --- /dev/null +++ b/tests/Framework/functions.setUp.tearDown.phpt @@ -0,0 +1,57 @@ +