Repository: Behat/Behat (php)

Issue:
Running multiple *.feature files
Currently there is no way to run behat with multiple *.feature files in one execution. 
Use case is running suite in parallel. I want to run half my features on one server and another half on the other.

For example
```bash
bin/behat $(find ./features -name "*.feature"  | sed -n '0~2p'  | tr '\n' ' ')
```
but currently it throws RuntimeException "Too many arguments."



Pull request description:
feat: support providing multiple paths
Resolves #834.

This PR adds support for providing multiple paths to behat cli: `behat one.feature two.feature nested/features/`

There is a room for improvement to deduplicate the features, but I do not consider this a critical requirement, as this can be considered user error for now. The example of this would be: `behat features/a/one.feature features/a`

Patch:
diff --git a/features/name_filters.feature b/features/name_filters.feature
index 33c97974..e83a58eb 100644
--- a/features/name_filters.feature
+++ b/features/name_filters.feature
@@ -1,7 +1,7 @@
 Feature: Name filters
   In order to run only needed features
   As a Behat user
-  I need to Behat support features & scenario/outline names filtering
+  I need Behat to support features & scenario/outline names filtering
 
   Background:
     Given a file named "features/bootstrap/FeatureContext.php" with:
diff --git a/features/path_filters.feature b/features/path_filters.feature
new file mode 100644
index 00000000..a3716460
--- /dev/null
+++ b/features/path_filters.feature
@@ -0,0 +1,167 @@
+Feature: Path filters
+  In order to run only needed features
+  As a Behat user
+  I need Behat to support path(s) filtering
+
+  Background:
+    Given I set the working directory to the "PathFilters" fixtures folder
+    And I provide the following options for all behat invocations:
+      | option      | value   |
+      | --no-colors |         |
+      | --format    | pretty  |
+
+  Scenario: First feature file only
+    When I run "behat features/a/feature1.feature"
+    Then it should pass with:
+      """
+      Feature: First Feature
+
+        Scenario: First Scenario   # features/a/feature1.feature:3
+          Given Some slow step N12 # FeatureContext::someSlowStepN()
+          And Some normal step N13 # FeatureContext::someNormalStepN()
+
+        Scenario: Second Scenario  # features/a/feature1.feature:7
+          Given Some fast step N14 # FeatureContext::someFastStepN()
+
+      2 scenarios (2 passed)
+      3 steps (3 passed)
+      """
+
+  Scenario: Second feature file only
+    When I run "behat features/a/feature2.feature"
+    Then it should pass with:
+      """
+      Feature: Second Feature
+
+        Background:                  # features/a/feature2.feature:3
+          Given Some normal step N21 # FeatureContext::someNormalStepN()
+
+        Scenario: First Scenario   # features/a/feature2.feature:6
+          Given Some slow step N22 # FeatureContext::someSlowStepN()
+          And Some fast step N23   # FeatureContext::someFastStepN()
+
+      1 scenario (1 passed)
+      3 steps (3 passed)
+      """
+
+  Scenario: Both feature files
+    When I run "behat features/a/feature1.feature features/a/feature2.feature"
+    Then it should pass with:
+      """
+      Feature: First Feature
+
+        Scenario: First Scenario   # features/a/feature1.feature:3
+          Given Some slow step N12 # FeatureContext::someSlowStepN()
+          And Some normal step N13 # FeatureContext::someNormalStepN()
+
+        Scenario: Second Scenario  # features/a/feature1.feature:7
+          Given Some fast step N14 # FeatureContext::someFastStepN()
+
+      Feature: Second Feature
+
+        Background:                  # features/a/feature2.feature:3
+          Given Some normal step N21 # FeatureContext::someNormalStepN()
+
+        Scenario: First Scenario   # features/a/feature2.feature:6
+          Given Some slow step N22 # FeatureContext::someSlowStepN()
+          And Some fast step N23   # FeatureContext::someFastStepN()
+
+      3 scenarios (3 passed)
+      6 steps (6 passed)
+      """
+
+    Scenario: Single nested directory
+      When I run "behat features/a"
+      Then it should pass with:
+      """
+      Feature: First Feature
+
+        Scenario: First Scenario   # features/a/feature1.feature:3
+          Given Some slow step N12 # FeatureContext::someSlowStepN()
+          And Some normal step N13 # FeatureContext::someNormalStepN()
+
+        Scenario: Second Scenario  # features/a/feature1.feature:7
+          Given Some fast step N14 # FeatureContext::someFastStepN()
+
+      Feature: Second Feature
+
+        Background:                  # features/a/feature2.feature:3
+          Given Some normal step N21 # FeatureContext::someNormalStepN()
+
+        Scenario: First Scenario   # features/a/feature2.feature:6
+          Given Some slow step N22 # FeatureContext::someSlowStepN()
+          And Some fast step N23   # FeatureContext::someFastStepN()
+
+      3 scenarios (3 passed)
+      6 steps (6 passed)
+      """
+
+  Scenario: Directory with nested directories
+    When I run "behat features"
+    Then it should pass with:
+      """
+      Feature: First Feature
+
+        Scenario: First Scenario   # features/a/feature1.feature:3
+          Given Some slow step N12 # FeatureContext::someSlowStepN()
+          And Some normal step N13 # FeatureContext::someNormalStepN()
+
+        Scenario: Second Scenario  # features/a/feature1.feature:7
+          Given Some fast step N14 # FeatureContext::someFastStepN()
+
+      Feature: Second Feature
+
+        Background:                  # features/a/feature2.feature:3
+          Given Some normal step N21 # FeatureContext::someNormalStepN()
+
+        Scenario: First Scenario   # features/a/feature2.feature:6
+          Given Some slow step N22 # FeatureContext::someSlowStepN()
+          And Some fast step N23   # FeatureContext::someFastStepN()
+
+      Feature: Third Feature
+
+        Scenario: First Scenario   # features/b/feature1.feature:3
+          Given Some fast step N14 # FeatureContext::someFastStepN()
+
+        Scenario: Second Scenario  # features/b/feature1.feature:6
+          Given Some fast step N14 # FeatureContext::someFastStepN()
+          And Some fast step N14   # FeatureContext::someFastStepN()
+
+      5 scenarios (5 passed)
+      9 steps (9 passed)
+      """
+
+    Scenario: Single scenario from single feature file
+      When I run "behat features/a/feature1.feature:7"
+      Then it should pass with:
+      """
+      Feature: First Feature
+
+        Scenario: Second Scenario  # features/a/feature1.feature:7
+          Given Some fast step N14 # FeatureContext::someFastStepN()
+
+      1 scenario (1 passed)
+      1 step (1 passed)
+      """
+
+  Scenario: Single scenario from each feature file
+    When I run "behat features/a/feature1.feature:7 features/a/feature2.feature:6"
+    Then it should pass with:
+      """
+      Feature: First Feature
+
+        Scenario: Second Scenario  # features/a/feature1.feature:7
+          Given Some fast step N14 # FeatureContext::someFastStepN()
+
+      Feature: Second Feature
+
+        Background:                  # features/a/feature2.feature:3
+          Given Some normal step N21 # FeatureContext::someNormalStepN()
+
+        Scenario: First Scenario   # features/a/feature2.feature:6
+          Given Some slow step N22 # FeatureContext::someSlowStepN()
+          And Some fast step N23   # FeatureContext::someFastStepN()
+
+      2 scenarios (2 passed)
+      4 steps (4 passed)
+      """
diff --git a/features/tag_filters.feature b/features/tag_filters.feature
index e2a95b18..06eefc79 100644
--- a/features/tag_filters.feature
+++ b/features/tag_filters.feature
@@ -1,7 +1,7 @@
 Feature: Tags
   In order to run only needed features
   As a Behat user
-  I need to Behat support features & scenario/outline tags
+  I need Behat to support features & scenario/outline tags
 
   Background:
     Given a file named "features/bootstrap/FeatureContext.php" with:


Test patch:
diff --git a/src/Behat/Behat/Tester/Cli/RerunController.php b/src/Behat/Behat/Tester/Cli/RerunController.php
index d654f73e..1b9e61c7 100644
--- a/src/Behat/Behat/Tester/Cli/RerunController.php
+++ b/src/Behat/Behat/Tester/Cli/RerunController.php
@@ -97,7 +97,7 @@ final class RerunController implements Controller
             return null;
         }
 
-        $input->setArgument('paths', $this->getFileName());
+        $input->setArgument('paths', [$this->getFileName()]);
 
         return null;
     }
@@ -155,7 +155,7 @@ final class RerunController implements Controller
             implode(' ', $input->getOption('name')) .
             implode(' ', $input->getOption('tags')) .
             $input->getOption('role') .
-            $input->getArgument('paths') .
+            \implode($input->getArgument('paths')) .
             $this->basepath
         );
     }
diff --git a/src/Behat/Testwork/Specification/SpecificationFinder.php b/src/Behat/Testwork/Specification/SpecificationFinder.php
index faaf63e9..0dfe2f23 100644
--- a/src/Behat/Testwork/Specification/SpecificationFinder.php
+++ b/src/Behat/Testwork/Specification/SpecificationFinder.php
@@ -56,11 +56,10 @@ final class SpecificationFinder
      * Finds all specifications for all provided suites matching provided locator and wraps them into a spec iterator.
      *
      * @param Suite[]     $suites
-     * @param string|null $locator
      *
      * @return list<SpecificationIterator<T>>
      */
-    public function findSuitesSpecifications(array $suites, $locator = null)
+    public function findSuitesSpecifications(array $suites, ?string $locator = null)
     {
         $iterators = [];
         foreach ($suites as $suite) {
diff --git a/src/Behat/Testwork/Tester/Cli/ExerciseController.php b/src/Behat/Testwork/Tester/Cli/ExerciseController.php
index 966bb9d7..e92b166e 100644
--- a/src/Behat/Testwork/Tester/Cli/ExerciseController.php
+++ b/src/Behat/Testwork/Tester/Cli/ExerciseController.php
@@ -87,8 +87,8 @@ final class ExerciseController implements Controller
         $command
             ->addArgument(
                 'paths',
-                InputArgument::OPTIONAL,
-                'Optional path(s) to execute. Could be:' . PHP_EOL . $locatorsExamples
+                InputArgument::OPTIONAL | InputArgument::IS_ARRAY,
+                'Optional path(s) to execute. Could be:' . PHP_EOL . $locatorsExamples,
             )
             ->addOption(
                 '--dry-run',
@@ -109,16 +109,17 @@ final class ExerciseController implements Controller
      */
     public function execute(InputInterface $input, OutputInterface $output)
     {
-        $specs = $this->findSpecifications($input);
+        $paths = $this->extractUniquePaths($input);
+        $specs = $this->findSpecifications($paths);
         $result = $this->testSpecifications($input, $specs);
 
-        if ($input->getArgument('paths') && !$input->getOption('allow-no-tests') && TestResults::NO_TESTS === $result->getResultCode()) {
+        if ($paths !== null && !$input->getOption('allow-no-tests') && TestResults::NO_TESTS === $result->getResultCode()) {
             throw new WrongPathsException(
                 sprintf(
                     'No specifications found at path(s) `%s`. This might be because of incorrect paths configuration in your `suites`.',
-                    $input->getArgument('paths')
+                    implode(', ', $paths)
                 ),
-                $input->getArgument('paths')
+                implode(', ', $paths)
             );
         }
 
@@ -128,11 +129,24 @@ final class ExerciseController implements Controller
     /**
      * Finds exercise specifications.
      *
+     * @param list<string>|null $paths
+     *
      * @return SpecificationIterator[]
      */
-    private function findSpecifications(InputInterface $input)
+    private function findSpecifications(?array $paths): array
     {
-        return $this->findSuitesSpecifications($this->getAvailableSuites(), $input->getArgument('paths'));
+        $availableSuites = $this->getAvailableSuites();
+        if ($paths === null) {
+            return $this->findSuitesSpecifications($availableSuites, null);
+        }
+
+        $specifications = [];
+
+        foreach ($paths as $path) {
+            $specifications = array_merge($specifications, $this->findSuitesSpecifications($availableSuites, $path));
+        }
+
+        return $specifications;
     }
 
     /**
@@ -170,12 +184,24 @@ final class ExerciseController implements Controller
      * Finds specification iterators for all provided suites using locator.
      *
      * @param Suite[]     $suites
-     * @param string|null $locator
      *
      * @return SpecificationIterator[]
      */
-    private function findSuitesSpecifications($suites, $locator)
+    private function findSuitesSpecifications(array $suites, ?string $locator): array
     {
         return $this->specificationFinder->findSuitesSpecifications($suites, $locator);
     }
+
+    /**
+     * Extracts unique paths from input argument. Returns null if no paths were supplied.
+     */
+    private function extractUniquePaths(InputInterface $input): ?array
+    {
+        $paths = $input->getArgument('paths') ?: null;
+        if ($paths === null) {
+            return null;
+        }
+
+        return array_values(array_unique($paths));
+    }
 }
diff --git a/tests/Fixtures/PathFilters/features/a/feature1.feature b/tests/Fixtures/PathFilters/features/a/feature1.feature
new file mode 100644
index 00000000..93b4a7ac
--- /dev/null
+++ b/tests/Fixtures/PathFilters/features/a/feature1.feature
@@ -0,0 +1,8 @@
+Feature: First Feature
+
+  Scenario: First Scenario
+    Given Some slow step N12
+    And Some normal step N13
+
+  Scenario: Second Scenario
+    Given Some fast step N14
diff --git a/tests/Fixtures/PathFilters/features/a/feature2.feature b/tests/Fixtures/PathFilters/features/a/feature2.feature
new file mode 100644
index 00000000..6badf391
--- /dev/null
+++ b/tests/Fixtures/PathFilters/features/a/feature2.feature
@@ -0,0 +1,8 @@
+Feature: Second Feature
+
+  Background:
+    Given Some normal step N21
+
+  Scenario: First Scenario
+    Given Some slow step N22
+    And Some fast step N23
diff --git a/tests/Fixtures/PathFilters/features/b/feature1.feature b/tests/Fixtures/PathFilters/features/b/feature1.feature
new file mode 100644
index 00000000..4b5ddafc
--- /dev/null
+++ b/tests/Fixtures/PathFilters/features/b/feature1.feature
@@ -0,0 +1,8 @@
+Feature: Third Feature
+
+  Scenario: First Scenario
+    Given Some fast step N14
+
+  Scenario: Second Scenario
+    Given Some fast step N14
+    And Some fast step N14
diff --git a/tests/Fixtures/PathFilters/features/bootstrap/FeatureContext.php b/tests/Fixtures/PathFilters/features/bootstrap/FeatureContext.php
new file mode 100644
index 00000000..8f4d77cb
--- /dev/null
+++ b/tests/Fixtures/PathFilters/features/bootstrap/FeatureContext.php
@@ -0,0 +1,22 @@
+<?php
+
+use Behat\Behat\Context\Context;
+use Behat\Step\Given;
+
+class FeatureContext implements Context
+{
+    #[Given('/^Some slow step N(\d+)$/')]
+    public function someSlowStepN($num)
+    {
+    }
+
+    #[Given('/^Some normal step N(\d+)$/')]
+    public function someNormalStepN($num)
+    {
+    }
+
+    #[Given('/^Some fast step N(\d+)$/')]
+    public function someFastStepN($num)
+    {
+    }
+}