diff --git a/.gitattributes b/.gitattributes index 14e3353..b2b4f67 100644 --- a/.gitattributes +++ b/.gitattributes @@ -13,7 +13,7 @@ Dockerfile export-ignore infection.* export-ignore tests export-ignore docs export-ignore -resources/mock export-ignore +CLAUDE.local.md export-ignore *.http binary *.gpg binary diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..f37d822 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "docs/guidelines"] + path = docs/guidelines + url = https://github.com/php-internal/guidelines diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..b58eb6d --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,10 @@ +include(__DIR__ . '/src') + ->include(__FILE__) + ->build(); diff --git a/CLAUDE.local.md b/CLAUDE.local.md new file mode 100644 index 0000000..9dd4261 --- /dev/null +++ b/CLAUDE.local.md @@ -0,0 +1,50 @@ +- Always use guidelines from the `docs/guidelines` folder + +## Guidelines Index + +### ๐Ÿ“– Documentation Guidelines +**Path:** `docs/guidelines/how-to-translate-readme-docs.md` +**Value:** Standardizes documentation translation process and multilingual content management +**Key Areas:** +- Translation workflow using LLMs for documentation +- Multilanguage README pattern using ISO 639-1 codes (`README-{lang_code}.md`) +- Quality standards: preserve technical content, ensure natural language flow +- Review process for translated content +- MAPS framework for complex translations + +### ๐Ÿ–ฅ๏ธ Console Command Development +**Path:** `docs/guidelines/how-to-write-console-command.md` +**Value:** Ensures consistent CLI interface design and proper Symfony console integration +**Key Areas:** +- Command structure: extend `Base` class, use `#[AsCommand]` attribute +- Required methods: `configure()` and `execute()` +- Type system: always use `Path` value object instead of strings for file paths +- Interactive patterns: use `$input->isInteractive()` for detection +- Error handling: proper return codes (`Command::SUCCESS`, `Command::FAILURE`, `Command::INVALID`) +- Best practices: method extraction, confirmation dialogs, file operation patterns +- Available services through DI container (Logger, StyleInterface, etc.) + +### ๐Ÿ“ PHP Code Standards +**Path:** `docs/guidelines/how-to-write-php-code-best-practices.md` +**Value:** Maintains modern PHP code quality and leverages latest language features for better performance and maintainability +**Key Areas:** +- Modern PHP 8.1+ features: constructor promotion, union types, match expressions, throw expressions +- Code structure: PER-2 standards, single responsibility, final classes by default +- Enumerations: use enums for fixed value sets, CamelCase naming, backed enums for primitives +- Immutability: readonly properties, `with` prefix for immutable updates +- Type system: precise PHPDoc annotations, generics, non-empty-string types +- Comparison patterns: strict equality (`===`), null coalescing (`??`), avoid `empty()` +- Dependency injection and IoC container patterns + +### ๐Ÿงช Testing Guidelines +**Path:** `docs/guidelines/how-to-write-tests.md` +**Value:** Ensures comprehensive test coverage with modern PHPUnit practices and proper test isolation +**Key Areas:** +- Test structure: mirror source structure, `final` test classes, Arrange-Act-Assert pattern +- Module testing: independent test areas with dedicated `Stub` directories +- Naming: `{ClassUnderTest}Test`, descriptive method names +- Modern PHPUnit: PHP 8.1+ attributes (`#[CoversClass]`, `#[DataProvider]`), data providers with generators +- Isolation: mock dependencies, use test doubles, reset state between tests +- **Critical restrictions**: DO NOT mock enums or final classes - use real instances +- Error testing: expectException before Act phase +- Test traits for shared functionality \ No newline at end of file diff --git a/bin/testo b/bin/testo new file mode 100644 index 0000000..d28c042 --- /dev/null +++ b/bin/testo @@ -0,0 +1,56 @@ +#!/usr/bin/env php +setCommandLoader( + new FactoryCommandLoader([ + Command\Run::getDefaultName() => static fn() => new Command\Run(), + ]), + ); + $application->setDefaultCommand(Command\Run::getDefaultName(), false); + $application->setVersion(Info::version()); + $application->setName(Info::NAME); + $application->run(); +})(); diff --git a/bin/testo.bat b/bin/testo.bat new file mode 100644 index 0000000..223c5cf --- /dev/null +++ b/bin/testo.bat @@ -0,0 +1,10 @@ +@echo off +@setlocal + +set BIN_PATH=%~dp0 + +if "%PHP_COMMAND%" == "" set PHP_COMMAND=php + +"%PHP_COMMAND%" "%BIN_PATH%testo" %* + +@endlocal diff --git a/composer.json b/composer.json index b1529c7..91d6aeb 100644 --- a/composer.json +++ b/composer.json @@ -25,14 +25,15 @@ "react/async": "^3.2 || ^4.3", "react/promise": "^2.10 || ^3.2", "symfony/console": "^6.4 || ^7", + "symfony/finder": "^6.4 || ^7", "symfony/process": "^6.4 || ^7", "webmozart/assert": "^1.11", "yiisoft/injector": "^1.2" }, "require-dev": { "buggregator/trap": "^1.10", - "infection/infection": "^0.31", "internal/dload": "^1.6", + "phpunit/phpunit": "^10.5", "spiral/code-style": "^2.2.2", "vimeo/psalm": "^6.10" }, @@ -46,7 +47,10 @@ "autoload-dev": { "psr-4": { "Tests\\": "tests/" - } + }, + "files": [ + "tests/Fixture/functions.php" + ] }, "bin": [ "bin/testo" @@ -74,6 +78,7 @@ ], "psalm": "psalm", "psalm:baseline": "psalm --set-baseline=psalm-baseline.xml", - "psalm:ci": "psalm --output-format=github --shepherd --show-info=false --stats --threads=4" + "psalm:ci": "psalm --output-format=github --shepherd --show-info=false --stats --threads=4", + "test": "bin/testo" } } diff --git a/docs/guidelines b/docs/guidelines new file mode 160000 index 0000000..b822129 --- /dev/null +++ b/docs/guidelines @@ -0,0 +1 @@ +Subproject commit b822129c966179f9aee844095463ab14659f7efa diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..21157c1 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,32 @@ + + + + + tests/Unit + + + + + + + + + src + + + diff --git a/psalm.xml b/psalm.xml new file mode 100644 index 0000000..f5356e7 --- /dev/null +++ b/psalm.xml @@ -0,0 +1,21 @@ + + + + + + + + + diff --git a/resources/version.json b/resources/version.json new file mode 100644 index 0000000..1772e6f --- /dev/null +++ b/resources/version.json @@ -0,0 +1,3 @@ +{ + ".": "1.0.0" +} diff --git a/src/Application.php b/src/Application.php new file mode 100644 index 0000000..cee540e --- /dev/null +++ b/src/Application.php @@ -0,0 +1,49 @@ +withConfig($config->services) + ->finish(); + $container->set($config); + return new self($container); + } + + public function run($filter = new Filter()): RunResult + { + $suiteResults = []; + + $suiteProvider = $this->container->get(SuiteProvider::class); + $suiteRunner = $this->container->get(SuiteRunner::class); + + # Iterate Test Suites + foreach ($suiteProvider->withFilter($filter)->getSuites() as $suite) { + $suiteResults[] = $suiteRunner->run($suite, $filter); + } + + # Run suites + return new RunResult($suiteResults); + } +} diff --git a/src/Attribute/Interceptable.php b/src/Attribute/Interceptable.php new file mode 100644 index 0000000..a850683 --- /dev/null +++ b/src/Attribute/Interceptable.php @@ -0,0 +1,12 @@ + + */ + public readonly array $suites = [ + new SuiteConfig( + name: 'default', + location: new FinderConfig(['tests']), + ), + ], + + /** + * Services bindings configuration. + */ + public readonly ServicesConfig $services = new ServicesConfig(), + ) { + # Validate suite configs + $suites === [] and throw new \InvalidArgumentException('At least one test suite must be defined.'); + \array_walk( + $suites, + static fn(mixed $suite) => $suite instanceof SuiteConfig or throw new \InvalidArgumentException( + 'Each suite must be an instance of SuiteConfig.', + ), + ); + } +} diff --git a/src/Config/FinderConfig.php b/src/Config/FinderConfig.php new file mode 100644 index 0000000..458d10b --- /dev/null +++ b/src/Config/FinderConfig.php @@ -0,0 +1,84 @@ + $include Include directories or files to the scope + * @param iterable $exclude Exclude directories or files from the scope + * + * @note Glob and regex patterns are not supported + */ + public function __construct( + iterable $include = [], + iterable $exclude = [], + ) { + foreach ($include as $dir) { + $this->include($dir); + } + + foreach ($exclude as $dir) { + $this->exclude($dir); + } + } + + /** + * Include directory or file to the scope + * + * @param non-empty-string|Path $path + */ + public function include(string|Path $path): self + { + $path = Path::create($path); + $path->exists() or throw new \InvalidArgumentException("File or directory not found: $path"); + $path->isDir() and $this->includeDirs[] = (string) $path->absolute(); + $path->isFile() and $this->includeFiles[] = (string) $path->absolute(); + return $this; + } + + /** + * Exclude a directory or a file from the scope + * + * @param non-empty-string|Path $path + */ + public function exclude(string|Path $path): self + { + $path = Path::create($path); + $path->exists() or throw new \InvalidArgumentException("File or directory not found: $path"); + $path->isDir() and $this->excludeDirs[] = (string) $path->absolute(); + $path->isFile() and $this->excludeFiles[] = (string) $path->absolute(); + return $this; + } +} diff --git a/src/Config/ServicesConfig.php b/src/Config/ServicesConfig.php new file mode 100644 index 0000000..9d2634e --- /dev/null +++ b/src/Config/ServicesConfig.php @@ -0,0 +1,10 @@ + Names of the test suites to filter by. + */ + public readonly array $testSuites = [], + ) {} + + /** + * Filter tests by Suite names. + * + * @param non-empty-string ...$names Names of the test suites to filter by. + * + * @return self A new instance of Filter with the specified test names. + */ + public function withTestSuites(string ...$names): self + { + return $this->with('testSuites', \array_unique(\array_merge($this->testSuites, $names))); + } + + public function withTestCases($name): self + { + // TODO + return $this; + } +} diff --git a/src/Dto/Run/RunResult.php b/src/Dto/Run/RunResult.php new file mode 100644 index 0000000..961e7e3 --- /dev/null +++ b/src/Dto/Run/RunResult.php @@ -0,0 +1,29 @@ + + */ +final class RunResult implements \IteratorAggregate +{ + public function __construct( + /** + * Test result collection. + * + * @var iterable + */ + public readonly iterable $results, + ) {} + + public function getIterator(): \Traversable + { + yield from $this->results; + } +} diff --git a/src/Finder/Finder.php b/src/Finder/Finder.php new file mode 100644 index 0000000..2891241 --- /dev/null +++ b/src/Finder/Finder.php @@ -0,0 +1,79 @@ + + */ +final class Finder implements \Countable, \IteratorAggregate +{ + private SymfonyFinder $finder; + + /** + * @param FinderConfig $config Configuration for finder with absolute paths. + */ + public function __construct(FinderConfig $config) + { + $this->finder = (new SymfonyFinder()); + $this->finder->in($config->includeDirs); + $this->finder->append($config->includeFiles); + + $config->excludeDirs !== [] || $config->excludeFiles !== [] and $this->finder->filter( + static function (\SplFileInfo $file) use ($config): bool { + $path = Path::create($file->getRealPath())->absolute(); + + # Files in excluded files + if ($path->isFile() && \in_array((string) $path, $config->excludeFiles, true)) { + return false; + } + + # Directories in excluded dirs + $target = (string) $path; + while (!\in_array($target, $config->includeDirs, true)) { + if (\in_array($target, $config->excludeDirs, true)) { + return false; + } + + $target = \dirname($target); + } + + return true; + }, + ); + } + + public function files(): self + { + $self = clone $this; + $self->finder->files(); + return $self; + } + + public function directories(): self + { + $self = clone $this; + $self->finder->directories(); + return $self; + } + + public function getIterator(): \IteratorAggregate + { + return $this->finder; + } + + public function count(): int + { + return $this->finder->count(); + } + + public function __clone(): void + { + $this->finder = clone $this->finder; + } +} diff --git a/src/Finder/Path.php b/src/Finder/Path.php new file mode 100644 index 0000000..2ca2155 --- /dev/null +++ b/src/Finder/Path.php @@ -0,0 +1,290 @@ +path; + + foreach ($paths as $path) { + if ($path instanceof self) { + $path->isAbsolute and throw new \LogicException('Joining an absolute path is not allowed.'); + $result .= self::DS . $path->path; + continue; + } + + if ($path === '') { + continue; + } + + $path = self::normalizePath($path); + self::_isAbsolute($path) and throw new \LogicException('Joining an absolute path is not allowed.'); + + $result .= self::DS . $path; + } + + // We return the raw string, not a normalized path, since it's already normalized + return self::create($result); + } + + /** + * Return the file name (the final path component) + */ + public function name(): string + { + $pos = \strrpos($this->path, self::DS); + return $pos === false + ? $this->path + : \substr($this->path, $pos + 1); + } + + /** + * Return the file stem (the file name without its extension) + * + * @return non-empty-string + */ + public function stem(): string + { + $name = $this->name(); + $pos = \strrpos($name, '.'); + return $pos === false || $pos === 0 ? $name : \substr($name, 0, $pos); + } + + /** + * Return the file suffix (extension) without the leading dot + */ + public function extension(): string + { + $name = $this->name(); + + return \pathinfo($name, PATHINFO_EXTENSION); + } + + /** + * Return the parent directory path + */ + public function parent(): self + { + $parts = \explode(self::DS, $this->path); + + if (\count($parts) === 1) { + return match ($this->path) { + '.' => self::create('..'), + '..' => self::create('../..'), + default => self::create('.'), + }; + } + + if ($this->isAbsolute && \count($parts) === 2) { + // If the path is absolute and has only two parts, return the root + return self::create($parts[0] . self::DS); + } + + if (!$this->isAbsolute && $parts[\array_key_last($parts)] === '..') { + return $this->join('..'); + } + + // Remove the last part of the path + \array_pop($parts); + return self::create(\implode(self::DS, $parts)); + } + + /** + * Return whether this path is absolute + */ + public function isAbsolute(): bool + { + return $this->isAbsolute; + } + + /** + * Return whether this path is relative + */ + public function isRelative(): bool + { + return !$this->isAbsolute; + } + + /** + * Check if the path exists. + */ + public function exists(): bool + { + return \file_exists($this->path); + } + + /** + * Check if the path is a directory. + * True as the result doesn't mean that the directory exists. + */ + public function isDir(): bool + { + return match (true) { + $this->path === '.', + $this->path === '..', + $this->isAbsolute && \substr($this->path, -2) === self::DS . '.', + \is_dir($this->path) => true, + default => false, + }; + } + + /** + * @return bool True if the path exists and is writable. + */ + public function isWriteable(): bool + { + return $this->exists() && \is_writable($this->path); + } + + /** + * Check if the path is a file. + * True as the result doesn't mean that the file exists. + */ + public function isFile(): bool + { + return match (true) { + $this->path === '.', + $this->path === '..', + $this->isAbsolute && \substr($this->path, -2) === self::DS . '.' => false, + \is_file($this->path) => true, + default => false, + }; + } + + /** + * Return a normalized absolute version of this path + */ + public function absolute(): self + { + if ($this->isAbsolute()) { + return $this; + } + + $cwd = \getcwd(); + $cwd === false and throw new \RuntimeException('Cannot get current working directory.'); + return self::create($cwd . self::DS . $this->path); + } + + /** + * Return a normalized relative version of this path. + * + * @return non-empty-string + */ + public function __toString(): string + { + return $this->path; + } + + /** + * Check if a path is absolute. + * + * @param non-empty-string $path A normalized path. + */ + private static function _isAbsolute(string $path): bool + { + return \preg_match('~^[a-zA-Z]:~', $path) === 1 || \str_starts_with($path, self::DS); + } + + /** + * Normalize a path by converting directory separators and resolving special path segments. + * + * @return non-empty-string + */ + private static function normalizePath(string $path): string + { + // Normalize directory separators + $path = \trim(\str_replace(['\\', '/'], self::DS, $path)); + + // Normalize multiple separators + $path = (string) \preg_replace( + '~' . \preg_quote(self::DS, '~') . '{2,}~', + self::DS, + $path, + ); + + // Empty path becomes current directory + if ($path === '') { + return '.'; + } + + + // Determine if the path is absolute + $isAbsolute = self::_isAbsolute($path); + + // Resolve special path segments + $parts = \explode(self::DS, $path); + /** @var non-empty-string|null $driverLetter */ + + if ($isAbsolute && \preg_match('~^([a-zA-Z]):~', $path, $matches) === 1) { + // Windows-style path with a drive letter + $driverLetter = $matches[1]; + \array_shift($parts); + } else { + $driverLetter = null; + } + + $result = []; + foreach ($parts as $part) { + $part = \trim($part, ' '); + if ($part === '.' || $part === '') { + continue; + } + + if ($part === '..') { + if ($result !== [] && $result[\array_key_last($result)] !== '..') { + \array_pop($result); + continue; + } + + $isAbsolute and throw new \LogicException("Cannot go up from root in `{$path}`"); + $result[] = '..'; + continue; + } + + $result[] = $part; + } + + // Reconstruct the path + $normalizedPath = $result === [] ? '.' : \implode(self::DS, $result); + + // Add an absolute path prefix if necessary + if ($isAbsolute) { + $normalizedPath = $driverLetter !== null + ? "$driverLetter:" . self::DS . $normalizedPath + : self::DS . $normalizedPath; + } + + return $normalizedPath; + } +} diff --git a/src/Interceptor/CaseLocatorInterceptor.php b/src/Interceptor/CaseLocatorInterceptor.php new file mode 100644 index 0000000..ddcf78c --- /dev/null +++ b/src/Interceptor/CaseLocatorInterceptor.php @@ -0,0 +1,27 @@ + + */ +interface CaseLocatorInterceptor extends InterceptorMarker +{ + /** + * Locate test cases in the given file. + * + * Class and function reflections are available there. + * + * @param FileDefinitions $file File to locate test cases in. + * @param callable(FileDefinitions): CaseDefinitions $next Next interceptor or core logic to locate test cases. + */ + public function locateTestCases(FileDefinitions $file, callable $next): CaseDefinitions; +} diff --git a/src/Interceptor/Exception/TestCaseInstantiationException.php b/src/Interceptor/Exception/TestCaseInstantiationException.php new file mode 100644 index 0000000..4ce3573 --- /dev/null +++ b/src/Interceptor/Exception/TestCaseInstantiationException.php @@ -0,0 +1,10 @@ +injector = $injector->withCacheReflections(true); + } + + /** + * Creates an instance of the given class with the given arguments. + * + * @template T of InterceptorMarker + * + * @param class-string $class The class to create. + * @param array $arguments The arguments to pass to the constructor. + * + * @return T The created instance. + */ + public function make(string $class, array $arguments = []): InterceptorMarker + { + return $this->injector->make($class, $arguments); + } +} diff --git a/src/Interceptor/FileLocatorInterceptor.php b/src/Interceptor/FileLocatorInterceptor.php new file mode 100644 index 0000000..e5d2b68 --- /dev/null +++ b/src/Interceptor/FileLocatorInterceptor.php @@ -0,0 +1,31 @@ + + */ +interface FileLocatorInterceptor extends InterceptorMarker +{ + /** + * Return true if the file might be interesting as a test file. + * + * The file is not loaded yet, so the interceptor should not try to use reflection on it. + * Try to use only the file path, class name, doc comments, function names, + * or other parsed tokens to determine if the file is interesting or dangerous to load. + * + * @param TokenizedFile $file Information about the test to be run. + * @param callable(TokenizedFile): (null|bool) $next Next interceptor or core logic + * to determine possible test file. + * @return null|bool True if the file might be interesting as a test file, + * false if dangerous to load, null to other interceptors. + */ + public function locateFile(TokenizedFile $file, callable $next): ?bool; +} diff --git a/src/Interceptor/InterceptorProvider.php b/src/Interceptor/InterceptorProvider.php new file mode 100644 index 0000000..d753e10 --- /dev/null +++ b/src/Interceptor/InterceptorProvider.php @@ -0,0 +1,93 @@ +, class-string> + */ + private array $map = []; + + public function __construct( + private readonly Factory $factory = new Factory(), + ) {} + + public static function createDefault(): self + { + $self = new self(); + $self->map = [ + RetryPolicy::class => RetryPolicyCallInterceptor::class, + ]; + return $self; + } + + /** + * Get interceptors for + * + * @template-covariant T of InterceptorMarker + * + * @param class-string $class The target interceptor class. + * @param class-string|InterceptorMarker ...$interceptors Interceptor classes or instances + * to filter by the given class. + * + * @return list Interceptor instances of the given class. + */ + public function fromClasses(string $class, string|InterceptorMarker ...$interceptors): array + { + return []; + } + + /** + * Get interceptors for the given attributes set filtered by the given class. + * + * @template-covariant T of InterceptorMarker + * + * @param class-string $class The target interceptor class. + * @param Interceptable ...$attributes Attributes to get interceptors for. + * + * @return list Interceptors for the given attributes. + */ + public function fromAttributes(string $class, Interceptable ...$attributes): array + { + $result = []; + + foreach ($attributes as $attribute) { + # Get alias interceptor + $iClass = $this->resolveAlias($attribute::class) ?? throw new \RuntimeException( + \sprintf('No interceptor found for attribute %s.', $attribute::class), + ); + + \is_a($iClass, $class, true) and $result[] = $this->factory->make($iClass, [$attribute]); + } + + return $result; + } + + /** + * Resolve alias interceptor for the given attribute class. + * + * @param class-string $class The attribute class. + * @return class-string|null The interceptor class or null if not found. + */ + private function resolveAlias(string $class): ?string + { + do { + if (\array_key_exists($class, $this->map)) { + return $this->map[$class]; + } + + $class = \get_parent_class($class); + } while ($class); + + return null; + } +} diff --git a/src/Interceptor/Internal/InterceptorMarker.php b/src/Interceptor/Internal/InterceptorMarker.php new file mode 100644 index 0000000..2224400 --- /dev/null +++ b/src/Interceptor/Internal/InterceptorMarker.php @@ -0,0 +1,16 @@ + */ + private array $interceptors = []; + + /** @var int<0, max> Current interceptor key */ + private int $current = 0; + + /** + * @param array $interceptors + */ + private function __construct( + array $interceptors, + ) { + // Reset keys + $this->interceptors = \array_values($interceptors); + } + + /** + * Make sure that interceptors implement the same interface. + * @template-covariant TInt of TInterceptor + * @template TIn + * @template-covariant TOut + * @param TInt ...$interceptors + * @return self + */ + public static function prepare(TInterceptor ...$interceptors): self + { + return new self($interceptors); + } + + /** + * @param non-empty-string $method Method name of the all interceptors. + * + * @return callable(object): TOutput + */ + public function with(\Closure $last, string $method): callable + { + $new = clone $this; + + $new->last = $last; + $new->method = $method; + + return $new; + } + + /** + * Must be used after {@see self::with()} method. + * + * @param TInput $input Input value for the first interceptor. + * + * @return TOutput + */ + public function __invoke(object $input): mixed + { + $interceptor = $this->interceptors[$this->current] ?? null; + + if ($interceptor === null) { + return ($this->last)($input); + } + + $next = $this->next(); + + return $interceptor->{$this->method}($input, $next); + } + + private function next(): self + { + $new = clone $this; + ++$new->current; + + return $new; + } +} diff --git a/src/Interceptor/Locator/FilePostfixTestLocatorInterceptor.php b/src/Interceptor/Locator/FilePostfixTestLocatorInterceptor.php new file mode 100644 index 0000000..2fd5709 --- /dev/null +++ b/src/Interceptor/Locator/FilePostfixTestLocatorInterceptor.php @@ -0,0 +1,45 @@ +path->stem(), 'Test') ? true : $next($file); + } + + /** + * @inheritDoc + */ + public function locateTestCases(FileDefinitions $file, callable $next): CaseDefinitions + { + foreach ($file->classes as $class) { + if (!$class->isAbstract() && \str_ends_with($class->getName(), 'Test')) { + $case = $file->cases->define($class); + foreach ($class->getMethods() as $method) { + if ($method->isPublic() && \str_starts_with($method->getName(), 'test')) { + $case->tests->define($method); + } + } + } + } + + return $next($file); + } +} diff --git a/src/Interceptor/Locator/SecureLocatorInterceptor.php b/src/Interceptor/Locator/SecureLocatorInterceptor.php new file mode 100644 index 0000000..a69ecf4 --- /dev/null +++ b/src/Interceptor/Locator/SecureLocatorInterceptor.php @@ -0,0 +1,20 @@ +hasIncludes ? false : $next($file); + } +} diff --git a/src/Interceptor/Locator/TestoAttributesLocatorInterceptor.php b/src/Interceptor/Locator/TestoAttributesLocatorInterceptor.php new file mode 100644 index 0000000..159e81c --- /dev/null +++ b/src/Interceptor/Locator/TestoAttributesLocatorInterceptor.php @@ -0,0 +1,49 @@ +getClasses() !== [] || $file->getFunctions() !== []) ? true : $next($file); + } + + #[\Override] + public function locateTestCases(FileDefinitions $file, callable $next): CaseDefinitions + { + foreach ($file->classes as $class) { + if ($class->isAbstract()) { + continue; + } + + foreach ($class->getMethods() as $method) { + if ($method->isPublic() && Reflection::fetchFunctionAttributes($method, attributeClass: Test::class)) { + $file->cases->define($class)->tests->define($method); + } + } + } + + foreach ($file->functions as $function) { + if ($function->isPublic() && Reflection::fetchFunctionAttributes($function, attributeClass: Test::class)) { + $file->cases->define(null)->tests->define($function); + } + } + + return $next($file); + } +} diff --git a/src/Interceptor/TestCallInterceptor.php b/src/Interceptor/TestCallInterceptor.php new file mode 100644 index 0000000..41c4c78 --- /dev/null +++ b/src/Interceptor/TestCallInterceptor.php @@ -0,0 +1,23 @@ + + */ +interface TestCallInterceptor extends InterceptorMarker +{ + /** + * @param TestInfo $info Information about the test to be run. + * @param callable(TestInfo): TestResult $next Next interceptor or core logic to run the test. + */ + public function runTest(TestInfo $info, callable $next): TestResult; +} diff --git a/src/Interceptor/TestCallInterceptor/RetryPolicyCallInterceptor.php b/src/Interceptor/TestCallInterceptor/RetryPolicyCallInterceptor.php new file mode 100644 index 0000000..53c9772 --- /dev/null +++ b/src/Interceptor/TestCallInterceptor/RetryPolicyCallInterceptor.php @@ -0,0 +1,46 @@ +options->maxAttempts; + $isFlaky = false; + + run: + --$attempts; + try { + $result = $next($info); + return $isFlaky && $this->options->markFlaky + ? $result->with(status: Status::Flaky) + : $result; + } catch (\Throwable $e) { + # No more attempts left, rethrow the exception + $attempts > 0 or throw $e; + + $isFlaky = true; + unset($e); + goto run; + } + } +} diff --git a/src/Interceptor/TestCaseCallInterceptor.php b/src/Interceptor/TestCaseCallInterceptor.php new file mode 100644 index 0000000..f37543e --- /dev/null +++ b/src/Interceptor/TestCaseCallInterceptor.php @@ -0,0 +1,25 @@ + + */ +interface TestCaseCallInterceptor extends InterceptorMarker +{ + /** + * @param CaseInfo $info Test case to run. + * @param callable(CaseInfo): CaseResult $next Next interceptor or core logic to run the test case. + */ + public function runTestCase(CaseInfo $info, callable $next): CaseResult; +} diff --git a/src/Interceptor/TestCaseCallInterceptor/InstantiateTestCase.php b/src/Interceptor/TestCaseCallInterceptor/InstantiateTestCase.php new file mode 100644 index 0000000..a6ec687 --- /dev/null +++ b/src/Interceptor/TestCaseCallInterceptor/InstantiateTestCase.php @@ -0,0 +1,32 @@ +instance === null && $info->definition->reflection !== null) { + // TODO autowire dependencies + try { + $instance = $info->definition->reflection->newInstance(); + } catch (\Throwable $e) { + throw new TestCaseInstantiationException(previous: $e); + } + + $info = $info->withInstance($instance); + } + + return $next($info); + } +} diff --git a/src/Internal/Bootstrap.php b/src/Internal/Bootstrap.php new file mode 100644 index 0000000..f8f8026 --- /dev/null +++ b/src/Internal/Bootstrap.php @@ -0,0 +1,62 @@ +container; + unset($this->container); + + return $c; + } + + /** + * Configures the container with the provided application services configuration. + * + * Registers core services and bindings. + */ + public function withConfig( + ServicesConfig $config, + ): self { + foreach ($config as $id => $service) { + $this->container->bind($id, $service); + } + + return $this; + } +} diff --git a/src/Internal/CloneWith.php b/src/Internal/CloneWith.php new file mode 100644 index 0000000..3c6c38e --- /dev/null +++ b/src/Internal/CloneWith.php @@ -0,0 +1,33 @@ +newInstanceWithoutConstructor(); + $new->{$key} = $value; + /** @psalm-suppress RawObjectIteration */ + foreach ($this as $k => $v) { + if ($k === $key) { + continue; + } + + $new->{$k} = $v; + } + return $new; + } +} diff --git a/src/Internal/Command/Base.php b/src/Internal/Command/Base.php new file mode 100644 index 0000000..9fd419e --- /dev/null +++ b/src/Internal/Command/Base.php @@ -0,0 +1,94 @@ +addOption('config', null, InputOption::VALUE_OPTIONAL, 'Path to the configuration file'); + } + + /** + * Initializes the command execution environment. + * + * Sets up logger, container, and registers input/output in the container. + * + * @param InputInterface $input Command input + * @param OutputInterface $output Command output + * + * @return int Command success code + */ + protected function execute( + InputInterface $input, + OutputInterface $output, + ): int { + $cfg = new ApplicationConfig( + src: new FinderConfig(['src']), + suites: [ + new SuiteConfig( + name: 'default', + location: new FinderConfig(['tests/Testo']), + ), + ], + ); + + $this->application = Application::create($cfg); + $this->container = $this->application->container; + + $this->container->set($input, InputInterface::class); + $this->container->set($output, OutputInterface::class); + $this->container->set(new SymfonyStyle($input, $output), StyleInterface::class); + + return $this->container->get(Injector::class)->invoke($this) ?? Command::SUCCESS; + } +} diff --git a/src/Internal/Command/Run.php b/src/Internal/Command/Run.php new file mode 100644 index 0000000..f5fe43a --- /dev/null +++ b/src/Internal/Command/Run.php @@ -0,0 +1,24 @@ +application->run(); + return Command::SUCCESS; + } +} diff --git a/src/Internal/Container.php b/src/Internal/Container.php new file mode 100644 index 0000000..f240a60 --- /dev/null +++ b/src/Internal/Container.php @@ -0,0 +1,84 @@ +get(Downloader::class); + * + * // Binding a factory for service creation + * $container->bind(Logger::class, function (Container $c) { + * return new Logger($c->get(OutputInterface::class)); + * }); + * ``` + * + * @internal + */ +interface Container extends \Internal\Destroy\Destroyable, \Psr\Container\ContainerInterface +{ + /** + * Retrieves a service from the container. + * + * If the service is requested for the first time, it will be instantiated and persisted for future requests. + * + * @template T + * @param class-string $id Service identifier + * @param array $arguments Constructor arguments used only on first instantiation + * @return T The requested service instance + * + * @psalm-suppress MoreSpecificImplementedParamType, InvalidReturnType + */ + public function get(string $id, array $arguments = []): object; + + /** + * Checks if the service is registered in the container. + * + * It means that the container has a cached service instance or a binding. + * + * @param class-string $id Service identifier + * @return bool Whether the service is available + * + * @psalm-suppress MoreSpecificImplementedParamType + */ + public function has(string $id): bool; + + /** + * Registers an existing service instance in the container. + * + * @template T + * @param T $service Service instance to register + * @param class-string|null $id Optional service identifier (defaults to object's class) + */ + public function set(object $service, ?string $id = null): void; + + /** + * Creates a new instance without storing it in the container. + * + * @template T + * @param class-string $class Class to instantiate + * @param array $arguments Constructor arguments + * @return T Newly created instance + */ + public function make(string $class, array $arguments = []): object; + + /** + * Declares a factory or predefined arguments for the specified class. + * + * Configures how a service should be instantiated. + * + * @template T + * @param class-string $id Service identifier + * @param null|class-string|array|\Closure(Container): T $binding Factory + * function, constructor arguments, or alias class name. + */ + public function bind(string $id, \Closure|string|array|null $binding = null): void; +} diff --git a/src/Internal/Info.php b/src/Internal/Info.php new file mode 100644 index 0000000..06c9d3b --- /dev/null +++ b/src/Internal/Info.php @@ -0,0 +1,55 @@ + */ + private array $cache = []; + + /** @var array */ + private array $factory = []; + + private readonly Injector $injector; + + /** + * @psalm-suppress PropertyTypeCoercion + */ + public function __construct() + { + $this->injector = (new Injector($this))->withCacheReflections(false); + $this->cache[Injector::class] = $this->injector; + $this->cache[self::class] = $this; + $this->cache[ObjectContainer::class] = $this; + $this->cache[ContainerInterface::class] = $this; + } + + public function get(string $id, array $arguments = []): object + { + /** @psalm-suppress InvalidReturnStatement */ + return $this->cache[$id] ??= $this->make($id, $arguments); + } + + public function has(string $id): bool + { + return \array_key_exists($id, $this->cache) || \array_key_exists($id, $this->factory); + } + + public function set(object $service, ?string $id = null): void + { + \assert($id === null || $service instanceof $id, "Service must be instance of {$id}."); + $this->cache[$id ?? \get_class($service)] = $service; + } + + public function make(string $class, array $arguments = []): object + { + $binding = $this->factory[$class] ?? null; + + if ($binding instanceof \Closure) { + $result = $this->injector->invoke($binding); + } else { + try { + $result = $this->injector->make($class, \array_merge((array) $binding, $arguments)); + } catch (\Throwable $e) { + throw new class("Unable to create object of class $class.", previous: $e) extends \RuntimeException implements NotFoundExceptionInterface {}; + } + } + + \assert($result instanceof $class, "Created object must be instance of {$class}."); + + // Detect related types + // Configs + // if (\str_starts_with($class, 'Internal\\Module\\Config\\Schema\\')) { + // // Hydrate config + // /** @var ConfigLoader $configLoader */ + // $configLoader = $this->get(ConfigLoader::class); + // $configLoader->hydrate($result); + // } + + return $result; + } + + /** + * @template T + * @param class-string $id Service identifier + * @param null|class-string|array|\Closure(mixed ...): T $binding + */ + public function bind(string $id, \Closure|string|array|null $binding = null): void + { + if (\is_string($binding)) { + \class_exists($binding) or throw new \InvalidArgumentException( + "Class `$binding` does not exist.", + ); + + /** @var class-string $binding */ + $binding = \is_a($binding, Factoriable::class, true) + ? fn(): object => $this->injector->invoke([$binding, 'create']) + : fn(): object => $this->injector->make($binding); + } + + if ($binding !== null) { + $this->factory[$id] = $binding; + return; + } + + (\class_exists($id) && \is_a($id, Factoriable::class, true)) or throw new \InvalidArgumentException( + "Class `$id` must have a factory or be a factory itself and implement `Factoriable`.", + ); + + /** @var \Closure(mixed ...): T $object */ + $object = $id::create(...); + $this->factory[$id] = $object; + } + + public function destroy(): void + { + unset($this->cache, $this->factory, $this->injector); + } +} diff --git a/src/Module/Tokenizer/DefinitionLocator.php b/src/Module/Tokenizer/DefinitionLocator.php new file mode 100644 index 0000000..239f74f --- /dev/null +++ b/src/Module/Tokenizer/DefinitionLocator.php @@ -0,0 +1,128 @@ + + */ + public static function getClasses(TokenizedFile $file): array + { + $classes = []; + foreach ($file->getClasses() as $class) { + try { + $classes[$class] = self::classReflection($class); + } catch (LocatorException $e) { + // if ($file->isDebug()) { + // throw $e; + // } + + //Ignoring + continue; + } + } + + return $classes; + } + + /** + * Safely get class reflection, class loading errors will be blocked and reflection will be + * excluded from analysis. + * + * @template T + * @param class-string $class + * @return \ReflectionClass + * + * @throws LocatorException + */ + private static function classReflection(string $class): \ReflectionClass + { + $loader = static function ($class): void { + if ($class === LocatorException::class) { + return; + } + + throw new LocatorException(\sprintf("Class '%s' can not be loaded", $class)); + }; + + //To suspend class dependency exception + \spl_autoload_register($loader); + + try { + //In some cases reflection can thrown an exception if class invalid or can not be loaded, + //we are going to handle such exception and convert it soft exception + return new \ReflectionClass($class); + } catch (\Throwable $e) { + if ($e instanceof LocatorException && $e->getPrevious() != null) { + $e = $e->getPrevious(); + } + + // if ($this->debug) { + // $this->getLogger()->error( + // \sprintf('%s: %s in %s:%s', $class, $e->getMessage(), $e->getFile(), $e->getLine()), + // ['error' => $e], + // ); + // } + + throw new LocatorException($e->getMessage(), (int) $e->getCode(), $e); + } finally { + \spl_autoload_unregister($loader); + } + } + + /** + * Safely get enum reflection, class loading errors will be blocked and reflection will be + * excluded from analysis. + * + * @param class-string $enum + * + * @throws LocatorException + */ + private function enumReflection(string $enum): \ReflectionEnum + { + $loader = static function (string $enum): void { + if ($enum === LocatorException::class) { + return; + } + + throw new LocatorException(\sprintf("Enum '%s' can not be loaded", $enum)); + }; + + //To suspend class dependency exception + \spl_autoload_register($loader); + + try { + //In some enum reflection can thrown an exception if enum invalid or can not be loaded, + //we are going to handle such exception and convert it soft exception + return new \ReflectionEnum($enum); + } catch (\Throwable $e) { + if ($e instanceof LocatorException && $e->getPrevious() != null) { + $e = $e->getPrevious(); + } + + // if ($this->debug) { + // $this->getLogger()->error( + // \sprintf('%s: %s in %s:%s', $enum, $e->getMessage(), $e->getFile(), $e->getLine()), + // ['error' => $e], + // ); + // } + + throw new LocatorException($e->getMessage(), (int) $e->getCode(), $e); + } finally { + \spl_autoload_unregister($loader); + } + } +} diff --git a/src/Module/Tokenizer/Exception/LocatorException.php b/src/Module/Tokenizer/Exception/LocatorException.php new file mode 100644 index 0000000..4759466 --- /dev/null +++ b/src/Module/Tokenizer/Exception/LocatorException.php @@ -0,0 +1,10 @@ + + */ +final class FileLocator implements \IteratorAggregate +{ + protected readonly Finder $finder; + + public function __construct( + Finder $finder, + protected readonly bool $debug = false, + ) { + $this->finder = $finder->files(); + } + + /** + * Available file reflections. Generator. + * + * @return \Generator + * @throws \Exception + */ + public function getIterator(): \Generator + { + foreach ($this->finder->getIterator() as $file) { + yield new TokenizedFile($file, (string) $file); + } + } +} diff --git a/src/Module/Tokenizer/Reflection.php b/src/Module/Tokenizer/Reflection.php new file mode 100644 index 0000000..06d94be --- /dev/null +++ b/src/Module/Tokenizer/Reflection.php @@ -0,0 +1,116 @@ +getAttributes($attributeClass, $flags)); + + if ($includeParents && $function instanceof \ReflectionMethod) { + $parentClass = $function->getDeclaringClass()->getParentClass(); + if ($parentClass !== false && $parentClass->hasMethod($function->getName())) { + $function = $parentClass->getMethod($function->getName()); + continue; + } + } + + break; + } while (true); + + return $attributes; + } + + /** + * Fetch all attributes for a given class. + * + * @param class-string $class + * @param bool $includeParents Whether to include attributes from parent classes. + * @param bool $includeTraits Whether to include attributes from traits. + * @param class-string|null $attributeClass If provided, only attributes of this class will be returned. + * @param int $flags Flags to pass to {@see ReflectionClass::getAttributes()}. + * + * @return \ReflectionAttribute[] + */ + public static function fetchClassAttributes( + \ReflectionClass|string $class, + bool $includeParents = true, + bool $includeTraits = true, + ?string $attributeClass = null, + int $flags = 0, + ): array { + $attributes = []; + + do { + \is_string($class) and $reflection = new \ReflectionClass($class); + $attributes = \array_merge( + $attributes, + $reflection->getAttributes($attributeClass, $flags), + ); + + if ($includeTraits) { + foreach (self::fetchTraits($class->getName(), includeParents: false) as $trait) { + $traitReflection = new \ReflectionClass($trait); + $attributes = \array_merge( + $attributes, + $traitReflection->getAttributes($attributeClass, $flags), + ); + } + } + + $class = $includeParents ? $reflection->getParentClass() : null; + } while ($class !== null); + + return $attributes; + } + + /** + * Get every class trait (including traits used in parents). + * + * @param class-string $class + * @param bool $includeParents Whether to include traits from parent classes. + * + * @return non-empty-string[] + */ + public static function fetchTraits( + string $class, + bool $includeParents = true, + ): array { + $traits = []; + + do { + $traits = \array_merge(\class_uses($class), $traits); + $class = \get_parent_class($class); + } while ($includeParents && $class !== false); + + //Traits from traits + foreach (\array_flip($traits) as $trait) { + $traits = \array_merge(\class_uses($trait), $traits); + } + + return \array_unique($traits); + } +} diff --git a/src/Module/Tokenizer/Reflection/FileDefinitions.php b/src/Module/Tokenizer/Reflection/FileDefinitions.php new file mode 100644 index 0000000..be7fa5c --- /dev/null +++ b/src/Module/Tokenizer/Reflection/FileDefinitions.php @@ -0,0 +1,56 @@ + + */ + public readonly array $classes; + + /** + * Interface reflections found in the file. + * @var array + */ + public readonly array $interfaces; + + /** + * Enum reflections found in the file. + * @var array + */ + public readonly array $enums; + + /** + * Function reflections found in the file. + * @var array + */ + public readonly array $functions; + + /** + * Trait reflections found in the file. + * @var array + */ + public readonly array $traits; + + public function __construct( + public readonly TokenizedFile $tokenizedFile, + public readonly CaseDefinitions $cases = new CaseDefinitions(), + ) { + $this->classes = DefinitionLocator::getClasses($tokenizedFile); + $this->enums = []; + // $this->enums = DefinitionLocator::getEnums($tokenizedFile); + $this->functions = []; + // $this->functions = DefinitionLocator::getFunctions($tokenizedFile); + $this->interfaces = []; + // $this->interfaces = DefinitionLocator::getInterfaces($tokenizedFile); + $this->traits = []; + // $this->traits = DefinitionLocator::getTraits($tokenizedFile); + } +} diff --git a/src/Module/Tokenizer/Reflection/TokenizedArgument.php b/src/Module/Tokenizer/Reflection/TokenizedArgument.php new file mode 100644 index 0000000..1e5cfe3 --- /dev/null +++ b/src/Module/Tokenizer/Reflection/TokenizedArgument.php @@ -0,0 +1,140 @@ + self::EXPRESSION, 'value' => '', 'tokens' => []]; + } + + if ($token[TokenizedFile::TOKEN_TYPE] === '(' || $token[TokenizedFile::TOKEN_TYPE] === '[') { + ++$level; + $definition['value'] .= $token[TokenizedFile::TOKEN_CODE]; + continue; + } + + if ($token[TokenizedFile::TOKEN_TYPE] === ')' || $token[TokenizedFile::TOKEN_TYPE] === ']') { + --$level; + $definition['value'] .= $token[TokenizedFile::TOKEN_CODE]; + continue; + } + + if ($level) { + $definition['value'] .= $token[TokenizedFile::TOKEN_CODE]; + continue; + } + + if ($token[TokenizedFile::TOKEN_TYPE] === ',') { + $result[] = self::createArgument($definition); + $definition = null; + continue; + } + + $definition['tokens'][] = $token; + $definition['value'] .= $token[TokenizedFile::TOKEN_CODE]; + } + + //Last argument + if (\is_array($definition)) { + $definition = self::createArgument($definition); + if (!empty($definition->getType())) { + $result[] = $definition; + } + } + + return $result; + } + + public function getType(): string + { + return $this->type; + } + + public function getValue(): string + { + return $this->value; + } + + /** + * Convert argument value into valid string. Can be applied only for STRING type arguments. + * + * @throws ReflectionException When value can not be converted into string. + */ + public function stringValue(): string + { + if ($this->type !== self::STRING) { + throw new ReflectionException( + \sprintf("Unable to represent value as string, value type is '%s'", $this->type), + ); + } + + //The most reliable way + return eval("return {$this->value};"); + } + + /** + * Create Argument reflection using token definition. Internal method. + * + * @param array{value: string, tokens: array, type: string} $definition + * @see locateArguments + */ + private static function createArgument(array $definition): TokenizedArgument + { + $result = new self(self::EXPRESSION, $definition['value']); + + if (\count($definition['tokens']) == 1) { + $result->type = match ($definition['tokens'][0][0]) { + T_VARIABLE => self::VARIABLE, + T_LNUMBER, T_DNUMBER => self::CONSTANT, + T_CONSTANT_ENCAPSED_STRING => self::STRING, + default => $result->type, + }; + } + + return $result; + } +} diff --git a/src/Module/Tokenizer/Reflection/TokenizedFile.php b/src/Module/Tokenizer/Reflection/TokenizedFile.php new file mode 100644 index 0000000..9e79cd4 --- /dev/null +++ b/src/Module/Tokenizer/Reflection/TokenizedFile.php @@ -0,0 +1,720 @@ +path = Path::create($path); + $this->tokens = self::fetchTokens($this->path); + $this->countTokens = \count($this->tokens); + + //Looking for declarations + $this->locateDeclarations(); + } + + /** + * List of declared function names + */ + public function getFunctions(): array + { + return \array_keys($this->functions); + } + + /** + * List of declared class names + */ + public function getClasses(): array + { + if (!isset($this->declarations['T_CLASS'])) { + return []; + } + + return \array_keys($this->declarations['T_CLASS']); + } + + /** + * List of declared enums names + */ + public function getEnums(): array + { + if (!isset($this->declarations['T_ENUM'])) { + return []; + } + + return \array_keys($this->declarations['T_ENUM']); + } + + /** + * List of declared trait names + */ + public function getTraits(): array + { + if (!isset($this->declarations['T_TRAIT'])) { + return []; + } + + return \array_keys($this->declarations['T_TRAIT']); + } + + /** + * List of declared interface names + */ + public function getInterfaces(): array + { + if (!isset($this->declarations['T_INTERFACE'])) { + return []; + } + + return \array_keys($this->declarations['T_INTERFACE']); + } + + /** + * Locate and return list of every method or function call in specified file. Only static and + * $this calls will be indexed + * + * @return TokenizedInvocation[] + */ + public function getInvocations(): array + { + if (empty($this->invocations)) { + $this->locateInvocations($this->tokens); + } + + return $this->invocations; + } + + /** + * Export found declaration as array for caching purposes. + */ + public function exportSchema(): array + { + return [$this->hasIncludes, $this->declarations, $this->functions, $this->namespaces]; + } + + /** + * Import cached reflection schema. + */ + protected function importSchema(array $cache): void + { + [$this->hasIncludes, $this->declarations, $this->functions, $this->namespaces] = $cache; + } + + /** + * Locate every class, interface, trait or function definition. + */ + protected function locateDeclarations(): void + { + $hasIncludes = false; + foreach ($this->tokens as $tokenID => $token) { + if (!\in_array($token[self::TOKEN_TYPE], self::$processTokens)) { + continue; + } + + switch ($token[self::TOKEN_TYPE]) { + case T_NAMESPACE: + $this->registerNamespace($tokenID); + break; + + case T_USE: + $this->registerUse($tokenID); + break; + + case T_FUNCTION: + $this->registerFunction($tokenID); + break; + + case T_CLASS: + case T_TRAIT: + case T_INTERFACE: + case T_ENUM: + if ($this->isClassNameConst($tokenID)) { + // PHP5.5 ClassName::class constant + continue 2; + } + + if ($this->isAnonymousClass($tokenID)) { + // PHP7.0 Anonymous classes new class ('foo', 'bar') + continue 2; + } + + if (!$this->isCorrectDeclaration($tokenID)) { + // PHP8.0 Named parameters ->foo(class: 'bar') + continue 2; + } + + $this->registerDeclaration($tokenID, $token[self::TOKEN_TYPE]); + break; + + case T_INCLUDE: + case T_INCLUDE_ONCE: + case T_REQUIRE: + case T_REQUIRE_ONCE: + $hasIncludes = true; + } + } + + $this->hasIncludes = $hasIncludes; + + //Dropping empty namespace + if (isset($this->namespaces[''])) { + $this->namespaces['\\'] = $this->namespaces['']; + unset($this->namespaces['']); + } + } + + /** + * Get all tokes for specific file. + */ + private static function fetchTokens(Path $filename): array + { + $tokens = \token_get_all(\file_get_contents((string) $filename)); + + $line = 0; + foreach ($tokens as &$token) { + isset($token[self::TOKEN_LINE]) and $line = $token[self::TOKEN_LINE]; + \is_array($token) or $token = [$token, $token, $line]; + unset($token); + } + + return $tokens; + } + + /** + * Handle namespace declaration. + */ + private function registerNamespace(int $tokenID): void + { + $namespace = ''; + $localID = $tokenID + 1; + + do { + $token = $this->tokens[$localID++]; + if ($token[self::TOKEN_CODE] === '{') { + break; + } + + $namespace .= $token[self::TOKEN_CODE]; + } while ( + isset($this->tokens[$localID]) + && $this->tokens[$localID][self::TOKEN_CODE] !== '{' + && $this->tokens[$localID][self::TOKEN_CODE] !== ';' + ); + + //Whitespaces + $namespace = \trim($namespace); + + $uses = []; + if (isset($this->namespaces[$namespace])) { + $uses = $this->namespaces[$namespace]; + } + + if ($this->tokens[$localID][self::TOKEN_CODE] === ';') { + $endingID = \count($this->tokens) - 1; + } else { + $endingID = $this->endingToken($tokenID); + } + + $this->namespaces[$namespace] = [ + self::O_TOKEN => $tokenID, + self::C_TOKEN => $endingID, + self::N_USES => $uses, + ]; + } + + /** + * Handle use (import class from another namespace). + */ + private function registerUse(int $tokenID): void + { + $namespace = \rtrim($this->activeNamespace($tokenID), '\\'); + + $class = ''; + $localAlias = null; + for ($localID = $tokenID + 1; $this->tokens[$localID][self::TOKEN_CODE] !== ';'; ++$localID) { + if ($this->tokens[$localID][self::TOKEN_TYPE] == T_AS) { + $localAlias = ''; + continue; + } + + if ($localAlias === null) { + $class .= $this->tokens[$localID][self::TOKEN_CODE]; + } else { + $localAlias .= $this->tokens[$localID][self::TOKEN_CODE]; + } + } + + if (empty($localAlias)) { + $names = \explode('\\', $class); + $localAlias = \end($names); + } + + $this->namespaces[$namespace][self::N_USES][\trim($localAlias)] = \trim($class); + } + + /** + * Handle function declaration (function creation). + */ + private function registerFunction(int $tokenID): void + { + foreach ($this->declarations as $declarations) { + foreach ($declarations as $location) { + if ($tokenID >= $location[self::O_TOKEN] && $tokenID <= $location[self::C_TOKEN]) { + //We are inside class, function is method + return; + } + } + } + + $localID = $tokenID + 1; + while ($this->tokens[$localID][self::TOKEN_TYPE] !== T_STRING) { + //Fetching function name + ++$localID; + } + + $name = $this->tokens[$localID][self::TOKEN_CODE]; + if (!empty($namespace = $this->activeNamespace($tokenID))) { + $name = $namespace . self::NS_SEPARATOR . $name; + } + + $this->functions[$name] = [ + self::O_TOKEN => $tokenID, + self::C_TOKEN => $this->endingToken($tokenID), + ]; + } + + /** + * Handle declaration of class, trait of interface. Declaration will be stored under it's token + * type in declarations array. + */ + private function registerDeclaration(int $tokenID, int $tokenType): void + { + $localID = $tokenID + 1; + while ($this->tokens[$localID][self::TOKEN_TYPE] !== T_STRING) { + ++$localID; + } + + $name = $this->tokens[$localID][self::TOKEN_CODE]; + if (!empty($namespace = $this->activeNamespace($tokenID))) { + $name = $namespace . self::NS_SEPARATOR . $name; + } + + $this->declarations[\token_name($tokenType)][$name] = [ + self::O_TOKEN => $tokenID, + self::C_TOKEN => $this->endingToken($tokenID), + ]; + } + + /** + * Check if token ID represents `ClassName::class` constant statement. + */ + private function isClassNameConst(int $tokenID): bool + { + return $this->tokens[$tokenID][self::TOKEN_TYPE] === T_CLASS + && isset($this->tokens[$tokenID - 1]) + && $this->tokens[$tokenID - 1][self::TOKEN_TYPE] === T_PAAMAYIM_NEKUDOTAYIM; + } + + /** + * Check if token ID represents anonymous class creation, e.g. `new class ('foo', 'bar')`. + */ + private function isAnonymousClass(int|string $tokenID): bool + { + return $this->tokens[$tokenID][self::TOKEN_TYPE] === T_CLASS + && isset($this->tokens[$tokenID - 2]) + && $this->tokens[$tokenID - 2][self::TOKEN_TYPE] === T_NEW; + } + + /** + * Check if token ID represents named parameter with name `class`, e.g. `foo(class: SomeClass::name)`. + */ + private function isCorrectDeclaration(int|string $tokenID): bool + { + return \in_array($this->tokens[$tokenID][self::TOKEN_TYPE], [T_CLASS, T_TRAIT, T_INTERFACE, T_ENUM], true) + && isset($this->tokens[$tokenID + 2]) + && $this->tokens[$tokenID + 1][self::TOKEN_TYPE] === T_WHITESPACE + && $this->tokens[$tokenID + 2][self::TOKEN_TYPE] === T_STRING; + } + + /** + * Locate every function or static method call (including $this calls). + * + * This is pretty old code, potentially to be improved using AST. + * + * @param array $tokens + */ + private function locateInvocations(array $tokens, int $invocationLevel = 0): void + { + //Multiple "(" and ")" statements nested. + $level = 0; + + //Skip all tokens until next function + $ignore = false; + + //Were function was found + $invocationTID = 0; + + //Parsed arguments and their first token id + $arguments = []; + $argumentsTID = false; + + //Tokens used to re-enable token detection + $stopTokens = [T_STRING, T_WHITESPACE, T_DOUBLE_COLON, T_OBJECT_OPERATOR, T_NS_SEPARATOR]; + foreach ($tokens as $tokenID => $token) { + $tokenType = $token[self::TOKEN_TYPE]; + + //We are not indexing function declarations or functions called from $objects. + if (\in_array($tokenType, [T_FUNCTION, T_OBJECT_OPERATOR, T_NEW])) { + if ( + empty($argumentsTID) + && ( + empty($invocationTID) + || $this->getSource($invocationTID, $tokenID - 1) !== '$this' + ) + ) { + //Not a call, function declaration, or object method + $ignore = true; + continue; + } + } elseif ($ignore) { + if (!\in_array($tokenType, $stopTokens)) { + //Returning to search + $ignore = false; + } + continue; + } + + //We are inside function, and there is "(", indexing arguments. + if (!empty($invocationTID) && ($tokenType === '(' || $tokenType === '[')) { + if (empty($argumentsTID)) { + $argumentsTID = $tokenID; + } + + ++$level; + if ($level != 1) { + //Not arguments beginning, but arguments part + $arguments[$tokenID] = $token; + } + + continue; + } + + //We are inside function arguments and ")" met. + if (!empty($invocationTID) && ($tokenType === ')' || $tokenType === ']')) { + --$level; + if ($level == -1) { + $invocationTID = false; + $level = 0; + continue; + } + + //Function fully indexed, we can process it now. + if ($level == 0) { + $this->registerInvocation( + $invocationTID, + $argumentsTID, + $tokenID, + $arguments, + $invocationLevel, + ); + + //Closing search + $arguments = []; + $argumentsTID = $invocationTID = false; + } else { + //Not arguments beginning, but arguments part + $arguments[$tokenID] = $token; + } + + continue; + } + + //Still inside arguments. + if (!empty($invocationTID) && !empty($level)) { + $arguments[$tokenID] = $token; + continue; + } + + //Nothing valuable to remember, will be parsed later. + if (!empty($invocationTID) && \in_array($tokenType, $stopTokens)) { + continue; + } + + //Seems like we found function/method call + if ( + $tokenType == T_STRING + || $tokenType == T_STATIC + || $tokenType == T_NS_SEPARATOR + || ($tokenType == T_VARIABLE && $token[self::TOKEN_CODE] === '$this') + ) { + $invocationTID = $tokenID; + $level = 0; + + $argumentsTID = false; + continue; + } + + //Returning to search + $invocationTID = false; + $arguments = []; + } + } + + /** + * Registering invocation. + */ + private function registerInvocation( + int $invocationID, + int $argumentsID, + int $endID, + array $arguments, + int $invocationLevel, + ): void { + //Nested invocations + $this->locateInvocations($arguments, $invocationLevel + 1); + + [$class, $operator, $name] = $this->fetchContext($invocationID, $argumentsID); + + if (!empty($operator) && empty($class)) { + //Non detectable + return; + } + + $this->invocations[] = new TokenizedInvocation( + $this->path, + $this->lineNumber($invocationID), + $class, + $operator, + $name, + TokenizedArgument::locateArguments($arguments), + $this->getSource($invocationID, $endID), + $invocationLevel, + ); + } + + /** + * Fetching invocation context. + * @return array{class-string|"", "::"|"->"|"", non-empty-string} [class, operator, name] + */ + private function fetchContext(int $invocationTID, int $argumentsTID): array + { + $class = $operator = ''; + $name = \trim($this->getSource($invocationTID, $argumentsTID), '( '); + + //Let's try to fetch all information we need + if (\str_contains($name, '->')) { + $operator = '->'; + } elseif (\str_contains($name, '::')) { + $operator = '::'; + } + + if (!empty($operator)) { + [$class, $name] = \explode($operator, $name); + + //We now have to clarify class name + if (\in_array($class, ['self', 'static', '$this'])) { + $class = $this->activeDeclaration($invocationTID); + } + } + + return [$class, $operator, $name]; + } + + /** + * Get declaration which is active in given token position. + */ + private function activeDeclaration(int $tokenID): string + { + foreach ($this->declarations as $declarations) { + foreach ($declarations as $name => $position) { + if ($tokenID >= $position[self::O_TOKEN] && $tokenID <= $position[self::C_TOKEN]) { + return $name; + } + } + } + + //Can not be detected + return ''; + } + + /** + * Get namespace name active at specified token position. + */ + private function activeNamespace(int $tokenID): string + { + foreach ($this->namespaces as $namespace => $position) { + if ($tokenID >= $position[self::O_TOKEN] && $tokenID <= $position[self::C_TOKEN]) { + return $namespace; + } + } + + //Seems like no namespace declaration + $this->namespaces[''] = [ + self::O_TOKEN => 0, + self::C_TOKEN => \count($this->tokens), + self::N_USES => [], + ]; + + return ''; + } + + /** + * Find token ID of ending brace. + */ + private function endingToken(int $tokenID): int + { + $level = null; + for ($localID = $tokenID; $localID < $this->countTokens; ++$localID) { + $token = $this->tokens[$localID]; + if ($token[self::TOKEN_CODE] === '{') { + ++$level; + continue; + } + + if ($token[self::TOKEN_CODE] === '}') { + --$level; + } + + if ($level === 0) { + break; + } + } + + return $localID; + } + + /** + * Get line number associated with token. + */ + private function lineNumber(int $tokenID): int + { + while (empty($this->tokens[$tokenID][self::TOKEN_LINE])) { + --$tokenID; + } + + return $this->tokens[$tokenID][self::TOKEN_LINE]; + } + + /** + * Get src located between two tokens. + */ + private function getSource(int $startID, int $endID): string + { + $result = ''; + for ($tokenID = $startID; $tokenID <= $endID; ++$tokenID) { + //Collecting function usage src + $result .= $this->tokens[$tokenID][self::TOKEN_CODE]; + } + + return $result; + } +} diff --git a/src/Module/Tokenizer/Reflection/TokenizedInvocation.php b/src/Module/Tokenizer/Reflection/TokenizedInvocation.php new file mode 100644 index 0000000..d6b7c91 --- /dev/null +++ b/src/Module/Tokenizer/Reflection/TokenizedInvocation.php @@ -0,0 +1,83 @@ +). + * @var '::'|'->'|'' + */ + public readonly string $operator, + /** + * Function or method name. + * @var non-empty-string + */ + public readonly string $name, + /** + * All parsed function arguments. + * + * @var TokenizedArgument[] + */ + public readonly array $arguments, + /** + * Function usage src. + */ + public readonly string $source, + /** + * Invoking level. + */ + public readonly int $level, + ) {} + + /** + * Call made by class method. + */ + public function isMethod(): bool + { + return !empty($this->class); + } + + /** + * Get call argument by it position. + */ + public function getArgument(int $index): TokenizedArgument + { + if (!isset($this->arguments[$index])) { + throw new ReflectionException(\sprintf("No such argument with index '%d'", $index)); + } + + return $this->arguments[$index]; + } +} diff --git a/src/Suite/Dto/CaseDefinitions.php b/src/Suite/Dto/CaseDefinitions.php new file mode 100644 index 0000000..06b23b3 --- /dev/null +++ b/src/Suite/Dto/CaseDefinitions.php @@ -0,0 +1,47 @@ + + */ + private array $cases = []; + + public static function fromArray(CaseDefinition ...$values): self + { + $self = new self(); + $self->cases = \array_values($values); + return $self; + } + + public function define(?\ReflectionClass $reflection): CaseDefinition + { + foreach ($this->cases as $case) { + if ($case->reflection === $reflection) { + return $case; + } + } + + return $this->cases[] = new CaseDefinition($reflection); + } + + /** + * Get all located test cases. + * + * @return list + */ + public function getCases(): array + { + return $this->cases; + } +} diff --git a/src/Suite/Dto/SuiteInfo.php b/src/Suite/Dto/SuiteInfo.php new file mode 100644 index 0000000..f5b9da6 --- /dev/null +++ b/src/Suite/Dto/SuiteInfo.php @@ -0,0 +1,14 @@ + + */ +final class SuiteResult implements \IteratorAggregate +{ + public function __construct( + /** + * Test result collection. + * + * @var iterable + */ + public readonly iterable $results, + ) {} + + /** + * @return \Traversable + */ + public function getIterator(): \Traversable + { + yield from $this->results; + } +} diff --git a/src/Suite/SuiteCollector.php b/src/Suite/SuiteCollector.php new file mode 100644 index 0000000..1a0a4bf --- /dev/null +++ b/src/Suite/SuiteCollector.php @@ -0,0 +1,133 @@ + */ + private array $suites = []; + + public function __construct( + // private readonly ClassLoader $classLoader, + private readonly InterceptorProvider $interceptorProvider, + ) {} + + public function get(string $name): ?SuiteInfo + { + return $this->suites[$name] ?? null; + } + + public function getOrCreate(SuiteConfig $config): SuiteInfo + { + return $this->suites[$config->name] ??= $this->createInfo($config); + } + + private function createInfo(SuiteConfig $config): SuiteInfo + { + $files = $this->getFilesIterator($config); + $definitions = $this->getCaseDefinitions($config, $files); + + $cases = []; + foreach ($definitions as $definition) { + # Skip empty test cases + if ($definition->tests === []) { + continue; + } + + $cases[] = $definition; + } + + return new SuiteInfo( + name: $config->name, + testCases: CaseDefinitions::fromArray(...$cases), + ); + } + + /** + * Locate test files based on the suite configuration and {@see FileLocatorInterceptor} interceptors. + * + * @return iterable + */ + private function getFilesIterator(SuiteConfig $config): iterable + { + $locator = new FileLocator(new Finder($config->location)); + + # Prepare interceptors pipeline + $interceptors = $this->interceptorProvider->fromClasses(FileLocatorInterceptor::class); + + # todo remove: + // $interceptors[] = new FilePostfixTestLocatorInterceptor(); + $interceptors[] = new TestoAttributesLocatorInterceptor(); + + /** + * @see FileLocatorInterceptor::locateFile() + * @var callable(TokenizedFile): (null|bool) $pipeline + */ + $pipeline = Pipeline::prepare(...$interceptors) + ->with(static fn(TokenizedFile $file): ?bool => null, 'locateFile'); + + foreach ($locator->getIterator() as $fileReflection) { + $match = $pipeline($fileReflection); + + if ($match === true) { + yield $fileReflection; + } + } + } + + /** + * Fetch test case definitions from the given files using {@see CaseLocatorInterceptor} interceptors. + * + * @param iterable $files + * @return list + */ + private function getCaseDefinitions(SuiteConfig $config, iterable $files): array + { + $cases = []; + # Prepare interceptors pipeline + $interceptors = $this->interceptorProvider->fromClasses(CaseLocatorInterceptor::class); + + // todo remove: + $interceptors[] = new FilePostfixTestLocatorInterceptor(); + + /** + * @see CaseLocatorInterceptor::locateTestCases() + * @var callable(FileDefinitions): CaseDefinitions $pipeline + */ + $pipeline = Pipeline::prepare(...$interceptors) + ->with( + static fn(FileDefinitions $definitions): CaseDefinitions => $definitions->cases, + 'locateTestCases', + ); + + foreach ($files as $file) { + $fileDef = new FileDefinitions($file); + $result = $pipeline($fileDef); + + $cases = \array_merge($cases, $result->getCases()); + } + + return $cases; + } +} diff --git a/src/Suite/SuiteProvider.php b/src/Suite/SuiteProvider.php new file mode 100644 index 0000000..03450dd --- /dev/null +++ b/src/Suite/SuiteProvider.php @@ -0,0 +1,63 @@ + */ + private readonly array $configs; + + public function __construct( + ApplicationConfig $applicationConfig, + private readonly SuiteCollector $collector, + ) { + $this->configs = $applicationConfig->suites; + } + + /** + * @psalm-immutable + */ + public function withFilter(Filter $filter): self + { + # Apply suite name filter if exists + if ($filter->testSuites === []) { + return $this; + } + + $suites = \array_filter( + $this->configs, + static fn(SuiteConfig $suite) => \in_array($suite->name, $filter->testSuites, true), + ); + + /** @see self::$suites */ + return $this->with('suites', $suites); + } + + /** + * Gets test suite definitions with applied filter. + * + * @return array + */ + public function getSuites(): array + { + $result = []; + foreach ($this->configs as $config) { + $result[] = $this->collector->getOrCreate($config); + } + + return $result; + } +} diff --git a/src/Suite/SuiteRunner.php b/src/Suite/SuiteRunner.php new file mode 100644 index 0000000..b3e4c36 --- /dev/null +++ b/src/Suite/SuiteRunner.php @@ -0,0 +1,45 @@ +name === null or $filter = $filter->withTestSuites($suite->name); + + // todo if random, run in random order? + + $runner = $this->caseRunner; + $results = []; + # Run tests for each case + foreach ($suite->testCases->getCases() as $caseDefinition) { + try { + $caseInfo = new CaseInfo( + definition: $caseDefinition, + ); + $results[] = $runner->runCase($caseInfo, $filter); + } catch (\Throwable) { + // Skip for now + } + } + + return new SuiteResult($results); + } +} diff --git a/src/Test/CaseRunner.php b/src/Test/CaseRunner.php new file mode 100644 index 0000000..b430016 --- /dev/null +++ b/src/Test/CaseRunner.php @@ -0,0 +1,63 @@ + $interceptors + * @var callable(CaseInfo): CaseResult $pipeline + */ + $interceptors = $this->interceptorProvider->fromClasses(TestCaseCallInterceptor::class); + + // todo remove + $interceptors[] = new TestCaseCallInterceptor\InstantiateTestCase(); + + $pipeline = Pipeline::prepare(...$interceptors) + ->with( + fn(CaseInfo $info): CaseResult => $this->run($info), + 'runTestCase', + ); + + return $pipeline($info); + } + + public function run(CaseInfo $info): CaseResult + { + $results = []; + foreach ($info->definition->tests->getTests() as $testDefinition) { + $testInfo = new TestInfo( + caseInfo: $info, + testDefinition: $testDefinition, + ); + + $results[] = $this->testRunner->runTest($testInfo); + } + + return new CaseResult($results); + } +} diff --git a/src/Test/Dto/CaseDefinition.php b/src/Test/Dto/CaseDefinition.php new file mode 100644 index 0000000..b78b03a --- /dev/null +++ b/src/Test/Dto/CaseDefinition.php @@ -0,0 +1,14 @@ +with('instance', $instance); + } +} diff --git a/src/Test/Dto/CaseResult.php b/src/Test/Dto/CaseResult.php new file mode 100644 index 0000000..4da38f6 --- /dev/null +++ b/src/Test/Dto/CaseResult.php @@ -0,0 +1,27 @@ + + */ +final class CaseResult implements \IteratorAggregate +{ + public function __construct( + /** + * Test result collection. + * + * @var iterable + */ + public readonly iterable $results, + ) {} + + public function getIterator(): \Traversable + { + yield from $this->results; + } +} diff --git a/src/Test/Dto/Status.php b/src/Test/Dto/Status.php new file mode 100644 index 0000000..635863e --- /dev/null +++ b/src/Test/Dto/Status.php @@ -0,0 +1,43 @@ + + */ + private array $tests = []; + + public static function fromArray(TestDefinition ...$values): self + { + $self = new self(); + $self->tests = \array_values($values); + return $self; + } + + public function define(\ReflectionFunctionAbstract $reflection): TestDefinition + { + return $this->tests[$reflection->getShortName()] = new TestDefinition($reflection); + } + + /** + * Get all located test cases. + * + * @return array + */ + public function getTests(): array + { + return $this->tests; + } +} diff --git a/src/Test/Dto/TestInfo.php b/src/Test/Dto/TestInfo.php new file mode 100644 index 0000000..dd172aa --- /dev/null +++ b/src/Test/Dto/TestInfo.php @@ -0,0 +1,16 @@ +info, + result: $this->result, + status: $status ?? $this->status, + ); + } + + public function withResult(mixed $result): self + { + return new self( + info: $this->info, + result: $result, + status: $this->status, + ); + } +} diff --git a/src/Test/TestRunner.php b/src/Test/TestRunner.php new file mode 100644 index 0000000..2e8460b --- /dev/null +++ b/src/Test/TestRunner.php @@ -0,0 +1,66 @@ +prepareInterceptors($info); + return Pipeline::prepare(...$interceptors)->with( + static function (TestInfo $info): TestResult { + # TODO resolve arguments + $instance = $info->caseInfo->instance; + $result = $instance === null + ? $info->testDefinition->reflection->invoke() + : $info->testDefinition->reflection->invoke($instance); + + return new TestResult( + $info, + $result, + Status::Passed, + ); + }, + /** @see TestCallInterceptor::runTest() */ + 'runTest', + )($info); + } + + /** + * @return list + */ + private function prepareInterceptors(TestInfo $info): array + { + $classAttributes = $info->caseInfo->definition->reflection?->getAttributes( + Interceptable::class, + \ReflectionAttribute::IS_INSTANCEOF, + ) ?? []; + $methodAttributes = $info->testDefinition->reflection->getAttributes( + Interceptable::class, + \ReflectionAttribute::IS_INSTANCEOF, + ); + + # Merge and instantiate attributes + $attrs = \array_map( + static fn(\ReflectionAttribute $a): Interceptable => $a->newInstance(), + \array_merge($classAttributes, $methodAttributes), + ); + + return $this->interceptorProvider->fromAttributes(TestCallInterceptor::class, ...$attrs); + } +} diff --git a/src/Test/TestsProvider.php b/src/Test/TestsProvider.php new file mode 100644 index 0000000..cbf1709 --- /dev/null +++ b/src/Test/TestsProvider.php @@ -0,0 +1,40 @@ + + */ + public function getTests(): iterable + { + yield from []; + } + + /** + * Gets test case definitions with applied filter. + * + * @return iterable + */ + public function getCases(): iterable + { + yield from []; + } +} diff --git a/tests/Fixture/TestInterceptors.php b/tests/Fixture/TestInterceptors.php new file mode 100644 index 0000000..6778f38 --- /dev/null +++ b/tests/Fixture/TestInterceptors.php @@ -0,0 +1,20 @@ + ['C:/Users/test', true]; + yield 'windows drive letter' => ['C:', true]; + yield 'windows relative path' => ['Users/test', false]; + yield 'windows implicit relative' => ['./test', false]; + yield 'unix absolute path' => ['/home/user', true]; + yield 'unix relative path' => ['home/user', false]; + yield 'unix implicit relative' => ['./test', false]; + yield 'dot path' => ['.', false]; + yield 'double dot path' => ['..', false]; + } + + public static function providePathsForParent(): \Generator + { + yield ['.', '..']; + yield ['..', '../..']; + yield ['path/to/..', '.']; + yield ['/home', '/.']; + yield ['C:/Users', 'C:/.']; + yield ['C:/.', 'C:/.']; + yield ['filename.txt', '.']; + yield ['some/path/file.txt', 'some/path']; + } + + public function testCreateReturnsPathInstance(): void + { + // Arrange & Act + $path = Path::create('test/path'); + + // Assert + self::assertInstanceOf(Path::class, $path); + } + + public function testCreateWithEmptyPathReturnsCurrentDirectory(): void + { + // Arrange & Act + $path = Path::create(''); + + // Assert + self::assertSame('.', (string) $path); + } + + public function testCreateNormalizesDirectorySeparators(): void + { + // Arrange & Act + $path = Path::create('test\\path/mixed/separators\\here'); + + // Assert + self::assertSame('test/path/mixed/separators/here', (string) $path); + } + + public function testCreateRemovesMultipleSeparators(): void + { + // Arrange & Act + $path = Path::create('test//path///extra//separators'); + + // Assert + self::assertSame('test/path/extra/separators', (string) $path); + } + + public function testCreateResolvesCurrentDirectorySegments(): void + { + // Arrange & Act + $path = Path::create('test/./path/./current'); + + // Assert + self::assertSame('test/path/current', (string) $path); + } + + public function testCreateResolvesParentDirectorySegments(): void + { + // Arrange & Act + $path = Path::create('test/parent/../path'); + + // Assert + self::assertSame('test/path', (string) $path); + } + + public function testCreateThrowsExceptionForInvalidParentNavigation(): void + { + // Arrange & Assert + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Cannot go up from root'); + + // Act + Path::create('/test/../..'); + } + + public function testJoinPathComponents(): void + { + // Arrange + $path = Path::create('base/path'); + + // Act + $result = $path->join('additional', 'components'); + + // Assert + self::assertSame('base/path/additional/components', (string) $result); + } + + public function testJoinWithEmptyComponentsIgnoresThem(): void + { + // Arrange + $path = Path::create('base/path'); + + // Act + $result = $path->join('', 'component', ''); + + // Assert + self::assertSame('base/path/component', (string) $result); + } + + public function testJoinWithPathObjects(): void + { + // Arrange + $path = Path::create('base/path'); + $additionalPath = Path::create('additional/path'); + + // Assert (prepare for expected exception) + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Joining an absolute path is not allowed'); + + // Act + // Using an absolute Path object which should throw + $path->join($additionalPath->absolute()); + } + + public function testJoinWithRelativePathObjects(): void + { + // Arrange + $path = Path::create('base/path'); + $additionalPath = Path::create('additional/path'); + + // Act + $result = $path->join($additionalPath); + + // Assert + self::assertSame('base/path/additional/path', (string) $result); + } + + public function testJoinWithAbsolutePathString(): void + { + // Arrange + $path = Path::create('base/path'); + + // Assert (prepare for expected exception) + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Joining an absolute path is not allowed'); + + // Act + $path->join('/absolute/path'); + } + + public function testName(): void + { + // Arrange + $path = Path::create('some/path/file.txt'); + + // Act + $name = $path->name(); + + // Assert + self::assertSame('file.txt', $name); + } + + public function testNameWithNoDirectoryComponents(): void + { + // Arrange + $path = Path::create('file.txt'); + + // Act + $name = $path->name(); + + // Assert + self::assertSame('file.txt', $name); + } + + public function testStem(): void + { + // Arrange + $path = Path::create('some/path/file.txt'); + + // Act + $stem = $path->stem(); + + // Assert + self::assertSame('file', $stem); + } + + public function testStemWithNoExtension(): void + { + // Arrange + $path = Path::create('some/path/file'); + + // Act + $stem = $path->stem(); + + // Assert + self::assertSame('file', $stem); + } + + public function testStemWithMultipleDots(): void + { + // Arrange + $path = Path::create('some/path/file.config.json'); + + // Act + $stem = $path->stem(); + + // Assert + self::assertSame('file.config', $stem); + } + + public function testStemWithHiddenFile(): void + { + // Arrange + $path = Path::create('some/path/.hidden'); + + // Act + $stem = $path->stem(); + + // Assert + self::assertSame('.hidden', $stem); + } + + public function testExtension(): void + { + // Arrange + $path = Path::create('some/path/file.txt'); + + // Act + $extension = $path->extension(); + + // Assert + self::assertSame('txt', $extension); + } + + public function testExtensionWithMultipleDots(): void + { + // Arrange + $path = Path::create('some/path/file.config.json'); + + // Act + $extension = $path->extension(); + + // Assert + self::assertSame('json', $extension); + } + + public function testExtensionWithNoExtension(): void + { + // Arrange + $path = Path::create('some/path/file'); + + // Act + $extension = $path->extension(); + + // Assert + self::assertSame('', $extension); + } + + public function testExtensionWithHiddenFile(): void + { + // Arrange + $path = Path::create('some/path/.hidden'); + + // Act + $extension = $path->extension(); + + // Assert + self::assertSame('hidden', $extension); + } + + #[DataProvider('providePathsForParent')] + public function testParent(string $inputPath, string $expectedParent): void + { + // Arrange + $path = Path::create($inputPath); + + // Act + $parent = $path->parent(); + + // Assert + self::assertSame($expectedParent, (string) $parent); + } + + #[DataProvider('providePathsForAbsoluteDetection')] + public function testIsAbsolute(string $pathString, bool $expected): void + { + // Arrange + $path = Path::create($pathString); + + // Act + $isAbsolute = $path->isAbsolute(); + + // Assert + self::assertSame($expected, $isAbsolute, "Path '$pathString' should be " . ($expected ? 'absolute' : 'relative')); + } + + public function testIsRelative(): void + { + // Arrange + $absolutePath = DIRECTORY_SEPARATOR === '\\' + ? Path::create('C:/Users/test') + : Path::create('/home/user'); + + $relativePath = Path::create('relative/path'); + + // Act & Assert + self::assertFalse($absolutePath->isRelative()); + self::assertTrue($relativePath->isRelative()); + } + + /** + * This test uses real filesystem access to check if a path exists. + * It creates a temporary file and checks its existence. + */ + public function testExists(): void + { + // Arrange + $tempFile = \tempnam(\sys_get_temp_dir(), 'path_test_'); + self::assertIsString($tempFile, 'Failed to create temp file'); + + $path = Path::create($tempFile); + $nonExistingPath = Path::create('non/existing/path/file.txt'); + + // Act & Assert + try { + self::assertTrue($path->exists()); + self::assertFalse($nonExistingPath->exists()); + } finally { + // Clean up + @\unlink($tempFile); + } + } + + /** + * Note: This test might have limitations depending on the environment. + * It checks the expected behavior of isDir without requiring an actual directory to exist. + */ + public function testIsDir(): void + { + // Arrange + $currentDirPath = Path::create('.'); + $parentDirPath = Path::create('..'); + $filePath = Path::create('file.txt'); + + // Act & Assert + self::assertTrue($currentDirPath->isDir()); + self::assertTrue($parentDirPath->isDir()); + self::assertFalse($filePath->isDir()); + } + + /** + * Note: This test might have limitations depending on the environment. + * It checks the expected behavior of isFile without requiring an actual file to exist. + */ + public function testIsFile(): void + { + // Arrange + $currentDirPath = Path::create('.'); + $parentDirPath = Path::create('..'); + $filePath = Path::create('file.txt'); + + // Create a temporary file to test with + $tempFile = \tempnam(\sys_get_temp_dir(), 'path_test_'); + self::assertIsString($tempFile, 'Failed to create temp file'); + $realFilePath = Path::create($tempFile); + + // Act & Assert + try { + self::assertFalse($currentDirPath->isFile()); + self::assertFalse($parentDirPath->isFile()); + self::assertFalse($filePath->isFile()); // Doesn't exist yet + self::assertTrue($realFilePath->isFile(), "Temporary file should be a file `$realFilePath`"); + } finally { + // Clean up + @\unlink($tempFile); + } + } + + public function testAbsoluteForAlreadyAbsolutePath(): void + { + // Arrange + $absolutePath = DIRECTORY_SEPARATOR === '\\' + ? Path::create('C:/Users/test') + : Path::create('/home/user'); + + // Act + $result = $absolutePath->absolute(); + + // Assert + self::assertSame((string) $absolutePath, (string) $result); + } + + public function testAbsoluteForRelativePath(): void + { + // Arrange + $relativePath = Path::create('relative/path'); + + // Skip this test if we can't get cwd + $cwd = \getcwd(); + if ($cwd === false) { + self::markTestSkipped('Cannot get current working directory'); + } + + $expected = Path::create($cwd . DIRECTORY_SEPARATOR . 'relative/path'); + + // Act + $result = $relativePath->absolute(); + + // Assert + self::assertSame((string) $expected, (string) $result); + } + + public function testCreateWindowsTmpFile(): void + { + $path = Path::create('C:\Users\roxbl\AppData\Local\Temp\patB6E7.tmp'); + + self::assertSame('C:/Users/roxbl/AppData/Local/Temp/patB6E7.tmp', (string) $path); + } + + public function testToString(): void + { + // Arrange + $pathString = 'some/path/file.txt'; + $path = Path::create($pathString); + + // Act + $result = (string) $path; + + // Assert + self::assertSame('some/path/file.txt', $result); + } +} diff --git a/tests/Unit/Test/TestsRunnerTest.php b/tests/Unit/Test/TestsRunnerTest.php new file mode 100644 index 0000000..2242198 --- /dev/null +++ b/tests/Unit/Test/TestsRunnerTest.php @@ -0,0 +1,68 @@ +runTest($info); + + self::assertSame(3, $result->result); + self::assertSame(Status::Flaky, $result->status); + } + + /** + * @see \Tests\Fixture\withRetryPolicy() + */ + public function testRunFunctionWithRetry(): void + { + $instance = self::createInstance(); + $info = new TestInfo( + caseInfo: new CaseInfo( + instance: new TestInterceptors(), + ), + testDefinition: new TestDefinition( + reflection: new \ReflectionFunction(\Tests\Fixture\withRetryPolicy(...)), + ), + ); + + $result = $instance->runTest($info); + + self::assertSame(3, $result->result); + self::assertSame(Status::Passed, $result->status); + } + + private static function createInstance(): TestRunner + { + return new TestRunner(InterceptorProvider::createDefault()); + } +}