From b9f9d6d70ff1a0ae5b79e5b97b64a1e894e71da2 Mon Sep 17 00:00:00 2001 From: vdvukhzhilov Date: Fri, 7 Aug 2026 12:02:24 +0200 Subject: [PATCH] feat: treat hooks errors as allure report global errors --- .gitignore | 1 + codeception-hooks.yml | 17 ++ composer.json | 7 +- phpcs.xml.dist | 1 + src/AllureAdapter.php | 122 +++++++++ src/AllureAdapterInterface.php | 32 +++ src/AllureCodeception.php | 242 +++++++++++++++++- src/Internal/HookFailureMessage.php | 22 ++ src/Internal/TestLifecycle.php | 207 ++++++++++++++- src/Internal/TestLifecycleInterface.php | 41 +++ .../unit/HookFailureIntegrationTest.php | 204 +++++++++++++++ .../unit/Internal/HookFailureMessageTest.php | 38 +++ .../Internal/TestLifecycleHookFixtureTest.php | 189 ++++++++++++++ .../_support/Helper/HookFailHelper.php | 55 ++++ .../_support/Setup/OutputDirectoryHook.php | 23 ++ test/hooks-fixtures/_support/UnitTester.php | 25 ++ test/hooks-fixtures/unit.suite.yml | 4 + test/hooks-fixtures/unit/PassingCest.php | 23 ++ 18 files changed, 1238 insertions(+), 15 deletions(-) create mode 100644 codeception-hooks.yml create mode 100644 src/AllureAdapter.php create mode 100644 src/AllureAdapterInterface.php create mode 100644 src/Internal/HookFailureMessage.php create mode 100644 test/codeception/unit/HookFailureIntegrationTest.php create mode 100644 test/codeception/unit/Internal/HookFailureMessageTest.php create mode 100644 test/codeception/unit/Internal/TestLifecycleHookFixtureTest.php create mode 100644 test/hooks-fixtures/_support/Helper/HookFailHelper.php create mode 100644 test/hooks-fixtures/_support/Setup/OutputDirectoryHook.php create mode 100644 test/hooks-fixtures/_support/UnitTester.php create mode 100644 test/hooks-fixtures/unit.suite.yml create mode 100644 test/hooks-fixtures/unit/PassingCest.php diff --git a/.gitignore b/.gitignore index f114c38..ed725b2 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,6 @@ composer.phar composer.lock /build/ /test/codeception*/_support/_generated/ +/test/hooks-fixtures/_support/_generated/ .phpunit.result.cache diff --git a/codeception-hooks.yml b/codeception-hooks.yml new file mode 100644 index 0000000..335f604 --- /dev/null +++ b/codeception-hooks.yml @@ -0,0 +1,17 @@ +namespace: Qameta\Allure\Codeception\Test\Hooks + +settings: + lint: true +paths: + tests: test/hooks-fixtures + output: build/hooks-fixtures + support: test/hooks-fixtures/_support + data: test/hooks-fixtures/_data + +extensions: + enabled: + - Qameta\Allure\Codeception\AllureCodeception + config: + Qameta\Allure\Codeception\AllureCodeception: + outputDirectory: allure-results-hooks + setupHook: Qameta\Allure\Codeception\Test\Hooks\Setup\OutputDirectoryHook diff --git a/composer.json b/composer.json index b6d4d63..36fd0b5 100644 --- a/composer.json +++ b/composer.json @@ -46,13 +46,18 @@ "Qameta\\Allure\\Codeception\\Test\\Report\\": "test/codeception-report/_support/", "Qameta\\Allure\\Codeception\\Test\\Report\\Functional\\": "test/codeception-report/functional/", "Qameta\\Allure\\Codeception\\Test\\Report\\Acceptance\\": "test/codeception-report/acceptance/", - "Qameta\\Allure\\Codeception\\Test\\Report\\Unit\\": "test/codeception-report/unit/" + "Qameta\\Allure\\Codeception\\Test\\Report\\Unit\\": "test/codeception-report/unit/", + "Qameta\\Allure\\Codeception\\Test\\Hooks\\": [ + "test/hooks-fixtures/_support/", + "test/hooks-fixtures/unit/" + ] } }, "scripts": { "build": [ "vendor/bin/codecept build", "vendor/bin/codecept build -c codeception-report.yml", + "vendor/bin/codecept build -c codeception-hooks.yml", "vendor/bin/codecept gherkin:snippets acceptance -c codeception-report.yml" ], "test-cs": "vendor/bin/phpcs -sp", diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 0ba55b0..939ac43 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -6,6 +6,7 @@ test test/codeception/_support/_generated/* test/codeception-report/_support/_generated/* + test/hooks-fixtures/_support/_generated/* diff --git a/src/AllureAdapter.php b/src/AllureAdapter.php new file mode 100644 index 0000000..a26101b --- /dev/null +++ b/src/AllureAdapter.php @@ -0,0 +1,122 @@ + + */ + private array $suiteContainers = []; + + /** + * @var array + */ + private array $emittedHookGlobals = []; + + /** + * @var array + */ + private array $startedTests = []; + + private ?string $activeFixtureUuid = null; + + private ?string $activeHookName = null; + + private function __construct() + { + } + + public static function getInstance(): AllureAdapterInterface + { + return self::$instance ??= new self(); + } + + public static function setInstance(AllureAdapterInterface $instance): void + { + self::$instance = $instance; + } + + public static function reset(): void + { + self::$instance = null; + } + + #[\Override] + public function setActiveFixture(string $uuid, string $hookName): void + { + $this->activeFixtureUuid = $uuid; + $this->activeHookName = $hookName; + } + + #[\Override] + public function getActiveFixtureUuid(): ?string + { + return $this->activeFixtureUuid; + } + + #[\Override] + public function getActiveHookName(): ?string + { + return $this->activeHookName; + } + + #[\Override] + public function clearActiveFixture(): void + { + $this->activeFixtureUuid = null; + $this->activeHookName = null; + } + + #[\Override] + public function hasEmittedHookGlobalError(string $fixtureUuid): bool + { + return isset($this->emittedHookGlobals[$fixtureUuid]); + } + + #[\Override] + public function markHookGlobalErrorEmitted(string $fixtureUuid): void + { + $this->emittedHookGlobals[$fixtureUuid] = true; + } + + #[\Override] + public function registerSuiteContainer(string $suiteName, string $containerUuid): void + { + $this->suiteContainers[$suiteName] = $containerUuid; + } + + #[\Override] + public function getSuiteContainerId(string $suiteName): ?string + { + return $this->suiteContainers[$suiteName] ?? null; + } + + #[\Override] + public function clearSuiteContainer(string $suiteName): void + { + unset($this->suiteContainers[$suiteName]); + } + + #[\Override] + public function markTestStarted(string $testUuid): void + { + $this->startedTests[$testUuid] = true; + } + + #[\Override] + public function wasTestStarted(string $testUuid): bool + { + return isset($this->startedTests[$testUuid]); + } + + #[\Override] + public function clearTestStarted(string $testUuid): void + { + unset($this->startedTests[$testUuid]); + } +} diff --git a/src/AllureAdapterInterface.php b/src/AllureAdapterInterface.php new file mode 100644 index 0000000..3df00a6 --- /dev/null +++ b/src/AllureAdapterInterface.php @@ -0,0 +1,32 @@ + 'moduleInit', - Events::SUITE_BEFORE => 'suiteBefore', - Events::SUITE_AFTER => 'suiteAfter', + Events::SUITE_BEFORE => [ + ['suiteBeforeHookPhase', 100], + ], + Events::SUITE_AFTER => [ + ['suiteAfterHookStart', 200], + ['suiteAfterModuleWrap', 1], + ], Events::TEST_START => 'testStart', + Events::TEST_BEFORE => [ + ['testBeforeHookPhase', 100], + ], + Events::TEST_AFTER => [ + ['testAfterHookPhase', 100], + ], Events::TEST_FAIL => 'testFail', Events::TEST_ERROR => 'testError', Events::TEST_INCOMPLETE => 'testIncomplete', @@ -55,7 +89,7 @@ final class AllureCodeception extends Extension Events::TEST_SUCCESS => 'testSuccess', Events::TEST_END => 'testEnd', Events::STEP_BEFORE => 'stepBefore', - Events::STEP_AFTER => 'stepAfter' + Events::STEP_AFTER => 'stepAfter', ]; private ?ThreadDetectorInterface $threadDetector = null; @@ -86,6 +120,7 @@ public function moduleInit(): void private function reconfigure(): void { QametaAllure::reset(); + AllureAdapter::reset(); $this->testLifecycle = null; $this->threadDetector = null; QametaAllure::getLifecycleConfigurator() @@ -156,9 +191,12 @@ class_exists($linkConfig) && is_a($linkConfig, LinkTemplateInterface::class, tru } /** + * Wraps `_beforeSuite` (+ beforeClass) because SUITE_BEFORE throws abort SuiteManager + * before its try/finally — SUITE_AFTER never runs, so orphan flush cannot help. + * * @psalm-suppress MissingDependency */ - public function suiteBefore(SuiteEvent $suiteEvent): void + public function suiteBeforeHookPhase(SuiteEvent $suiteEvent): void { /** @psalm-suppress InternalMethod */ $suiteName = $suiteEvent->getSuite()?->getName(); @@ -166,16 +204,141 @@ public function suiteBefore(SuiteEvent $suiteEvent): void return; } - $this + $lifecycle = $this ->getTestLifecycle() - ->switchToSuite(new SuiteInfo($suiteName)); + ->switchToSuite(new SuiteInfo($suiteName)) + ->ensureSuiteContainer() + ->startBeforeHookFixture('_beforeSuite'); + + try { + $this->runBeforeClassMethods($suiteEvent); + foreach ($this->getModulesList() as $module) { + $module->_beforeSuite($suiteEvent->getSettings()); + } + $lifecycle->completeHookFixtureSuccess(); + } catch (Throwable $e) { + $lifecycle + ->completeHookFixtureFailure( + $this->statusForHookThrowable($e), + $e->getMessage(), + $e->getTraceAsString(), + ) + ->writeSuiteContainer() + ->resetSuite(); + throw $e; + } finally { + $suiteEvent->stopPropagation(); + } } - public function suiteAfter(): void + public function suiteAfterHookStart(): void { + // Module::_after may throw and skip TEST_END. $this ->getTestLifecycle() - ->resetSuite(); + ->flushActiveHookFixtureFailure(Status::broken()) + ->finalizePendingTest() + ->ensureSuiteContainer() + ->startAfterHookFixture('_afterSuite'); + } + + /** + * Runs Module `_afterSuite` inside the active fixture and stops propagation so + * Module subscriber does not double-invoke. afterClass (prio 100) already ran. + */ + public function suiteAfterModuleWrap(SuiteEvent $suiteEvent): void + { + $lifecycle = $this->getTestLifecycle(); + try { + foreach (array_reverse($this->getModulesList()) as $module) { + $module->_afterSuite(); + } + $lifecycle + ->completeHookFixtureSuccess() + ->writeSuiteContainer() + ->resetSuite(); + } catch (Throwable $e) { + $lifecycle + ->completeHookFixtureFailure( + $this->statusForHookThrowable($e), + $e->getMessage(), + $e->getTraceAsString(), + ) + ->writeSuiteContainer() + ->resetSuite(); + throw $e; + } finally { + $suiteEvent->stopPropagation(); + } + } + + /** + * Wraps Module `_after` so failures still emit fixture + global (low-prio stop + * would be skipped when Module throws; TEST_END would also be skipped). + * + * @psalm-suppress MissingDependency + */ + public function testAfterHookPhase(TestEvent $testEvent): void + { + $lifecycle = $this + ->getTestLifecycle() + ->switchToTest($testEvent->getTest()) + ->startAfterHookFixture('_after'); + + try { + foreach (array_reverse($this->getModulesList()) as $module) { + $module->_after($testEvent->getTest()); + $module->_resetConfig(); + } + $lifecycle->completeHookFixtureSuccess(); + } catch (Throwable $e) { + $lifecycle->completeHookFixtureFailure( + $this->statusForHookThrowable($e), + $e->getMessage(), + $e->getTraceAsString(), + ); + throw $e; + } finally { + $testEvent->stopPropagation(); + } + } + + /** + * @return list<\Codeception\Module> + */ + private function getModulesList(): array + { + $modules = []; + foreach ($this->getCurrentModuleNames() as $name) { + $modules[] = $this->getModule($name); + } + + return $modules; + } + + /** + * Mirrors Codeception\Subscriber\BeforeAfterTest::beforeClass so it stays inside + * the `_beforeSuite` fixture when we stopPropagation on SUITE_BEFORE. + */ + private function runBeforeClassMethods(SuiteEvent $suiteEvent): void + { + $suite = $suiteEvent->getSuite(); + if ($suite === null) { + return; + } + + foreach ($suite->getTests() as $test) { + $methods = $test->getMetadata()->getBeforeClassMethods(); + $target = $test; + if ($test instanceof \Codeception\Test\TestCaseWrapper) { + $target = $test->getTestCase(); + } + foreach ($methods as $method) { + if (is_callable([$target, $method])) { + $target->{$method}(); + } + } + } } /** @@ -188,8 +351,41 @@ public function testStart(TestEvent $testEvent): void ->getTestLifecycle() ->switchToTest($test) ->create() - ->updateTest() - ->startTest(); + ->updateTest(); + // startTest deferred until TEST_BEFORE succeeds (Prepared-after-hooks alignment). + } + + /** + * Wraps Module `_before` using Extension module list. Required because + * suiteBeforeHookPhase stopPropagation skips Module::beforeSuite which would + * otherwise populate Module subscriber's module list for TEST_BEFORE. + * + * @psalm-suppress MissingDependency + */ + public function testBeforeHookPhase(TestEvent $testEvent): void + { + $lifecycle = $this + ->getTestLifecycle() + ->switchToTest($testEvent->getTest()) + ->startBeforeHookFixture('_before'); + + try { + foreach ($this->getModulesList() as $module) { + $module->_before($testEvent->getTest()); + } + $lifecycle + ->completeHookFixtureSuccess() + ->startTest(); + } catch (Throwable $e) { + $lifecycle->completeHookFixtureFailure( + $this->statusForHookThrowable($e), + $e->getMessage(), + $e->getTraceAsString(), + ); + throw $e; + } finally { + $testEvent->stopPropagation(); + } } private function getThreadDetector(): ThreadDetectorInterface @@ -202,11 +398,18 @@ private function getThreadDetector(): ThreadDetectorInterface */ public function testError(FailEvent $failEvent): void { + $error = $failEvent->getFail(); + $status = $this->statusForHookThrowable($error); $this ->getTestLifecycle() ->switchToTest($failEvent->getTest()) + ->completeOrphanHookFailure( + $status, + $error->getMessage(), + $error->getTraceAsString(), + ) ->updateTestFailure( - $failEvent->getFail(), + $error, Status::broken(), ); } @@ -220,8 +423,13 @@ public function testFail(FailEvent $failEvent): void $this ->getTestLifecycle() ->switchToTest($failEvent->getTest()) + ->completeOrphanHookFailure( + Status::failed(), + $error->getMessage(), + $error->getTraceAsString(), + ) ->updateTestFailure( - $failEvent->getFail(), + $error, Status::failed(), new StatusDetails(message: $error->getMessage(), trace: $error->getTraceAsString()), ); @@ -308,6 +516,13 @@ public function stepAfter(StepEvent $stepEvent): void ->stopStep(); } + private function statusForHookThrowable(Throwable $error): Status + { + return $error instanceof AssertionFailedError + ? Status::failed() + : Status::broken(); + } + private function getTestLifecycle(): TestLifecycleInterface { return $this->testLifecycle ??= new TestLifecycle( @@ -318,6 +533,7 @@ private function getTestLifecycle(): TestLifecycleInterface threadDetector: $this->getThreadDetector(), linkTemplates: Allure::getConfig()->getLinkTemplates(), env: $_ENV, + adapter: AllureAdapter::getInstance(), ); } } diff --git a/src/Internal/HookFailureMessage.php b/src/Internal/HookFailureMessage.php new file mode 100644 index 0000000..5232ed2 --- /dev/null +++ b/src/Internal/HookFailureMessage.php @@ -0,0 +1,22 @@ + */ @@ -55,6 +60,7 @@ public function __construct( private ThreadDetectorInterface $threadDetector, private LinkTemplateCollectionInterface $linkTemplates, private array $env, + private AllureAdapterInterface $adapter, ) { /** @psalm-var WeakMap $this->stepStarts */ $this->stepStarts = new WeakMap(); @@ -92,6 +98,7 @@ public function switchToSuite(SuiteInfo $suiteInfo): self public function resetSuite(): self { $this->currentSuite = null; + $this->suiteHookContext = false; return $this; } @@ -102,6 +109,7 @@ public function switchToTest(object $test): self $thread = $this->threadDetector->getThread(); $this->lifecycle->switchThread($thread); + $this->suiteHookContext = false; $this->currentTest = $this ->getTestInfoBuilder($test) ->build( @@ -136,6 +144,7 @@ public function create(): self containerUuid: $containerResult->getUuid(), testUuid: $testResult->getUuid(), ); + $this->adapter->clearTestStarted($testResult->getUuid()); return $this; } @@ -180,7 +189,9 @@ private function createModelProvidersForTest(mixed $test): array #[\Override] public function startTest(): self { - $this->lifecycle->startTest($this->getCurrentTestStart()->getTestUuid()); + $testUuid = $this->getCurrentTestStart()->getTestUuid(); + $this->lifecycle->startTest($testUuid); + $this->adapter->markTestStarted($testUuid); return $this; } @@ -188,6 +199,8 @@ public function startTest(): self #[\Override] public function stopTest(): self { + $this->completeHookFixtureSuccess(); + $testUuid = $this->getCurrentTestStart()->getTestUuid(); $this ->lifecycle @@ -200,6 +213,7 @@ public function stopTest(): self ->stopContainer($containerUuid); $this->lifecycle->writeContainer($containerUuid); + $this->adapter->clearTestStarted($testUuid); $this->currentTest = null; $this->currentTestStart = null; @@ -389,4 +403,195 @@ public function updateStepResult(): self return $this; } + + #[\Override] + public function ensureSuiteContainer(): self + { + $suite = $this->getCurrentSuite(); + $this->suiteHookContext = true; + + if ($this->adapter->getSuiteContainerId($suite->getName()) !== null) { + return $this; + } + + $containerResult = $this->resultFactory->createContainer(); + $this->lifecycle->startContainer($containerResult); + $this->adapter->registerSuiteContainer($suite->getName(), $containerResult->getUuid()); + + return $this; + } + + #[\Override] + public function writeSuiteContainer(): self + { + $suite = $this->currentSuite; + if ($suite === null) { + return $this; + } + + $containerId = $this->adapter->getSuiteContainerId($suite->getName()); + if ($containerId === null) { + return $this; + } + + $this->completeHookFixtureSuccess(); + $this->lifecycle->stopContainer($containerId); + $this->lifecycle->writeContainer($containerId); + $this->adapter->clearSuiteContainer($suite->getName()); + $this->suiteHookContext = false; + + return $this; + } + + #[\Override] + public function startBeforeHookFixture(string $hookName): self + { + return $this->startHookFixture($hookName, before: true); + } + + #[\Override] + public function startAfterHookFixture(string $hookName): self + { + return $this->startHookFixture($hookName, before: false); + } + + #[\Override] + public function completeHookFixtureSuccess(): self + { + $uuid = $this->adapter->getActiveFixtureUuid(); + if ($uuid === null) { + return $this; + } + + $this->lifecycle->updateFixture( + static fn (FixtureResult $fixture) => $fixture->setStatus(Status::passed()), + $uuid, + ); + $this->lifecycle->stopFixture($uuid); + $this->adapter->clearActiveFixture(); + + return $this; + } + + #[\Override] + public function completeHookFixtureFailure( + Status $status, + ?string $message = null, + ?string $trace = null, + ): self { + $uuid = $this->adapter->getActiveFixtureUuid(); + $hookName = $this->adapter->getActiveHookName() ?? 'hook'; + if ($uuid === null) { + return $this; + } + + $prefixed = HookFailureMessage::format($hookName, $message); + + $this->lifecycle->updateFixture( + static function (FixtureResult $fixture) use ($status, $prefixed, $trace): void { + $fixture + ->setStatus($status) + ->setStatusDetails( + (new StatusDetails()) + ->setMessage($prefixed) + ->setTrace($trace), + ); + }, + $uuid, + ); + $this->lifecycle->stopFixture($uuid); + + if (!$this->adapter->hasEmittedHookGlobalError($uuid)) { + Allure::globalError($prefixed, $trace); + $this->adapter->markHookGlobalErrorEmitted($uuid); + } + + $this->adapter->clearActiveFixture(); + + return $this; + } + + #[\Override] + public function completeOrphanHookFailure( + Status $status, + ?string $message = null, + ?string $trace = null, + ): self { + if ($this->currentTestStart === null) { + return $this; + } + + if ($this->adapter->getActiveFixtureUuid() === null) { + return $this; + } + + if ($this->adapter->wasTestStarted($this->getCurrentTestStart()->getTestUuid())) { + return $this; + } + + $this->completeHookFixtureFailure($status, $message, $trace); + + return $this; + } + + #[\Override] + public function flushActiveHookFixtureFailure( + Status $status, + ?string $message = null, + ?string $trace = null, + ): self { + if ($this->adapter->getActiveFixtureUuid() === null) { + return $this; + } + + $this->completeHookFixtureFailure($status, $message, $trace); + + return $this; + } + + #[\Override] + public function finalizePendingTest(): self + { + if ($this->currentTestStart === null) { + return $this; + } + + $this->updateTestResult(); + $this->stopTest(); + + return $this; + } + + private function startHookFixture(string $hookName, bool $before): self + { + $this->completeHookFixtureSuccess(); + + $fixture = $this + ->resultFactory + ->createFixture() + ->setName($hookName); + + $containerId = $this->resolveContainerId(); + if ($before) { + $this->lifecycle->startBeforeFixture($fixture, $containerId); + } else { + $this->lifecycle->startAfterFixture($fixture, $containerId); + } + + $this->adapter->setActiveFixture($fixture->getUuid(), $hookName); + + return $this; + } + + private function resolveContainerId(): string + { + if ($this->suiteHookContext) { + $suite = $this->getCurrentSuite(); + + return $this->adapter->getSuiteContainerId($suite->getName()) + ?? throw new RuntimeException("Suite container is not set for {$suite->getName()}"); + } + + return $this->getCurrentTestStart()->getContainerUuid(); + } } diff --git a/src/Internal/TestLifecycleInterface.php b/src/Internal/TestLifecycleInterface.php index 6d4d8f5..2a6d186 100644 --- a/src/Internal/TestLifecycleInterface.php +++ b/src/Internal/TestLifecycleInterface.php @@ -46,4 +46,45 @@ public function stopStep(): TestLifecycleInterface; public function updateStep(): TestLifecycleInterface; public function updateStepResult(): TestLifecycleInterface; + + public function ensureSuiteContainer(): TestLifecycleInterface; + + public function writeSuiteContainer(): TestLifecycleInterface; + + public function startBeforeHookFixture(string $hookName): TestLifecycleInterface; + + public function startAfterHookFixture(string $hookName): TestLifecycleInterface; + + public function completeHookFixtureSuccess(): TestLifecycleInterface; + + public function completeHookFixtureFailure( + Status $status, + ?string $message = null, + ?string $trace = null, + ): TestLifecycleInterface; + + /** + * Completes an active before-hook fixture when Module throws and the low-priority + * stop listener does not run; failure surfaces as TEST_ERROR / TEST_FAIL. + */ + public function completeOrphanHookFailure( + Status $status, + ?string $message = null, + ?string $trace = null, + ): TestLifecycleInterface; + + /** + * Completes a still-active after-hook fixture (e.g. Module::_after threw and + * TEST_END was skipped) before suite teardown. + */ + public function flushActiveHookFixtureFailure( + Status $status, + ?string $message = null, + ?string $trace = null, + ): TestLifecycleInterface; + + /** + * Writes the current test/container when TEST_END was skipped (e.g. `_after` threw). + */ + public function finalizePendingTest(): TestLifecycleInterface; } diff --git a/test/codeception/unit/HookFailureIntegrationTest.php b/test/codeception/unit/HookFailureIntegrationTest.php new file mode 100644 index 0000000..62437f4 --- /dev/null +++ b/test/codeception/unit/HookFailureIntegrationTest.php @@ -0,0 +1,204 @@ +runHookSuite($outputDir, $hookName, $mode); + self::assertNotSame(0, $exitCode, implode("\n", $output)); + + $globals = $this->readGlobalMessages($outputDir); + $matching = array_values(array_filter( + $globals, + static fn (string $message): bool => str_starts_with($message, $messagePrefix), + )); + self::assertCount( + 1, + $matching, + sprintf( + "Expected one global starting with %s, got: %s\nCodecept output:\n%s", + $messagePrefix, + (string) json_encode($globals), + implode("\n", $output), + ), + ); + + $fixtures = $this->readFixturesNamed($outputDir, $hookName); + self::assertNotEmpty($fixtures, "Expected fixture named {$hookName}\n" . implode("\n", $output)); + $statuses = array_map( + static fn (array $fixture): ?string => $fixture['status'] ?? null, + $fixtures, + ); + self::assertContains($fixtureStatus, $statuses, (string) json_encode($fixtures)); + } + + /** + * @return iterable + */ + public static function providerHookFailures(): iterable + { + yield 'before throw' => ['_before', 'throw', '_before failed', 'broken']; + yield 'after throw' => ['_after', 'throw', '_after failed', 'broken']; + yield 'beforeSuite throw' => ['_beforeSuite', 'throw', '_beforeSuite failed', 'broken']; + yield 'afterSuite throw' => ['_afterSuite', 'throw', '_afterSuite failed', 'broken']; + yield 'before assertion' => ['_before', 'assert', '_before failed', 'failed']; + yield 'before empty message' => ['_before', 'empty', '_before failed', 'broken']; + } + + /** + * Runs the hooks-fixtures suite with env overrides (OS-safe; no shell VAR=value). + * + * @return array{0: list, 1: int} + */ + private function runHookSuite(string $outputDir, string $hookName, string $mode): array + { + $root = dirname(__DIR__, 3); + $cmd = [ + PHP_BINARY, + $root . '/vendor/bin/codecept', + 'run', + 'unit', + '-c', + $root . '/codeception-hooks.yml', + '--no-colors', + ]; + + $env = array_merge( + getenv(), + [ + 'ALLURE_HOOK_OUTPUT' => $outputDir, + 'ALLURE_HOOK_FAIL' => $hookName, + 'ALLURE_HOOK_MODE' => $mode, + ], + ); + + $descriptors = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + $proc = proc_open($cmd, $descriptors, $pipes, $root, $env); + self::assertTrue(is_resource($proc), 'Failed to start codecept subprocess'); + + fclose($pipes[0]); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $exitCode = proc_close($proc); + + $combined = trim(($stdout === false ? '' : $stdout) . "\n" . ($stderr === false ? '' : $stderr)); + $split = $combined === '' ? false : preg_split("/\r\n|\n|\r/", $combined); + $lines = $split === false ? [] : $split; + + return [$lines, $exitCode]; + } + + /** + * @return list + */ + private function readGlobalMessages(string $outputDir): array + { + $messages = []; + $files = glob($outputDir . '/*-globals.json'); + if (!is_array($files)) { + return []; + } + + foreach ($files as $file) { + /** @var array{errors?: list} $data */ + $data = json_decode((string) file_get_contents($file), true, 512, JSON_THROW_ON_ERROR); + foreach ($data['errors'] ?? [] as $error) { + if (isset($error['message'])) { + $messages[] = $error['message']; + } + } + } + + return $messages; + } + + /** + * @return list + */ + private function readFixturesNamed(string $outputDir, string $hookName): array + { + $fixtures = []; + $files = glob($outputDir . '/*-container.json'); + if (!is_array($files)) { + return []; + } + + foreach ($files as $file) { + /** + * @var array{ + * befores?: list, + * afters?: list + * } $data + */ + $data = json_decode((string) file_get_contents($file), true, 512, JSON_THROW_ON_ERROR); + foreach ([...($data['befores'] ?? []), ...($data['afters'] ?? [])] as $fixture) { + if (($fixture['name'] ?? null) === $hookName) { + $fixtures[] = $fixture; + } + } + } + + return $fixtures; + } +} diff --git a/test/codeception/unit/Internal/HookFailureMessageTest.php b/test/codeception/unit/Internal/HookFailureMessageTest.php new file mode 100644 index 0000000..3d8a6df --- /dev/null +++ b/test/codeception/unit/Internal/HookFailureMessageTest.php @@ -0,0 +1,38 @@ + + */ + public static function providerFormat(): iterable + { + yield 'with details' => ['_before', 'boom', '_before failed: boom']; + yield 'empty string' => ['_before', '', '_before failed']; + yield 'whitespace only' => ['_after', " \t ", '_after failed']; + yield 'null' => ['_beforeSuite', null, '_beforeSuite failed']; + yield 'assertion message' => [ + '_before', + 'Failed asserting that false is true.', + '_before failed: Failed asserting that false is true.', + ]; + } +} diff --git a/test/codeception/unit/Internal/TestLifecycleHookFixtureTest.php b/test/codeception/unit/Internal/TestLifecycleHookFixtureTest.php new file mode 100644 index 0000000..f74dfe3 --- /dev/null +++ b/test/codeception/unit/Internal/TestLifecycleHookFixtureTest.php @@ -0,0 +1,189 @@ +outputDirectory = sys_get_temp_dir() . '/allure-codeception-hook-' . uniqid('', true); + mkdir($this->outputDirectory); + Allure::getLifecycleConfigurator()->setOutputDirectory($this->outputDirectory); + } + + #[\Override] + protected function tearDown(): void + { + Allure::reset(); + AllureAdapter::reset(); + parent::tearDown(); + } + + public function testCompleteHookFixtureFailure_EmitsPrefixedGlobalAndStopsFixture(): void + { + $lifecycle = $this->createLifecycle(); + $lifecycle + ->switchToSuite(new SuiteInfo('unit')) + ->switchToTest($this) + ->create() + ->startBeforeHookFixture('_before'); + $lifecycle->completeHookFixtureFailure(Status::broken(), 'boom', 'trace-line'); + + $globals = $this->readGlobalErrors(); + self::assertCount(1, $globals); + self::assertSame('_before failed: boom', $globals[0]['message'] ?? null); + self::assertSame('trace-line', $globals[0]['trace'] ?? null); + } + + public function testCompleteHookFixtureFailure_BlankMessage_UsesFallback(): void + { + $lifecycle = $this->createLifecycle(); + $lifecycle + ->switchToSuite(new SuiteInfo('unit')) + ->switchToTest($this) + ->create() + ->startBeforeHookFixture('_before'); + $lifecycle->completeHookFixtureFailure(Status::failed(), ' ', null); + + $globals = $this->readGlobalErrors(); + self::assertCount(1, $globals); + self::assertSame('_before failed', $globals[0]['message'] ?? null); + } + + public function testCompleteHookFixtureFailure_CalledTwice_DoesNotDuplicateGlobal(): void + { + $lifecycle = $this->createLifecycle(); + $lifecycle + ->switchToSuite(new SuiteInfo('unit')) + ->switchToTest($this) + ->create() + ->startBeforeHookFixture('_before'); + + $adapter = AllureAdapter::getInstance(); + $uuid = $adapter->getActiveFixtureUuid(); + self::assertNotNull($uuid); + + $lifecycle->completeHookFixtureFailure(Status::broken(), 'once', 't'); + $adapter->setActiveFixture($uuid, '_before'); + $lifecycle->completeHookFixtureFailure(Status::broken(), 'twice', 't'); + + $globals = $this->readGlobalErrors(); + self::assertCount(1, $globals); + self::assertSame('_before failed: once', $globals[0]['message'] ?? null); + } + + public function testCompleteHookFixtureSuccess_MarksFixturePassed(): void + { + $lifecycle = $this->createLifecycle(); + $lifecycle + ->switchToSuite(new SuiteInfo('unit')) + ->switchToTest($this) + ->create() + ->startBeforeHookFixture('_before') + ->completeHookFixtureSuccess() + ->startTest() + ->stopTest(); + + $fixtures = $this->readFixturesNamed('_before'); + self::assertNotEmpty($fixtures); + self::assertSame('passed', $fixtures[0]['status'] ?? null); + } + + private function createLifecycle(): TestLifecycle + { + return new TestLifecycle( + rootDir: getcwd() ?: '/', + lifecycle: Allure::getLifecycle(), + resultFactory: Allure::getConfig()->getResultFactory(), + statusDetector: Allure::getConfig()->getStatusDetector(), + threadDetector: new DefaultThreadDetector(), + linkTemplates: Allure::getConfig()->getLinkTemplates(), + env: [], + adapter: AllureAdapter::getInstance(), + ); + } + + /** + * @return list + */ + private function readGlobalErrors(): array + { + $files = glob($this->outputDirectory . '/*-globals.json'); + if (!is_array($files)) { + return []; + } + + $errors = []; + foreach ($files as $file) { + /** @var array{errors?: list} $data */ + $data = json_decode((string) file_get_contents($file), true, 512, JSON_THROW_ON_ERROR); + foreach ($data['errors'] ?? [] as $error) { + $errors[] = $error; + } + } + + return $errors; + } + + /** + * @return list + */ + private function readFixturesNamed(string $hookName): array + { + $fixtures = []; + $files = glob($this->outputDirectory . '/*-container.json'); + if (!is_array($files)) { + return []; + } + + foreach ($files as $file) { + /** + * @var array{ + * befores?: list, + * afters?: list + * } $data + */ + $data = json_decode((string) file_get_contents($file), true, 512, JSON_THROW_ON_ERROR); + foreach ([...($data['befores'] ?? []), ...($data['afters'] ?? [])] as $fixture) { + if (($fixture['name'] ?? null) === $hookName) { + $fixtures[] = $fixture; + } + } + } + + return $fixtures; + } +} diff --git a/test/hooks-fixtures/_support/Helper/HookFailHelper.php b/test/hooks-fixtures/_support/Helper/HookFailHelper.php new file mode 100644 index 0000000..c7fc02e --- /dev/null +++ b/test/hooks-fixtures/_support/Helper/HookFailHelper.php @@ -0,0 +1,55 @@ +maybeFail('_beforeSuite'); + } + + public function _afterSuite(): void + { + $this->maybeFail('_afterSuite'); + } + + public function _before(TestInterface $test): void + { + $this->maybeFail('_before'); + } + + public function _after(TestInterface $test): void + { + $this->maybeFail('_after'); + } + // phpcs:enable PSR2.Methods.MethodDeclaration.Underscore + + private function maybeFail(string $hookName): void + { + $target = getenv('ALLURE_HOOK_FAIL'); + if ($target !== $hookName) { + return; + } + + $mode = getenv('ALLURE_HOOK_MODE') ?: 'throw'; + match ($mode) { + 'assert' => Assert::fail("{$hookName} assertion"), + 'empty' => throw new RuntimeException(''), + default => throw new RuntimeException("{$hookName} boom"), + }; + } +} diff --git a/test/hooks-fixtures/_support/Setup/OutputDirectoryHook.php b/test/hooks-fixtures/_support/Setup/OutputDirectoryHook.php new file mode 100644 index 0000000..108b965 --- /dev/null +++ b/test/hooks-fixtures/_support/Setup/OutputDirectoryHook.php @@ -0,0 +1,23 @@ +setOutputDirectory($output); + } +} diff --git a/test/hooks-fixtures/_support/UnitTester.php b/test/hooks-fixtures/_support/UnitTester.php new file mode 100644 index 0000000..568b16e --- /dev/null +++ b/test/hooks-fixtures/_support/UnitTester.php @@ -0,0 +1,25 @@ +