diff --git a/plugin/lifecycle/src/Internal/LifecycleInterceptor.php b/plugin/lifecycle/src/Internal/LifecycleInterceptor.php index 356b7c04..68b7e17c 100644 --- a/plugin/lifecycle/src/Internal/LifecycleInterceptor.php +++ b/plugin/lifecycle/src/Internal/LifecycleInterceptor.php @@ -37,7 +37,7 @@ * Both class-based cases (methods of a {@see \Testo\Test} class) and function-based cases (a file of * top-level functions, whose {@see CaseDefinition::$reflection} is null) are supported. For a * function-based case the hooks are the file's lifecycle-annotated functions, discovered from the - * source file of the case's tests. + * case's source file ({@see CaseDefinition::$file}). * * @internal * @psalm-internal Testo\Lifecycle @@ -167,27 +167,25 @@ private static function group(iterable $hooks): array /** * Collect the lifecycle-annotated free functions of a function-based case. * - * A function-based case carries no class reflection, so the hooks are located from the source - * file of the case's tests: every function declared there is reflected and filtered down to - * those carrying a lifecycle attribute. + * A function-based case carries no class reflection, so the hooks are located from the case's + * source file ({@see CaseDefinition::$file}): every function declared there is reflected and + * filtered down to those carrying a lifecycle attribute. + * + * The path comes from the definition itself, never from the surviving tests: outer case + * interceptors may prune tests from the case, and the `#[BeforeClass]`/`#[AfterClass]` hooks + * must still run for a fully pruned case. * * @return list<\ReflectionFunction> */ private static function collectCaseFunctions(CaseDefinition $definition): array { - $path = null; - foreach ($definition->tests->getTests() as $test) { - if ($test->reflection instanceof \ReflectionFunction) { - $path = $test->reflection->getFileName(); - break; - } - } - - if (!\is_string($path)) { + $path = $definition->file; + if (!$path->isFile()) { + # A synthetic definition without a real source file has no discoverable hooks. return []; } - $file = new TokenizedFile(file: new \SplFileInfo($path), path: $path); + $file = new TokenizedFile(file: new \SplFileInfo((string) $path), path: $path); return \array_values(\array_filter( DefinitionLocator::getFunctions($file), diff --git a/plugin/lifecycle/tests/Unit/Fixture/PrunedFunctionsWithLifecycle.php b/plugin/lifecycle/tests/Unit/Fixture/PrunedFunctionsWithLifecycle.php new file mode 100644 index 00000000..0df36c89 --- /dev/null +++ b/plugin/lifecycle/tests/Unit/Fixture/PrunedFunctionsWithLifecycle.php @@ -0,0 +1,50 @@ +hasKeys('fnAlpha', 'fnBeta'); } + /** + * Regression: hook discovery for a function-based case must come from + * {@see CaseDefinition::$file}, not from the surviving tests. Any outer case interceptor + * may prune tests from the case before this interceptor runs, and the + * `#[BeforeClass]`/`#[AfterClass]` hooks must still fire — also when nothing survived. + * Discovery used to locate the source file from the first surviving test function, so a + * fully pruned case silently lost its class-level hooks. + */ + public function runsClassHooksForFunctionCaseWhoseTestsWereAllPruned(): void + { + # Functions are not autoloadable: load the fixture to reach the counters. + require_once $this->fixturesDir . 'PrunedFunctionsWithLifecycle.php'; + PrunedFunctionsState::$beforeClassCalls = 0; + PrunedFunctionsState::$afterClassCalls = 0; + $info = $this->makeFunctionCaseInfoWithoutTests($this->fixturesDir . 'PrunedFunctionsWithLifecycle.php'); + + $hooksAtNext = null; + $beforeClassAtNext = null; + $afterClassAtNext = null; + $this->interceptor->runTestCase( + $info, + static function (CaseInfo $case) use (&$hooksAtNext, &$beforeClassAtNext, &$afterClassAtNext): CaseResult { + $hooksAtNext = $case->getAttribute(LifecycleInterceptor::class, []); + $beforeClassAtNext = PrunedFunctionsState::$beforeClassCalls; + $afterClassAtNext = PrunedFunctionsState::$afterClassCalls; + return new CaseResult(results: [], status: Status::Passed); + }, + ); + + # All four hooks were discovered from the case file and published for the inner pipeline. + Assert::array($hooksAtNext) + ->hasKeys(BeforeClass::class, AfterClass::class, BeforeTest::class, AfterTest::class); + # The class hooks fire exactly once, around the inner pipeline: BeforeClass has already + # fired when `$next` runs, AfterClass has not yet. + Assert::same($beforeClassAtNext, 1); + Assert::same($afterClassAtNext, 0); + Assert::same(PrunedFunctionsState::$beforeClassCalls, 1); + Assert::same(PrunedFunctionsState::$afterClassCalls, 1); + } + + /** + * A synthetic function-based definition whose file does not exist on disk has no + * discoverable hooks — the case must still pass through the pipeline instead of + * failing on tokenization. + */ + public function passesThroughFunctionCaseWithoutRealSourceFile(): void + { + $info = $this->makeFunctionCaseInfoWithoutTests(__DIR__ . '/does-not-exist.php'); + $expected = new CaseResult(results: [], status: Status::Passed); + + $result = $this->interceptor->runTestCase($info, static fn(): CaseResult => $expected); + + Assert::same($result, $expected); + } + private function makeDefinitionWithAllPublicMethodsAsTests(string $fixture, string $classFqn): FileDefinitions { $path = $this->fixturesDir . $fixture; @@ -203,4 +270,22 @@ private function makeDefinitionWithAllFunctionsAsTests(string $fixture): FileDef return $definition; } + + /** + * Build a {@see CaseInfo} over a function-based case (null reflection) whose test set is + * empty — the state an outer case interceptor leaves behind after pruning every test. + */ + private function makeFunctionCaseInfoWithoutTests(string $path): CaseInfo + { + $file = Path::create($path); + $definition = new CaseDefinition( + name: $file->name(), + type: TestType::Test->value, + file: $file, + reflection: null, + tests: new TestDefinitions(), + ); + + return new CaseInfo(definition: $definition, suiteIdentity: new SuiteIdentity('Lifecycle/Unit')); + } }