Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,24 @@ This rule spots definitions that are no longer needed, so you can remove them.

<br>

### 4. Find duplicate scenario names (`duplicate-scenario-names`)

In Behat, each scenario should have a unique name to ensure clarity and avoid confusion during test execution and later debugging. This rule identifies scenarios that share the same name within your feature files:

```yaml
Feature: User Authentication

Scenario: User logs in successfully
When the user enters valid credentials
Then login should be successful

Scenario: User logs in successfully
When the user enters invalid credentials
Then an error message should be displayed
```

<br>

*Protip*: Add this command to your CI, to get instant feedback of any changes in every pull-request.

That's it!
Expand Down
39 changes: 39 additions & 0 deletions src/Analyzer/DuplicatedScenarioNamesAnalyzer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace Rector\Behastan\Analyzer;

use Entropy\Attributes\RelatedTest;
use Entropy\Utils\Regex;
use Rector\Behastan\Tests\Analyzer\DuplicatedScenarioNamesAnalyzer\DuplicatedScenarioNamesAnalyzerTest;
use Symfony\Component\Finder\SplFileInfo;

#[RelatedTest(DuplicatedScenarioNamesAnalyzerTest::class)]
final class DuplicatedScenarioNamesAnalyzer
{
private const string SCENARIO_NAME_REGEX = '#\s+Scenario:\s+(?<name>.*?)\n#';

/**
* @param SplFileInfo[] $featureFiles
* @return array<string, string[]>
*/
public function analyze(array $featureFiles): array
{
$scenarioNamesToFiles = [];

foreach ($featureFiles as $featureFile) {
// match Scenario: "<name>"
$matches = Regex::matchAll($featureFile->getContents(), self::SCENARIO_NAME_REGEX);

foreach ($matches as $match) {
$scenarioName = $match['name'];
$scenarioNamesToFiles[$scenarioName][] = $featureFile->getRealPath();
}
}

return array_filter($scenarioNamesToFiles, function (array $files): bool {
return count($files) > 1;
});
}
}
3 changes: 1 addition & 2 deletions src/Analyzer/UnusedDefinitionsAnalyzer.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,11 @@ public function __construct(
}

/**
* @param SplFileInfo[] $contextFiles
* @param SplFileInfo[] $featureFiles
*
* @return AbstractPattern[]
*/
public function analyse(array $contextFiles, array $featureFiles, PatternCollection $patternCollection): array
public function analyse(array $featureFiles, PatternCollection $patternCollection): array
{
foreach ($featureFiles as $featureFile) {
Assert::endsWith($featureFile->getFilename(), '.feature');
Expand Down
12 changes: 7 additions & 5 deletions src/Command/AnalyzeCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,9 @@ public function run(?string $projectDirectory = null, array $skip = []): int
$contextFileInfos = BehatMetafilesFinder::findContextFiles([$projectDirectory]);
if ($contextFileInfos === []) {
$this->outputPrinter->redBackground(sprintf(
'No *.Context files found in "%s". Please provide correct directory',
$projectDirectory
'No *.Context files found in "%s".%sPlease provide correct directory',
$projectDirectory,
PHP_EOL
));

return ExitCode::ERROR;
Expand All @@ -53,8 +54,9 @@ public function run(?string $projectDirectory = null, array $skip = []): int
$featureFileInfos = BehatMetafilesFinder::findFeatureFiles([$projectDirectory]);
if ($featureFileInfos === []) {
$this->outputPrinter->redBackground(sprintf(
'No *.feature files found in "%s". Please provide correct directory',
$projectDirectory
'No *.feature files found in "%s".%sPlease provide correct directory',
$projectDirectory,
PHP_EOL
));

return ExitCode::ERROR;
Expand All @@ -79,7 +81,7 @@ public function run(?string $projectDirectory = null, array $skip = []): int
/** @var RuleError[] $allRuleErrors */
$allRuleErrors = [];
foreach ($this->rulesRegistry->all() as $rule) {
if ($skip !== [] && in_array($rule->getIdentifier(), $skip, true)) {
if (in_array($rule->getIdentifier(), $skip, true)) {
$this->outputPrinter->writeln(sprintf('<fg=cyan>Skipping "%s" rule</>', $rule->getIdentifier()));
$this->outputPrinter->newLine();
continue;
Expand Down
2 changes: 2 additions & 0 deletions src/Enum/RuleIdentifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ final class RuleIdentifier
{
public const string DUPLICATED_CONTENTS = 'duplicated-contents';

public const string DUPLICATED_SCENARIO_NAMES = 'duplicated-scenario-names';

public const string DUPLICATED_PATTERNS = 'duplicated-patterns';

public const string UNUSED_DEFINITIONS = 'unused-definitions';
Expand Down
53 changes: 53 additions & 0 deletions src/Rule/DuplicatedScenarioNamesRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

declare(strict_types=1);

namespace Rector\Behastan\Rule;

use Rector\Behastan\Analyzer\DuplicatedScenarioNamesAnalyzer;
use Rector\Behastan\Contract\RuleInterface;
use Rector\Behastan\Enum\RuleIdentifier;
use Rector\Behastan\ValueObject\PatternCollection;
use Rector\Behastan\ValueObject\RuleError;
use Symfony\Component\Finder\SplFileInfo;

final readonly class DuplicatedScenarioNamesRule implements RuleInterface
{
public function __construct(
private DuplicatedScenarioNamesAnalyzer $duplicatedScenarioNamesAnalyzer
) {
}

/**
* @param SplFileInfo[] $contextFiles
* @param SplFileInfo[] $featureFiles
*
* @return RuleError[]
*/
public function process(
array $contextFiles,
array $featureFiles,
PatternCollection $patternCollection,
string $projectDirectory
): array {
$scenarioNamesToFiles = $this->duplicatedScenarioNamesAnalyzer->analyze($featureFiles);

$ruleErrors = [];
foreach ($scenarioNamesToFiles as $scenarioName => $files) {
// it can be used multiple times in single file
$uniqueFiles = array_unique($files);
$uniqueCount = count($uniqueFiles);

$errorMessage = sprintf('Scenario name "%s" is duplicated %d-times', $scenarioName, $uniqueCount);

$ruleErrors[] = new RuleError($errorMessage, $uniqueFiles, $this->getIdentifier());
}

return $ruleErrors;
}

public function getIdentifier(): string
{
return RuleIdentifier::DUPLICATED_SCENARIO_NAMES;
}
}
2 changes: 1 addition & 1 deletion src/Rule/UnusedContextDefinitionsRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public function process(
PatternCollection $patternCollection,
string $projectDirectory
): array {
$unusedPatterns = $this->unusedDefinitionsAnalyzer->analyse($contextFiles, $featureFiles, $patternCollection);
$unusedPatterns = $this->unusedDefinitionsAnalyzer->analyse($featureFiles, $patternCollection);

$ruleErrors = [];

Expand Down
2 changes: 1 addition & 1 deletion src/RulesRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public function __construct(
) {
Assert::allObject($rules);
Assert::allIsInstanceOf($rules, RuleInterface::class);
Assert::greaterThan(count($rules), 2);
Assert::greaterThan(count($rules), 3);
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace Rector\Behastan\Tests\Analyzer\DuplicatedScenarioNamesAnalyzer;

use Rector\Behastan\Analyzer\DuplicatedScenarioNamesAnalyzer;
use Rector\Behastan\Finder\BehatMetafilesFinder;
use Rector\Behastan\Tests\AbstractTestCase;

final class DuplicatedScenarioNamesAnalyzerTest extends AbstractTestCase
{
private DuplicatedScenarioNamesAnalyzer $duplicatedScenarioNamesAnalyzer;

protected function setUp(): void
{
parent::setUp();

$this->duplicatedScenarioNamesAnalyzer = $this->make(DuplicatedScenarioNamesAnalyzer::class);
}

public function test(): void
{
$featureFiles = BehatMetafilesFinder::findFeatureFiles([__DIR__ . '/Fixture']);
$this->assertCount(2, $featureFiles);

$duplicatedScenarioNamesToFiles = $this->duplicatedScenarioNamesAnalyzer->analyze($featureFiles);

$this->assertCount(1, $duplicatedScenarioNamesToFiles);
$this->assertArrayHasKey('Same scenario name', $duplicatedScenarioNamesToFiles);

$givenFiles = $duplicatedScenarioNamesToFiles['Same scenario name'];

$this->assertSame([__DIR__ . '/Fixture/some.feature', __DIR__ . '/Fixture/another.feature'], $givenFiles);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Feature: Some feature

Scenario: Same scenario name
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Feature: Another feature

Scenario: Same scenario name
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,7 @@ public function testEverythingUsed(): void

$patternCollection = $this->definitionPatternsExtractor->extract($contextFiles);

$unusedDefinitions = $this->unusedDefinitionsAnalyzer->analyse(
$contextFiles,
$featureFiles,
$patternCollection
);
$unusedDefinitions = $this->unusedDefinitionsAnalyzer->analyse($featureFiles, $patternCollection);

$this->assertCount(0, $unusedDefinitions);
}
Expand All @@ -53,7 +49,7 @@ public function testFoundPattern(): void

$patternCollection = $this->definitionPatternsExtractor->extract($contextFiles);

$unusedPatterns = $this->unusedDefinitionsAnalyzer->analyse($contextFiles, $featureFiles, $patternCollection);
$unusedPatterns = $this->unusedDefinitionsAnalyzer->analyse($featureFiles, $patternCollection);

$this->assertCount(1, $unusedPatterns);
$this->assertContainsOnlyInstancesOf(AbstractPattern::class, $unusedPatterns);
Expand Down