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 @@
+