From 0b47d2a13861ac78c9b7701e713e5d8e0b0c2caa Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sun, 12 Oct 2025 18:12:27 +0400 Subject: [PATCH 01/20] feat: Prepare console command `run` --- bin/testo | 56 +++++++++++++ bin/testo.bat | 10 +++ resources/version.json | 3 + src/Internal/Bootstrap.php | 61 +++++++++++++++ src/Internal/Command/Base.php | 76 ++++++++++++++++++ src/Internal/Command/Run.php | 23 ++++++ src/Internal/Info.php | 55 +++++++++++++ src/Internal/Service/Container.php | 122 +++++++++++++++++++++++++++++ 8 files changed, 406 insertions(+) create mode 100644 bin/testo create mode 100644 bin/testo.bat create mode 100644 resources/version.json create mode 100644 src/Internal/Bootstrap.php create mode 100644 src/Internal/Command/Base.php create mode 100644 src/Internal/Command/Run.php create mode 100644 src/Internal/Info.php create mode 100644 src/Internal/Service/Container.php 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/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/Internal/Bootstrap.php b/src/Internal/Bootstrap.php new file mode 100644 index 0000000..391f9a2 --- /dev/null +++ b/src/Internal/Bootstrap.php @@ -0,0 +1,61 @@ +container; + unset($this->container); + + return $c; + } + + /** + * Configures the container with XML configuration and input values. + * + * Registers core services and bindings for system architecture, OS detection, + * and stability settings. + * + * @return self Configured bootstrap instance + * @throws \InvalidArgumentException When config file is not found + * @throws \RuntimeException When config file cannot be read + */ + public function withConfig( + ): self { + return $this; + } +} diff --git a/src/Internal/Command/Base.php b/src/Internal/Command/Base.php new file mode 100644 index 0000000..4f681f5 --- /dev/null +++ b/src/Internal/Command/Base.php @@ -0,0 +1,76 @@ +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 { + $this->container = $container = Bootstrap::init()->withConfig()->finish(); + + $container->set($input, InputInterface::class); + $container->set($output, OutputInterface::class); + $container->set(new SymfonyStyle($input, $output), StyleInterface::class); + + return (new Injector($container))->invoke($this) ?? Command::SUCCESS; + } +} diff --git a/src/Internal/Command/Run.php b/src/Internal/Command/Run.php new file mode 100644 index 0000000..8d8a71f --- /dev/null +++ b/src/Internal/Command/Run.php @@ -0,0 +1,23 @@ + */ + 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[Container::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); + } +} From 77d11d8192b403bc22c01f72da7b009281d59f8c Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sun, 12 Oct 2025 18:20:36 +0400 Subject: [PATCH 02/20] chore: Add LLM guidelines; update metafiles --- .gitattributes | 2 +- .gitmodules | 3 +++ CLAUDE.local.md | 50 +++++++++++++++++++++++++++++++++++++++++++++++++ docs/guidelines | 1 + psalm.xml | 21 +++++++++++++++++++++ 5 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 .gitmodules create mode 100644 CLAUDE.local.md create mode 160000 docs/guidelines create mode 100644 psalm.xml 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/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/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/psalm.xml b/psalm.xml new file mode 100644 index 0000000..f5356e7 --- /dev/null +++ b/psalm.xml @@ -0,0 +1,21 @@ + + + + + + + + + From 5dd77bbcc8040ab58fc3e54da7e79a0de2856d5d Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sun, 12 Oct 2025 20:04:07 +0400 Subject: [PATCH 03/20] feat: Init interceptors abstraction --- src/Attribute/Interceptable.php | 12 +++ src/Interceptor/Factory.php | 37 +++++++ src/Interceptor/InterceptorProvider.php | 34 +++++++ .../Internal/InterceptorMarker.php | 13 +++ src/Interceptor/Internal/Pipeline.php | 97 +++++++++++++++++++ src/Interceptor/RunTest/Input.php | 8 ++ src/Interceptor/RunTest/Output.php | 8 ++ src/Interceptor/RunTestInterceptor.php | 17 ++++ 8 files changed, 226 insertions(+) create mode 100644 src/Attribute/Interceptable.php create mode 100644 src/Interceptor/Factory.php create mode 100644 src/Interceptor/InterceptorProvider.php create mode 100644 src/Interceptor/Internal/InterceptorMarker.php create mode 100644 src/Interceptor/Internal/Pipeline.php create mode 100644 src/Interceptor/RunTest/Input.php create mode 100644 src/Interceptor/RunTest/Output.php create mode 100644 src/Interceptor/RunTestInterceptor.php 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 @@ +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/InterceptorProvider.php b/src/Interceptor/InterceptorProvider.php new file mode 100644 index 0000000..3cbe838 --- /dev/null +++ b/src/Interceptor/InterceptorProvider.php @@ -0,0 +1,34 @@ +, class-string> + */ + private array $map = []; + + public function fromAttribute(Interceptable $attribute): InterceptorMarker + { + $class = $attribute::class; + do { + if (\array_key_exists($class, $this->map)) { + return $this->factory->make($this->map[$class], [$attribute]); + } + + $class = \get_parent_class($attribute); + } while ($class); + + throw new \RuntimeException("No interceptor found for attribute {$attribute::class}."); + } +} diff --git a/src/Interceptor/Internal/InterceptorMarker.php b/src/Interceptor/Internal/InterceptorMarker.php new file mode 100644 index 0000000..afef4d2 --- /dev/null +++ b/src/Interceptor/Internal/InterceptorMarker.php @@ -0,0 +1,13 @@ + */ + 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. + */ + public static function prepare(TInterceptor ...$interceptors): self + { + return new self($interceptors); + } + + /** + * @param non-empty-string $method Method name of the all interceptors. + * + * @return TCallable + */ + 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 object $input Input value for the first interceptor. + * + * @return TReturn + */ + public function __invoke(object $input): object + { + $interceptor = $this->interceptors[$this->current] ?? null; + + if ($interceptor === null) { + return ($this->last)($input); + } + + $next = $this->next(); + $input[] = $next; + + return $interceptor->{$this->method}($input); + } + + private function next(): self + { + $new = clone $this; + ++$new->current; + + return $new; + } +} diff --git a/src/Interceptor/RunTest/Input.php b/src/Interceptor/RunTest/Input.php new file mode 100644 index 0000000..d5c2540 --- /dev/null +++ b/src/Interceptor/RunTest/Input.php @@ -0,0 +1,8 @@ + Date: Sun, 12 Oct 2025 21:36:39 +0400 Subject: [PATCH 04/20] feat: Add internal Container --- src/Internal/Bootstrap.php | 4 +- src/Internal/CloneWith.php | 29 ++++++ src/Internal/Command/Base.php | 2 +- src/Internal/Container.php | 84 ++++++++++++++++ src/Internal/Service/ObjectContainer.php | 123 +++++++++++++++++++++++ 5 files changed, 239 insertions(+), 3 deletions(-) create mode 100644 src/Internal/CloneWith.php create mode 100644 src/Internal/Container.php create mode 100644 src/Internal/Service/ObjectContainer.php diff --git a/src/Internal/Bootstrap.php b/src/Internal/Bootstrap.php index 391f9a2..0881fa0 100644 --- a/src/Internal/Bootstrap.php +++ b/src/Internal/Bootstrap.php @@ -4,7 +4,7 @@ namespace Testo\Internal; -use Testo\Internal\Service\Container; +use Testo\Internal\Service\ObjectContainer; /** * Bootstraps the application by configuring the dependency container. @@ -26,7 +26,7 @@ private function __construct( * @param Container $container Dependency injection container * @return self Bootstrap instance */ - public static function init(Container $container = new Container()): self + public static function init(Container $container = new ObjectContainer()): self { return new self($container); } diff --git a/src/Internal/CloneWith.php b/src/Internal/CloneWith.php new file mode 100644 index 0000000..2e58b42 --- /dev/null +++ b/src/Internal/CloneWith.php @@ -0,0 +1,29 @@ +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 index 4f681f5..e0d53bd 100644 --- a/src/Internal/Command/Base.php +++ b/src/Internal/Command/Base.php @@ -11,7 +11,7 @@ use Symfony\Component\Console\Style\StyleInterface; use Symfony\Component\Console\Style\SymfonyStyle; use Testo\Internal\Bootstrap; -use Testo\Internal\Service\Container; +use Testo\Internal\Container; use Yiisoft\Injector\Injector; /** 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/Service/ObjectContainer.php b/src/Internal/Service/ObjectContainer.php new file mode 100644 index 0000000..d73ee83 --- /dev/null +++ b/src/Internal/Service/ObjectContainer.php @@ -0,0 +1,123 @@ + */ + 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); + } +} From 0630c773bc862af4c64d71ac032ebb4ba1c74ee6 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sun, 12 Oct 2025 21:38:43 +0400 Subject: [PATCH 05/20] chore: Configure CS Fixer --- .php-cs-fixer.dist.php | 10 ++++++++++ composer.json | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 .php-cs-fixer.dist.php 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/composer.json b/composer.json index b1529c7..62b863c 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,6 @@ }, "require-dev": { "buggregator/trap": "^1.10", - "infection/infection": "^0.31", "internal/dload": "^1.6", "spiral/code-style": "^2.2.2", "vimeo/psalm": "^6.10" @@ -74,6 +73,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" } } From 8142a43a72a2acea38cfb0b592c100fd50018bc1 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 13 Oct 2025 00:16:40 +0400 Subject: [PATCH 06/20] feat: Add test runner; fix interceptors --- composer.json | 6 +- src/Attribute/RetryPolicy.php | 16 +++++ src/Attribute/Test.php | 11 +++ src/Dto/Case/CaseDefinition.php | 12 ++++ src/Dto/Test/TestDefinition.php | 12 ++++ src/Dto/Test/TestResult.php | 13 ++++ .../Implementation/RetryPolicyInterceptor.php | 46 ++++++++++++ src/Interceptor/InterceptorProvider.php | 63 ++++++++++++++--- src/Interceptor/Internal/Pipeline.php | 5 +- src/Interceptor/RunTest/Input.php | 10 ++- src/Interceptor/RunTest/Output.php | 8 --- src/Interceptor/RunTestInterceptor.php | 4 +- src/Test/TestsRunner.php | 70 +++++++++++++++++++ tests/Fixture/TestInterceptors.php | 20 ++++++ tests/Fixture/functions.php | 17 +++++ tests/Unit/Test/TestsRunnerTest.php | 52 ++++++++++++++ 16 files changed, 340 insertions(+), 25 deletions(-) create mode 100644 src/Attribute/RetryPolicy.php create mode 100644 src/Attribute/Test.php create mode 100644 src/Dto/Case/CaseDefinition.php create mode 100644 src/Dto/Test/TestDefinition.php create mode 100644 src/Dto/Test/TestResult.php create mode 100644 src/Interceptor/Implementation/RetryPolicyInterceptor.php delete mode 100644 src/Interceptor/RunTest/Output.php create mode 100644 src/Test/TestsRunner.php create mode 100644 tests/Fixture/TestInterceptors.php create mode 100644 tests/Fixture/functions.php create mode 100644 tests/Unit/Test/TestsRunnerTest.php diff --git a/composer.json b/composer.json index 62b863c..05888c2 100644 --- a/composer.json +++ b/composer.json @@ -32,6 +32,7 @@ "require-dev": { "buggregator/trap": "^1.10", "internal/dload": "^1.6", + "phpunit/phpunit": "^10.5", "spiral/code-style": "^2.2.2", "vimeo/psalm": "^6.10" }, @@ -45,7 +46,10 @@ "autoload-dev": { "psr-4": { "Tests\\": "tests/" - } + }, + "files": [ + "tests/Fixture/functions.php" + ] }, "bin": [ "bin/testo" diff --git a/src/Attribute/RetryPolicy.php b/src/Attribute/RetryPolicy.php new file mode 100644 index 0000000..799897e --- /dev/null +++ b/src/Attribute/RetryPolicy.php @@ -0,0 +1,16 @@ +options->maxAttempts; + $isFlaky = false; + + run: + --$attempts; + try { + $result = $next($dto); + # TODO set flaky status on result if $isFlaky is true + return $isFlaky ? $result : $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/InterceptorProvider.php b/src/Interceptor/InterceptorProvider.php index 3cbe838..b85ec2d 100644 --- a/src/Interceptor/InterceptorProvider.php +++ b/src/Interceptor/InterceptorProvider.php @@ -5,30 +5,73 @@ namespace Testo\Interceptor; use Testo\Attribute\Interceptable; +use Testo\Attribute\RetryPolicy; +use Testo\Interceptor\Implementation\RetryPolicyInterceptor; use Testo\Interceptor\Internal\InterceptorMarker; -final class InterceptorProvider { - public function __construct( - private readonly Factory $factory, - ) {} - +final class InterceptorProvider +{ /** * Map of interceptable attributes to their interceptors. * @var array, class-string> */ private array $map = []; - public function fromAttribute(Interceptable $attribute): InterceptorMarker + public function __construct( + private readonly Factory $factory = new Factory(), + ) {} + + public static function createDefault(): self + { + $self = new self(); + $self->map = [ + RetryPolicy::class => RetryPolicyInterceptor::class, + ]; + return $self; + } + + /** + * Get interceptors for the given attributes set filtered by the given class. + * + * @template-covariant T of InterceptorMarker + * + * @param class-string $class The 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 { - $class = $attribute::class; do { if (\array_key_exists($class, $this->map)) { - return $this->factory->make($this->map[$class], [$attribute]); + return $this->map[$class]; } - $class = \get_parent_class($attribute); + $class = \get_parent_class($class); } while ($class); - throw new \RuntimeException("No interceptor found for attribute {$attribute::class}."); + return null; } } diff --git a/src/Interceptor/Internal/Pipeline.php b/src/Interceptor/Internal/Pipeline.php index b5a63b4..f9b9994 100644 --- a/src/Interceptor/Internal/Pipeline.php +++ b/src/Interceptor/Internal/Pipeline.php @@ -40,7 +40,7 @@ private function __construct( array $interceptors, ) { // Reset keys - $this->interceptors[] = \array_values($interceptors); + $this->interceptors = \array_values($interceptors); } /** @@ -82,9 +82,8 @@ public function __invoke(object $input): object } $next = $this->next(); - $input[] = $next; - return $interceptor->{$this->method}($input); + return $interceptor->{$this->method}($input, $next); } private function next(): self diff --git a/src/Interceptor/RunTest/Input.php b/src/Interceptor/RunTest/Input.php index d5c2540..5e97ee7 100644 --- a/src/Interceptor/RunTest/Input.php +++ b/src/Interceptor/RunTest/Input.php @@ -4,5 +4,13 @@ namespace Testo\Interceptor\RunTest; -class Input { +use Testo\Dto\Test\TestDefinition; + +final class Input +{ + public function __construct( + public readonly TestDefinition $definition, + /** Test Case Class Instance if exists */ + public readonly ?object $instance, + ) {} } diff --git a/src/Interceptor/RunTest/Output.php b/src/Interceptor/RunTest/Output.php deleted file mode 100644 index 9037840..0000000 --- a/src/Interceptor/RunTest/Output.php +++ /dev/null @@ -1,8 +0,0 @@ -reflection?->newInstance(); + + # Build interceptors pipeline + $interceptors = $this->prepareInterceptors($definition); + return Pipeline::prepare(...$interceptors)->with( + static function (Input $input): TestResult { + # TODO resolve arguments + $result = $input->instance === null + ? $input->definition->reflection->invoke() + : $input->definition->reflection->invoke($input->instance); + + return new TestResult( + $input->definition, + $result, + ); + }, + /** @see RunTestInterceptor::runTest() */ + 'runTest', + )(new Input( + definition: $definition, + instance: $instance, + )); + } + + /** + * @return list + */ + private function prepareInterceptors(TestDefinition $test) + { + $classAttributes = $test->reflection instanceof \ReflectionMethod + ? $test->reflection->getDeclaringClass() + ->getAttributes(Interceptable::class, \ReflectionAttribute::IS_INSTANCEOF) + : []; + $methodAttributes = $test->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(RunTestInterceptor::class, ...$attrs); + } +} diff --git a/tests/Fixture/TestInterceptors.php b/tests/Fixture/TestInterceptors.php new file mode 100644 index 0000000..31c1c80 --- /dev/null +++ b/tests/Fixture/TestInterceptors.php @@ -0,0 +1,20 @@ +reflection->getMethod('withRetryPolicy'), + ); + + $result = $instance->run($caseDefinition, $testDefinition); + + self::assertSame(3, $result->result); + } + + public function testRunFunctionWithRetry(): void + { + $instance = self::createInstance(); + $caseDefinition = new CaseDefinition( + reflection: null, + ); + $testDefinition = new TestDefinition( + /** @see \Tests\Fixture\withRetryPolicy() */ + reflection: new \ReflectionFunction(\Tests\Fixture\withRetryPolicy(...)), + ); + + $result = $instance->run($caseDefinition, $testDefinition); + + self::assertSame(3, $result->result); + } + + private static function createInstance(): TestsRunner + { + return new TestsRunner(InterceptorProvider::createDefault()); + } +} From 89aa2758bcc2a0719c8e74457490a115b8c9f12e Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 13 Oct 2025 11:51:23 +0400 Subject: [PATCH 07/20] feat: Add test case runner; reorganize DTOs --- phpunit.xml.dist | 32 +++++ src/Attribute/RetryPolicy.php | 8 ++ src/Dto/Filter.php | 47 +++++++ src/Dto/Test/TestResult.php | 13 -- .../Implementation/RetryPolicyInterceptor.php | 16 +-- src/Interceptor/RunTest/Input.php | 16 --- src/Interceptor/RunTestInterceptor.php | 11 +- src/Internal/Service/Container.php | 122 ------------------ src/Test/CaseRunner.php | 41 ++++++ src/{Dto/Case => Test/Dto}/CaseDefinition.php | 2 +- src/Test/Dto/CaseInfo.php | 15 +++ src/Test/Dto/CaseResult.php | 27 ++++ src/Test/Dto/Status.php | 43 ++++++ src/{Dto/Test => Test/Dto}/TestDefinition.php | 2 +- src/Test/Dto/TestInfo.php | 21 +++ src/Test/Dto/TestResult.php | 33 +++++ src/Test/TestsProvider.php | 40 ++++++ src/Test/TestsRunner.php | 47 +++---- tests/Fixture/TestInterceptors.php | 2 +- tests/Fixture/functions.php | 2 +- tests/Unit/Test/TestsRunnerTest.php | 51 +++++--- 21 files changed, 383 insertions(+), 208 deletions(-) create mode 100644 phpunit.xml.dist create mode 100644 src/Dto/Filter.php delete mode 100644 src/Dto/Test/TestResult.php delete mode 100644 src/Interceptor/RunTest/Input.php delete mode 100644 src/Internal/Service/Container.php create mode 100644 src/Test/CaseRunner.php rename src/{Dto/Case => Test/Dto}/CaseDefinition.php (86%) create mode 100644 src/Test/Dto/CaseInfo.php create mode 100644 src/Test/Dto/CaseResult.php create mode 100644 src/Test/Dto/Status.php rename src/{Dto/Test => Test/Dto}/TestDefinition.php (86%) create mode 100644 src/Test/Dto/TestInfo.php create mode 100644 src/Test/Dto/TestResult.php create mode 100644 src/Test/TestsProvider.php 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/src/Attribute/RetryPolicy.php b/src/Attribute/RetryPolicy.php index 799897e..60b7e60 100644 --- a/src/Attribute/RetryPolicy.php +++ b/src/Attribute/RetryPolicy.php @@ -11,6 +11,14 @@ final class RetryPolicy implements Interceptable { public function __construct( + /** + * Maximum number of attempts. + */ public readonly int $maxAttempts = 3, + + /** + * Mark the test as flaky if it passed on retry. + */ + public readonly bool $markFlaky = true, ) {} } diff --git a/src/Dto/Filter.php b/src/Dto/Filter.php new file mode 100644 index 0000000..1bf5153 --- /dev/null +++ b/src/Dto/Filter.php @@ -0,0 +1,47 @@ + Names of the test suites to filter by. + */ + public readonly array $testSuites = [], + ) {} + + public static function new(): self + { + return new self(); + } + + /** + * 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/Test/TestResult.php b/src/Dto/Test/TestResult.php deleted file mode 100644 index 5eeac8f..0000000 --- a/src/Dto/Test/TestResult.php +++ /dev/null @@ -1,13 +0,0 @@ -options->maxAttempts; $isFlaky = false; @@ -32,8 +31,9 @@ public function runTest(Input $dto, callable $next): TestResult --$attempts; try { $result = $next($dto); - # TODO set flaky status on result if $isFlaky is true - return $isFlaky ? $result : $result; + 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; diff --git a/src/Interceptor/RunTest/Input.php b/src/Interceptor/RunTest/Input.php deleted file mode 100644 index 5e97ee7..0000000 --- a/src/Interceptor/RunTest/Input.php +++ /dev/null @@ -1,16 +0,0 @@ - */ - 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[Container::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/Test/CaseRunner.php b/src/Test/CaseRunner.php new file mode 100644 index 0000000..6790247 --- /dev/null +++ b/src/Test/CaseRunner.php @@ -0,0 +1,41 @@ +definition->reflection?->newInstance(); + + $tests = $this->testsProvider->withFilter($filter)->getTests(); + foreach ($tests as $testDefinition) { + $testInfo = new TestInfo( + caseInfo: $info, + testDefinition: $testDefinition, + instance: $instance, + ); + $results[] = $this->testRunner->runTest($testInfo); + } + + return new CaseResult($results); + } +} diff --git a/src/Dto/Case/CaseDefinition.php b/src/Test/Dto/CaseDefinition.php similarity index 86% rename from src/Dto/Case/CaseDefinition.php rename to src/Test/Dto/CaseDefinition.php index 994e016..0615482 100644 --- a/src/Dto/Case/CaseDefinition.php +++ b/src/Test/Dto/CaseDefinition.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Testo\Dto\Case; +namespace Testo\Test\Dto; final class CaseDefinition { diff --git a/src/Test/Dto/CaseInfo.php b/src/Test/Dto/CaseInfo.php new file mode 100644 index 0000000..b41ac63 --- /dev/null +++ b/src/Test/Dto/CaseInfo.php @@ -0,0 +1,15 @@ + + */ +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 @@ +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/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/src/Test/TestsRunner.php b/src/Test/TestsRunner.php index cdbcd6d..213d5c2 100644 --- a/src/Test/TestsRunner.php +++ b/src/Test/TestsRunner.php @@ -5,13 +5,12 @@ namespace Testo\Test; use Testo\Attribute\Interceptable; -use Testo\Dto\Case\CaseDefinition; -use Testo\Dto\Test\TestDefinition; -use Testo\Dto\Test\TestResult; use Testo\Interceptor\InterceptorProvider; use Testo\Interceptor\Internal\Pipeline; -use Testo\Interceptor\RunTest\Input; use Testo\Interceptor\RunTestInterceptor; +use Testo\Test\Dto\Status; +use Testo\Test\Dto\TestInfo; +use Testo\Test\Dto\TestResult; final class TestsRunner { @@ -19,45 +18,41 @@ public function __construct( private readonly InterceptorProvider $interceptorProvider, ) {} - public function run(CaseDefinition $case, TestDefinition $definition): TestResult + public function runTest(TestInfo $info): TestResult { - # Instantiate test case - # TODO autowire dependencies - $instance = $case->reflection?->newInstance(); - # Build interceptors pipeline - $interceptors = $this->prepareInterceptors($definition); + $interceptors = $this->prepareInterceptors($info); return Pipeline::prepare(...$interceptors)->with( - static function (Input $input): TestResult { + static function (TestInfo $info): TestResult { # TODO resolve arguments - $result = $input->instance === null - ? $input->definition->reflection->invoke() - : $input->definition->reflection->invoke($input->instance); + $result = $info->instance === null + ? $info->testDefinition->reflection->invoke() + : $info->testDefinition->reflection->invoke($info->instance); return new TestResult( - $input->definition, + $info, $result, + Status::Passed, ); }, /** @see RunTestInterceptor::runTest() */ 'runTest', - )(new Input( - definition: $definition, - instance: $instance, - )); + )($info); } /** * @return list */ - private function prepareInterceptors(TestDefinition $test) + private function prepareInterceptors(TestInfo $info): array { - $classAttributes = $test->reflection instanceof \ReflectionMethod - ? $test->reflection->getDeclaringClass() - ->getAttributes(Interceptable::class, \ReflectionAttribute::IS_INSTANCEOF) - : []; - $methodAttributes = $test->reflection - ->getAttributes(Interceptable::class, \ReflectionAttribute::IS_INSTANCEOF); + $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( diff --git a/tests/Fixture/TestInterceptors.php b/tests/Fixture/TestInterceptors.php index 31c1c80..6778f38 100644 --- a/tests/Fixture/TestInterceptors.php +++ b/tests/Fixture/TestInterceptors.php @@ -14,7 +14,7 @@ final class TestInterceptors public function withRetryPolicy(): int { static $runs = 0; - ++$runs < 3 and throw new \RuntimeException('Failed attempt ' . $runs); + ++$runs < 3 and throw new \RuntimeException("Failed attempt $runs."); return $runs; } } diff --git a/tests/Fixture/functions.php b/tests/Fixture/functions.php index 62e4973..654ae5d 100644 --- a/tests/Fixture/functions.php +++ b/tests/Fixture/functions.php @@ -8,7 +8,7 @@ use Testo\Attribute\Test; #[Test] -#[RetryPolicy(maxAttempts: 3)] +#[RetryPolicy(maxAttempts: 3, markFlaky: false)] function withRetryPolicy(): int { static $runs = 0; diff --git a/tests/Unit/Test/TestsRunnerTest.php b/tests/Unit/Test/TestsRunnerTest.php index ebdf29c..9c06581 100644 --- a/tests/Unit/Test/TestsRunnerTest.php +++ b/tests/Unit/Test/TestsRunnerTest.php @@ -5,44 +5,63 @@ namespace Tests\Unit\Test; use PHPUnit\Framework\TestCase; -use Testo\Dto\Case\CaseDefinition; -use Testo\Dto\Test\TestDefinition; use Testo\Interceptor\InterceptorProvider; +use Testo\Test\Dto\CaseDefinition; +use Testo\Test\Dto\CaseInfo; +use Testo\Test\Dto\Status; +use Testo\Test\Dto\TestInfo; +use Testo\Test\Dto\TestDefinition; use Testo\Test\TestsRunner; use Tests\Fixture\TestInterceptors; final class TestsRunnerTest extends TestCase { + /** + * @see TestInterceptors::withRetryPolicy() + */ public function testRunMethodWithRetry(): void { $instance = self::createInstance(); - $caseDefinition = new CaseDefinition( - reflection: new \ReflectionClass(TestInterceptors::class), - ); - $testDefinition = new TestDefinition( - /** @see TestInterceptors::withRetryPolicy() */ - reflection: $caseDefinition->reflection->getMethod('withRetryPolicy'), + $info = new TestInfo( + caseInfo: new CaseInfo( + definition: new CaseDefinition( + reflection: new \ReflectionClass(TestInterceptors::class), + ), + ), + testDefinition: new TestDefinition( + reflection: new \ReflectionMethod(TestInterceptors::class, 'withRetryPolicy'), + ), + instance: new TestInterceptors(), ); - $result = $instance->run($caseDefinition, $testDefinition); + $result = $instance->runTest($info); self::assertSame(3, $result->result); + self::assertSame(Status::Flaky, $result->status); } + /** + * @see \Tests\Fixture\withRetryPolicy() + */ public function testRunFunctionWithRetry(): void { $instance = self::createInstance(); - $caseDefinition = new CaseDefinition( - reflection: null, - ); - $testDefinition = new TestDefinition( - /** @see \Tests\Fixture\withRetryPolicy() */ - reflection: new \ReflectionFunction(\Tests\Fixture\withRetryPolicy(...)), + $info = new TestInfo( + caseInfo: new CaseInfo( + definition: new CaseDefinition( + reflection: null, + ), + ), + testDefinition: new TestDefinition( + reflection: new \ReflectionFunction(\Tests\Fixture\withRetryPolicy(...)), + ), + instance: new TestInterceptors(), ); - $result = $instance->run($caseDefinition, $testDefinition); + $result = $instance->runTest($info); self::assertSame(3, $result->result); + self::assertSame(Status::Passed, $result->status); } private static function createInstance(): TestsRunner From ffb8fff4bff344d465426158f84fffdbed34d2fb Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 13 Oct 2025 13:00:28 +0400 Subject: [PATCH 08/20] feat: Add `Path` object --- src/Finder/Path.php | 290 +++++++++++++++++++++ src/Test/TestsRunner.php | 65 ----- tests/Unit/Finder/PathTest.php | 450 +++++++++++++++++++++++++++++++++ 3 files changed, 740 insertions(+), 65 deletions(-) create mode 100644 src/Finder/Path.php delete mode 100644 src/Test/TestsRunner.php create mode 100644 tests/Unit/Finder/PathTest.php 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/Test/TestsRunner.php b/src/Test/TestsRunner.php deleted file mode 100644 index 213d5c2..0000000 --- a/src/Test/TestsRunner.php +++ /dev/null @@ -1,65 +0,0 @@ -prepareInterceptors($info); - return Pipeline::prepare(...$interceptors)->with( - static function (TestInfo $info): TestResult { - # TODO resolve arguments - $result = $info->instance === null - ? $info->testDefinition->reflection->invoke() - : $info->testDefinition->reflection->invoke($info->instance); - - return new TestResult( - $info, - $result, - Status::Passed, - ); - }, - /** @see RunTestInterceptor::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(RunTestInterceptor::class, ...$attrs); - } -} diff --git a/tests/Unit/Finder/PathTest.php b/tests/Unit/Finder/PathTest.php new file mode 100644 index 0000000..50a797c --- /dev/null +++ b/tests/Unit/Finder/PathTest.php @@ -0,0 +1,450 @@ + ['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); + } +} From 0a9943e4e5a19373c25b25d723890e55ef107c93 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 13 Oct 2025 13:02:33 +0400 Subject: [PATCH 09/20] feat: Init configs --- src/Config/ApplicationConfig.php | 32 ++++++++++++++++++++++++++++++++ src/Config/FinderConfig.php | 15 +++++++++++++++ src/Config/SuiteConfig.php | 23 +++++++++++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 src/Config/ApplicationConfig.php create mode 100644 src/Config/FinderConfig.php create mode 100644 src/Config/SuiteConfig.php diff --git a/src/Config/ApplicationConfig.php b/src/Config/ApplicationConfig.php new file mode 100644 index 0000000..da1b845 --- /dev/null +++ b/src/Config/ApplicationConfig.php @@ -0,0 +1,32 @@ + + */ + public readonly array $suites = [ + new SuiteConfig( + name: 'default', + location: new FinderConfig('tests'), + ), + ], + ) { + $suites === [] and throw new \InvalidArgumentException('At least one test suite must be defined.'); + } +} diff --git a/src/Config/FinderConfig.php b/src/Config/FinderConfig.php new file mode 100644 index 0000000..d478969 --- /dev/null +++ b/src/Config/FinderConfig.php @@ -0,0 +1,15 @@ + Date: Mon, 13 Oct 2025 13:15:59 +0400 Subject: [PATCH 10/20] feat: Add `ServiceConfig` --- src/Config/ApplicationConfig.php | 6 ++++++ src/Config/ServicesConfig.php | 10 ++++++++++ src/Internal/Bootstrap.php | 15 ++++++++------- 3 files changed, 24 insertions(+), 7 deletions(-) create mode 100644 src/Config/ServicesConfig.php diff --git a/src/Config/ApplicationConfig.php b/src/Config/ApplicationConfig.php index da1b845..31fe313 100644 --- a/src/Config/ApplicationConfig.php +++ b/src/Config/ApplicationConfig.php @@ -9,6 +9,7 @@ */ final class ApplicationConfig { + public function __construct( /** * Source code location. @@ -26,6 +27,11 @@ public function __construct( location: new FinderConfig('tests'), ), ], + + /** + * Services bindings configuration. + */ + public readonly ServicesConfig $services = new ServicesConfig(), ) { $suites === [] and throw new \InvalidArgumentException('At least one test suite must be defined.'); } 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 @@ + $service) { + $this->container->bind($id, $service); + } + return $this; } } From fb71ca1863899d125798c2412d81219bf0dd808f Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 14 Oct 2025 11:03:55 +0400 Subject: [PATCH 11/20] feat: Add `Finder` --- src/Config/FinderConfig.php | 71 ++++++++++++++++++++++++++++++++++-- src/Finder/Finder.php | 72 +++++++++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 src/Finder/Finder.php diff --git a/src/Config/FinderConfig.php b/src/Config/FinderConfig.php index d478969..66dd597 100644 --- a/src/Config/FinderConfig.php +++ b/src/Config/FinderConfig.php @@ -4,12 +4,79 @@ namespace Testo\Config; +use Testo\Finder\Path; + /** * File system scope configuration. */ final class FinderConfig { + /** + * @var non-empty-string[] + * @readonly + */ + public array $includeDirs; + + /** + * @var non-empty-string[] + * @readonly + */ + public array $excludeDirs; + + /** + * @var non-empty-string[] + * @readonly + */ + public array $includeFiles; + + /** + * @var non-empty-string[] + * @readonly + */ + public array $excludeFiles; + + /** + * @param iterable $include Include directories or files to the scope + * @param iterable $exclude Exclude directories or files from the scope + */ public function __construct( - string|\Stringable $rootPath, - ) {} + 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/Finder/Finder.php b/src/Finder/Finder.php new file mode 100644 index 0000000..b8790fb --- /dev/null +++ b/src/Finder/Finder.php @@ -0,0 +1,72 @@ +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; + } +} From f4ce62b1dd933f00209f00c79b8ea243bacbb2b6 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 14 Oct 2025 11:11:13 +0400 Subject: [PATCH 12/20] feat: Add `Application` --- composer.json | 1 + src/Application.php | 48 +++++++++++++++++++++++++++++ src/Dto/Run/RunResult.php | 29 +++++++++++++++++ src/Dto/Suite/SuiteInfo.php | 13 ++++++++ src/Dto/Suite/SuiteResult.php | 29 +++++++++++++++++ src/Internal/Command/Base.php | 42 +++++++++++++++---------- src/Suite/SuiteProvider.php | 34 ++++++++++++++++++++ src/Suite/SuiteRunner.php | 45 +++++++++++++++++++++++++++ src/Test/CaseRunner.php | 4 ++- src/Test/Dto/CaseDefinition.php | 2 +- src/Test/Dto/CaseInfo.php | 2 +- src/Test/Dto/TestInfo.php | 11 +++++-- tests/Unit/Test/TestsRunnerTest.php | 12 +++----- 13 files changed, 242 insertions(+), 30 deletions(-) create mode 100644 src/Application.php create mode 100644 src/Dto/Run/RunResult.php create mode 100644 src/Dto/Suite/SuiteInfo.php create mode 100644 src/Dto/Suite/SuiteResult.php create mode 100644 src/Suite/SuiteProvider.php create mode 100644 src/Suite/SuiteRunner.php diff --git a/composer.json b/composer.json index 05888c2..91d6aeb 100644 --- a/composer.json +++ b/composer.json @@ -25,6 +25,7 @@ "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" diff --git a/src/Application.php b/src/Application.php new file mode 100644 index 0000000..830044c --- /dev/null +++ b/src/Application.php @@ -0,0 +1,48 @@ +withConfig($config->services) + ->finish(); + return new self($container); + } + + public function run(?Filter $filter = null): RunResult + { + $suiteResults = []; + + $filter ??= Filter::new(); + $suiteProvider = $this->container->get(SuiteProvider::class); + + # Iterate Test Suites + foreach ($suiteProvider->withFilter($filter) as $suite) { + $suiteResults[] = $this->container->get(SuiteRunner::class)->run($suite, $filter); + } + + # Run suites + return new RunResult($suiteResults); + } +} diff --git a/src/Dto/Run/RunResult.php b/src/Dto/Run/RunResult.php new file mode 100644 index 0000000..056dbd4 --- /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/Dto/Suite/SuiteInfo.php b/src/Dto/Suite/SuiteInfo.php new file mode 100644 index 0000000..01a3f1b --- /dev/null +++ b/src/Dto/Suite/SuiteInfo.php @@ -0,0 +1,13 @@ + + */ +final class SuiteResult 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/Internal/Command/Base.php b/src/Internal/Command/Base.php index e0d53bd..df4af94 100644 --- a/src/Internal/Command/Base.php +++ b/src/Internal/Command/Base.php @@ -10,7 +10,8 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\StyleInterface; use Symfony\Component\Console\Style\SymfonyStyle; -use Testo\Internal\Bootstrap; +use Testo\Application; +use Testo\Config\ApplicationConfig; use Testo\Internal\Container; use Yiisoft\Injector\Injector; @@ -20,17 +21,19 @@ * Provides common functionality for command initialization, container setup, * and configuration handling. * - * ```php - * // Extend to create a custom command - * final class CustomCommand extends Base - * { - * protected function execute(InputInterface $input, OutputInterface $output): int - * { - * parent::execute($input, $output); - * // Command implementation - * return Command::SUCCESS; - * } - * } + * ``` + * // Extend to create a custom command + * final class CustomCommand extends Base + * { + * protected function __invoke( + * InputInterface $input, + * OutputInterface $output, + * ClassName $anyParam): int + * { + * // Return a Command code + * return Command::SUCCESS; + * } + * } * ``` * * @internal @@ -40,6 +43,8 @@ abstract class Base extends Command /** @var Container IoC container with services */ protected Container $container; + protected Application $application; + /** * Configures command options. * @@ -65,12 +70,15 @@ protected function execute( InputInterface $input, OutputInterface $output, ): int { - $this->container = $container = Bootstrap::init()->withConfig()->finish(); + $cfg = new ApplicationConfig(); + + $this->application = Application::create($cfg); + $this->container = $this->application->container; - $container->set($input, InputInterface::class); - $container->set($output, OutputInterface::class); - $container->set(new SymfonyStyle($input, $output), StyleInterface::class); + $this->container->set($input, InputInterface::class); + $this->container->set($output, OutputInterface::class); + $this->container->set(new SymfonyStyle($input, $output), StyleInterface::class); - return (new Injector($container))->invoke($this) ?? Command::SUCCESS; + return $this->container->get(Injector::class)->invoke($this) ?? Command::SUCCESS; } } diff --git a/src/Suite/SuiteProvider.php b/src/Suite/SuiteProvider.php new file mode 100644 index 0000000..824621a --- /dev/null +++ b/src/Suite/SuiteProvider.php @@ -0,0 +1,34 @@ + + */ + public function getSuites(): array + { + return [ + new SuiteInfo(null), + ]; + } +} diff --git a/src/Suite/SuiteRunner.php b/src/Suite/SuiteRunner.php new file mode 100644 index 0000000..60bc55e --- /dev/null +++ b/src/Suite/SuiteRunner.php @@ -0,0 +1,45 @@ +name === null or $filter = $filter->withTestSuites($suite->name); + + # Get tests + $cases = $this->testProvider + ->withFilter($filter) + ->getCases(); + + // todo if random, run in random order + + # Run tests in each case + foreach ($cases as $case) { + $this->caseRunner->runCase( + $case, + $case->reflection === null ? $filter : $filter->withTestCases($case->name), + ); + } + + return new SuiteResult([]); + } +} diff --git a/src/Test/CaseRunner.php b/src/Test/CaseRunner.php index 6790247..24a8665 100644 --- a/src/Test/CaseRunner.php +++ b/src/Test/CaseRunner.php @@ -5,6 +5,7 @@ namespace Testo\Test; use Testo\Dto\Filter; +use Testo\Interceptor\InterceptorProvider; use Testo\Test\Dto\CaseResult; use Testo\Test\Dto\CaseInfo; use Testo\Test\Dto\TestInfo; @@ -12,8 +13,9 @@ final class CaseRunner { public function __construct( - private readonly TestsRunner $testRunner, + private readonly TestRunner $testRunner, private readonly TestsProvider $testsProvider, + private readonly InterceptorProvider $interceptorProvider, ) {} public function runCase(CaseInfo $info, Filter $filter): CaseResult diff --git a/src/Test/Dto/CaseDefinition.php b/src/Test/Dto/CaseDefinition.php index 0615482..e9acb53 100644 --- a/src/Test/Dto/CaseDefinition.php +++ b/src/Test/Dto/CaseDefinition.php @@ -7,6 +7,6 @@ final class CaseDefinition { public function __construct( - public readonly ?\ReflectionClass $reflection, + public readonly ?\ReflectionClass $reflection = null, ) {} } diff --git a/src/Test/Dto/CaseInfo.php b/src/Test/Dto/CaseInfo.php index b41ac63..af2e0c6 100644 --- a/src/Test/Dto/CaseInfo.php +++ b/src/Test/Dto/CaseInfo.php @@ -10,6 +10,6 @@ final class CaseInfo { public function __construct( - public readonly CaseDefinition $definition, + public readonly CaseDefinition $definition = new CaseDefinition(), ) {} } diff --git a/src/Test/Dto/TestInfo.php b/src/Test/Dto/TestInfo.php index 9df492a..f821558 100644 --- a/src/Test/Dto/TestInfo.php +++ b/src/Test/Dto/TestInfo.php @@ -4,11 +4,15 @@ namespace Testo\Test\Dto; +use Testo\Dto\Filter; + /** * Information about run test. */ final class TestInfo { + public readonly Filter $filter; + public function __construct( public readonly CaseInfo $caseInfo, public readonly TestDefinition $testDefinition, @@ -16,6 +20,9 @@ public function __construct( /** * Test Case class instance if class is defined, null otherwise. */ - public readonly ?object $instance, - ) {} + public readonly ?object $instance = null, + ?Filter $filter = null, + ) { + $this->filter = $filter ?? Filter::new(); + } } diff --git a/tests/Unit/Test/TestsRunnerTest.php b/tests/Unit/Test/TestsRunnerTest.php index 9c06581..064b70f 100644 --- a/tests/Unit/Test/TestsRunnerTest.php +++ b/tests/Unit/Test/TestsRunnerTest.php @@ -11,7 +11,7 @@ use Testo\Test\Dto\Status; use Testo\Test\Dto\TestInfo; use Testo\Test\Dto\TestDefinition; -use Testo\Test\TestsRunner; +use Testo\Test\TestRunner; use Tests\Fixture\TestInterceptors; final class TestsRunnerTest extends TestCase @@ -47,11 +47,7 @@ public function testRunFunctionWithRetry(): void { $instance = self::createInstance(); $info = new TestInfo( - caseInfo: new CaseInfo( - definition: new CaseDefinition( - reflection: null, - ), - ), + caseInfo: new CaseInfo(), testDefinition: new TestDefinition( reflection: new \ReflectionFunction(\Tests\Fixture\withRetryPolicy(...)), ), @@ -64,8 +60,8 @@ public function testRunFunctionWithRetry(): void self::assertSame(Status::Passed, $result->status); } - private static function createInstance(): TestsRunner + private static function createInstance(): TestRunner { - return new TestsRunner(InterceptorProvider::createDefault()); + return new TestRunner(InterceptorProvider::createDefault()); } } From 690aec6a95ec4ceb949d60d4d2725f0eab4df7bd Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 14 Oct 2025 12:36:43 +0400 Subject: [PATCH 13/20] feat: Add `SuiteCollector` --- src/Application.php | 8 +++--- src/Config/ApplicationConfig.php | 10 +++++-- src/Config/FinderConfig.php | 10 ++++--- src/Dto/Filter.php | 7 +---- src/Finder/Finder.php | 4 +++ src/Internal/CloneWith.php | 6 ++++- src/Internal/Command/Base.php | 12 ++++++++- src/Internal/Command/Run.php | 1 + src/Suite/SuiteCollector.php | 46 ++++++++++++++++++++++++++++++++ src/Suite/SuiteProvider.php | 39 +++++++++++++++++++++++---- src/Test/Dto/TestInfo.php | 8 ++---- 11 files changed, 122 insertions(+), 29 deletions(-) create mode 100644 src/Suite/SuiteCollector.php diff --git a/src/Application.php b/src/Application.php index 830044c..0bf5bb2 100644 --- a/src/Application.php +++ b/src/Application.php @@ -30,16 +30,16 @@ public static function create( return new self($container); } - public function run(?Filter $filter = null): RunResult + public function run($filter = new Filter()): RunResult { $suiteResults = []; - $filter ??= Filter::new(); $suiteProvider = $this->container->get(SuiteProvider::class); + $suiteRunner = $this->container->get(SuiteRunner::class); # Iterate Test Suites - foreach ($suiteProvider->withFilter($filter) as $suite) { - $suiteResults[] = $this->container->get(SuiteRunner::class)->run($suite, $filter); + foreach ($suiteProvider->withFilter($filter)->getConfigs() as $suite) { + $suiteResults[] = $suiteRunner->run($suite, $filter); } # Run suites diff --git a/src/Config/ApplicationConfig.php b/src/Config/ApplicationConfig.php index 31fe313..bf06395 100644 --- a/src/Config/ApplicationConfig.php +++ b/src/Config/ApplicationConfig.php @@ -9,7 +9,6 @@ */ final class ApplicationConfig { - public function __construct( /** * Source code location. @@ -24,7 +23,7 @@ public function __construct( public readonly array $suites = [ new SuiteConfig( name: 'default', - location: new FinderConfig('tests'), + location: new FinderConfig(['tests']), ), ], @@ -33,6 +32,13 @@ public function __construct( */ 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 index 66dd597..458d10b 100644 --- a/src/Config/FinderConfig.php +++ b/src/Config/FinderConfig.php @@ -15,29 +15,31 @@ final class FinderConfig * @var non-empty-string[] * @readonly */ - public array $includeDirs; + public array $includeDirs = []; /** * @var non-empty-string[] * @readonly */ - public array $excludeDirs; + public array $excludeDirs = []; /** * @var non-empty-string[] * @readonly */ - public array $includeFiles; + public array $includeFiles = []; /** * @var non-empty-string[] * @readonly */ - public array $excludeFiles; + public array $excludeFiles = []; /** * @param iterable $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 = [], diff --git a/src/Dto/Filter.php b/src/Dto/Filter.php index 1bf5153..0eeacdd 100644 --- a/src/Dto/Filter.php +++ b/src/Dto/Filter.php @@ -15,18 +15,13 @@ final class Filter { use CloneWith; - private function __construct( + public function __construct( /** * @var list Names of the test suites to filter by. */ public readonly array $testSuites = [], ) {} - public static function new(): self - { - return new self(); - } - /** * Filter tests by Suite names. * diff --git a/src/Finder/Finder.php b/src/Finder/Finder.php index b8790fb..94bbe25 100644 --- a/src/Finder/Finder.php +++ b/src/Finder/Finder.php @@ -4,9 +4,13 @@ namespace Testo\Finder; +use Symfony\Component\Finder\SplFileInfo; use Testo\Config\FinderConfig; use Symfony\Component\Finder\Finder as SymfonyFinder; +/** + * @implements \IteratorAggregate + */ final class Finder implements \Countable, \IteratorAggregate { private SymfonyFinder $finder; diff --git a/src/Internal/CloneWith.php b/src/Internal/CloneWith.php index 2e58b42..3c6c38e 100644 --- a/src/Internal/CloneWith.php +++ b/src/Internal/CloneWith.php @@ -14,7 +14,11 @@ trait CloneWith */ private function with(string $key, mixed $value): static { - $new = (new \ReflectionClass($this))->newInstanceWithoutConstructor(); + # Reflection caching + static $cache = []; + $reflection = $cache[static::class] ??= (new \ReflectionClass(static::class)); + + $new = $reflection->newInstanceWithoutConstructor(); $new->{$key} = $value; /** @psalm-suppress RawObjectIteration */ foreach ($this as $k => $v) { diff --git a/src/Internal/Command/Base.php b/src/Internal/Command/Base.php index df4af94..9fd419e 100644 --- a/src/Internal/Command/Base.php +++ b/src/Internal/Command/Base.php @@ -12,6 +12,8 @@ use Symfony\Component\Console\Style\SymfonyStyle; use Testo\Application; use Testo\Config\ApplicationConfig; +use Testo\Config\FinderConfig; +use Testo\Config\SuiteConfig; use Testo\Internal\Container; use Yiisoft\Injector\Injector; @@ -70,7 +72,15 @@ protected function execute( InputInterface $input, OutputInterface $output, ): int { - $cfg = new ApplicationConfig(); + $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; diff --git a/src/Internal/Command/Run.php b/src/Internal/Command/Run.php index 8d8a71f..f5fe43a 100644 --- a/src/Internal/Command/Run.php +++ b/src/Internal/Command/Run.php @@ -18,6 +18,7 @@ public function __invoke( InputInterface $input, OutputInterface $output, ): int { + $result = $this->application->run(); return Command::SUCCESS; } } diff --git a/src/Suite/SuiteCollector.php b/src/Suite/SuiteCollector.php new file mode 100644 index 0000000..38bfada --- /dev/null +++ b/src/Suite/SuiteCollector.php @@ -0,0 +1,46 @@ + */ + private array $suites = []; + + public function __construct( + // private readonly ClassLoader $classLoader, + ) {} + + 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 + { + $finder = new Finder($config->location); + + foreach ($finder->files() as $file) { + // todo fetch test cases + } + + return new SuiteInfo( + name: $config->name, + ); + } +} diff --git a/src/Suite/SuiteProvider.php b/src/Suite/SuiteProvider.php index 824621a..ca61dec 100644 --- a/src/Suite/SuiteProvider.php +++ b/src/Suite/SuiteProvider.php @@ -4,20 +4,46 @@ namespace Testo\Suite; +use Testo\Config\ApplicationConfig; +use Testo\Config\SuiteConfig; use Testo\Dto\Filter; use Testo\Dto\Suite\SuiteInfo; +use Testo\Internal\CloneWith; /** * Provides test suites. */ final class SuiteProvider { + use CloneWith; + + /** @var list */ + 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 { - return $this; + # 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); } /** @@ -25,10 +51,13 @@ public function withFilter(Filter $filter): self * * @return array */ - public function getSuites(): array + public function getConfigs(): array { - return [ - new SuiteInfo(null), - ]; + $result = []; + foreach ($this->configs as $config) { + $result[] = $this->collector->getOrCreate($config); + } + + return $result; } } diff --git a/src/Test/Dto/TestInfo.php b/src/Test/Dto/TestInfo.php index f821558..07e3452 100644 --- a/src/Test/Dto/TestInfo.php +++ b/src/Test/Dto/TestInfo.php @@ -11,8 +11,6 @@ */ final class TestInfo { - public readonly Filter $filter; - public function __construct( public readonly CaseInfo $caseInfo, public readonly TestDefinition $testDefinition, @@ -21,8 +19,6 @@ public function __construct( * Test Case class instance if class is defined, null otherwise. */ public readonly ?object $instance = null, - ?Filter $filter = null, - ) { - $this->filter = $filter ?? Filter::new(); - } + private readonly Filter $filter = new Filter(), + ) {} } From 991d7c226fae6a8e6deb16b807ffac25ecc32cb8 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 14 Oct 2025 15:03:23 +0400 Subject: [PATCH 14/20] feat: Add `FileLocator` --- src/Module/Tokenizer/FileLocator.php | 143 ++++ .../Reflection/ReflectionArgument.php | 140 ++++ .../Tokenizer/Reflection/ReflectionFile.php | 729 ++++++++++++++++++ .../Reflection/ReflectionInvocation.php | 83 ++ 4 files changed, 1095 insertions(+) create mode 100644 src/Module/Tokenizer/FileLocator.php create mode 100644 src/Module/Tokenizer/Reflection/ReflectionArgument.php create mode 100644 src/Module/Tokenizer/Reflection/ReflectionFile.php create mode 100644 src/Module/Tokenizer/Reflection/ReflectionInvocation.php diff --git a/src/Module/Tokenizer/FileLocator.php b/src/Module/Tokenizer/FileLocator.php new file mode 100644 index 0000000..538ba7d --- /dev/null +++ b/src/Module/Tokenizer/FileLocator.php @@ -0,0 +1,143 @@ + + */ +final class FileLocator implements \IteratorAggregate +{ + use TargetTrait; + + 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) { + $reflection = new ReflectionFile((string) $file); + + if ($reflection->hasIncludes) { + // // We are not analyzing files which has includes, it's not safe to require such reflections + // if ($this->debug) { + // $this->getLogger()->warning( + // \sprintf('File `%s` has includes and excluded from analysis', (string) $file), + // ['file' => $file], + // ); + // } + + continue; + } + + yield $reflection; + } + } + + /** + * 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 + */ + protected 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 + */ + protected 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/Reflection/ReflectionArgument.php b/src/Module/Tokenizer/Reflection/ReflectionArgument.php new file mode 100644 index 0000000..5722add --- /dev/null +++ b/src/Module/Tokenizer/Reflection/ReflectionArgument.php @@ -0,0 +1,140 @@ + self::EXPRESSION, 'value' => '', 'tokens' => []]; + } + + if ($token[ReflectionFile::TOKEN_TYPE] === '(' || $token[ReflectionFile::TOKEN_TYPE] === '[') { + ++$level; + $definition['value'] .= $token[ReflectionFile::TOKEN_CODE]; + continue; + } + + if ($token[ReflectionFile::TOKEN_TYPE] === ')' || $token[ReflectionFile::TOKEN_TYPE] === ']') { + --$level; + $definition['value'] .= $token[ReflectionFile::TOKEN_CODE]; + continue; + } + + if ($level) { + $definition['value'] .= $token[ReflectionFile::TOKEN_CODE]; + continue; + } + + if ($token[ReflectionFile::TOKEN_TYPE] === ',') { + $result[] = self::createArgument($definition); + $definition = null; + continue; + } + + $definition['tokens'][] = $token; + $definition['value'] .= $token[ReflectionFile::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): ReflectionArgument + { + $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/ReflectionFile.php b/src/Module/Tokenizer/Reflection/ReflectionFile.php new file mode 100644 index 0000000..9881623 --- /dev/null +++ b/src/Module/Tokenizer/Reflection/ReflectionFile.php @@ -0,0 +1,729 @@ +filename = Path::create($filename); + $this->tokens = self::fetchTokens($this->filename); + $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']); + } + + /** + * Get list of tokens associated with given file. + */ + public function getTokens(): array + { + return $this->tokens; + } + + /** + * Locate and return list of every method or function call in specified file. Only static and + * $this calls will be indexed + * + * @return ReflectionInvocation[] + */ + public function getInvocations(): array + { + if (empty($this->invocations)) { + $this->locateInvocations($this->getTokens()); + } + + 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 + { + foreach ($this->getTokens() 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: + $this->hasIncludes = true; + } + } + + //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 + { + dump($filename); + $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 ReflectionInvocation( + $this->filename, + $this->lineNumber($invocationID), + $class, + $operator, + $name, + ReflectionArgument::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/ReflectionInvocation.php b/src/Module/Tokenizer/Reflection/ReflectionInvocation.php new file mode 100644 index 0000000..ebfb091 --- /dev/null +++ b/src/Module/Tokenizer/Reflection/ReflectionInvocation.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 ReflectionArgument[] + */ + 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): ReflectionArgument + { + if (!isset($this->arguments[$index])) { + throw new ReflectionException(\sprintf("No such argument with index '%d'", $index)); + } + + return $this->arguments[$index]; + } +} From 09094cb523cca09651cd1df819992b517ed67961 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 14 Oct 2025 22:54:05 +0400 Subject: [PATCH 15/20] feat: Add `LocatorInterceptor` interceptor interface --- ...tor.php => RetryPolicyCallInterceptor.php} | 8 ++-- src/Interceptor/InterceptorProvider.php | 22 +++++++++-- src/Interceptor/Internal/Pipeline.php | 9 ++--- src/Interceptor/LocatorInterceptor.php | 28 +++++++++++++ ...nterceptor.php => TestCallInterceptor.php} | 7 ++-- src/Module/Tokenizer/FileLocator.php | 16 +------- .../Tokenizer/Reflection/ReflectionFile.php | 39 ++++++++----------- src/Suite/SuiteCollector.php | 30 ++++++++++++-- 8 files changed, 101 insertions(+), 58 deletions(-) rename src/Interceptor/Implementation/{RetryPolicyInterceptor.php => RetryPolicyCallInterceptor.php} (80%) create mode 100644 src/Interceptor/LocatorInterceptor.php rename src/Interceptor/{RunTestInterceptor.php => TestCallInterceptor.php} (60%) diff --git a/src/Interceptor/Implementation/RetryPolicyInterceptor.php b/src/Interceptor/Implementation/RetryPolicyCallInterceptor.php similarity index 80% rename from src/Interceptor/Implementation/RetryPolicyInterceptor.php rename to src/Interceptor/Implementation/RetryPolicyCallInterceptor.php index 524fc45..8147297 100644 --- a/src/Interceptor/Implementation/RetryPolicyInterceptor.php +++ b/src/Interceptor/Implementation/RetryPolicyCallInterceptor.php @@ -5,7 +5,7 @@ namespace Testo\Interceptor\Implementation; use Testo\Attribute\RetryPolicy; -use Testo\Interceptor\RunTestInterceptor; +use Testo\Interceptor\TestCallInterceptor; use Testo\Test\Dto\Status; use Testo\Test\Dto\TestInfo; use Testo\Test\Dto\TestResult; @@ -15,14 +15,14 @@ * * @see RetryPolicy */ -final class RetryPolicyInterceptor implements RunTestInterceptor +final class RetryPolicyCallInterceptor implements TestCallInterceptor { public function __construct( private readonly RetryPolicy $options, ) {} #[\Override] - public function runTest(TestInfo $dto, callable $next): TestResult + public function runTest(TestInfo $info, callable $next): TestResult { $attempts = $this->options->maxAttempts; $isFlaky = false; @@ -30,7 +30,7 @@ public function runTest(TestInfo $dto, callable $next): TestResult run: --$attempts; try { - $result = $next($dto); + $result = $next($info); return $isFlaky && $this->options->markFlaky ? $result->with(status: Status::Flaky) : $result; diff --git a/src/Interceptor/InterceptorProvider.php b/src/Interceptor/InterceptorProvider.php index b85ec2d..6dd9d52 100644 --- a/src/Interceptor/InterceptorProvider.php +++ b/src/Interceptor/InterceptorProvider.php @@ -6,7 +6,7 @@ use Testo\Attribute\Interceptable; use Testo\Attribute\RetryPolicy; -use Testo\Interceptor\Implementation\RetryPolicyInterceptor; +use Testo\Interceptor\Implementation\RetryPolicyCallInterceptor; use Testo\Interceptor\Internal\InterceptorMarker; final class InterceptorProvider @@ -25,17 +25,33 @@ public static function createDefault(): self { $self = new self(); $self->map = [ - RetryPolicy::class => RetryPolicyInterceptor::class, + 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 interceptor class. + * @param class-string $class The target interceptor class. * @param Interceptable ...$attributes Attributes to get interceptors for. * * @return list Interceptors for the given attributes. diff --git a/src/Interceptor/Internal/Pipeline.php b/src/Interceptor/Internal/Pipeline.php index f9b9994..e94c07e 100644 --- a/src/Interceptor/Internal/Pipeline.php +++ b/src/Interceptor/Internal/Pipeline.php @@ -10,10 +10,7 @@ * Processor for interceptors chain. * * @template TInterceptor of TInterceptor - * @template TReturn of mixed - * - * @psalm-type TLast = \Closure(mixed ...): mixed - * @psalm-type TCallable = callable(mixed ...): mixed + * @template-covariant TReturn of mixed * * @psalm-immutable * @@ -54,7 +51,7 @@ public static function prepare(TInterceptor ...$interceptors): self /** * @param non-empty-string $method Method name of the all interceptors. * - * @return TCallable + * @return callable(object): TReturn */ public function with(\Closure $last, string $method): callable { @@ -73,7 +70,7 @@ public function with(\Closure $last, string $method): callable * * @return TReturn */ - public function __invoke(object $input): object + public function __invoke(object $input): mixed { $interceptor = $this->interceptors[$this->current] ?? null; diff --git a/src/Interceptor/LocatorInterceptor.php b/src/Interceptor/LocatorInterceptor.php new file mode 100644 index 0000000..f45f552 --- /dev/null +++ b/src/Interceptor/LocatorInterceptor.php @@ -0,0 +1,28 @@ +finder->getIterator() as $file) { - $reflection = new ReflectionFile((string) $file); - - if ($reflection->hasIncludes) { - // // We are not analyzing files which has includes, it's not safe to require such reflections - // if ($this->debug) { - // $this->getLogger()->warning( - // \sprintf('File `%s` has includes and excluded from analysis', (string) $file), - // ['file' => $file], - // ); - // } - - continue; - } - - yield $reflection; + yield new ReflectionFile($file, (string) $file); } } diff --git a/src/Module/Tokenizer/Reflection/ReflectionFile.php b/src/Module/Tokenizer/Reflection/ReflectionFile.php index 9881623..373a9f7 100644 --- a/src/Module/Tokenizer/Reflection/ReflectionFile.php +++ b/src/Module/Tokenizer/Reflection/ReflectionFile.php @@ -41,6 +41,11 @@ final class ReflectionFile public readonly Path $filename; + /** + * Indication that file contains require/include statements + */ + public readonly bool $hasIncludes; + /** * Set of tokens required to detect classes, traits, interfaces and function declarations. We * don't need any other token for that. @@ -67,11 +72,9 @@ final class ReflectionFile ]; /** - * Parsed tokens array. - * - * @internal + * Get list of parsed tokens associated with given file. */ - private array $tokens = []; + public readonly array $tokens; /** * Total tokens count. @@ -80,11 +83,6 @@ final class ReflectionFile */ private int $countTokens = 0; - /** - * Indication that file contains require/include statements - */ - public readonly bool $hasIncludes; - /** * Namespaces used in file and their token positions. * @@ -115,9 +113,10 @@ final class ReflectionFile private array $invocations = []; public function __construct( - string|Path $filename, + public readonly \SplFileInfo $file, + string|Path $path, ) { - $this->filename = Path::create($filename); + $this->filename = Path::create($path); $this->tokens = self::fetchTokens($this->filename); $this->countTokens = \count($this->tokens); @@ -181,14 +180,6 @@ public function getInterfaces(): array return \array_keys($this->declarations['T_INTERFACE']); } - /** - * Get list of tokens associated with given file. - */ - public function getTokens(): array - { - return $this->tokens; - } - /** * Locate and return list of every method or function call in specified file. Only static and * $this calls will be indexed @@ -198,7 +189,7 @@ public function getTokens(): array public function getInvocations(): array { if (empty($this->invocations)) { - $this->locateInvocations($this->getTokens()); + $this->locateInvocations($this->tokens); } return $this->invocations; @@ -225,7 +216,8 @@ protected function importSchema(array $cache): void */ protected function locateDeclarations(): void { - foreach ($this->getTokens() as $tokenID => $token) { + $hasIncludes = false; + foreach ($this->tokens as $tokenID => $token) { if (!\in_array($token[self::TOKEN_TYPE], self::$processTokens)) { continue; } @@ -269,10 +261,12 @@ protected function locateDeclarations(): void case T_INCLUDE_ONCE: case T_REQUIRE: case T_REQUIRE_ONCE: - $this->hasIncludes = true; + $hasIncludes = true; } } + $this->hasIncludes = $hasIncludes; + //Dropping empty namespace if (isset($this->namespaces[''])) { $this->namespaces['\\'] = $this->namespaces['']; @@ -285,7 +279,6 @@ protected function locateDeclarations(): void */ private static function fetchTokens(Path $filename): array { - dump($filename); $tokens = \token_get_all(\file_get_contents((string) $filename)); $line = 0; diff --git a/src/Suite/SuiteCollector.php b/src/Suite/SuiteCollector.php index 38bfada..e6c3e17 100644 --- a/src/Suite/SuiteCollector.php +++ b/src/Suite/SuiteCollector.php @@ -7,6 +7,11 @@ use Testo\Config\SuiteConfig; use Testo\Dto\Suite\SuiteInfo; use Testo\Finder\Finder; +use Testo\Interceptor\InterceptorProvider; +use Testo\Interceptor\Internal\Pipeline; +use Testo\Interceptor\LocatorInterceptor; +use Testo\Module\Tokenizer\FileLocator; +use Testo\Module\Tokenizer\Reflection\ReflectionFile; /** * Test suite collection and producer of SuiteInfo. @@ -19,6 +24,7 @@ final class SuiteCollector public function __construct( // private readonly ClassLoader $classLoader, + private readonly InterceptorProvider $interceptorProvider, ) {} public function get(string $name): ?SuiteInfo @@ -33,14 +39,32 @@ public function getOrCreate(SuiteConfig $config): SuiteInfo private function createInfo(SuiteConfig $config): SuiteInfo { - $finder = new Finder($config->location); + $files = $this->getFilesIterator($config); - foreach ($finder->files() as $file) { - // todo fetch test cases + foreach ($files as $file) { } return new SuiteInfo( name: $config->name, ); } + + private function getFilesIterator(SuiteConfig $config): iterable + { + $locator = new FileLocator(new Finder($config->location)); + + # Prepare interceptors pipeline + $interceptors = $this->interceptorProvider->fromClasses(LocatorInterceptor::class); + /** @see LocatorInterceptor::locateFile() */ + $pipeline = Pipeline::prepare(...$interceptors) + ->with(static fn(ReflectionFile $file): ?bool => null, 'locateFile'); + + foreach ($locator->getIterator() as $fileReflection) { + $match = $pipeline($fileReflection); + + if ($match === true) { + yield $fileReflection; + } + } + } } From 7d6831610a6eb9647bfbd42626ab68c2b0bff3b0 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 14 Oct 2025 23:06:26 +0400 Subject: [PATCH 16/20] feat: Add a few locator interceptors --- .../FilePostfixTestLocatorInterceptor.php | 21 +++++++++++++++++++ .../SecureLocatorInterceptor.php | 20 ++++++++++++++++++ .../Tokenizer/Reflection/ReflectionFile.php | 18 ++++++++-------- 3 files changed, 50 insertions(+), 9 deletions(-) create mode 100644 src/Interceptor/Implementation/FilePostfixTestLocatorInterceptor.php create mode 100644 src/Interceptor/Implementation/SecureLocatorInterceptor.php diff --git a/src/Interceptor/Implementation/FilePostfixTestLocatorInterceptor.php b/src/Interceptor/Implementation/FilePostfixTestLocatorInterceptor.php new file mode 100644 index 0000000..3a5ae8b --- /dev/null +++ b/src/Interceptor/Implementation/FilePostfixTestLocatorInterceptor.php @@ -0,0 +1,21 @@ +path->stem(), 'Test') ? true : $next($file); + } +} diff --git a/src/Interceptor/Implementation/SecureLocatorInterceptor.php b/src/Interceptor/Implementation/SecureLocatorInterceptor.php new file mode 100644 index 0000000..4059621 --- /dev/null +++ b/src/Interceptor/Implementation/SecureLocatorInterceptor.php @@ -0,0 +1,20 @@ +hasIncludes ? false : $next($file); + } +} diff --git a/src/Module/Tokenizer/Reflection/ReflectionFile.php b/src/Module/Tokenizer/Reflection/ReflectionFile.php index 373a9f7..40fbf5d 100644 --- a/src/Module/Tokenizer/Reflection/ReflectionFile.php +++ b/src/Module/Tokenizer/Reflection/ReflectionFile.php @@ -39,13 +39,18 @@ final class ReflectionFile */ public const N_USES = 2; - public readonly Path $filename; + public readonly Path $path; /** * Indication that file contains require/include statements */ public readonly bool $hasIncludes; + /** + * Get list of parsed tokens associated with given file. + */ + public readonly array $tokens; + /** * Set of tokens required to detect classes, traits, interfaces and function declarations. We * don't need any other token for that. @@ -71,11 +76,6 @@ final class ReflectionFile T_AS, ]; - /** - * Get list of parsed tokens associated with given file. - */ - public readonly array $tokens; - /** * Total tokens count. * @@ -116,8 +116,8 @@ public function __construct( public readonly \SplFileInfo $file, string|Path $path, ) { - $this->filename = Path::create($path); - $this->tokens = self::fetchTokens($this->filename); + $this->path = Path::create($path); + $this->tokens = self::fetchTokens($this->path); $this->countTokens = \count($this->tokens); //Looking for declarations @@ -592,7 +592,7 @@ private function registerInvocation( } $this->invocations[] = new ReflectionInvocation( - $this->filename, + $this->path, $this->lineNumber($invocationID), $class, $operator, From 3d3621ac512a1335412198373bb3313fd7d32b36 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 14 Oct 2025 23:36:11 +0400 Subject: [PATCH 17/20] refactor: Cleanup `FileLocator` --- ...rceptor.php => FileLocatorInterceptor.php} | 4 +- .../FilePostfixTestLocatorInterceptor.php | 4 +- .../SecureLocatorInterceptor.php | 4 +- src/Module/Tokenizer/FileLocator.php | 98 +------------------ src/Suite/SuiteCollector.php | 5 +- 5 files changed, 14 insertions(+), 101 deletions(-) rename src/Interceptor/{LocatorInterceptor.php => FileLocatorInterceptor.php} (90%) diff --git a/src/Interceptor/LocatorInterceptor.php b/src/Interceptor/FileLocatorInterceptor.php similarity index 90% rename from src/Interceptor/LocatorInterceptor.php rename to src/Interceptor/FileLocatorInterceptor.php index f45f552..98c0a7f 100644 --- a/src/Interceptor/LocatorInterceptor.php +++ b/src/Interceptor/FileLocatorInterceptor.php @@ -8,9 +8,9 @@ use Testo\Module\Tokenizer\Reflection\ReflectionFile; /** - * Intercept locating test files and test cases. + * Intercept locating test files. */ -interface LocatorInterceptor extends InterceptorMarker +interface FileLocatorInterceptor extends InterceptorMarker { /** * Return true if the file might be interesting as a test file. diff --git a/src/Interceptor/Implementation/FilePostfixTestLocatorInterceptor.php b/src/Interceptor/Implementation/FilePostfixTestLocatorInterceptor.php index 3a5ae8b..4773756 100644 --- a/src/Interceptor/Implementation/FilePostfixTestLocatorInterceptor.php +++ b/src/Interceptor/Implementation/FilePostfixTestLocatorInterceptor.php @@ -4,7 +4,7 @@ namespace Testo\Interceptor\Implementation; -use Testo\Interceptor\LocatorInterceptor; +use Testo\Interceptor\FileLocatorInterceptor; use Testo\Module\Tokenizer\Reflection\ReflectionFile; /** @@ -12,7 +12,7 @@ * * E.g. "MyClassTest.php" will be accepted, while "MyClass.php" will not. */ -final class FilePostfixTestLocatorInterceptor implements LocatorInterceptor +final class FilePostfixTestLocatorInterceptor implements FileLocatorInterceptor { public function locateFile(ReflectionFile $file, callable $next): ?bool { diff --git a/src/Interceptor/Implementation/SecureLocatorInterceptor.php b/src/Interceptor/Implementation/SecureLocatorInterceptor.php index 4059621..410f1b8 100644 --- a/src/Interceptor/Implementation/SecureLocatorInterceptor.php +++ b/src/Interceptor/Implementation/SecureLocatorInterceptor.php @@ -4,14 +4,14 @@ namespace Testo\Interceptor\Implementation; -use Testo\Interceptor\LocatorInterceptor; +use Testo\Interceptor\FileLocatorInterceptor; use Testo\Module\Tokenizer\Reflection\ReflectionFile; /** * Interceptor that skips files with {@see include()}, {@see include_once()}, {@see require()}, * or {@see require_once()} statements. */ -final class SecureLocatorInterceptor implements LocatorInterceptor +final class SecureLocatorInterceptor implements FileLocatorInterceptor { public function locateFile(ReflectionFile $file, callable $next): ?bool { diff --git a/src/Module/Tokenizer/FileLocator.php b/src/Module/Tokenizer/FileLocator.php index 7ff9709..1b21b0c 100644 --- a/src/Module/Tokenizer/FileLocator.php +++ b/src/Module/Tokenizer/FileLocator.php @@ -5,18 +5,18 @@ namespace Testo\Module\Tokenizer; use Testo\Finder\Finder; -use Testo\Module\Tokenizer\Exception\LocatorException; use Testo\Module\Tokenizer\Reflection\ReflectionFile; -use Testo\Module\Tokenizer\Traits\TargetTrait; /** - * Base class for Class and Invocation locators. + * Locates and tokenizes PHP files within a given FS scope. + * + * Reads files discovered by {@see Finder}, tokenizes their contents, + * and creates {@see ReflectionFile} objects. + * * @implements \IteratorAggregate */ final class FileLocator implements \IteratorAggregate { - use TargetTrait; - protected readonly Finder $finder; public function __construct( @@ -38,92 +38,4 @@ public function getIterator(): \Generator yield new ReflectionFile($file, (string) $file); } } - - /** - * 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 - */ - protected 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 - */ - protected 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/Suite/SuiteCollector.php b/src/Suite/SuiteCollector.php index e6c3e17..0bf58b0 100644 --- a/src/Suite/SuiteCollector.php +++ b/src/Suite/SuiteCollector.php @@ -54,8 +54,9 @@ private function getFilesIterator(SuiteConfig $config): iterable $locator = new FileLocator(new Finder($config->location)); # Prepare interceptors pipeline - $interceptors = $this->interceptorProvider->fromClasses(LocatorInterceptor::class); - /** @see LocatorInterceptor::locateFile() */ + $interceptors = $this->interceptorProvider->fromClasses(FileLocatorInterceptor::class); + + /** @see FileLocatorInterceptor::locateFile() */ $pipeline = Pipeline::prepare(...$interceptors) ->with(static fn(ReflectionFile $file): ?bool => null, 'locateFile'); From 11666d171f3b8272351e2a1a80fa490b3c074fa2 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 15 Oct 2025 00:35:07 +0400 Subject: [PATCH 18/20] feat: Add `Reflection` utility --- .../Exception/ReflectionException.php | 10 +++ .../Exception/TokenizerException.php | 10 +++ src/Module/Tokenizer/Reflection.php | 81 +++++++++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 src/Module/Tokenizer/Exception/ReflectionException.php create mode 100644 src/Module/Tokenizer/Exception/TokenizerException.php create mode 100644 src/Module/Tokenizer/Reflection.php diff --git a/src/Module/Tokenizer/Exception/ReflectionException.php b/src/Module/Tokenizer/Exception/ReflectionException.php new file mode 100644 index 0000000..7247ff5 --- /dev/null +++ b/src/Module/Tokenizer/Exception/ReflectionException.php @@ -0,0 +1,10 @@ +getAttributes($attributeClass, $flags), + ); + + if ($includeTraits) { + foreach (self::fetchTraits($class, includeParents: false) as $trait) { + $traitReflection = new \ReflectionClass($trait); + $attributes = \array_merge( + $attributes, + $traitReflection->getAttributes($attributeClass, $flags), + ); + } + } + + $class = $includeParents ? $reflection->getParentClass()?->getName() : 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); + } +} From 752a182517c870ae070b700fef5d85cb68fa8337 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 15 Oct 2025 12:35:06 +0400 Subject: [PATCH 19/20] feat: Implement basic tests finding --- src/Dto/Run/RunResult.php | 2 +- src/Dto/Suite/SuiteInfo.php | 13 -- src/Dto/Suite/SuiteResult.php | 29 ---- src/Interceptor/CaseLocatorInterceptor.php | 27 ++++ src/Interceptor/FileLocatorInterceptor.php | 11 +- .../FilePostfixTestLocatorInterceptor.php | 32 ++++- .../SecureLocatorInterceptor.php | 4 +- .../Internal/InterceptorMarker.php | 3 + src/Interceptor/Internal/Pipeline.php | 20 ++- src/Interceptor/TestCallInterceptor.php | 2 + src/Module/Tokenizer/DefinitionLocator.php | 128 ++++++++++++++++++ .../Tokenizer/Exception/LocatorException.php | 10 ++ src/Module/Tokenizer/FileLocator.php | 10 +- .../Tokenizer/Reflection/FileDefinitions.php | 52 +++++++ ...tionArgument.php => TokenizedArgument.php} | 20 +-- .../{ReflectionFile.php => TokenizedFile.php} | 18 ++- ...Invocation.php => TokenizedInvocation.php} | 8 +- src/Suite/Dto/CasesCollection.php | 40 ++++++ src/Suite/SuiteCollector.php | 66 ++++++++- src/Suite/SuiteProvider.php | 2 +- src/Suite/SuiteRunner.php | 4 +- src/Test/Dto/CaseDefinition.php | 14 ++ 22 files changed, 417 insertions(+), 98 deletions(-) delete mode 100644 src/Dto/Suite/SuiteInfo.php delete mode 100644 src/Dto/Suite/SuiteResult.php create mode 100644 src/Interceptor/CaseLocatorInterceptor.php create mode 100644 src/Module/Tokenizer/DefinitionLocator.php create mode 100644 src/Module/Tokenizer/Exception/LocatorException.php create mode 100644 src/Module/Tokenizer/Reflection/FileDefinitions.php rename src/Module/Tokenizer/Reflection/{ReflectionArgument.php => TokenizedArgument.php} (81%) rename src/Module/Tokenizer/Reflection/{ReflectionFile.php => TokenizedFile.php} (98%) rename src/Module/Tokenizer/Reflection/{ReflectionInvocation.php => TokenizedInvocation.php} (91%) create mode 100644 src/Suite/Dto/CasesCollection.php diff --git a/src/Dto/Run/RunResult.php b/src/Dto/Run/RunResult.php index 056dbd4..961e7e3 100644 --- a/src/Dto/Run/RunResult.php +++ b/src/Dto/Run/RunResult.php @@ -4,7 +4,7 @@ namespace Testo\Dto\Run; -use Testo\Dto\Suite\SuiteResult; +use Testo\Suite\Dto\SuiteResult; /** * Result of running tests. diff --git a/src/Dto/Suite/SuiteInfo.php b/src/Dto/Suite/SuiteInfo.php deleted file mode 100644 index 01a3f1b..0000000 --- a/src/Dto/Suite/SuiteInfo.php +++ /dev/null @@ -1,13 +0,0 @@ - - */ -final class SuiteResult 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/Interceptor/CaseLocatorInterceptor.php b/src/Interceptor/CaseLocatorInterceptor.php new file mode 100644 index 0000000..4f778f8 --- /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): CasesCollection $next Next interceptor or core logic to locate test cases. + */ + public function locateTestCases(FileDefinitions $file, callable $next): CasesCollection; +} diff --git a/src/Interceptor/FileLocatorInterceptor.php b/src/Interceptor/FileLocatorInterceptor.php index 98c0a7f..e5d2b68 100644 --- a/src/Interceptor/FileLocatorInterceptor.php +++ b/src/Interceptor/FileLocatorInterceptor.php @@ -5,10 +5,12 @@ namespace Testo\Interceptor; use Testo\Interceptor\Internal\InterceptorMarker; -use Testo\Module\Tokenizer\Reflection\ReflectionFile; +use Testo\Module\Tokenizer\Reflection\TokenizedFile; /** * Intercept locating test files. + * + * @extends InterceptorMarker */ interface FileLocatorInterceptor extends InterceptorMarker { @@ -19,10 +21,11 @@ interface FileLocatorInterceptor extends InterceptorMarker * 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 ReflectionFile $file Information about the test to be run. - * @param callable(ReflectionFile): (null|bool) $next Next interceptor or core logic to run the test. + * @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(ReflectionFile $file, callable $next): ?bool; + public function locateFile(TokenizedFile $file, callable $next): ?bool; } diff --git a/src/Interceptor/Implementation/FilePostfixTestLocatorInterceptor.php b/src/Interceptor/Implementation/FilePostfixTestLocatorInterceptor.php index 4773756..130d19b 100644 --- a/src/Interceptor/Implementation/FilePostfixTestLocatorInterceptor.php +++ b/src/Interceptor/Implementation/FilePostfixTestLocatorInterceptor.php @@ -4,18 +4,42 @@ namespace Testo\Interceptor\Implementation; +use Testo\Interceptor\CaseLocatorInterceptor; use Testo\Interceptor\FileLocatorInterceptor; -use Testo\Module\Tokenizer\Reflection\ReflectionFile; +use Testo\Module\Tokenizer\Reflection\FileDefinitions; +use Testo\Module\Tokenizer\Reflection\TokenizedFile; +use Testo\Suite\Dto\CasesCollection; /** - * Accepts files with the postfix "Test". + * Accepts files with the postfix "Test" and fetches test cases from them. * * E.g. "MyClassTest.php" will be accepted, while "MyClass.php" will not. + * Then it will look for classes with the postfix "Test" inside the file. + * If there are no such classes, it tries to find functions and considers them as test cases. */ -final class FilePostfixTestLocatorInterceptor implements FileLocatorInterceptor +final class FilePostfixTestLocatorInterceptor implements FileLocatorInterceptor, CaseLocatorInterceptor { - public function locateFile(ReflectionFile $file, callable $next): ?bool + public function locateFile(TokenizedFile $file, callable $next): ?bool { return \str_ends_with($file->path->stem(), 'Test') ? true : $next($file); } + + /** + * @inheritDoc + */ + public function locateTestCases(FileDefinitions $file, callable $next): CasesCollection + { + foreach ($file->classes as $class) { + if (!$class->isAbstract() && \str_ends_with($class->getName(), 'Test')) { + $case = $file->cases->declareCase($class); + foreach ($class->getMethods() as $method) { + if ($method->isPublic() && \str_starts_with($method->getName(), 'test')) { + $case->defineTest($method); + } + } + } + } + + return $next($file); + } } diff --git a/src/Interceptor/Implementation/SecureLocatorInterceptor.php b/src/Interceptor/Implementation/SecureLocatorInterceptor.php index 410f1b8..4f54aca 100644 --- a/src/Interceptor/Implementation/SecureLocatorInterceptor.php +++ b/src/Interceptor/Implementation/SecureLocatorInterceptor.php @@ -5,7 +5,7 @@ namespace Testo\Interceptor\Implementation; use Testo\Interceptor\FileLocatorInterceptor; -use Testo\Module\Tokenizer\Reflection\ReflectionFile; +use Testo\Module\Tokenizer\Reflection\TokenizedFile; /** * Interceptor that skips files with {@see include()}, {@see include_once()}, {@see require()}, @@ -13,7 +13,7 @@ */ final class SecureLocatorInterceptor implements FileLocatorInterceptor { - public function locateFile(ReflectionFile $file, callable $next): ?bool + public function locateFile(TokenizedFile $file, callable $next): ?bool { return $file->hasIncludes ? false : $next($file); } diff --git a/src/Interceptor/Internal/InterceptorMarker.php b/src/Interceptor/Internal/InterceptorMarker.php index afef4d2..2224400 100644 --- a/src/Interceptor/Internal/InterceptorMarker.php +++ b/src/Interceptor/Internal/InterceptorMarker.php @@ -7,6 +7,9 @@ /** * Marker interface for interceptors. * + * @template TInput + * @template-covariant TOutput + * * @internal * @psalm-internal Testo\Interceptor */ diff --git a/src/Interceptor/Internal/Pipeline.php b/src/Interceptor/Internal/Pipeline.php index e94c07e..d1661dc 100644 --- a/src/Interceptor/Internal/Pipeline.php +++ b/src/Interceptor/Internal/Pipeline.php @@ -9,8 +9,9 @@ /** * Processor for interceptors chain. * - * @template TInterceptor of TInterceptor - * @template-covariant TReturn of mixed + * @template-covariant TClass of TInterceptor + * @template TInput + * @template-covariant TOutput of mixed * * @psalm-immutable * @@ -24,14 +25,14 @@ final class Pipeline private \Closure $last; - /** @var list */ + /** @var list */ private array $interceptors = []; /** @var int<0, max> Current interceptor key */ private int $current = 0; /** - * @param array $interceptors + * @param array $interceptors */ private function __construct( array $interceptors, @@ -42,6 +43,11 @@ private function __construct( /** * 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 { @@ -51,7 +57,7 @@ public static function prepare(TInterceptor ...$interceptors): self /** * @param non-empty-string $method Method name of the all interceptors. * - * @return callable(object): TReturn + * @return callable(object): TOutput */ public function with(\Closure $last, string $method): callable { @@ -66,9 +72,9 @@ public function with(\Closure $last, string $method): callable /** * Must be used after {@see self::with()} method. * - * @param object $input Input value for the first interceptor. + * @param TInput $input Input value for the first interceptor. * - * @return TReturn + * @return TOutput */ public function __invoke(object $input): mixed { diff --git a/src/Interceptor/TestCallInterceptor.php b/src/Interceptor/TestCallInterceptor.php index 37515b6..41c4c78 100644 --- a/src/Interceptor/TestCallInterceptor.php +++ b/src/Interceptor/TestCallInterceptor.php @@ -10,6 +10,8 @@ /** * Interceptor for running tests. + * + * @extends InterceptorMarker */ interface TestCallInterceptor extends InterceptorMarker { 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 @@ + + * @implements \IteratorAggregate */ final class FileLocator implements \IteratorAggregate { @@ -29,13 +29,13 @@ public function __construct( /** * Available file reflections. Generator. * - * @return \Generator + * @return \Generator * @throws \Exception */ public function getIterator(): \Generator { foreach ($this->finder->getIterator() as $file) { - yield new ReflectionFile($file, (string) $file); + yield new TokenizedFile($file, (string) $file); } } } diff --git a/src/Module/Tokenizer/Reflection/FileDefinitions.php b/src/Module/Tokenizer/Reflection/FileDefinitions.php new file mode 100644 index 0000000..e743ddf --- /dev/null +++ b/src/Module/Tokenizer/Reflection/FileDefinitions.php @@ -0,0 +1,52 @@ + + */ + 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 CasesCollection $cases = new CasesCollection(), + ) { + $this->classes = DefinitionLocator::getClasses($tokenizedFile); + // $this->enums = DefinitionLocator::getEnums($tokenizedFile); + // $this->functions = DefinitionLocator::getFunctions($tokenizedFile); + // $this->interfaces = DefinitionLocator::getInterfaces($tokenizedFile); + // $this->traits = DefinitionLocator::getTraits($tokenizedFile); + } +} diff --git a/src/Module/Tokenizer/Reflection/ReflectionArgument.php b/src/Module/Tokenizer/Reflection/TokenizedArgument.php similarity index 81% rename from src/Module/Tokenizer/Reflection/ReflectionArgument.php rename to src/Module/Tokenizer/Reflection/TokenizedArgument.php index 5722add..1e5cfe3 100644 --- a/src/Module/Tokenizer/Reflection/ReflectionArgument.php +++ b/src/Module/Tokenizer/Reflection/TokenizedArgument.php @@ -9,7 +9,7 @@ /** * Represent argument using in method or function invocation with it's type and value. */ -final class ReflectionArgument +final class TokenizedArgument { /** * Argument types. @@ -43,7 +43,7 @@ public static function locateArguments(array $tokens): array $result = []; foreach ($tokens as $token) { - if ($token[ReflectionFile::TOKEN_TYPE] === T_WHITESPACE) { + if ($token[TokenizedFile::TOKEN_TYPE] === T_WHITESPACE) { continue; } @@ -51,31 +51,31 @@ public static function locateArguments(array $tokens): array $definition = ['type' => self::EXPRESSION, 'value' => '', 'tokens' => []]; } - if ($token[ReflectionFile::TOKEN_TYPE] === '(' || $token[ReflectionFile::TOKEN_TYPE] === '[') { + if ($token[TokenizedFile::TOKEN_TYPE] === '(' || $token[TokenizedFile::TOKEN_TYPE] === '[') { ++$level; - $definition['value'] .= $token[ReflectionFile::TOKEN_CODE]; + $definition['value'] .= $token[TokenizedFile::TOKEN_CODE]; continue; } - if ($token[ReflectionFile::TOKEN_TYPE] === ')' || $token[ReflectionFile::TOKEN_TYPE] === ']') { + if ($token[TokenizedFile::TOKEN_TYPE] === ')' || $token[TokenizedFile::TOKEN_TYPE] === ']') { --$level; - $definition['value'] .= $token[ReflectionFile::TOKEN_CODE]; + $definition['value'] .= $token[TokenizedFile::TOKEN_CODE]; continue; } if ($level) { - $definition['value'] .= $token[ReflectionFile::TOKEN_CODE]; + $definition['value'] .= $token[TokenizedFile::TOKEN_CODE]; continue; } - if ($token[ReflectionFile::TOKEN_TYPE] === ',') { + if ($token[TokenizedFile::TOKEN_TYPE] === ',') { $result[] = self::createArgument($definition); $definition = null; continue; } $definition['tokens'][] = $token; - $definition['value'] .= $token[ReflectionFile::TOKEN_CODE]; + $definition['value'] .= $token[TokenizedFile::TOKEN_CODE]; } //Last argument @@ -122,7 +122,7 @@ public function stringValue(): string * @param array{value: string, tokens: array, type: string} $definition * @see locateArguments */ - private static function createArgument(array $definition): ReflectionArgument + private static function createArgument(array $definition): TokenizedArgument { $result = new self(self::EXPRESSION, $definition['value']); diff --git a/src/Module/Tokenizer/Reflection/ReflectionFile.php b/src/Module/Tokenizer/Reflection/TokenizedFile.php similarity index 98% rename from src/Module/Tokenizer/Reflection/ReflectionFile.php rename to src/Module/Tokenizer/Reflection/TokenizedFile.php index 40fbf5d..9e79cd4 100644 --- a/src/Module/Tokenizer/Reflection/ReflectionFile.php +++ b/src/Module/Tokenizer/Reflection/TokenizedFile.php @@ -5,14 +5,13 @@ namespace Testo\Module\Tokenizer\Reflection; use Testo\Finder\Path; -use Testo\Module\Tokenizer\Tokenizer; /** * File reflections can fetch information about classes, interfaces, functions and traits declared * in file. In addition file reflection provides ability to fetch and describe every method/function * call. */ -final class ReflectionFile +final class TokenizedFile { /** * Namespace separator. @@ -22,10 +21,9 @@ final class ReflectionFile /** * Constants for convenience. */ - public const TOKEN_TYPE = Tokenizer::TYPE; - - public const TOKEN_CODE = Tokenizer::CODE; - public const TOKEN_LINE = Tokenizer::LINE; + public const TOKEN_TYPE = 0; + public const TOKEN_CODE = 1; + public const TOKEN_LINE = 2; /** * Opening and closing token ids. @@ -108,7 +106,7 @@ final class ReflectionFile * Every found method/function invocation. * * @internal - * @var ReflectionInvocation[] + * @var TokenizedInvocation[] */ private array $invocations = []; @@ -184,7 +182,7 @@ public function getInterfaces(): array * Locate and return list of every method or function call in specified file. Only static and * $this calls will be indexed * - * @return ReflectionInvocation[] + * @return TokenizedInvocation[] */ public function getInvocations(): array { @@ -591,13 +589,13 @@ private function registerInvocation( return; } - $this->invocations[] = new ReflectionInvocation( + $this->invocations[] = new TokenizedInvocation( $this->path, $this->lineNumber($invocationID), $class, $operator, $name, - ReflectionArgument::locateArguments($arguments), + TokenizedArgument::locateArguments($arguments), $this->getSource($invocationID, $endID), $invocationLevel, ); diff --git a/src/Module/Tokenizer/Reflection/ReflectionInvocation.php b/src/Module/Tokenizer/Reflection/TokenizedInvocation.php similarity index 91% rename from src/Module/Tokenizer/Reflection/ReflectionInvocation.php rename to src/Module/Tokenizer/Reflection/TokenizedInvocation.php index ebfb091..d6b7c91 100644 --- a/src/Module/Tokenizer/Reflection/ReflectionInvocation.php +++ b/src/Module/Tokenizer/Reflection/TokenizedInvocation.php @@ -12,13 +12,13 @@ * This reflection is very useful for static analysis and mainly used in Translator component to * index translation function usages. */ -final class ReflectionInvocation +final class TokenizedInvocation { /** * New call reflection. * * @param class-string $class - * @param ReflectionArgument[] $arguments + * @param TokenizedArgument[] $arguments * @param int $level Was a function used inside another function call? */ public function __construct( @@ -48,7 +48,7 @@ public function __construct( /** * All parsed function arguments. * - * @var ReflectionArgument[] + * @var TokenizedArgument[] */ public readonly array $arguments, /** @@ -72,7 +72,7 @@ public function isMethod(): bool /** * Get call argument by it position. */ - public function getArgument(int $index): ReflectionArgument + public function getArgument(int $index): TokenizedArgument { if (!isset($this->arguments[$index])) { throw new ReflectionException(\sprintf("No such argument with index '%d'", $index)); diff --git a/src/Suite/Dto/CasesCollection.php b/src/Suite/Dto/CasesCollection.php new file mode 100644 index 0000000..aa8c963 --- /dev/null +++ b/src/Suite/Dto/CasesCollection.php @@ -0,0 +1,40 @@ + + */ + private array $cases = []; + + public function declareCase(?\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/SuiteCollector.php b/src/Suite/SuiteCollector.php index 0bf58b0..59c1928 100644 --- a/src/Suite/SuiteCollector.php +++ b/src/Suite/SuiteCollector.php @@ -5,13 +5,19 @@ namespace Testo\Suite; use Testo\Config\SuiteConfig; -use Testo\Dto\Suite\SuiteInfo; use Testo\Finder\Finder; +use Testo\Interceptor\CaseLocatorInterceptor; +use Testo\Interceptor\FileLocatorInterceptor; +use Testo\Interceptor\Implementation\FilePostfixTestLocatorInterceptor; use Testo\Interceptor\InterceptorProvider; use Testo\Interceptor\Internal\Pipeline; -use Testo\Interceptor\LocatorInterceptor; +use Testo\Module\Tokenizer\DefinitionLocator; use Testo\Module\Tokenizer\FileLocator; -use Testo\Module\Tokenizer\Reflection\ReflectionFile; +use Testo\Module\Tokenizer\Reflection\FileDefinitions; +use Testo\Module\Tokenizer\Reflection\TokenizedFile; +use Testo\Suite\Dto\CasesCollection; +use Testo\Suite\Dto\SuiteInfo; +use Testo\Test\Dto\CaseDefinition; /** * Test suite collection and producer of SuiteInfo. @@ -40,8 +46,16 @@ public function getOrCreate(SuiteConfig $config): SuiteInfo private function createInfo(SuiteConfig $config): SuiteInfo { $files = $this->getFilesIterator($config); + $definitions = $this->getCaseDefinitions($config, $files); - foreach ($files as $file) { + $result = []; + foreach ($definitions as $definition) { + # Skip empty test cases + if ($definition->tests === []) { + continue; + } + + $result[] = $definition; } return new SuiteInfo( @@ -49,6 +63,11 @@ private function createInfo(SuiteConfig $config): SuiteInfo ); } + /** + * 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)); @@ -56,9 +75,12 @@ private function getFilesIterator(SuiteConfig $config): iterable # Prepare interceptors pipeline $interceptors = $this->interceptorProvider->fromClasses(FileLocatorInterceptor::class); - /** @see FileLocatorInterceptor::locateFile() */ + /** + * @see FileLocatorInterceptor::locateFile() + * @var callable(TokenizedFile): (null|bool) $pipeline + */ $pipeline = Pipeline::prepare(...$interceptors) - ->with(static fn(ReflectionFile $file): ?bool => null, 'locateFile'); + ->with(static fn(TokenizedFile $file): ?bool => null, 'locateFile'); foreach ($locator->getIterator() as $fileReflection) { $match = $pipeline($fileReflection); @@ -68,4 +90,36 @@ private function getFilesIterator(SuiteConfig $config): iterable } } } + + /** + * 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); + + /** + * @see CaseLocatorInterceptor::locateTestCases() + * @var callable(FileDefinitions): CasesCollection $pipeline + */ + $pipeline = Pipeline::prepare(...$interceptors) + ->with( + static fn(FileDefinitions $definitions): CasesCollection => $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 index ca61dec..2bf87ef 100644 --- a/src/Suite/SuiteProvider.php +++ b/src/Suite/SuiteProvider.php @@ -7,8 +7,8 @@ use Testo\Config\ApplicationConfig; use Testo\Config\SuiteConfig; use Testo\Dto\Filter; -use Testo\Dto\Suite\SuiteInfo; use Testo\Internal\CloneWith; +use Testo\Suite\Dto\SuiteInfo; /** * Provides test suites. diff --git a/src/Suite/SuiteRunner.php b/src/Suite/SuiteRunner.php index 60bc55e..4610be6 100644 --- a/src/Suite/SuiteRunner.php +++ b/src/Suite/SuiteRunner.php @@ -5,8 +5,8 @@ namespace Testo\Suite; use Testo\Dto\Filter; -use Testo\Dto\Suite\SuiteInfo; -use Testo\Dto\Suite\SuiteResult; +use Testo\Suite\Dto\SuiteInfo; +use Testo\Suite\Dto\SuiteResult; use Testo\Test\CaseRunner; use Testo\Test\TestsProvider; diff --git a/src/Test/Dto/CaseDefinition.php b/src/Test/Dto/CaseDefinition.php index e9acb53..1157262 100644 --- a/src/Test/Dto/CaseDefinition.php +++ b/src/Test/Dto/CaseDefinition.php @@ -8,5 +8,19 @@ final class CaseDefinition { public function __construct( public readonly ?\ReflectionClass $reflection = null, + /** + * List of tests in the case. + * + * @var array + * @note The key must be a function name. + * @readonly Use {@see defineTest()} to populate this property. + */ + public array $tests = [], + public ?string $runner = null, ) {} + + public function defineTest(\ReflectionMethod $method): TestDefinition + { + return $this->tests[$method->getName()] ??= new TestDefinition($method); + } } From ebfb516d6fed4f5691be377ec18bbccb6327810d Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 15 Oct 2025 17:41:32 +0400 Subject: [PATCH 20/20] feat: Improved pipeline --- src/Application.php | 3 +- src/Finder/Finder.php | 3 + src/Interceptor/CaseLocatorInterceptor.php | 8 +-- .../TestCaseInstantiationException.php | 10 +++ src/Interceptor/InterceptorProvider.php | 2 +- .../FilePostfixTestLocatorInterceptor.php | 10 +-- .../SecureLocatorInterceptor.php | 2 +- .../TestoAttributesLocatorInterceptor.php | 49 ++++++++++++++ .../RetryPolicyCallInterceptor.php | 2 +- src/Interceptor/TestCaseCallInterceptor.php | 25 +++++++ .../InstantiateTestCase.php | 32 +++++++++ src/Module/Tokenizer/Reflection.php | 43 ++++++++++-- .../Tokenizer/Reflection/FileDefinitions.php | 8 ++- ...asesCollection.php => CaseDefinitions.php} | 11 +++- src/Suite/Dto/SuiteInfo.php | 14 ++++ src/Suite/Dto/SuiteResult.php | 32 +++++++++ src/Suite/SuiteCollector.php | 22 +++++-- src/Suite/SuiteProvider.php | 2 +- src/Suite/SuiteRunner.php | 32 ++++----- src/Test/CaseRunner.php | 36 +++++++--- src/Test/Dto/CaseDefinition.php | 16 +---- src/Test/Dto/CaseInfo.php | 15 +++++ src/Test/Dto/TestDefinitions.php | 39 +++++++++++ src/Test/Dto/TestInfo.php | 8 --- src/Test/TestRunner.php | 66 +++++++++++++++++++ tests/Testo/Test.php | 16 +++++ tests/Unit/Test/TestsRunnerTest.php | 7 +- 27 files changed, 435 insertions(+), 78 deletions(-) create mode 100644 src/Interceptor/Exception/TestCaseInstantiationException.php rename src/Interceptor/{Implementation => Locator}/FilePostfixTestLocatorInterceptor.php (85%) rename src/Interceptor/{Implementation => Locator}/SecureLocatorInterceptor.php (91%) create mode 100644 src/Interceptor/Locator/TestoAttributesLocatorInterceptor.php rename src/Interceptor/{Implementation => TestCallInterceptor}/RetryPolicyCallInterceptor.php (95%) create mode 100644 src/Interceptor/TestCaseCallInterceptor.php create mode 100644 src/Interceptor/TestCaseCallInterceptor/InstantiateTestCase.php rename src/Suite/Dto/{CasesCollection.php => CaseDefinitions.php} (70%) create mode 100644 src/Suite/Dto/SuiteInfo.php create mode 100644 src/Suite/Dto/SuiteResult.php create mode 100644 src/Test/Dto/TestDefinitions.php create mode 100644 src/Test/TestRunner.php create mode 100644 tests/Testo/Test.php diff --git a/src/Application.php b/src/Application.php index 0bf5bb2..cee540e 100644 --- a/src/Application.php +++ b/src/Application.php @@ -27,6 +27,7 @@ public static function create( $container = Bootstrap::init() ->withConfig($config->services) ->finish(); + $container->set($config); return new self($container); } @@ -38,7 +39,7 @@ public function run($filter = new Filter()): RunResult $suiteRunner = $this->container->get(SuiteRunner::class); # Iterate Test Suites - foreach ($suiteProvider->withFilter($filter)->getConfigs() as $suite) { + foreach ($suiteProvider->withFilter($filter)->getSuites() as $suite) { $suiteResults[] = $suiteRunner->run($suite, $filter); } diff --git a/src/Finder/Finder.php b/src/Finder/Finder.php index 94bbe25..2891241 100644 --- a/src/Finder/Finder.php +++ b/src/Finder/Finder.php @@ -15,6 +15,9 @@ 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()); diff --git a/src/Interceptor/CaseLocatorInterceptor.php b/src/Interceptor/CaseLocatorInterceptor.php index 4f778f8..ddcf78c 100644 --- a/src/Interceptor/CaseLocatorInterceptor.php +++ b/src/Interceptor/CaseLocatorInterceptor.php @@ -6,12 +6,12 @@ use Testo\Interceptor\Internal\InterceptorMarker; use Testo\Module\Tokenizer\Reflection\FileDefinitions; -use Testo\Suite\Dto\CasesCollection; +use Testo\Suite\Dto\CaseDefinitions; /** * Intercept locating test files and test cases.TokenizedFile * - * @extends InterceptorMarker + * @extends InterceptorMarker */ interface CaseLocatorInterceptor extends InterceptorMarker { @@ -21,7 +21,7 @@ interface CaseLocatorInterceptor extends InterceptorMarker * Class and function reflections are available there. * * @param FileDefinitions $file File to locate test cases in. - * @param callable(FileDefinitions): CasesCollection $next Next interceptor or core logic to locate test cases. + * @param callable(FileDefinitions): CaseDefinitions $next Next interceptor or core logic to locate test cases. */ - public function locateTestCases(FileDefinitions $file, callable $next): CasesCollection; + 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 @@ +classes as $class) { if (!$class->isAbstract() && \str_ends_with($class->getName(), 'Test')) { - $case = $file->cases->declareCase($class); + $case = $file->cases->define($class); foreach ($class->getMethods() as $method) { if ($method->isPublic() && \str_starts_with($method->getName(), 'test')) { - $case->defineTest($method); + $case->tests->define($method); } } } diff --git a/src/Interceptor/Implementation/SecureLocatorInterceptor.php b/src/Interceptor/Locator/SecureLocatorInterceptor.php similarity index 91% rename from src/Interceptor/Implementation/SecureLocatorInterceptor.php rename to src/Interceptor/Locator/SecureLocatorInterceptor.php index 4f54aca..a69ecf4 100644 --- a/src/Interceptor/Implementation/SecureLocatorInterceptor.php +++ b/src/Interceptor/Locator/SecureLocatorInterceptor.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Testo\Interceptor\Implementation; +namespace Testo\Interceptor\Locator; use Testo\Interceptor\FileLocatorInterceptor; use Testo\Module\Tokenizer\Reflection\TokenizedFile; 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/Implementation/RetryPolicyCallInterceptor.php b/src/Interceptor/TestCallInterceptor/RetryPolicyCallInterceptor.php similarity index 95% rename from src/Interceptor/Implementation/RetryPolicyCallInterceptor.php rename to src/Interceptor/TestCallInterceptor/RetryPolicyCallInterceptor.php index 8147297..53c9772 100644 --- a/src/Interceptor/Implementation/RetryPolicyCallInterceptor.php +++ b/src/Interceptor/TestCallInterceptor/RetryPolicyCallInterceptor.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Testo\Interceptor\Implementation; +namespace Testo\Interceptor\TestCallInterceptor; use Testo\Attribute\RetryPolicy; use Testo\Interceptor\TestCallInterceptor; 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/Module/Tokenizer/Reflection.php b/src/Module/Tokenizer/Reflection.php index 2e06eeb..06d94be 100644 --- a/src/Module/Tokenizer/Reflection.php +++ b/src/Module/Tokenizer/Reflection.php @@ -9,6 +9,41 @@ */ final class Reflection { + /** + * Fetch all attributes for a given function or method. + * + * @param \ReflectionFunctionAbstract $function The function or method to fetch attributes from. + * @param bool $includeParents Whether to include attributes from parent methods (only applicable for methods). + * @param class-string|null $attributeClass If provided, only attributes of this class will be returned. + * @param int $flags Flags to pass to {@see ReflectionFunctionAbstract::getAttributes()}. + * + * @return \ReflectionAttribute[] + */ + public static function fetchFunctionAttributes( + \ReflectionFunctionAbstract $function, + bool $includeParents = true, + ?string $attributeClass = null, + int $flags = 0, + ): array { + $attributes = []; + + do { + $attributes = \array_merge($attributes, $function->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. * @@ -21,7 +56,7 @@ final class Reflection * @return \ReflectionAttribute[] */ public static function fetchClassAttributes( - string $class, + \ReflectionClass|string $class, bool $includeParents = true, bool $includeTraits = true, ?string $attributeClass = null, @@ -30,14 +65,14 @@ public static function fetchClassAttributes( $attributes = []; do { - $reflection = new \ReflectionClass($class); + \is_string($class) and $reflection = new \ReflectionClass($class); $attributes = \array_merge( $attributes, $reflection->getAttributes($attributeClass, $flags), ); if ($includeTraits) { - foreach (self::fetchTraits($class, includeParents: false) as $trait) { + foreach (self::fetchTraits($class->getName(), includeParents: false) as $trait) { $traitReflection = new \ReflectionClass($trait); $attributes = \array_merge( $attributes, @@ -46,7 +81,7 @@ public static function fetchClassAttributes( } } - $class = $includeParents ? $reflection->getParentClass()?->getName() : null; + $class = $includeParents ? $reflection->getParentClass() : null; } while ($class !== null); return $attributes; diff --git a/src/Module/Tokenizer/Reflection/FileDefinitions.php b/src/Module/Tokenizer/Reflection/FileDefinitions.php index e743ddf..be7fa5c 100644 --- a/src/Module/Tokenizer/Reflection/FileDefinitions.php +++ b/src/Module/Tokenizer/Reflection/FileDefinitions.php @@ -5,7 +5,7 @@ namespace Testo\Module\Tokenizer\Reflection; use Testo\Module\Tokenizer\DefinitionLocator; -use Testo\Suite\Dto\CasesCollection; +use Testo\Suite\Dto\CaseDefinitions; final class FileDefinitions { @@ -41,12 +41,16 @@ final class FileDefinitions public function __construct( public readonly TokenizedFile $tokenizedFile, - public readonly CasesCollection $cases = new CasesCollection(), + 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/Suite/Dto/CasesCollection.php b/src/Suite/Dto/CaseDefinitions.php similarity index 70% rename from src/Suite/Dto/CasesCollection.php rename to src/Suite/Dto/CaseDefinitions.php index aa8c963..06b23b3 100644 --- a/src/Suite/Dto/CasesCollection.php +++ b/src/Suite/Dto/CaseDefinitions.php @@ -9,7 +9,7 @@ /** * Collection of test cases located in a file. */ -final class CasesCollection +final class CaseDefinitions { /** * Located test cases. @@ -17,7 +17,14 @@ final class CasesCollection */ private array $cases = []; - public function declareCase(?\ReflectionClass $reflection): CaseDefinition + 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) { 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 index 59c1928..1a0a4bf 100644 --- a/src/Suite/SuiteCollector.php +++ b/src/Suite/SuiteCollector.php @@ -8,14 +8,14 @@ use Testo\Finder\Finder; use Testo\Interceptor\CaseLocatorInterceptor; use Testo\Interceptor\FileLocatorInterceptor; -use Testo\Interceptor\Implementation\FilePostfixTestLocatorInterceptor; use Testo\Interceptor\InterceptorProvider; use Testo\Interceptor\Internal\Pipeline; -use Testo\Module\Tokenizer\DefinitionLocator; +use Testo\Interceptor\Locator\FilePostfixTestLocatorInterceptor; +use Testo\Interceptor\Locator\TestoAttributesLocatorInterceptor; use Testo\Module\Tokenizer\FileLocator; use Testo\Module\Tokenizer\Reflection\FileDefinitions; use Testo\Module\Tokenizer\Reflection\TokenizedFile; -use Testo\Suite\Dto\CasesCollection; +use Testo\Suite\Dto\CaseDefinitions; use Testo\Suite\Dto\SuiteInfo; use Testo\Test\Dto\CaseDefinition; @@ -48,18 +48,19 @@ private function createInfo(SuiteConfig $config): SuiteInfo $files = $this->getFilesIterator($config); $definitions = $this->getCaseDefinitions($config, $files); - $result = []; + $cases = []; foreach ($definitions as $definition) { # Skip empty test cases if ($definition->tests === []) { continue; } - $result[] = $definition; + $cases[] = $definition; } return new SuiteInfo( name: $config->name, + testCases: CaseDefinitions::fromArray(...$cases), ); } @@ -75,6 +76,10 @@ private function getFilesIterator(SuiteConfig $config): iterable # 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 @@ -103,13 +108,16 @@ private function getCaseDefinitions(SuiteConfig $config, iterable $files): array # Prepare interceptors pipeline $interceptors = $this->interceptorProvider->fromClasses(CaseLocatorInterceptor::class); + // todo remove: + $interceptors[] = new FilePostfixTestLocatorInterceptor(); + /** * @see CaseLocatorInterceptor::locateTestCases() - * @var callable(FileDefinitions): CasesCollection $pipeline + * @var callable(FileDefinitions): CaseDefinitions $pipeline */ $pipeline = Pipeline::prepare(...$interceptors) ->with( - static fn(FileDefinitions $definitions): CasesCollection => $definitions->cases, + static fn(FileDefinitions $definitions): CaseDefinitions => $definitions->cases, 'locateTestCases', ); diff --git a/src/Suite/SuiteProvider.php b/src/Suite/SuiteProvider.php index 2bf87ef..03450dd 100644 --- a/src/Suite/SuiteProvider.php +++ b/src/Suite/SuiteProvider.php @@ -51,7 +51,7 @@ public function withFilter(Filter $filter): self * * @return array */ - public function getConfigs(): array + public function getSuites(): array { $result = []; foreach ($this->configs as $config) { diff --git a/src/Suite/SuiteRunner.php b/src/Suite/SuiteRunner.php index 4610be6..b3e4c36 100644 --- a/src/Suite/SuiteRunner.php +++ b/src/Suite/SuiteRunner.php @@ -8,7 +8,7 @@ use Testo\Suite\Dto\SuiteInfo; use Testo\Suite\Dto\SuiteResult; use Testo\Test\CaseRunner; -use Testo\Test\TestsProvider; +use Testo\Test\Dto\CaseInfo; /** * A test suite runner that executes a suite of tests and returns the results. @@ -16,7 +16,6 @@ final class SuiteRunner { public function __construct( - private readonly TestsProvider $testProvider, private readonly CaseRunner $caseRunner, ) {} @@ -25,21 +24,22 @@ public function run(SuiteInfo $suite, Filter $filter): SuiteResult # Apply suite name filter if exists $suite->name === null or $filter = $filter->withTestSuites($suite->name); - # Get tests - $cases = $this->testProvider - ->withFilter($filter) - ->getCases(); - - // todo if random, run in random order - - # Run tests in each case - foreach ($cases as $case) { - $this->caseRunner->runCase( - $case, - $case->reflection === null ? $filter : $filter->withTestCases($case->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([]); + return new SuiteResult($results); } } diff --git a/src/Test/CaseRunner.php b/src/Test/CaseRunner.php index 24a8665..b430016 100644 --- a/src/Test/CaseRunner.php +++ b/src/Test/CaseRunner.php @@ -6,6 +6,8 @@ use Testo\Dto\Filter; use Testo\Interceptor\InterceptorProvider; +use Testo\Interceptor\Internal\Pipeline; +use Testo\Interceptor\TestCaseCallInterceptor; use Testo\Test\Dto\CaseResult; use Testo\Test\Dto\CaseInfo; use Testo\Test\Dto\TestInfo; @@ -20,21 +22,39 @@ public function __construct( public function runCase(CaseInfo $info, Filter $filter): CaseResult { - $results = []; - # TODO handle async tests + # TODO handle random order + + /** + * Prepare interceptors pipeline + * + * @see TestCaseCallInterceptor::runTestCase() + * @var list $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', + ); - # Instantiate test case - # TODO autowire dependencies - $instance = $info->definition->reflection?->newInstance(); + return $pipeline($info); + } - $tests = $this->testsProvider->withFilter($filter)->getTests(); - foreach ($tests as $testDefinition) { + public function run(CaseInfo $info): CaseResult + { + $results = []; + foreach ($info->definition->tests->getTests() as $testDefinition) { $testInfo = new TestInfo( caseInfo: $info, testDefinition: $testDefinition, - instance: $instance, ); + $results[] = $this->testRunner->runTest($testInfo); } diff --git a/src/Test/Dto/CaseDefinition.php b/src/Test/Dto/CaseDefinition.php index 1157262..b78b03a 100644 --- a/src/Test/Dto/CaseDefinition.php +++ b/src/Test/Dto/CaseDefinition.php @@ -8,19 +8,7 @@ final class CaseDefinition { public function __construct( public readonly ?\ReflectionClass $reflection = null, - /** - * List of tests in the case. - * - * @var array - * @note The key must be a function name. - * @readonly Use {@see defineTest()} to populate this property. - */ - public array $tests = [], - public ?string $runner = null, + public readonly TestDefinitions $tests = new TestDefinitions(), + // public ?string $runner = null, ) {} - - public function defineTest(\ReflectionMethod $method): TestDefinition - { - return $this->tests[$method->getName()] ??= new TestDefinition($method); - } } diff --git a/src/Test/Dto/CaseInfo.php b/src/Test/Dto/CaseInfo.php index af2e0c6..e036685 100644 --- a/src/Test/Dto/CaseInfo.php +++ b/src/Test/Dto/CaseInfo.php @@ -4,12 +4,27 @@ namespace Testo\Test\Dto; +use Testo\Internal\CloneWith; + /** * Information about run test case. */ final class CaseInfo { + use CloneWith; + + public function __construct( public readonly CaseDefinition $definition = new CaseDefinition(), + /** + * Test Case class instance if class is defined, null otherwise. + */ + public readonly ?object $instance = null, ) {} + + public function withInstance(?object $instance): self + { + /** @see self::$instance */ + return $this->with('instance', $instance); + } } diff --git a/src/Test/Dto/TestDefinitions.php b/src/Test/Dto/TestDefinitions.php new file mode 100644 index 0000000..32df751 --- /dev/null +++ b/src/Test/Dto/TestDefinitions.php @@ -0,0 +1,39 @@ + + */ + 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 index 07e3452..dd172aa 100644 --- a/src/Test/Dto/TestInfo.php +++ b/src/Test/Dto/TestInfo.php @@ -4,8 +4,6 @@ namespace Testo\Test\Dto; -use Testo\Dto\Filter; - /** * Information about run test. */ @@ -14,11 +12,5 @@ final class TestInfo public function __construct( public readonly CaseInfo $caseInfo, public readonly TestDefinition $testDefinition, - - /** - * Test Case class instance if class is defined, null otherwise. - */ - public readonly ?object $instance = null, - private readonly Filter $filter = new Filter(), ) {} } 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/tests/Testo/Test.php b/tests/Testo/Test.php new file mode 100644 index 0000000..3b9ec1a --- /dev/null +++ b/tests/Testo/Test.php @@ -0,0 +1,16 @@ +runTest($info); @@ -47,11 +47,12 @@ public function testRunFunctionWithRetry(): void { $instance = self::createInstance(); $info = new TestInfo( - caseInfo: new CaseInfo(), + caseInfo: new CaseInfo( + instance: new TestInterceptors(), + ), testDefinition: new TestDefinition( reflection: new \ReflectionFunction(\Tests\Fixture\withRetryPolicy(...)), ), - instance: new TestInterceptors(), ); $result = $instance->runTest($info);