Test.php

PHP

Path: src/Test.php

<?php

namespace mini;

use Composer\Autoload\ClassLoader;
use ReflectionClass;

/**
 * Base class for structured tests
 *
 * Usage:
 *   $test = new class extends mini\Test {
 *       public function testSomething(): void {
 *           $this->assertSame($expected, $actual);
 *       }
 *   };
 *   exit($test->run());
 *
 * Test methods must be public and start with "test".
 * Method names are converted from camelCase to readable output:
 *   testSingletonReturnsSameInstance → "Singleton returns same instance"
 */
abstract class Test
{
    private array $logs = [];
    private ?string $expectedExceptionClass = null;

    // Output state
    private bool $isTty;
    private bool $verbose;
    private int $passed = 0;
    private int $failed = 0;
    private ?string $currentTestName = null;
    private string $normal = '';
    private string $white = '';
    private string $green = '';
    private string $red = '';
    private float $startTime = 0;
    private string $indent = '';
    /** @var resource|null File descriptor 3 for reporting to test runner */
    private $runnerPipe = null;

    /**
     * Run a test file in a subprocess
     *
     * @return array{exitCode: int, info: ?array}
     */
    public static function runTestFile(string $path): array
    {
        $depth = ((int) getenv('MINI_TEST_RUNNER')) + 1;

        // Pass env to child without modifying parent's environment
        $env = getenv();
        $env['MINI_TEST_RUNNER'] = (string) $depth;

        $process = proc_open(
            ['php', '-d', 'zend.assertions=1', '-d', 'assert.exception=1', $path],
            [STDIN, STDOUT, STDERR, 3 => ['pipe', 'w']],
            $pipes,
            null,
            $env
        );

        $info = null;
        if (is_resource($process)) {
            $json = stream_get_contents($pipes[3]);
            fclose($pipes[3]);
            if ($json) {
                $info = json_decode($json, true);
            }
            $exitCode = proc_close($process);
        } else {
            $exitCode = 1;
        }

        return ['exitCode' => $exitCode, 'info' => $info];
    }

    private function getIndentString(): string
    {
        $depth = (int) getenv('MINI_TEST_RUNNER') ?: 0;
        return str_repeat('  ', 1 + $depth);
    }


    public function run(bool $exit = true): int
    {
        $this->isTty = stream_isatty(STDOUT);
        $this->indent = $this->getIndentString();
        if ($this->isTty) {
            $this->normal = "\033[0m";
            $this->green = "\033[92m";
            $this->red = "\033[91m";
            $this->white = "\033[97m";
        }
        $this->verbose = in_array('-v', $GLOBALS['argv']) || (bool) getenv('MINI_TEST_RUNNER');

        // Open fd 3 for reporting to test runner if available
        if (getenv('MINI_TEST_RUNNER')) {
            $this->runnerPipe = @fopen('php://fd/3', 'w');
        }

        // Check if test can run (e.g., required extensions)
        if (!$this->canRun()) {
            $reason = $this->skipReason();
            if ($this->verbose) {
                echo "{$this->indent}{$this->white}[{$this->normal}-{$this->white}]{$this->normal} Skipped" . ($reason ? ": $reason" : "") . "\n";
            }
            $this->reportToRunner(['skipped' => true, 'reason' => $reason]);
            if ($exit) {
                exit(0); // Skipped tests are not failures
            }
            return 0;
        }

        $this->setUp();

        if (Mini::$mini->phase->getCurrentState() !== Phase::Ready) {
            \mini\bootstrap();
        }

        foreach ($this->getTestMethods() as $method) {
            $this->expectedExceptionClass = null;
            $this->logs[$method] = [];
            $name = $this->methodToName($method);

            $this->startTest($name);

            try {
                $this->$method();

                if ($this->expectedExceptionClass !== null) {
                    throw new \AssertionError("Expected {$this->expectedExceptionClass} to be thrown");
                }

                $this->endTest(true);
            } catch (\Throwable $e) {
                if ($this->expectedExceptionClass !== null && $e instanceof $this->expectedExceptionClass) {
                    $this->endTest(true);
                } else {
                    $this->endTest(false, $e);
                }
            }

            // Show logs (only in standalone mode)
            if (!$this->verbose) {
                foreach ($this->logs[$method] as $log) {
                    echo "  → $log\n";
                }
            }
        }

        $this->printSummary();
        $this->reportToRunner([
            'passed' => $this->passed,
            'failed' => $this->failed,
        ]);

        $exitCode = $this->failed > 0 ? 1 : 0;
        if ($exit) {
            exit($exitCode);
        }
        return $exitCode;
    }

    /**
     * Write structured data to the test runner via fd 3
     */
    private function reportToRunner(array $data): void
    {
        if ($this->runnerPipe) {
            fwrite($this->runnerPipe, json_encode($data) . "\n");
        }
    }

    // ─────────────────────────────────────────────────────────────────────────
    // Output methods
    // ─────────────────────────────────────────────────────────────────────────

    private function startTest(string $name): void
    {
        $this->startTime = microtime(true);
        $this->currentTestName = $name;

        if ($this->verbose) {
            if ($this->isTty) {
                // Show name, will be cleared on success
                echo "{$this->indent}  {$this->white}]{$this->normal} $name\r{$this->indent}{$this->white}[{$this->normal}";
            } else {
                $displayName = strlen($name) >= 50 ? substr($name, 0, 45) . '...' : $name;
                echo "{$this->indent}" . str_pad($displayName, 50);
            }
        }
        ob_flush();
    }

    private function endTest(bool $success, ?\Throwable $error = null): void
    {
        $name = $this->currentTestName;

        if ($success) {
            $this->passed++;
            if ($this->verbose) {
                if ($this->isTty) {
                    $time = microtime(true) - $this->startTime;
                    if ($time > 3) {
                        if ($time > 1) {
                            $timeStr = number_format($time, 3) . ' s';
                        } else {
                            $timeStr = number_format($time * 1000, 1) . 'ms';
                        }
                        echo "\r{$this->indent}{$this->white}[{$this->green}✓{$this->white}]{$this->normal} $name ({$this->red}" . $timeStr . "{$this->normal})\n";
                    } else {
                        echo "\r\033[2K"; // Clear line
                    }
                } else {
                    echo "SUCCESS\n";
                }
            }
        } else {
            $this->failed++;
            if ($this->verbose) {
                if ($this->isTty) {
                    echo "\r{$this->indent}{$this->white}[{$this->red}✗{$this->white}]{$this->normal} $name\n";
                } else {
                    echo "FAIL\n";
                }
            } else {
                echo "{$this->indent}" . $this->currentTestName . " {$this->red}FAIL{$this->normal}\n";
            }
            if ($error !== null) {
                echo rtrim($this->indentText($this->cleanup($error))) . "\n\n";
            }
        }
    }

    private function printSummary(): void
    {
        if ($this->verbose) {
            if ($this->failed > 0) {
                echo "{$this->indent}✗ {$this->failed} failed, {$this->passed} passed\n";
            }
        } else {
            if ($this->failed === 0) {
                echo "{$this->indent}✅ All {$this->passed} test(s) passed!\n";
            } else {
                $total = $this->passed + $this->failed;
                echo "{$this->indent}❌ {$this->failed} of $total test(s) failed\n";
            }
        }
    }

    private function indentText(string $text): string {
        $indent = $this->indent . '  ';
        return preg_replace('/^/m', $indent, $text);
    }

    private function cleanup(string $text): string {
        if (class_exists(ClassLoader::class)) {
            $rc = new ReflectionClass(ClassLoader::class);
            $prefixDir = dirname($rc->getFileName(), 3) . '/';
        } else {
            $prefixDir = getcwd() . '/';
        }
        return str_replace($prefixDir, '', $text);
    }

    // ─────────────────────────────────────────────────────────────────────────
    // Lifecycle
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Check if test can run (override to skip based on requirements)
     *
     * Use this to check for required extensions, PHP versions, etc.
     * Return false to skip the entire test class.
     */
    protected function canRun(): bool
    {
        return true;
    }

    /**
     * Reason for skipping (shown when canRun() returns false)
     */
    protected function skipReason(): string
    {
        return '';
    }

    protected function setUp(): void {}

    protected function log(string $message): void
    {
        if ($this->currentTestName !== null) {
            $key = array_key_last($this->logs);
            if ($key !== null) {
                $this->logs[$key][] = $message;
            }
        }
    }

    // ─────────────────────────────────────────────────────────────────────────
    // Assertions
    // ─────────────────────────────────────────────────────────────────────────

    protected function assertTrue(mixed $value, string $message = ''): void
    {
        if ($value !== true) {
            throw new \AssertionError($message ?: "Expected true, got " . $this->export($value));
        }
    }

    protected function assertFalse(mixed $value, string $message = ''): void
    {
        if ($value !== false) {
            throw new \AssertionError($message ?: "Expected false, got " . $this->export($value));
        }
    }

    protected function assertSame(mixed $expected, mixed $actual, string $message = ''): void
    {
        if ($expected !== $actual) {
            throw new \AssertionError(
                $message ?: "Expected " . $this->export($expected) . ", got " . $this->export($actual)
            );
        }
    }

    protected function assertEquals(mixed $expected, mixed $actual, string $message = ''): void
    {
        if ($expected != $actual) {
            throw new \AssertionError(
                $message ?: "Expected " . $this->export($expected) . ", got " . $this->export($actual)
            );
        }
    }

    protected function assertNull(mixed $value, string $message = ''): void
    {
        if ($value !== null) {
            throw new \AssertionError($message ?: "Expected null, got " . $this->export($value));
        }
    }

    protected function assertNotNull(mixed $value, string $message = ''): void
    {
        if ($value === null) {
            throw new \AssertionError($message ?: "Expected non-null value");
        }
    }

    protected function assertThrows(callable $fn, string $exceptionClass = \Throwable::class, string $message = ''): void
    {
        try {
            $fn();
            throw new \AssertionError($message ?: "Expected $exceptionClass to be thrown");
        } catch (\Throwable $e) {
            if (!$e instanceof $exceptionClass) {
                throw new \AssertionError(
                    $message ?: "Expected $exceptionClass, got " . get_class($e) . ": " . $e->getMessage()
                );
            }
        }
    }

    protected function assertContains(string $needle, string $haystack, string $message = ''): void
    {
        if (!str_contains($haystack, $needle)) {
            throw new \AssertionError($message ?: "String does not contain '$needle'");
        }
    }

    protected function assertCount(int $expected, array|\Countable $value, string $message = ''): void
    {
        $actual = count($value);
        if ($expected !== $actual) {
            throw new \AssertionError($message ?: "Expected count $expected, got $actual");
        }
    }

    protected function assertInstanceOf(string $class, mixed $value, string $message = ''): void
    {
        if (!$value instanceof $class) {
            $actual = is_object($value) ? get_class($value) : gettype($value);
            throw new \AssertionError($message ?: "Expected instance of $class, got $actual");
        }
    }

    protected function assertArrayHasKey(string|int $key, array|\ArrayAccess $array, string $message = ''): void
    {
        $exists = $array instanceof \ArrayAccess
            ? $array->offsetExists($key)
            : array_key_exists($key, $array);

        if (!$exists) {
            throw new \AssertionError($message ?: "Array does not have key '$key'");
        }
    }

    protected function assertIsArray(mixed $value, string $message = ''): void
    {
        if (!is_array($value)) {
            throw new \AssertionError($message ?: "Expected array, got " . gettype($value));
        }
    }

    protected function assertIsObject(mixed $value, string $message = ''): void
    {
        if (!is_object($value)) {
            throw new \AssertionError($message ?: "Expected object, got " . gettype($value));
        }
    }

    protected function assertJson(string $value, string $message = ''): void
    {
        json_decode($value);
        if (json_last_error() !== JSON_ERROR_NONE) {
            throw new \AssertionError($message ?: "Invalid JSON: " . json_last_error_msg());
        }
    }

    protected function assertStringContainsString(string $needle, string $haystack, string $message = ''): void
    {
        if (!str_contains($haystack, $needle)) {
            throw new \AssertionError($message ?: "String does not contain '$needle'");
        }
    }

    protected function assertStringNotContainsString(string $needle, string $haystack, string $message = ''): void
    {
        if (str_contains($haystack, $needle)) {
            throw new \AssertionError($message ?: "String unexpectedly contains '$needle'");
        }
    }

    protected function assertStringStartsWith(string $prefix, string $string, string $message = ''): void
    {
        if (!str_starts_with($string, $prefix)) {
            throw new \AssertionError($message ?: "String does not start with '$prefix'");
        }
    }

    protected function assertStringEndsWith(string $suffix, string $string, string $message = ''): void
    {
        if (!str_ends_with($string, $suffix)) {
            throw new \AssertionError($message ?: "String does not end with '$suffix'");
        }
    }

    protected function assertNotSame(mixed $expected, mixed $actual, string $message = ''): void
    {
        if ($expected === $actual) {
            throw new \AssertionError($message ?: "Values are unexpectedly the same");
        }
    }

    protected function assertNotEmpty(mixed $value, string $message = ''): void
    {
        if (empty($value)) {
            throw new \AssertionError($message ?: "Value is unexpectedly empty");
        }
    }

    protected function assertGreaterThan(mixed $expected, mixed $actual, string $message = ''): void
    {
        if ($actual <= $expected) {
            throw new \AssertionError($message ?: "Expected value greater than $expected, got $actual");
        }
    }

    protected function assertLessThan(mixed $expected, mixed $actual, string $message = ''): void
    {
        if ($actual >= $expected) {
            throw new \AssertionError($message ?: "Expected value less than $expected, got $actual");
        }
    }

    protected function assertLessThanOrEqual(mixed $expected, mixed $actual, string $message = ''): void
    {
        if ($actual > $expected) {
            throw new \AssertionError($message ?: "Expected value <= $expected, got $actual");
        }
    }

    protected function assertGreaterThanOrEqual(mixed $expected, mixed $actual, string $message = ''): void
    {
        if ($actual < $expected) {
            throw new \AssertionError($message ?: "Expected value >= $expected, got $actual");
        }
    }

    protected function expectException(string $exceptionClass): void
    {
        $this->expectedExceptionClass = $exceptionClass;
    }

    protected function fail(string $message = 'Test failed'): void
    {
        throw new \AssertionError($message);
    }

    // ─────────────────────────────────────────────────────────────────────────
    // Helpers
    // ─────────────────────────────────────────────────────────────────────────

    private function getTestMethods(): array
    {
        $methods = [];
        $reflection = new \ReflectionClass($this);

        foreach ($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
            if (str_starts_with($method->getName(), 'test')) {
                $methods[] = $method->getName();
            }
        }

        return $methods;
    }

    private function methodToName(string $method): string
    {
        $name = substr($method, 4);
        $name = preg_replace('/([a-z])([A-Z])/', '$1 $2', $name);
        return ucfirst(strtolower($name));
    }

    private function export(mixed $value): string
    {
        if (is_object($value)) {
            return get_class($value) . ' object';
        }
        return var_export($value, true);
    }
}