diff --git a/composer.json b/composer.json index 6cc477f..b6d4d63 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,7 @@ "php": "^8", "ext-json": "*", "codeception/codeception": "^5.0.3", - "allure-framework/allure-php-commons": "^2.3.1" + "allure-framework/allure-php-commons": "^2.4.0" }, "require-dev": { "psalm/plugin-phpunit": "^0.19.0 || ^0.20.1", diff --git a/src/AllureCodeception.php b/src/AllureCodeception.php index fc796af..fe7b6c6 100644 --- a/src/AllureCodeception.php +++ b/src/AllureCodeception.php @@ -311,12 +311,13 @@ public function stepAfter(StepEvent $stepEvent): void private function getTestLifecycle(): TestLifecycleInterface { return $this->testLifecycle ??= new TestLifecycle( - Allure::getLifecycle(), - Allure::getConfig()->getResultFactory(), - Allure::getConfig()->getStatusDetector(), - $this->getThreadDetector(), - Allure::getConfig()->getLinkTemplates(), - $_ENV, + rootDir: $this->getRootDir(), + lifecycle: Allure::getLifecycle(), + resultFactory: Allure::getConfig()->getResultFactory(), + statusDetector: Allure::getConfig()->getStatusDetector(), + threadDetector: $this->getThreadDetector(), + linkTemplates: Allure::getConfig()->getLinkTemplates(), + env: $_ENV, ); } } diff --git a/src/Internal/CeptInfoBuilder.php b/src/Internal/CeptInfoBuilder.php index caa4eb0..07af512 100644 --- a/src/Internal/CeptInfoBuilder.php +++ b/src/Internal/CeptInfoBuilder.php @@ -6,9 +6,13 @@ use Codeception\Test\Cept; +use function array_pop; +use function realpath; + final class CeptInfoBuilder implements TestInfoBuilderInterface { public function __construct( + private string $rootDir, private Cept $test, ) { } @@ -16,11 +20,21 @@ public function __construct( #[\Override] public function build(?string $host, ?string $thread): TestInfo { + // May contain .. if the config is not in a parent directory. + $filePath = realpath($this->test->getFileName()); + $titlePath = $filePath !== false + ? ModelFunctions::getTitlePathByFile($this->rootDir, $filePath) + : []; + + // A cept file is a single test, so we're removing the file node from titlePath. + array_pop($titlePath); + return new TestInfo( originalTest: $this->test, signature: $this->test->getSignature(), class: $this->test->getName(), method: $this->test->getName(), + titlePath: $titlePath, host: $host, thread: $thread, ); diff --git a/src/Internal/CestInfoBuilder.php b/src/Internal/CestInfoBuilder.php index 634e427..1388f7e 100644 --- a/src/Internal/CestInfoBuilder.php +++ b/src/Internal/CestInfoBuilder.php @@ -19,11 +19,15 @@ public function __construct( #[\Override] public function build(?string $host, ?string $thread): TestInfo { + $class = $this->test->getTestInstance()::class; + $titlePath = ModelFunctions::getTitlePathByClass($class); + return new TestInfo( originalTest: $this->test, signature: $this->test->getSignature(), - class: $this->test->getTestInstance()::class, + class: $class, method: $this->test->getTestMethod(), + titlePath: $titlePath, dataLabel: $this->getDataLabel(), host: $host, thread: $thread, diff --git a/src/Internal/GherkinInfoBuilder.php b/src/Internal/GherkinInfoBuilder.php index 9d15b7d..eae4c76 100644 --- a/src/Internal/GherkinInfoBuilder.php +++ b/src/Internal/GherkinInfoBuilder.php @@ -6,11 +6,13 @@ use Codeception\Test\Gherkin; -use function is_string; +use function array_pop; +use function realpath; final class GherkinInfoBuilder implements TestInfoBuilderInterface { public function __construct( + private string $rootDir, private Gherkin $test, ) { } @@ -18,11 +20,33 @@ public function __construct( #[\Override] public function build(?string $host, ?string $thread): TestInfo { + // May contain .. if the config is not in a parent directory. + $filePath = realpath($this->test->getFileName()); + + /** + * @var list + */ + $titlePath = $filePath !== false + ? ModelFunctions::getTitlePathByFile($this->rootDir, $filePath) + : []; + + $featureName = $this->test->getFeature(); + if ($featureName) { + // Prefer a more human-readable feature name instead of the filename. + if ($titlePath) { + array_pop($titlePath); + $titlePath[] = $featureName; + } else { + $titlePath = [$featureName]; + } + } + return new TestInfo( originalTest: $this->test, signature: $this->test->getSignature(), class: $this->test->getFeature(), method: $this->test->getScenarioTitle(), + titlePath: $titlePath, host: $host, thread: $thread, ); diff --git a/src/Internal/ModelFunctions.php b/src/Internal/ModelFunctions.php new file mode 100644 index 0000000..270f67f --- /dev/null +++ b/src/Internal/ModelFunctions.php @@ -0,0 +1,85 @@ + + */ + public static function getTitlePathByFile(string $base, string $path, bool $final = false): array + { + if (!$path) { + return []; + } + + $baseParts = explode(DIRECTORY_SEPARATOR, rtrim($base, DIRECTORY_SEPARATOR)); + $pathParts = explode(DIRECTORY_SEPARATOR, $path); + + if (!$base || $baseParts[0] !== $pathParts[0]) { + // The base is not provided or is on another disk (on Windows) + // or is not an absolute path. + // Fallback to CWD if not yet in fallback mode + if (!$final) { + $cwd = getcwd(); + if ($cwd !== false) { + return self::getTitlePathByFile($cwd, $path, true); + } + } + + // CWD didn't work too. Turn the absolute path into titlePath. + // Add leading '/' node on Linux/MAC to avoid confusion with + // well-formed titlePath values of other tests. + return $pathParts[0] + ? $pathParts + : [DIRECTORY_SEPARATOR, ...$pathParts]; + } + + do { + // Skipping identical parts of both paths. + array_shift($baseParts); + array_shift($pathParts); + } while ($baseParts && $pathParts && $baseParts[0] === $pathParts[0]); + + if (!$pathParts) { + // If the path contains less parts than the base (is a parent of the base) + // or is equal to the base, return empty titlePath. + return []; + } + + // At this point we have three cases: + // - $pathParts is empty: the path contains less parts than the base (is a + // parent of the base) or is equal to the base, return empty titlePath. + // - $baseParts is empty: the path is inside the base. + // The titlePath consists entirely of the remaining path parts. + // - $baseParts is not empty: the path is not in the base but they share a + // common ancestor. We consider this ancestor the proper root directory + // and return the remaining parts of the path as titlePath. + // Essentially, all three cases are treated identically. + return $pathParts; + } + + /** + * @return list + */ + public static function getTitlePathByClass(?string $class): array + { + return is_string($class) + ? [...array_filter(explode("\\", $class))] + : []; + } +} diff --git a/src/Internal/TestInfo.php b/src/Internal/TestInfo.php index 2c86a98..85586ec 100644 --- a/src/Internal/TestInfo.php +++ b/src/Internal/TestInfo.php @@ -6,11 +6,15 @@ final class TestInfo { + /** + * @param list $titlePath + */ public function __construct( private object $originalTest, private string $signature, private ?string $class = null, private ?string $method = null, + private array $titlePath = [], private ?string $dataLabel = null, private ?string $host = null, private ?string $thread = null, @@ -37,6 +41,14 @@ public function getMethod(): ?string return $this->method; } + /** + * @return list + */ + public function getTitlePath(): array + { + return $this->titlePath; + } + public function getDataLabel(): ?string { return $this->dataLabel; diff --git a/src/Internal/TestLifecycle.php b/src/Internal/TestLifecycle.php index 4c24bca..6cba707 100644 --- a/src/Internal/TestLifecycle.php +++ b/src/Internal/TestLifecycle.php @@ -48,6 +48,7 @@ final class TestLifecycle implements TestLifecycleInterface private WeakMap $stepStarts; public function __construct( + private string $rootDir, private AllureLifecycleInterface $lifecycle, private ResultFactoryInterface $resultFactory, private StatusDetectorInterface $statusDetector, @@ -115,8 +116,8 @@ private function getTestInfoBuilder(object $test): TestInfoBuilderInterface { return match (true) { $test instanceof Cest => new CestInfoBuilder($test), - $test instanceof Gherkin => new GherkinInfoBuilder($test), - $test instanceof Cept => new CeptInfoBuilder($test), + $test instanceof Gherkin => new GherkinInfoBuilder($this->rootDir, $test), + $test instanceof Cept => new CeptInfoBuilder($this->rootDir, $test), $test instanceof TestCaseWrapper => new UnitInfoBuilder($test), default => new UnknownInfoBuilder($test), }; @@ -142,16 +143,18 @@ public function create(): self #[\Override] public function updateTest(): self { + $test = $this->getCurrentTest(); $provider = new ModelProviderChain( new EnvProvider($this->env), ...SuiteProvider::createForChain($this->getCurrentSuite(), $this->linkTemplates), - ...TestInfoProvider::createForChain($this->getCurrentTest()), - ...$this->createModelProvidersForTest($this->getCurrentTest()->getOriginalTest()), + ...TestInfoProvider::createForChain($test), + ...$this->createModelProvidersForTest($test->getOriginalTest()), ); $this->lifecycle->updateTest( fn (TestResult $t) => $t ->setName($provider->getDisplayName()) ->setFullName($provider->getFullName()) + ->setTitlePath(...$test->getTitlePath()) ->setDescription($provider->getDescription()) ->setDescriptionHtml($provider->getDescriptionHtml()) ->addLinks(...$provider->getLinks()) diff --git a/src/Internal/UnitInfoBuilder.php b/src/Internal/UnitInfoBuilder.php index 8b2711d..a2c176c 100644 --- a/src/Internal/UnitInfoBuilder.php +++ b/src/Internal/UnitInfoBuilder.php @@ -33,6 +33,7 @@ public function build(?string $host, ?string $thread): TestInfo signature: $this->test->getSignature(), class: $class ?? null, method: $this->test->getMetadata()->getName(), + titlePath: ModelFunctions::getTitlePathByClass($class), dataLabel: $dataLabel, host: $host, thread: $thread, diff --git a/test/codeception/report-check/ReportTest.php b/test/codeception/report-check/ReportTest.php index 509d353..0bfa9e1 100644 --- a/test/codeception/report-check/ReportTest.php +++ b/test/codeception/report-check/ReportTest.php @@ -98,7 +98,7 @@ public static function providerSingleNodeValueStartsFromString(): iterable 'Error message in test case without steps' => [ StepsTest::class, 'testNoStepsError', - fn (object $tr): mixed => self::property(self::objectProperty($tr, 'statusDetails'), 'message'), + fn (object $tr): mixed => self::statusMessage($tr), "Error\nException(0)", ], ]; @@ -168,7 +168,7 @@ public static function providerExistingNodeValue(): iterable 'Successful test case without steps: no steps' => [ StepsTest::class, 'testNoStepsSuccess', - fn (object $tr): mixed => self::objectListProperty($tr, 'steps'), + fn (object $tr): mixed => self::steps($tr), [], ], 'Error in test case without steps' => [ @@ -180,7 +180,7 @@ public static function providerExistingNodeValue(): iterable 'Failure message in test case without steps' => [ StepsTest::class, 'testNoStepsFailure', - fn (object $tr): mixed => self::property(self::objectProperty($tr, 'statusDetails'), 'message'), + fn (object $tr): mixed => self::statusMessage($tr), 'Failure', ], 'Test case without steps skipped' => [ @@ -192,7 +192,7 @@ public static function providerExistingNodeValue(): iterable 'Skipped message in test case without steps' => [ StepsTest::class, 'testNoStepsSkipped', - fn (object $tr): mixed => self::property(self::objectProperty($tr, 'statusDetails'), 'message'), + fn (object $tr): mixed => self::statusMessage($tr), 'Skipped', ], 'Successful test case with single step: status' => [ @@ -248,7 +248,7 @@ public static function providerExistingNodeValue(): iterable 'testTwoSuccessfulSteps', fn (object $tr): mixed => array_map( fn (object $s): mixed => self::property($s, 'status'), - self::objectListProperty($tr, 'steps'), + self::steps($tr), ), ['passed', 'passed'], ], @@ -257,7 +257,7 @@ public static function providerExistingNodeValue(): iterable 'testTwoSuccessfulSteps', fn (object $tr): mixed => array_map( fn (object $s): mixed => self::property($s, 'name'), - self::objectListProperty($tr, 'steps'), + self::steps($tr), ), ['step 1 name', 'step 2 name'], ], @@ -270,7 +270,7 @@ public static function providerExistingNodeValue(): iterable 'First step in test case with two steps fails: message' => [ StepsTest::class, 'testTwoStepsFirstFails', - fn (object $tr): mixed => self::property(self::objectProperty($tr, 'statusDetails'), 'message'), + fn (object $tr): mixed => self::statusMessage($tr), 'Failure', ], 'First step in test case with two steps fails: step status' => [ @@ -294,7 +294,7 @@ public static function providerExistingNodeValue(): iterable 'Second step in test case with two steps fails: message' => [ StepsTest::class, 'testTwoStepsSecondFails', - fn (object $tr): mixed => self::property(self::objectProperty($tr, 'statusDetails'), 'message'), + fn (object $tr): mixed => self::statusMessage($tr), 'Failure', ], 'Second step in test case with two steps fails: step status' => [ @@ -302,7 +302,7 @@ public static function providerExistingNodeValue(): iterable 'testTwoStepsSecondFails', fn (object $tr): mixed => array_map( fn (object $s): mixed => self::property($s, 'status'), - self::objectListProperty($tr, 'steps'), + self::steps($tr), ), ['passed', 'failed'], ], @@ -310,8 +310,8 @@ public static function providerExistingNodeValue(): iterable StepsTest::class, 'testTwoStepsSecondFails', fn (object $tr): mixed => array_map( - fn (object $s): mixed => self::property($s, 'name'), - self::objectListProperty($tr, 'steps'), + fn (object $s): mixed => self::allureName($s), + self::steps($tr), ), ['step 1 name', 'step 2 name'], ], @@ -326,7 +326,7 @@ public static function providerExistingNodeValue(): iterable 'makeNestedSteps', fn (object $tr): mixed => array_map( fn (object $s): mixed => self::property($s, 'name'), - self::objectListProperty(self::singleStep($tr), 'steps'), + self::steps(self::singleStep($tr)), ), ['i expect condition 1', 'Step 1.1', 'Step 1.2'], ], @@ -335,7 +335,7 @@ public static function providerExistingNodeValue(): iterable 'makeNestedSteps', fn (object $tr): mixed => array_map( fn (object $s): mixed => self::property($s, 'name'), - self::objectListProperty(self::findStep(self::singleStep($tr), "Step 1.1"), 'steps'), + self::steps(self::findStep(self::singleStep($tr), "Step 1.1")), ), ['i expect condition 1.1', 'Step 1.1.1'], ], @@ -357,11 +357,35 @@ public static function providerExistingNodeValue(): iterable NestedStepsCest::class, 'makeNestedSteps', fn (object $tr): mixed => array_map( - fn (object $s): mixed => self::property($s, 'name'), - self::objectListProperty(self::findStep(self::singleStep($tr), "Step 1.2"), 'steps'), + fn (object $s): mixed => self::allureName($s), + self::steps(self::findStep(self::singleStep($tr), "Step 1.2")), ), ['i expect condition 1.2'], ], + 'Title path of a unit test result' => [ + AnnotationTest::class, + 'testTitleAnnotation', + fn (object $tr): mixed => $tr->titlePath, + ["Qameta", "Allure", "Codeception", "Test", "Report", "Unit", "AnnotationTest"], + ], + 'Title path of a Cept result' => [ + "BasicScenario", + 'BasicScenario', + fn (object $tr): mixed => $tr->titlePath, + ["test", "codeception-report", "functional"], + ], + 'Title path of a Cest result' => [ + NestedStepsCest::class, + 'makeNestedSteps', + fn (object $tr): mixed => $tr->titlePath, + ["Qameta", "Allure", "Codeception", "Test", "Report", "Functional", "NestedStepsCest"], + ], + 'Title path of a Gherkin result' => [ + "Calculate absolute number", + 'negative number', + fn (object $tr): mixed => $tr->titlePath, + ["test", "codeception-report", "acceptance", "Calculate absolute number"], + ], ]; } @@ -444,6 +468,15 @@ private static function property(object $value, string $property): mixed return $value->{$property}; } + private static function stringProperty(object $value, string $property): string + { + $result = self::property($value, $property); + + self::assertIsString($result); + + return $result; + } + private static function objectProperty(object $value, string $property): object { $result = self::property($value, $property); @@ -463,4 +496,25 @@ private static function objectListProperty(object $value, string $property): arr /** @var list */ return array_values($result); } + + private static function allureName(object $value): string + { + return self::stringProperty($value, "name"); + } + + private static function statusMessage(object $value): string + { + return self::stringProperty( + self::objectProperty($value, "statusDetails"), + "message", + ); + } + + /** + * @return list + */ + private static function steps(object $parent): array + { + return self::objectListProperty($parent, "steps"); + } } diff --git a/test/codeception/unit/Internal/ModelFunctionsTest.php b/test/codeception/unit/Internal/ModelFunctionsTest.php new file mode 100644 index 0000000..266dd66 --- /dev/null +++ b/test/codeception/unit/Internal/ModelFunctionsTest.php @@ -0,0 +1,101 @@ +}> + */ + public static function providerGetTitlePathByFile(): iterable + { + return [ + "Direct child path" => ["/foo", "/foo/bar", ["bar"]], + "Nested child path" => ["/foo/bar", "/foo/bar/baz/qux", ["baz", "qux"]], + "Sibling path" => ["/foo/bar", "/foo/baz", ["baz"]], + "Path several levels up" => ["/foo/bar/baz", "/foo/qux", ["qux"]], + "Identical paths" => ["/foo/bar", "/foo/bar", []], + "Common root only" => ["/", "/foo/bar", ["foo", "bar"]], + ]; + } + + /** + * @dataProvider providerGetTitlePathByFileWithFallback + */ + public function testGetTitlePathByFile_FallbackToCwd( + string $base, + string $cwdRelPath, + array $expectedTitlePath + ): void { + // Quick Windows hack + $base = str_replace("/", DIRECTORY_SEPARATOR, $base); + $cwdRelPath = str_replace("/", DIRECTORY_SEPARATOR, $cwdRelPath); + $cwd = getcwd(); + + self::assertIsString($cwd); + + $path = $cwd . DIRECTORY_SEPARATOR . $cwdRelPath; + + self::assertSame($expectedTitlePath, ModelFunctions::getTitlePathByFile($base, $path)); + } + + /** + * @return iterable}> + */ + public static function providerGetTitlePathByFileWithFallback(): iterable + { + return [ + "Different trees" => ["BAD:/foo", "foo", ["foo"]], + "Relative base" => ["foo", "foo/bar", ["foo", "bar"]], + "Empty base" => ["", "foo/bar", ["foo", "bar"]], + ]; + } + + /** + * @dataProvider providerGetTitlePathByClass + */ + public function testGetTitlePathByClass(?string $class, array $expectedTitlePath): void + { + self::assertSame($expectedTitlePath, ModelFunctions::getTitlePathByClass($class)); + } + + /** + * @return iterable}> + */ + public static function providerGetTitlePathByClass(): iterable + { + return [ + "No namespace" => ["foo", ["foo"]], + "Single-level namespace" => ["foo\\bar", ["foo", "bar"]], + "Nested namespaces" => ["foo\\bar\\baz", ["foo", "bar", "baz"]], + "Fully qualified name" => ["\\foo\\bar\\baz", ["foo", "bar", "baz"]], + "Empty string" => ["", []], + "Null" => [null, []], + ]; + } +}