diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 32258acc3d..af4d3c6701 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@v6
+ uses: actions/checkout@v7
- name: Install PHP & tools
uses: shivammathur/setup-php@v2
@@ -70,7 +70,7 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@v6
+ uses: actions/checkout@v7
- name: Install PHP & Composer
uses: shivammathur/setup-php@v2
@@ -85,7 +85,7 @@ jobs:
run: echo "COMPOSER_CACHE_DIR=$(composer config cache-dir)" >> $GITHUB_ENV
- name: Cache dependencies installed with composer
- uses: actions/cache@v3
+ uses: actions/cache@v6
with:
path: ${{ env.COMPOSER_CACHE_DIR }}
key: php${{ matrix.php }}-${{ matrix.mode }}-composer-${{ hashFiles('**/composer.json') }}
@@ -158,7 +158,7 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@v6
+ uses: actions/checkout@v7
- name: Install PHP & Composer
uses: shivammathur/setup-php@v2
@@ -169,11 +169,10 @@ jobs:
tools: composer:v2
- name: Determine composer cache directory
- run: |
- echo "COMPOSER_CACHE_DIR=~\\AppData\\Local\\Composer" >> $GITHUB_ENV
+ run: echo "COMPOSER_CACHE_DIR=$(composer config cache-dir)" >> $env:GITHUB_ENV
- name: Cache dependencies installed with composer
- uses: actions/cache@v3
+ uses: actions/cache@v6
with:
path: ${{ env.COMPOSER_CACHE_DIR }}
key: php${{ matrix.php }}-composer-${{ hashFiles('**/composer.json') }}
diff --git a/app.php b/app.php
index ef78abe475..4ed58cf259 100644
--- a/app.php
+++ b/app.php
@@ -29,6 +29,7 @@
new Codeception\Command\GherkinSteps('gherkin:steps'),
new Codeception\Command\DryRun('dry-run'),
new Codeception\Command\ConfigValidate('config:validate'),
+ new Codeception\Command\ConfigToPhp('config:to-php'),
];
// Suggests package
diff --git a/src/Codeception/Command/Bootstrap.php b/src/Codeception/Command/Bootstrap.php
index 6dbdd434b1..60559fa42f 100644
--- a/src/Codeception/Command/Bootstrap.php
+++ b/src/Codeception/Command/Bootstrap.php
@@ -19,6 +19,7 @@
* By default, it will create 3 suites **Acceptance**, **Functional**, and **Unit**.
*
* * `codecept bootstrap` - creates `tests` dir and `codeception.yml` in current dir.
+ * * `codecept bootstrap --php` - generates PHP config files (`codeception.php`) instead of YAML
* * `codecept bootstrap --empty` - creates `tests` dir without suites
* * `codecept bootstrap --namespace Frontend` - creates tests, and use `Frontend` namespace for actor classes and helpers.
* * `codecept bootstrap --actor Wizard` - sets actor as Wizard, to have `TestWizard` actor in tests.
@@ -37,7 +38,8 @@ protected function configure(): void
->addArgument('path', InputArgument::OPTIONAL, 'custom installation dir')
->addOption('namespace', 's', InputOption::VALUE_OPTIONAL, 'Namespace to add for actor classes and helpers')
->addOption('actor', 'a', InputOption::VALUE_OPTIONAL, 'Custom actor instead of Tester')
- ->addOption('empty', 'e', InputOption::VALUE_NONE, "Don't create standard suites");
+ ->addOption('empty', 'e', InputOption::VALUE_NONE, "Don't create standard suites")
+ ->addOption('php', null, InputOption::VALUE_NONE, 'Generate PHP config files instead of YAML');
}
protected function execute(InputInterface $input, OutputInterface $output): int
diff --git a/src/Codeception/Command/ConfigToPhp.php b/src/Codeception/Command/ConfigToPhp.php
new file mode 100644
index 0000000000..943c32678b
--- /dev/null
+++ b/src/Codeception/Command/ConfigToPhp.php
@@ -0,0 +1,140 @@
+addArgument('path', InputArgument::OPTIONAL, 'Project directory or codeception.yml to convert', '.')
+ ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Print generated PHP without writing files');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $path = (string) $input->getArgument('path');
+ $dir = is_dir($path) ? rtrim($path, '/\\') : dirname($path);
+
+ $dryRun = (bool) $input->getOption('dry-run');
+ $renderer = new PhpConfigFile();
+
+ $candidates = is_dir($path)
+ ? array_filter(
+ [$dir . DIRECTORY_SEPARATOR . 'codeception.dist.yml', $dir . DIRECTORY_SEPARATOR . 'codeception.yml'],
+ 'file_exists'
+ )
+ : (file_exists($path) ? [$path] : []);
+ if ($candidates === []) {
+ $notFound = is_dir($path) ? $dir . DIRECTORY_SEPARATOR . 'codeception.yml' : $path;
+ $output->writeln("Global config not found: {$notFound}");
+ return Command::FAILURE;
+ }
+
+ $parsed = [];
+ foreach ($candidates as $candidate) {
+ $arr = $this->parseYaml($candidate);
+ if ($arr === null) {
+ $output->writeln("Failed to read {$candidate}");
+ return Command::FAILURE;
+ }
+ $parsed[$candidate] = $arr;
+ }
+
+ $globalYml = array_key_last($parsed);
+ $paths = [];
+ foreach ($parsed as $arr) {
+ if (is_array($arr['paths'] ?? null)) {
+ $paths = array_merge($paths, $arr['paths']);
+ }
+ }
+
+ $this->emit($output, $globalYml, $renderer->renderGlobal($parsed[$globalYml]), $dryRun);
+
+ $testsDir = $dir . DIRECTORY_SEPARATOR . ($paths['tests'] ?? 'tests');
+ if (is_dir($testsDir)) {
+ $suiteFiles = array_merge(
+ glob($testsDir . DIRECTORY_SEPARATOR . '*.suite.yml') ?: [],
+ glob($testsDir . DIRECTORY_SEPARATOR . '*.suite.dist.yml') ?: []
+ );
+ foreach ($suiteFiles as $suiteYml) {
+ $suiteArr = $this->parseYaml($suiteYml);
+ if ($suiteArr === null) {
+ $output->writeln("Skipped {$suiteYml} (could not be read)");
+ continue;
+ }
+ $this->emit($output, $suiteYml, $renderer->renderSuite($suiteArr), $dryRun);
+ }
+ }
+
+ foreach ($renderer->warnings() as $warning) {
+ $output->writeln("! {$warning}");
+ }
+ if (!$dryRun) {
+ $output->writeln("\nDone. Review the generated PHP, then remove the old .yml files.");
+ }
+ return Command::SUCCESS;
+ }
+
+ private function parseYaml(string $file): ?array
+ {
+ $contents = file_get_contents($file);
+ return $contents === false ? null : (Yaml::parse($contents) ?? []);
+ }
+
+ private function emit(OutputInterface $output, string $from, string $contents, bool $dryRun): void
+ {
+ $to = (string) preg_replace('/\.yml$/', '.php', $from);
+ if ($dryRun) {
+ $output->writeln("# {$to}");
+ $output->writeln($contents);
+ return;
+ }
+ if (file_exists($to)) {
+ $output->writeln("Skipped {$to} (already exists)");
+ return;
+ }
+ $this->createFile($to, $contents);
+ $output->writeln("Created {$to} from " . basename($from));
+ }
+}
diff --git a/src/Codeception/Command/ConfigValidate.php b/src/Codeception/Command/ConfigValidate.php
index 770ea63821..88a0e3e9b3 100644
--- a/src/Codeception/Command/ConfigValidate.php
+++ b/src/Codeception/Command/ConfigValidate.php
@@ -79,6 +79,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$suites = Configuration::suites();
$output->writeln("------------------------------\n");
+ $output->writeln('Loaded config file: ' . (Configuration::loadedConfigFile() ?? '(none)'));
$output->writeln("Codeception Config:\n");
$output->writeln($this->formatOutput($config));
diff --git a/src/Codeception/Command/GenerateEnvironment.php b/src/Codeception/Command/GenerateEnvironment.php
index c549a4bd6e..b9d61fe2c0 100644
--- a/src/Codeception/Command/GenerateEnvironment.php
+++ b/src/Codeception/Command/GenerateEnvironment.php
@@ -12,6 +12,8 @@
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
+use function file_exists;
+
/**
* Generates empty environment configuration file into envs dir:
*
@@ -39,17 +41,48 @@ protected function execute(InputInterface $input, OutputInterface $output): int
if (Configuration::envsDir() === '') {
throw new ConfigurationException(
"Path for environments configuration is not set.\n"
- . "Please specify envs path in your `codeception.yml`\n \n"
+ . "Please specify envs path in your configuration file (`codeception.yml` or `codeception.php`)\n \n"
. "envs: tests/_envs"
);
}
$relativePath = $config['paths']['envs'];
$env = $input->getArgument('env');
- $file = $env . '.yml';
+
+ if (Configuration::isPhpFormat()) {
+ $file = $env . '.php';
+ $contents = <<createDirectoryFor($relativePath, $file);
- $saved = $this->createFile($path . $file, sprintf('# `%s` environment config goes here', $env));
+
+ $otherFile = $env . (Configuration::isPhpFormat() ? '.yml' : '.php');
+ if (file_exists($path . $otherFile)) {
+ $output->writeln(sprintf(
+ 'Environment "%s" already has %s/%s; a %s would silently shadow one of them '
+ . '(PHP config wins over YAML). Remove %s or edit it directly.',
+ $env,
+ $relativePath,
+ $otherFile,
+ $file,
+ $otherFile
+ ));
+ return Command::FAILURE;
+ }
+
+ $saved = $this->createFile($path . $file, $contents);
if ($saved) {
$output->writeln(sprintf('%s config was created in %s/%s', $env, $relativePath, $file));
diff --git a/src/Codeception/Command/GenerateSuite.php b/src/Codeception/Command/GenerateSuite.php
index c2211162e2..c1033b6a70 100644
--- a/src/Codeception/Command/GenerateSuite.php
+++ b/src/Codeception/Command/GenerateSuite.php
@@ -13,8 +13,8 @@
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
-use Symfony\Component\Yaml\Yaml;
+use function addcslashes;
use function file_exists;
use function preg_match;
use function ucfirst;
@@ -57,9 +57,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int
return Command::FAILURE;
}
+ $isPhp = Configuration::isPhpFormat();
+ $ext = $isPhp ? 'php' : 'yml';
$dir = Configuration::testsDir();
- if (file_exists($dir . $suite . '.suite.yml')) {
- throw new Exception("Suite configuration file '{$suite}.suite.yml' already exists.");
+ foreach (['yml', 'php'] as $existingExt) {
+ if (file_exists($dir . $suite . '.suite.' . $existingExt)) {
+ throw new Exception("Suite configuration file '{$suite}.suite.{$existingExt}' already exists.");
+ }
}
$this->createDirectoryFor($dir . $suite);
@@ -72,19 +76,34 @@ protected function execute(InputInterface $input, OutputInterface $output): int
);
}
- $yamlSuiteConfigTemplate = <<actor('{$actorLiteral}');
+
+PHP;
+ } else {
+ $suiteConfig = (new Template(<<place('actor', $actor)
- ->place('suite_namespace', $config['namespace'] . '\\' . $suite)
- ->produce();
- $this->createFile($dir . $suite . '.suite.yml', $yamlSuiteConfig);
- Configuration::append(Yaml::parse($yamlSuiteConfig));
+EOF))
+ ->place('actor', $actor)
+ ->place('suite_namespace', $suiteNamespace)
+ ->produce();
+ }
+ $this->createFile($dir . $suite . '.suite.' . $ext, $suiteConfig);
+ Configuration::append(['actor' => $actor, 'suite_namespace' => $suiteNamespace, 'modules' => ['enabled' => []]]);
$actorGenerator = new ActorGenerator(Configuration::config());
$content = $actorGenerator->produce();
@@ -92,10 +111,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$this->createFile($file, $content);
$output->writeln("Actor {$actor} was created in {$file}");
- $output->writeln("Suite config {$suite}.suite.yml was created.");
+ $output->writeln("Suite config {$suite}.suite.{$ext} was created.");
$output->writeln(' ');
$output->writeln("Next steps:");
- $output->writeln("1. Edit {$suite}.suite.yml to enable modules for this suite");
+ $output->writeln("1. Edit {$suite}.suite.{$ext} to enable modules for this suite");
$output->writeln("2. Create first test with generate:cest testName ( or test|cept) command");
$output->writeln("3. Run tests of this suite with codecept run {$suite} command");
diff --git a/src/Codeception/Command/Init.php b/src/Codeception/Command/Init.php
index 04a5727685..a3ca3470fe 100644
--- a/src/Codeception/Command/Init.php
+++ b/src/Codeception/Command/Init.php
@@ -27,7 +27,8 @@ protected function configure(): void
$this
->addArgument('template', InputArgument::REQUIRED, 'Init template for the setup')
->addOption('path', null, InputOption::VALUE_REQUIRED, 'Change current directory')
- ->addOption('namespace', null, InputOption::VALUE_OPTIONAL, 'Namespace to add for actor classes and helpers');
+ ->addOption('namespace', null, InputOption::VALUE_OPTIONAL, 'Namespace to add for actor classes and helpers')
+ ->addOption('php', null, InputOption::VALUE_NONE, 'Generate PHP config files instead of YAML');
}
protected function execute(InputInterface $input, OutputInterface $output): int
diff --git a/src/Codeception/Config/AbstractConfigBuilder.php b/src/Codeception/Config/AbstractConfigBuilder.php
new file mode 100644
index 0000000000..4fbf29be59
--- /dev/null
+++ b/src/Codeception/Config/AbstractConfigBuilder.php
@@ -0,0 +1,139 @@
+
+ */
+ protected array $config = [];
+
+ final public function __construct()
+ {
+ }
+
+ public static function create(): static
+ {
+ return new static();
+ }
+
+ /**
+ * Enables a module. Call once per module.
+ *
+ * @param array $config Module options
+ * @param list|string|null $depends Module(s) this one depends on
+ */
+ public function module(string $name, array $config = [], array|string|null $depends = null): static
+ {
+ if ($name === '') {
+ throw new InvalidArgumentException('Module name cannot be empty.');
+ }
+ if ($depends !== null) {
+ if (isset($config['depends'])) {
+ throw new InvalidArgumentException("Module {$name}: 'depends' given both as argument and in config.");
+ }
+ $config['depends'] = $depends;
+ }
+ $this->config['modules']['enabled'][] = $config === [] ? $name : [$name => $config];
+ return $this;
+ }
+
+ /**
+ * Overrides the options of an already-enabled module. Use in env and dist layers.
+ *
+ * @param array $config
+ */
+ public function moduleConfig(string $name, array $config): static
+ {
+ if ($name === '') {
+ throw new InvalidArgumentException('Module name cannot be empty.');
+ }
+ $this->config['modules']['config'][$name] = $config;
+ return $this;
+ }
+
+ /**
+ * @param array $config
+ */
+ public function extension(string $class, array $config = []): static
+ {
+ $this->config['extensions']['enabled'][] = $class;
+ if ($config !== []) {
+ $this->config['extensions']['config'][$class] = $config;
+ }
+ return $this;
+ }
+
+ /**
+ * @param array> $groups
+ */
+ public function groups(array $groups): static
+ {
+ $this->config['groups'] = $groups;
+ return $this;
+ }
+
+ /**
+ * @param array $coverage
+ */
+ public function coverage(array $coverage): static
+ {
+ $this->config['coverage'] = $coverage;
+ return $this;
+ }
+
+ public function namespace(string $namespace): static
+ {
+ $this->config['namespace'] = $namespace;
+ return $this;
+ }
+
+ /**
+ * Deep-merges raw config keys for anything without a dedicated method.
+ * The argument wins over previously-set keys, like a later chained call.
+ * Example: ->merge(['reporters' => ['report' => Custom::class]])
+ *
+ * @param array $config
+ */
+ public function merge(array $config): static
+ {
+ $this->config = Configuration::mergeConfigs($this->config, $config);
+ return $this;
+ }
+
+ /**
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return self::normalize($this->config);
+ }
+
+ /**
+ * @param array $config
+ * @return array
+ */
+ private static function normalize(array $config): array
+ {
+ return array_map(
+ static fn (mixed $value): mixed => match (true) {
+ $value instanceof ConfigInterface => $value->toArray(),
+ is_array($value) => self::normalize($value),
+ default => $value,
+ },
+ $config
+ );
+ }
+}
diff --git a/src/Codeception/Config/ConfigInterface.php b/src/Codeception/Config/ConfigInterface.php
new file mode 100644
index 0000000000..d7306a3096
--- /dev/null
+++ b/src/Codeception/Config/ConfigInterface.php
@@ -0,0 +1,13 @@
+
+ */
+ public function toArray(): array;
+}
diff --git a/src/Codeception/Config/GlobalConfig.php b/src/Codeception/Config/GlobalConfig.php
new file mode 100644
index 0000000000..42246f2000
--- /dev/null
+++ b/src/Codeception/Config/GlobalConfig.php
@@ -0,0 +1,164 @@
+
+ * use Codeception\Config\GlobalConfig;
+ * use Codeception\Extension\RunFailed;
+ *
+ * return GlobalConfig::create()
+ * ->namespace('App\Tests')
+ * ->paths(tests: 'tests', output: 'tests/_output', support: 'tests/Support')
+ * ->extension(RunFailed::class)
+ * ->suite('Unit', SuiteConfig::create()->actor('UnitTester')->module('Asserts'));
+ *
+ *
+ * Notes:
+ * - Builders are mutable; a shared "base" builder reused for two suites mutates both.
+ * Return a fresh builder from a function to make a preset.
+ * - `%param%` placeholders are a YAML-only feature. In PHP configs use getenv() (global
+ * file) or {@see Params}::get() (suite and env files). Keys without a method go through
+ * {@see AbstractConfigBuilder::merge()}.
+ */
+final class GlobalConfig extends AbstractConfigBuilder
+{
+ public function supportNamespace(?string $namespace): static
+ {
+ $this->config['support_namespace'] = $namespace;
+ return $this;
+ }
+
+ public function actorSuffix(string $suffix): static
+ {
+ $this->config['actor_suffix'] = $suffix;
+ return $this;
+ }
+
+ /**
+ * Global bootstrap FILE, loaded once before any suite runs.
+ * (The per-suite bootstrap toggle is the `bootstrap:` argument of {@see self::settings()}.)
+ */
+ public function bootstrap(string|false $bootstrap): static
+ {
+ $this->config['bootstrap'] = $bootstrap;
+ return $this;
+ }
+
+ public function extends(string $configFile): static
+ {
+ $this->config['extends'] = $configFile;
+ return $this;
+ }
+
+ public function paths(
+ ?string $tests = null,
+ ?string $output = null,
+ ?string $data = null,
+ ?string $support = null,
+ ?string $envs = null,
+ ): static {
+ foreach (['tests' => $tests, 'output' => $output, 'data' => $data, 'support' => $support, 'envs' => $envs] as $key => $value) {
+ if ($value !== null) {
+ $this->config['paths'][$key] = $value;
+ }
+ }
+ return $this;
+ }
+
+ /**
+ * Run settings. Only the arguments you pass are written.
+ *
+ * @param string|false|null $bootstrap Per-suite bootstrap file name (not the global bootstrap()).
+ * @param int|string|null $memoryLimit e.g. 1024 or '1G'
+ */
+ public function settings(
+ ?bool $shuffle = null,
+ ?bool $colors = null,
+ ?bool $lint = null,
+ string|false|null $bootstrap = null,
+ int|string|null $memoryLimit = null,
+ ?bool $backupGlobals = null,
+ ?bool $reportUselessTests = null,
+ ?bool $strictXml = null,
+ ?bool $beStrictAboutChangesToGlobalState = null,
+ ): static {
+ $map = [
+ 'shuffle' => $shuffle,
+ 'colors' => $colors,
+ 'lint' => $lint,
+ 'bootstrap' => $bootstrap,
+ 'memory_limit' => $memoryLimit,
+ 'backup_globals' => $backupGlobals,
+ 'report_useless_tests' => $reportUselessTests,
+ 'strict_xml' => $strictXml,
+ 'be_strict_about_changes_to_global_state' => $beStrictAboutChangesToGlobalState,
+ ];
+ foreach ($map as $key => $value) {
+ if ($value !== null) {
+ $this->config['settings'][$key] = $value;
+ }
+ }
+ return $this;
+ }
+
+ /**
+ * @param array|string ...$sources Param file paths, 'env', or inline maps.
+ */
+ public function params(array|string ...$sources): static
+ {
+ $this->config['params'] = array_values($sources);
+ return $this;
+ }
+
+ public function include(string ...$paths): static
+ {
+ $this->config['include'] = array_values($paths);
+ return $this;
+ }
+
+ /**
+ * @param array $gherkin
+ */
+ public function gherkin(array $gherkin): static
+ {
+ $this->config['gherkin'] = $gherkin;
+ return $this;
+ }
+
+ /**
+ * Defines a suite inline (single-file config). An inline suite fully replaces a
+ * matching `{name}.suite.php`/`.yml` file on disk.
+ *
+ * @param SuiteConfig|array $config
+ */
+ public function suite(string $name, SuiteConfig|array $config): static
+ {
+ if ($name === '') {
+ throw new InvalidArgumentException('Suite name cannot be empty.');
+ }
+ $this->config['suites'][$name] = $config;
+ return $this;
+ }
+
+ /**
+ * Registers custom console commands (`extensions.commands`).
+ *
+ * @param class-string ...$commandClasses
+ */
+ public function commands(string ...$commandClasses): static
+ {
+ foreach ($commandClasses as $class) {
+ $this->config['extensions']['commands'][] = $class;
+ }
+ return $this;
+ }
+}
diff --git a/src/Codeception/Config/Params.php b/src/Codeception/Config/Params.php
new file mode 100644
index 0000000000..d55e548d9d
--- /dev/null
+++ b/src/Codeception/Config/Params.php
@@ -0,0 +1,22 @@
+
+ * use Codeception\Config\SuiteConfig;
+ *
+ * return SuiteConfig::create()
+ * ->actor('FunctionalTester')
+ * ->module('Symfony', ['app_path' => 'src', 'environment' => 'test'])
+ * ->module('Doctrine', depends: 'Symfony');
+ *
+ */
+final class SuiteConfig extends AbstractConfigBuilder
+{
+ public function actor(?string $actor): static
+ {
+ $this->config['actor'] = $actor;
+ return $this;
+ }
+
+ public function path(?string $path): static
+ {
+ $this->config['path'] = $path;
+ return $this;
+ }
+
+ public function extends(?string $configFile): static
+ {
+ $this->config['extends'] = $configFile;
+ return $this;
+ }
+
+ /**
+ * @param list|class-string|null $decorators
+ */
+ public function stepDecorators(array|string|null $decorators): static
+ {
+ $this->config['step_decorators'] = $decorators;
+ return $this;
+ }
+
+ /**
+ * @param list $formats
+ */
+ public function formats(array $formats): static
+ {
+ $this->config['formats'] = $formats;
+ return $this;
+ }
+
+ public function shuffle(bool $shuffle = true): static
+ {
+ $this->config['shuffle'] = $shuffle;
+ return $this;
+ }
+
+ /**
+ * Native constants work here: ->errorLevel(E_ALL & ~E_DEPRECATED)
+ */
+ public function errorLevel(int|string $errorLevel): static
+ {
+ $this->config['error_level'] = $errorLevel;
+ return $this;
+ }
+
+ public function convertDeprecationsToExceptions(bool $convert = true): static
+ {
+ $this->config['convert_deprecations_to_exceptions'] = $convert;
+ return $this;
+ }
+
+ /**
+ * Adds an environment overlay for this suite (merged when run with `--env {name}`).
+ *
+ * @param SuiteConfig|array $overrides
+ */
+ public function env(string $name, SuiteConfig|array $overrides): static
+ {
+ if ($name === '') {
+ throw new InvalidArgumentException('Environment name cannot be empty.');
+ }
+ $this->config['env'][$name] = $overrides;
+ return $this;
+ }
+}
diff --git a/src/Codeception/Configuration.php b/src/Codeception/Configuration.php
index e8441eb5dc..a112880c7f 100644
--- a/src/Codeception/Configuration.php
+++ b/src/Codeception/Configuration.php
@@ -4,7 +4,11 @@
namespace Codeception;
+use Codeception\Config\ConfigInterface;
+use Codeception\Config\GlobalConfig;
+use Codeception\Config\SuiteConfig;
use Codeception\Exception\ConfigurationException;
+use Codeception\Lib\ConfigFileLocator;
use Codeception\Lib\ParamsLoader;
use Codeception\Step\ConditionalAssertion;
use Codeception\Util\Autoload;
@@ -14,8 +18,14 @@
use Symfony\Component\Finder\Finder;
use Symfony\Component\Yaml\Exception\ParseException;
use Symfony\Component\Yaml\Yaml;
+use Throwable;
+use function array_map;
use function array_unique;
+use function get_debug_type;
+use function is_array;
+use function is_readable;
+use function str_ends_with;
class Configuration
{
@@ -67,6 +77,10 @@ class Configuration
* @var array|null
*/
protected static ?array $params = null;
+ /**
+ * @var string|null Path of the global config file that was loaded (`.php` or `.yml`).
+ */
+ protected static ?string $loadedConfigFile = null;
/**
* @var array
@@ -132,59 +146,67 @@ public static function config(?string $configFile = null): array
return self::$config;
}
if ($configFile === null) {
- $configFile = getcwd() . DIRECTORY_SEPARATOR . 'codeception.yml';
- }
- if (is_dir($configFile)) {
- $configFile = rtrim($configFile, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'codeception.yml';
+ $configFile = self::discoverConfigFile(getcwd());
+ } elseif (is_dir($configFile)) {
+ $configFile = self::discoverConfigFile(rtrim($configFile, DIRECTORY_SEPARATOR));
}
+
$dir = realpath(dirname($configFile));
if ($dir !== false) {
self::$dir = $dir;
self::$baseDir ??= $dir;
}
+ $baseDir = $dir !== false ? $dir : dirname($configFile);
- $configDistFile = ($dir !== false ? $dir : dirname($configFile)) . DIRECTORY_SEPARATOR . 'codeception.dist.yml';
- if (!file_exists($configFile) && !file_exists($configDistFile)) {
+ $ext = self::isPhpConfig($configFile) ? 'php' : 'yml';
+ $distFile = $baseDir . DIRECTORY_SEPARATOR . 'codeception.dist.' . $ext;
+ if (!file_exists($configFile) && !file_exists($distFile)) {
throw new ConfigurationException("Configuration file could not be found.\nRun bootstrap to initialize Codeception.", 404);
}
- $tempConfig = self::$defaultConfig;
- $distConfigContents = '';
- if (file_exists($configDistFile)) {
- $distConfigContents = file_get_contents($configDistFile);
- if ($distConfigContents === false) {
- throw new ConfigurationException("Failed to read {$configDistFile}");
- }
- $tempConfig = self::mergeConfigs($tempConfig, self::getConfFromContents($distConfigContents, $configDistFile));
- }
+ self::$loadedConfigFile = file_exists($configFile) ? $configFile : $distFile;
- $configContents = '';
- if (file_exists($configFile)) {
- $configContents = file_get_contents($configFile);
- if ($configContents === false) {
- throw new ConfigurationException("Failed to read {$configFile}");
- }
- $tempConfig = self::mergeConfigs($tempConfig, self::getConfFromContents($configContents, $configFile));
- }
+ $distArr = self::getConfFromAnyFile($distFile, [], GlobalConfig::class);
+ $mainArr = self::getConfFromAnyFile($configFile, [], GlobalConfig::class);
+ $tempConfig = self::mergeConfigs(self::$defaultConfig, $distArr);
+ $tempConfig = self::mergeConfigs($tempConfig, $mainArr);
self::prepareParams($tempConfig);
- $config = self::$defaultConfig;
- if ($distConfigContents !== '') {
- $config = self::mergeConfigs($config, self::getConfFromContents($distConfigContents, $configDistFile));
+ // Re-read YAML only, so %param% placeholders resolve now that params are loaded.
+ // PHP configs are already final and are not re-required (avoids double side effects).
+ if (!self::isPhpConfig($distFile)) {
+ $distArr = self::getConfFromAnyFile($distFile);
}
- if ($configContents !== '') {
- $config = self::mergeConfigs($config, self::getConfFromContents($configContents, $configFile));
+ if (!self::isPhpConfig($configFile)) {
+ $mainArr = self::getConfFromAnyFile($configFile);
}
+ $config = self::mergeConfigs(self::$defaultConfig, $distArr);
+ $config = self::mergeConfigs($config, $mainArr);
+
if ($config === self::$defaultConfig) {
throw new ConfigurationException("Configuration file is invalid");
}
+ return self::finalizeConfig($config);
+ }
+
+ /**
+ * PHP config wins: if a `codeception.php`/`codeception.dist.php` exists in the directory,
+ * YAML is ignored entirely. The same rule applies per suite and per environment name.
+ */
+ private static function discoverConfigFile(string $dir): string
+ {
+ return ConfigFileLocator::locate($dir, 'codeception')->mainFile;
+ }
+
+ private static function finalizeConfig(array $config): array
+ {
if (isset($config['extends'])) {
$presetFilePath = codecept_absolute_path($config['extends']);
if (file_exists($presetFilePath)) {
- $config = self::mergeConfigs(self::getConfFromFile($presetFilePath), $config);
+ $config = self::mergeConfigs(self::getConfFromAnyFile($presetFilePath, [], GlobalConfig::class), $config);
}
}
@@ -233,6 +255,7 @@ protected static function loadSuites(): void
$suites = Finder::create()
->files()
->name('*.{suite,suite.dist}.yml')
+ ->name('*.{suite,suite.dist}.php')
->in(self::$dir . DIRECTORY_SEPARATOR . self::$testsDir)
->depth('< 1')
->sortByName();
@@ -242,8 +265,9 @@ protected static function loadSuites(): void
self::$suites[$suite] = $suite;
}
foreach ($suites as $suite) {
- preg_match('#(.*?)(\\.suite|\\.suite\\.dist)\\.yml#', $suite->getFilename(), $matches);
- self::$suites[$matches[1]] = $matches[1];
+ if (preg_match('#(.*?)(\\.suite|\\.suite\\.dist)\\.(yml|php)#', $suite->getFilename(), $matches)) {
+ self::$suites[$matches[1]] = $matches[1];
+ }
}
}
@@ -323,14 +347,18 @@ protected static function loadEnvConfigs(string $path): array
self::$envConfig[$path] = [];
return self::$envConfig[$path];
}
- $envFiles = Finder::create()->files()->name('*.yml')->in($path)->depth('< 2');
+ $envFiles = Finder::create()->files()->name('*.yml')->name('*.php')->in($path)->depth('< 2');
$envConfig = [];
foreach ($envFiles as $envFile) {
- $env = str_replace(['.dist.yml', '.yml'], '', $envFile->getFilename());
+ $env = str_replace(['.dist.yml', '.yml', '.dist.php', '.php'], '', $envFile->getFilename());
+ if (isset($envConfig[$env])) {
+ continue;
+ }
$envConfig[$env] = [];
$envPath = $path . ($envFile->getRelativePath() !== '' ? DIRECTORY_SEPARATOR . $envFile->getRelativePath() : '');
- foreach (['.dist.yml', '.yml'] as $suffix) {
- $envConf = self::getConfFromFile($envPath . DIRECTORY_SEPARATOR . $env . $suffix);
+ $locator = ConfigFileLocator::locate($envPath, $env);
+ foreach ([$locator->distFile, $locator->mainFile] as $envFilePath) {
+ $envConf = self::getConfFromAnyFile($envFilePath, [], SuiteConfig::class);
$envConfig[$env] = self::mergeConfigs($envConfig[$env], $envConf);
}
}
@@ -395,6 +423,82 @@ protected static function getConfFromFile(string $filename, array $nonExistentVa
return self::getConfFromContents($contents, $filename);
}
+ /**
+ * @param array $nonExistentValue Value used if the file is not found
+ * @param class-string|null $expected Concrete builder the file must return
+ * @return array
+ * @throws ConfigurationException
+ */
+ protected static function getConfFromPhpFile(string $filename, array $nonExistentValue = [], ?string $expected = null): array
+ {
+ if (!file_exists($filename)) {
+ return $nonExistentValue;
+ }
+ if (!is_readable($filename)) {
+ throw new ConfigurationException("Failed to read {$filename}");
+ }
+ $level = ob_get_level();
+ try {
+ ob_start();
+ $result = (static fn (): mixed => require $filename)();
+ } catch (Throwable $e) {
+ throw new ConfigurationException(sprintf("Error loading PHP config from %s\n\n%s", $filename, $e->getMessage()), 0, $e);
+ } finally {
+ while (ob_get_level() > $level) {
+ ob_end_clean();
+ }
+ }
+ if ($result instanceof ConfigInterface) {
+ self::assertExpectedBuilder($result, $expected, $filename);
+ return $result->toArray();
+ }
+ if (is_array($result)) {
+ return $result;
+ }
+ throw new ConfigurationException(sprintf(
+ "PHP config file %s must return an array or a %s instance, got %s.",
+ $filename,
+ ConfigInterface::class,
+ get_debug_type($result)
+ ));
+ }
+
+ /**
+ * @param class-string|null $expected
+ * @throws ConfigurationException
+ */
+ private static function assertExpectedBuilder(ConfigInterface $result, ?string $expected, string $filename): void
+ {
+ $mismatch = ($expected === GlobalConfig::class && $result instanceof SuiteConfig)
+ || ($expected === SuiteConfig::class && $result instanceof GlobalConfig);
+ if ($mismatch) {
+ throw new ConfigurationException(sprintf(
+ 'PHP config file %s must return a %s instance, got %s.',
+ $filename,
+ $expected,
+ $result::class
+ ));
+ }
+ }
+
+ /**
+ * @param array $nonExistentValue
+ * @param class-string|null $expected
+ * @return array
+ * @throws ConfigurationException
+ */
+ protected static function getConfFromAnyFile(string $filename, array $nonExistentValue = [], ?string $expected = null): array
+ {
+ return self::isPhpConfig($filename)
+ ? self::getConfFromPhpFile($filename, $nonExistentValue, $expected)
+ : self::getConfFromFile($filename, $nonExistentValue);
+ }
+
+ private static function isPhpConfig(string $filename): bool
+ {
+ return str_ends_with(strtolower($filename), '.php');
+ }
+
/**
* @return string[]
*/
@@ -472,6 +576,22 @@ public static function outputDir(): string
return $dir;
}
+ /**
+ * Path of the loaded global config file (honors `-c`), or null if not loaded yet.
+ */
+ public static function loadedConfigFile(): ?string
+ {
+ return self::$loadedConfigFile;
+ }
+
+ /**
+ * Whether the current project is configured with PHP files instead of YAML.
+ */
+ public static function isPhpFormat(): bool
+ {
+ return self::$loadedConfigFile !== null && self::isPhpConfig(self::$loadedConfigFile);
+ }
+
/**
* Returns path to the root of your project.
* Basically returns path to current `codeception.yml` loaded.
@@ -576,9 +696,10 @@ protected static function loadSuiteConfig(string $suite, string $path, array $se
if (isset(self::$config['suites'][$suite])) {
return self::mergeConfigs($settings, self::$config['suites'][$suite]);
}
- $suiteDir = self::$dir . DIRECTORY_SEPARATOR . $path;
- $suiteDist = self::getConfFromFile($suiteDir . DIRECTORY_SEPARATOR . "{$suite}.suite.dist.yml");
- $suiteConf = self::getConfFromFile($suiteDir . DIRECTORY_SEPARATOR . "{$suite}.suite.yml");
+ $suiteDir = self::$dir . DIRECTORY_SEPARATOR . $path;
+ $locator = ConfigFileLocator::locate($suiteDir, "{$suite}.suite");
+ $suiteDist = self::getConfFromAnyFile($locator->distFile, [], SuiteConfig::class);
+ $suiteConf = self::getConfFromAnyFile($locator->mainFile, [], SuiteConfig::class);
if (isset($suiteConf['extends'])) {
$preset = PathResolver::isPathAbsolute($suiteConf['extends'])
? $suiteConf['extends']
@@ -587,7 +708,7 @@ protected static function loadSuiteConfig(string $suite, string $path, array $se
throw new ConfigurationException(sprintf("Configuration file %s does not exist", $suiteConf['extends']));
}
if (file_exists($preset)) {
- $settings = self::mergeConfigs(self::getConfFromFile($preset), $settings);
+ $settings = self::mergeConfigs(self::getConfFromAnyFile($preset, [], SuiteConfig::class), $settings);
}
}
$settings = self::mergeConfigs($settings, $suiteDist);
@@ -627,7 +748,7 @@ protected static function expandWildcardsFor(string $include): array
}
try {
$finder = Finder::create()->files()
- ->name('/codeception(\.dist\.yml|\.yml)/')
+ ->name('/codeception(\.dist\.yml|\.yml|\.dist\.php|\.php)/')
->in(self::$dir . DIRECTORY_SEPARATOR . $include);
} catch (InvalidArgumentException) {
throw new ConfigurationException("Configuration file(s) could not be found in \"{$include}\".");
@@ -650,4 +771,20 @@ private static function prepareParams(array $settings): void
self::$params = array_merge(self::$params, ParamsLoader::load($paramStorage));
}
}
+
+ /**
+ * Returns a loaded param. Backs {@see \Codeception\Config\Params::get()}.
+ *
+ * @throws ConfigurationException when called before params are loaded (i.e. from `codeception.php`).
+ */
+ public static function param(string $name, mixed $default = null): mixed
+ {
+ if (self::$params === null) {
+ throw new ConfigurationException(
+ "Params are not loaded yet while the global config is being read.\n" .
+ "Use getenv() in codeception.php; Params::get() works in suite and env config files."
+ );
+ }
+ return self::$params[$name] ?? $default;
+ }
}
diff --git a/src/Codeception/InitTemplate.php b/src/Codeception/InitTemplate.php
index 216d4bd56a..42b2161248 100644
--- a/src/Codeception/InitTemplate.php
+++ b/src/Codeception/InitTemplate.php
@@ -60,6 +60,11 @@ public function __construct(protected InputInterface $input, OutputInterface $ou
$this->output = $output;
}
+ protected function phpLiteral(string $value): string
+ {
+ return addcslashes($value, "\\'");
+ }
+
/**
* Change the directory where Codeception should be installed.
*/
@@ -194,8 +199,11 @@ protected function gitIgnore(string $path): void
protected function checkInstalled(string $dir = '.'): void
{
- if (file_exists("{$dir}/codeception.yml") || file_exists("{$dir}/codeception.dist.yml")) {
- throw new Exception('Codeception is already installed in this directory');
+ $configFiles = ['codeception.yml', 'codeception.dist.yml', 'codeception.php', 'codeception.dist.php'];
+ foreach ($configFiles as $configFile) {
+ if (file_exists("{$dir}/{$configFile}")) {
+ throw new Exception('Codeception is already installed in this directory');
+ }
}
}
diff --git a/src/Codeception/Lib/ConfigFileLocator.php b/src/Codeception/Lib/ConfigFileLocator.php
new file mode 100644
index 0000000000..533300e385
--- /dev/null
+++ b/src/Codeception/Lib/ConfigFileLocator.php
@@ -0,0 +1,26 @@
+ 'namespace',
+ 'support_namespace' => 'supportNamespace',
+ 'actor_suffix' => 'actorSuffix',
+ 'actor' => 'actor',
+ 'path' => 'path',
+ 'bootstrap' => 'bootstrap',
+ 'extends' => 'extends',
+ 'gherkin' => 'gherkin',
+ 'groups' => 'groups',
+ 'coverage' => 'coverage',
+ 'formats' => 'formats',
+ 'step_decorators' => 'stepDecorators',
+ 'shuffle' => 'shuffle',
+ 'error_level' => 'errorLevel',
+ 'convert_deprecations_to_exceptions' => 'convertDeprecationsToExceptions',
+ ];
+
+ private const VARIADIC_CALLS = [
+ 'include' => 'include',
+ 'params' => 'params',
+ ];
+
+ private const SETTINGS_MAP = [
+ 'shuffle' => 'shuffle',
+ 'colors' => 'colors',
+ 'lint' => 'lint',
+ 'bootstrap' => 'bootstrap',
+ 'memory_limit' => 'memoryLimit',
+ 'backup_globals' => 'backupGlobals',
+ 'report_useless_tests' => 'reportUselessTests',
+ 'strict_xml' => 'strictXml',
+ 'be_strict_about_changes_to_global_state' => 'beStrictAboutChangesToGlobalState',
+ ];
+
+ /** @var list */
+ private array $warnings = [];
+
+ /**
+ * @return list
+ */
+ public function warnings(): array
+ {
+ return $this->warnings;
+ }
+
+ /**
+ * @param array $config
+ */
+ public function renderGlobal(array $config): string
+ {
+ $usesSuite = isset($config['suites']) && $config['suites'] !== [];
+ $use = "use Codeception\\Config\\GlobalConfig;\n";
+ if ($usesSuite) {
+ $use .= "use Codeception\\Config\\SuiteConfig;\n";
+ }
+ return $this->wrap('GlobalConfig', $use, $this->buildCalls($config, self::GLOBAL));
+ }
+
+ /**
+ * @param array $config
+ */
+ public function renderSuite(array $config): string
+ {
+ return $this->wrap('SuiteConfig', "use Codeception\\Config\\SuiteConfig;\n", $this->buildCalls($config, self::SUITE));
+ }
+
+ /**
+ * @param list $calls
+ */
+ private function wrap(string $class, string $use, array $calls): string
+ {
+ $chain = $calls === [] ? '' : "\n " . implode("\n ", $calls);
+ return " $config
+ * @return list
+ */
+ private function buildCalls(array $config, string $mode): array
+ {
+ $calls = [];
+ $merge = [];
+ foreach ($config as $key => $value) {
+ $call = $this->callFor($key, $value, $mode);
+ if ($call === null) {
+ $merge[$key] = $value;
+ } else {
+ array_push($calls, ...$call);
+ }
+ }
+ if ($merge !== []) {
+ $calls[] = '->merge(' . $this->export($merge, $mode) . ')';
+ }
+ return $calls;
+ }
+
+ /**
+ * @return list|null Null routes the key to ->merge().
+ */
+ private function callFor(string $key, mixed $value, string $mode): ?array
+ {
+ if ($mode === self::SUITE && in_array($key, self::GLOBAL_ONLY, true)) {
+ return null;
+ }
+ if ($mode === self::GLOBAL && in_array($key, self::SUITE_ONLY, true)) {
+ return null;
+ }
+
+ if (isset(self::SIMPLE_CALLS[$key])) {
+ return [sprintf('->%s(%s)', self::SIMPLE_CALLS[$key], $this->export($value, $mode))];
+ }
+ if (isset(self::VARIADIC_CALLS[$key])) {
+ return [sprintf('->%s(%s)', self::VARIADIC_CALLS[$key], $this->args($value, $mode))];
+ }
+
+ return match ($key) {
+ 'paths' => $this->pathsCall($value, $mode),
+ 'settings' => $this->settingsCall($value, $mode),
+ 'modules' => $this->modulesCalls($value, $mode),
+ 'extensions' => $this->extensionsCalls($value, $mode),
+ 'suites' => $this->suitesCalls($value),
+ 'env' => $this->envCalls($value, $mode),
+ default => null,
+ };
+ }
+
+ /**
+ * @return list|null
+ */
+ private function pathsCall(mixed $paths, string $mode): ?array
+ {
+ if (!is_array($paths) || array_diff(array_keys($paths), self::PATH_KEYS) !== []) {
+ return null;
+ }
+ $args = [];
+ foreach (self::PATH_KEYS as $k) {
+ if (isset($paths[$k])) {
+ $args[] = sprintf('%s: %s', $k, $this->export($paths[$k], $mode));
+ }
+ }
+ return $args === [] ? null : [sprintf('->paths(%s)', implode(', ', $args))];
+ }
+
+ /**
+ * @return list|null
+ */
+ private function settingsCall(mixed $settings, string $mode): ?array
+ {
+ if (!is_array($settings)) {
+ return null;
+ }
+ $args = [];
+ $merge = [];
+ foreach ($settings as $k => $v) {
+ if (isset(self::SETTINGS_MAP[$k])) {
+ $args[] = sprintf('%s: %s', self::SETTINGS_MAP[$k], $this->export($v, $mode));
+ } else {
+ $merge[$k] = $v;
+ }
+ }
+ $calls = [];
+ if ($args !== []) {
+ $calls[] = sprintf('->settings(%s)', implode(', ', $args));
+ }
+ if ($merge !== []) {
+ $calls[] = sprintf('->merge(%s)', $this->export(['settings' => $merge], $mode));
+ }
+ return $calls === [] ? null : $calls;
+ }
+
+ /**
+ * @return list|null
+ */
+ private function modulesCalls(mixed $modules, string $mode): ?array
+ {
+ if (!is_array($modules)) {
+ return null;
+ }
+ $calls = [];
+ foreach ($modules['enabled'] ?? [] as $entry) {
+ if (is_array($entry)) {
+ foreach ($entry as $name => $conf) {
+ $calls[] = is_array($conf) && $conf !== []
+ ? sprintf('->module(%s, %s)', $this->export((string) $name, $mode), $this->export($conf, $mode))
+ : sprintf('->module(%s)', $this->export((string) $name, $mode));
+ }
+ } else {
+ $calls[] = sprintf('->module(%s)', $this->export($entry, $mode));
+ }
+ }
+ foreach ($modules['config'] ?? [] as $name => $conf) {
+ $calls[] = sprintf('->moduleConfig(%s, %s)', $this->export((string) $name, $mode), $this->export($conf, $mode));
+ }
+ $leftover = array_filter(
+ $modules,
+ static fn (mixed $value, string $key): bool =>
+ !in_array($key, ['enabled', 'config'], true) && $value !== [] && $value !== null,
+ ARRAY_FILTER_USE_BOTH
+ );
+ if ($leftover !== []) {
+ $calls[] = sprintf('->merge(%s)', $this->export(['modules' => $leftover], $mode));
+ }
+ return $calls === [] ? null : $calls;
+ }
+
+ /**
+ * @return list|null
+ */
+ private function extensionsCalls(mixed $extensions, string $mode): ?array
+ {
+ if (!is_array($extensions)) {
+ return null;
+ }
+ $calls = [];
+ $config = $extensions['config'] ?? [];
+ foreach ($extensions['enabled'] ?? [] as $class) {
+ if (isset($config[$class])) {
+ $calls[] = sprintf('->extension(%s, %s)', $this->export($class, $mode), $this->export($config[$class], $mode));
+ unset($config[$class]);
+ } else {
+ $calls[] = sprintf('->extension(%s)', $this->export($class, $mode));
+ }
+ }
+ if ($mode === self::GLOBAL && !empty($extensions['commands'])) {
+ $calls[] = sprintf('->commands(%s)', $this->args($extensions['commands'], $mode));
+ }
+ $leftover = [];
+ if ($config !== []) {
+ $leftover['config'] = $config;
+ }
+ if ($mode !== self::GLOBAL && !empty($extensions['commands'])) {
+ $leftover['commands'] = $extensions['commands'];
+ }
+ if ($leftover !== []) {
+ $calls[] = sprintf('->merge(%s)', $this->export(['extensions' => $leftover], $mode));
+ }
+ return $calls === [] ? null : $calls;
+ }
+
+ /**
+ * @return list
+ */
+ private function suitesCalls(mixed $suites): array
+ {
+ $calls = [];
+ foreach ((array) $suites as $name => $conf) {
+ $inner = is_array($conf) ? $this->buildCalls($conf, self::SUITE) : [];
+ $chain = 'SuiteConfig::create()' . implode('', array_map(static fn (string $c): string => "\n {$c}", $inner));
+ $calls[] = sprintf('->suite(%s, %s)', $this->export((string) $name, self::GLOBAL), $chain);
+ }
+ return $calls;
+ }
+
+ /**
+ * @return list|null
+ */
+ private function envCalls(mixed $env, string $mode): ?array
+ {
+ if (!is_array($env)) {
+ return null;
+ }
+ $calls = [];
+ foreach ($env as $name => $overrides) {
+ $calls[] = sprintf('->env(%s, %s)', $this->export((string) $name, $mode), $this->export($overrides, $mode));
+ }
+ return $calls === [] ? null : $calls;
+ }
+
+ private function args(mixed $value, string $mode): string
+ {
+ $items = array_map(fn (mixed $v): string => $this->export($v, $mode), (array) $value);
+ return implode(', ', $items);
+ }
+
+ private function export(mixed $value, string $mode): string
+ {
+ if ($value === null || is_bool($value) || is_int($value)) {
+ return var_export($value, true);
+ }
+ if (is_array($value)) {
+ return $this->exportArray($value, $mode);
+ }
+ if (is_string($value)) {
+ if (preg_match('/^%([A-Za-z_][A-Za-z0-9_]*)%$/', $value, $m) === 1) {
+ if ($mode === self::GLOBAL) {
+ $this->warnings[] = sprintf(
+ 'Whole-value param "%s" became getenv(\'%s\'); getenv() reads real environment '
+ . 'variables only, not param-file values — verify it resolves.',
+ $value,
+ $m[1]
+ );
+ return sprintf("getenv('%s')", $m[1]);
+ }
+ return sprintf("\\Codeception\\Config\\Params::get('%s')", $m[1]);
+ }
+ if (preg_match('/%[\w.]+%/', $value) === 1) {
+ $this->warnings[] = sprintf(
+ 'Param placeholder kept verbatim: "%s" — migrate manually (embedded or unsupported param name).',
+ $value
+ );
+ }
+ }
+ return var_export($value, true);
+ }
+
+ /**
+ * @param array $value
+ */
+ private function exportArray(array $value, string $mode): string
+ {
+ if ($value === []) {
+ return '[]';
+ }
+ $isList = array_is_list($value);
+ $parts = [];
+ foreach ($value as $k => $v) {
+ $parts[] = $isList
+ ? $this->export($v, $mode)
+ : sprintf('%s => %s', var_export($k, true), $this->export($v, $mode));
+ }
+ return '[' . implode(', ', $parts) . ']';
+ }
+}
diff --git a/src/Codeception/Template/Acceptance.php b/src/Codeception/Template/Acceptance.php
index bb5df8c2a1..a2ab9dee7a 100644
--- a/src/Codeception/Template/Acceptance.php
+++ b/src/Codeception/Template/Acceptance.php
@@ -96,22 +96,27 @@ public function setup(): void
$this->createSuiteDirs($dir);
$this->sayInfo("Created test directories at {$dir}");
$this->ensureModules(['WebDriver']);
- $config = (new Template($this->configTemplate))
- ->place('url', $url)
- ->place('browser', $browser)
- ->place('baseDir', $dir)
- ->produce();
-
$namespace = rtrim($this->namespace, '\\');
- $config = "namespace: {$namespace}\nsupport_namespace: {$this->supportNamespace}\n" . $config;
- $this->createFile('codeception.yml', $config);
- $settings = Yaml::parse($config)['suites']['Acceptance'];
+ if ($this->isPhp()) {
+ $this->createFile('codeception.php', $this->phpConfig($namespace, $dir, $url, $browser));
+ $settings = $this->loadPhpSuiteSettings('Acceptance');
+ $this->sayInfo('Created global config codeception.php inside the root directory');
+ } else {
+ $config = (new Template($this->configTemplate))
+ ->place('url', $url)
+ ->place('browser', $browser)
+ ->place('baseDir', $dir)
+ ->produce();
+ $config = "namespace: {$namespace}\nsupport_namespace: {$this->supportNamespace}\n" . $config;
+ $this->createFile('codeception.yml', $config);
+ $settings = Yaml::parse($config)['suites']['Acceptance'];
+ $this->sayInfo('Created global config codeception.yml inside the root directory');
+ }
+
$settings['support_namespace'] = $this->supportNamespace;
$this->createActor('AcceptanceTester', $dir . DIRECTORY_SEPARATOR . 'Support', $settings);
- $this->sayInfo('Created global config codeception.yml inside the root directory');
-
$firstTest = (new Template($this->firstTest))
->place('namespace', $namespace)
->place('support_namespace', $this->supportNamespace)
@@ -130,4 +135,50 @@ public function setup(): void
$this->say("HINT: See https://codeception.com/docs/03-AcceptanceTests#retry");
$this->say('Happy testing!');
}
+
+ private function phpConfig(string $namespace, string $dir, string $url, string $browser): string
+ {
+ $namespace = $this->phpLiteral($namespace);
+ $support = $this->phpLiteral($this->supportNamespace);
+ $dir = $this->phpLiteral($dir);
+ $url = $this->phpLiteral($url);
+ $browser = $this->phpLiteral($browser);
+
+ return <<namespace('{$namespace}')
+ ->supportNamespace('{$support}')
+ ->paths(
+ tests: '{$dir}',
+ output: '{$dir}/_output',
+ data: '{$dir}/Support/Data',
+ support: '{$dir}/Support',
+ envs: '{$dir}/_envs',
+ )
+ ->extension(RunFailed::class)
+ ->params('env')
+ ->settings(shuffle: false, lint: true)
+ ->suite('Acceptance', SuiteConfig::create()
+ ->actor('AcceptanceTester')
+ ->path('.')
+ ->module('WebDriver', ['url' => '{$url}', 'browser' => '{$browser}'])
+ ->stepDecorators([
+ ConditionalAssertion::class,
+ TryTo::class,
+ Retry::class,
+ ]));
+
+EOF;
+ }
}
diff --git a/src/Codeception/Template/Api.php b/src/Codeception/Template/Api.php
index 344935a46d..6e45b96cec 100644
--- a/src/Codeception/Template/Api.php
+++ b/src/Codeception/Template/Api.php
@@ -66,21 +66,26 @@ public function setup(): void
$this->createSuiteDirs($dir);
$this->sayInfo("Created test directories at {$dir}");
$this->ensureModules(['REST', 'PhpBrowser']);
- $config = (new Template($this->configTemplate))
- ->place('url', $url)
- ->place('baseDir', $dir)
- ->produce();
-
$namespace = rtrim($this->namespace, '\\');
- $config = "namespace: $namespace\nsupport_namespace: {$this->supportNamespace}\n" . $config;
- $this->createFile('codeception.yml', $config);
- $settings = Yaml::parse($config)['suites']['Api'];
+ if ($this->isPhp()) {
+ $this->createFile('codeception.php', $this->phpConfig($namespace, $dir, $url));
+ $settings = $this->loadPhpSuiteSettings('Api');
+ $this->sayInfo('Created global config codeception.php inside the root directory');
+ } else {
+ $config = (new Template($this->configTemplate))
+ ->place('url', $url)
+ ->place('baseDir', $dir)
+ ->produce();
+ $config = "namespace: $namespace\nsupport_namespace: {$this->supportNamespace}\n" . $config;
+ $this->createFile('codeception.yml', $config);
+ $settings = Yaml::parse($config)['suites']['Api'];
+ $this->sayInfo('Created global config codeception.yml inside the root directory');
+ }
+
$settings['support_namespace'] = $this->supportNamespace;
$this->createActor('ApiTester', $dir . DIRECTORY_SEPARATOR . 'Support', $settings);
- $this->sayInfo('Created global config codeception.yml inside the root directory');
-
$firstTest = (new Template($this->firstTest))
->place('namespace', $namespace)
->place('support_namespace', $this->supportNamespace)
@@ -97,4 +102,38 @@ public function setup(): void
$this->say();
$this->say('Happy testing!');
}
+
+ private function phpConfig(string $namespace, string $dir, string $url): string
+ {
+ $namespace = $this->phpLiteral($namespace);
+ $support = $this->phpLiteral($this->supportNamespace);
+ $dir = $this->phpLiteral($dir);
+ $url = $this->phpLiteral($url);
+
+ return <<namespace('{$namespace}')
+ ->supportNamespace('{$support}')
+ ->paths(
+ tests: '{$dir}',
+ output: '{$dir}/_output',
+ data: '{$dir}/Support/Data',
+ support: '{$dir}/Support',
+ )
+ ->settings(shuffle: false, lint: true)
+ ->suite('Api', SuiteConfig::create()
+ ->actor('ApiTester')
+ ->path('.')
+ ->module('REST', ['url' => '{$url}'], depends: 'PhpBrowser')
+ ->stepDecorators(['Codeception\\Step\\AsJson']));
+
+EOF;
+ }
}
diff --git a/src/Codeception/Template/Bootstrap.php b/src/Codeception/Template/Bootstrap.php
index 64ca54ce35..a6baa45a97 100644
--- a/src/Codeception/Template/Bootstrap.php
+++ b/src/Codeception/Template/Bootstrap.php
@@ -18,6 +18,12 @@ class Bootstrap extends InitTemplate
protected string $outputDir = 'tests/_output';
protected string $namespace = 'Tests';
protected string $supportNamespace = 'Support';
+ protected bool $php = false;
+
+ private function ext(): string
+ {
+ return $this->php ? 'php' : 'yml';
+ }
public function setup(): void
{
@@ -32,9 +38,11 @@ public function setup(): void
$this->actorSuffix = $input->getOption('actor');
}
+ $this->php = $input->hasOption('php') && (bool) $input->getOption('php');
+
$this->say(" Bootstrapping Codeception \n");
$this->createGlobalConfig();
- $this->say("File codeception.yml created <- global configuration");
+ $this->say('File codeception.' . $this->ext() . ' created <- global configuration');
$this->createDirs();
@@ -54,9 +62,10 @@ public function setup(): void
$this->say();
$this->saySuccess('Codeception is installed for acceptance, functional, and unit testing');
$this->say();
+ $ext = $this->ext();
$this->say('Next steps:');
- $this->say('1. Edit tests/Acceptance.suite.yml to set url of your application. Change PhpBrowser to WebDriver to enable browser testing');
- $this->say("2. Edit tests/Functional.suite.yml to enable a framework module. Remove this file if you don't use a framework");
+ $this->say("1. Edit tests/Acceptance.suite.{$ext} to set url of your application. Change PhpBrowser to WebDriver to enable browser testing");
+ $this->say("2. Edit tests/Functional.suite.{$ext} to enable a framework module. Remove this file if you don't use a framework");
$this->say('3. Create your first acceptance tests using codecept g:cest Acceptance First');
$this->say('4. Write first test in tests/Acceptance/FirstCest.php');
$this->say('5. Run tests using: codecept run');
@@ -90,9 +99,22 @@ protected function createFunctionalSuite(string $actor = 'Functional'): void
step_decorators: ~
EOF;
- $this->createSuite('Functional', $actor, $config);
+ $phpConfig = <<actor('{$this->phpLiteral($actor . $this->actorSuffix)}')
+ // ->module('Symfony') <- add a framework module here
+ ->stepDecorators(null);
+
+EOF;
+ $this->createSuite('Functional', $actor, $config, $phpConfig);
$this->say("tests/Functional/ created <- functional tests");
- $this->say("tests/Functional.suite.yml written <- functional test suite configuration");
+ $this->say("tests/Functional.suite.{$this->ext()} written <- functional test suite configuration");
}
protected function createAcceptanceSuite(string $actor = 'Acceptance'): void
@@ -115,9 +137,29 @@ protected function createAcceptanceSuite(string $actor = 'Acceptance'): void
- Codeception\Step\Retry
EOF;
- $this->createSuite('Acceptance', $actor, $config);
+ $phpConfig = <<actor('{$this->phpLiteral($actor . $this->actorSuffix)}')
+ ->module('PhpBrowser', ['url' => 'http://localhost/myapp'])
+ ->stepDecorators([
+ ConditionalAssertion::class,
+ TryTo::class,
+ Retry::class,
+ ]);
+
+EOF;
+ $this->createSuite('Acceptance', $actor, $config, $phpConfig);
$this->say("tests/Acceptance/ created <- acceptance tests");
- $this->say("tests/Acceptance.suite.yml written <- acceptance test suite configuration");
+ $this->say("tests/Acceptance.suite.{$this->ext()} written <- acceptance test suite configuration");
}
protected function createUnitSuite(string $actor = 'Unit'): void
@@ -134,13 +176,31 @@ protected function createUnitSuite(string $actor = 'Unit'): void
step_decorators: ~
EOF;
- $this->createSuite('Unit', $actor, $config);
+ $phpConfig = <<actor('{$this->phpLiteral($actor . $this->actorSuffix)}')
+ ->module('Asserts')
+ ->stepDecorators(null);
+
+EOF;
+ $this->createSuite('Unit', $actor, $config, $phpConfig);
$this->say("tests/Unit/ created <- unit tests");
- $this->say("tests/Unit.suite.yml written <- unit test suite configuration");
+ $this->say("tests/Unit.suite.{$this->ext()} written <- unit test suite configuration");
}
public function createGlobalConfig(): void
{
+ if ($this->php) {
+ $this->createFile('codeception.php', $this->globalPhpConfig());
+ return;
+ }
+
$config = [
'support_namespace' => $this->supportNamespace,
'paths' => [
@@ -161,13 +221,54 @@ public function createGlobalConfig(): void
$this->createFile('codeception.yml', $yaml);
}
- protected function createSuite(string $name, string $actor, string $config): void
+ protected function globalPhpConfig(): string
+ {
+ $namespaceLine = $this->namespace ? "\n ->namespace('{$this->phpLiteral($this->namespace)}')" : '';
+ $support = $this->phpLiteral($this->supportNamespace);
+ $output = $this->phpLiteral($this->outputDir);
+ $data = $this->phpLiteral($this->dataDir);
+ $supportDir = $this->phpLiteral($this->supportDir);
+ $envs = $this->phpLiteral($this->envsDir);
+
+ return <<supportNamespace('{$support}')
+ ->paths(
+ tests: 'tests',
+ output: '{$output}',
+ data: '{$data}',
+ support: '{$supportDir}',
+ envs: '{$envs}',
+ )
+ ->actorSuffix('Tester')
+ ->extension(RunFailed::class);
+
+EOF;
+ }
+
+ protected function createSuite(string $name, string $actor, string $config, ?string $phpConfig = null): void
{
- $settings = Yaml::parse($config);
- $settings['support_namespace'] = $this->supportNamespace;
$dir = 'tests' . DIRECTORY_SEPARATOR . $name;
- $this->createDirectoryFor($dir, "{$name}.suite.yml");
+ $filename = "{$name}.suite." . $this->ext();
+ $this->createDirectoryFor($dir, $filename);
+ $file = 'tests' . DIRECTORY_SEPARATOR . $filename;
+ $this->createFile($file, $this->php ? (string) $phpConfig : $config);
+
+ if ($this->php) {
+ /** @var \Codeception\Config\SuiteConfig $suiteConfig */
+ $suiteConfig = require getcwd() . DIRECTORY_SEPARATOR . $file;
+ $settings = $suiteConfig->toArray();
+ } else {
+ $settings = Yaml::parse($config);
+ }
+ $settings['support_namespace'] = $this->supportNamespace;
$this->createActor($actor . $this->actorSuffix, $this->supportDir, $settings);
- $this->createFile('tests' . DIRECTORY_SEPARATOR . "{$name}.suite.yml", $config);
}
}
diff --git a/src/Codeception/Template/Shared/TemplateHelpersTrait.php b/src/Codeception/Template/Shared/TemplateHelpersTrait.php
index 4fae0cc472..64224dfb03 100644
--- a/src/Codeception/Template/Shared/TemplateHelpersTrait.php
+++ b/src/Codeception/Template/Shared/TemplateHelpersTrait.php
@@ -6,6 +6,18 @@
trait TemplateHelpersTrait
{
+ protected function isPhp(): bool
+ {
+ return $this->input->hasOption('php') && (bool) $this->input->getOption('php');
+ }
+
+ protected function loadPhpSuiteSettings(string $suite): array
+ {
+ /** @var \Codeception\Config\GlobalConfig $config */
+ $config = require getcwd() . DIRECTORY_SEPARATOR . 'codeception.php';
+ return $config->toArray()['suites'][$suite];
+ }
+
protected function createSuiteDirs(string $dir): void
{
$paths = ['_output','Support','Support/Data','Support/_generated'];
diff --git a/src/Codeception/Template/Unit.php b/src/Codeception/Template/Unit.php
index 96c5e03b69..8a9a5eac03 100644
--- a/src/Codeception/Template/Unit.php
+++ b/src/Codeception/Template/Unit.php
@@ -56,19 +56,29 @@ public function setup(): void
$this->createSuiteDirs($dir);
$this->sayInfo("Created test directory at {$dir}");
- $config = (new Template($this->configTemplate))
- ->place('baseDir', $dir)
- ->place('tester', $haveTester ? $this->testerAndModules : '')
- ->produce();
-
- $namespace = rtrim($this->namespace, '\\');
- $config = "namespace: {$namespace}\nsupport_namespace: {$this->supportNamespace}\n" . $config;
- $this->createFile('codeception.yml', $config);
- $this->ensureModules(['Asserts']);
- if ($haveTester) {
- $settings = Yaml::parse($config)['suites']['Unit'];
- $settings['support_namespace'] = $this->supportNamespace;
- $this->createActor('UnitTester', $dir . DIRECTORY_SEPARATOR . 'Support', $settings);
+ $namespace = rtrim($this->namespace, '\\');
+
+ if ($this->isPhp()) {
+ $this->createFile('codeception.php', $this->phpConfig($namespace, $dir, (bool) $haveTester));
+ $this->ensureModules(['Asserts']);
+ if ($haveTester) {
+ $settings = $this->loadPhpSuiteSettings('Unit');
+ $settings['support_namespace'] = $this->supportNamespace;
+ $this->createActor('UnitTester', $dir . DIRECTORY_SEPARATOR . 'Support', $settings);
+ }
+ } else {
+ $config = (new Template($this->configTemplate))
+ ->place('baseDir', $dir)
+ ->place('tester', $haveTester ? $this->testerAndModules : '')
+ ->produce();
+ $config = "namespace: {$namespace}\nsupport_namespace: {$this->supportNamespace}\n" . $config;
+ $this->createFile('codeception.yml', $config);
+ $this->ensureModules(['Asserts']);
+ if ($haveTester) {
+ $settings = Yaml::parse($config)['suites']['Unit'];
+ $settings['support_namespace'] = $this->supportNamespace;
+ $this->createActor('UnitTester', $dir . DIRECTORY_SEPARATOR . 'Support', $settings);
+ }
}
$this->saySuccess('INSTALLATION COMPLETE');
@@ -87,4 +97,36 @@ public function setup(): void
$this->say('2. Run tests: codecept run');
$this->say('Happy testing!');
}
+
+ private function phpConfig(string $namespace, string $dir, bool $haveTester): string
+ {
+ $namespaceLine = $namespace !== '' ? "\n ->namespace('{$this->phpLiteral($namespace)}')" : '';
+ $support = $this->phpLiteral($this->supportNamespace);
+ $dir = $this->phpLiteral($dir);
+ $suite = "SuiteConfig::create()\n ->path('.')";
+ if ($haveTester) {
+ $suite .= "\n ->actor('UnitTester')\n ->module('Asserts')\n ->stepDecorators(null)";
+ }
+
+ return <<supportNamespace('{$support}')
+ ->paths(
+ tests: '{$dir}',
+ output: '{$dir}/_output',
+ support: '{$dir}/Support',
+ data: '{$dir}/Support/Data',
+ )
+ ->settings(shuffle: true, lint: true)
+ ->suite('Unit', {$suite});
+
+EOF;
+ }
}
diff --git a/tests/cli/BootstrapCest.php b/tests/cli/BootstrapCest.php
index 97e941a790..f9d6825ba1 100644
--- a/tests/cli/BootstrapCest.php
+++ b/tests/cli/BootstrapCest.php
@@ -65,6 +65,33 @@ public function bootstrapEmpty(CliTester $I)
$I->seeFileFound('codeception.yml');
}
+ public function bootstrapPhp(CliTester $I)
+ {
+ $I->executeCommand('bootstrap --php');
+ $I->seeFileFound('codeception.php');
+ $I->dontSeeFileFound('codeception.yml');
+ $I->seeFileFound('Functional.suite.php', 'tests');
+ $I->seeFileFound('Acceptance.suite.php', 'tests');
+ $I->seeFileFound('Unit.suite.php', 'tests');
+ $I->seeFileFound('AcceptanceTester.php', 'tests/Support');
+ $I->seeFileFound('FunctionalTester.php', 'tests/Support');
+ $I->seeFileFound('UnitTester.php', 'tests/Support');
+ }
+
+ public function bootstrapPhpWithNamespaceIsValid(CliTester $I)
+ {
+ $I->executeCommand('bootstrap --php --namespace Generated');
+ $I->seeFileFound('codeception.php');
+ $I->seeInThisFile("->namespace('Generated')");
+ $I->seeFileFound('AcceptanceTester.php', 'tests/Support');
+ $I->seeInThisFile('namespace Generated\\Support;');
+
+ $I->executeCommand('config:validate', false);
+ $I->dontSeeInShellOutput('ConfigurationException');
+ $I->seeInShellOutput('Loaded config file');
+ $I->seeInShellOutput('codeception.php');
+ }
+
public function bootstrapFromInit(CliTester $I)
{
$I->executeCommand('init bootstrap');
diff --git a/tests/cli/ConfigPhpFormatCest.php b/tests/cli/ConfigPhpFormatCest.php
new file mode 100644
index 0000000000..9f3e333456
--- /dev/null
+++ b/tests/cli/ConfigPhpFormatCest.php
@@ -0,0 +1,137 @@
+amInPath('tests/data/sandbox');
+ }
+
+ public function validatesPhpGlobalConfig(CliTester $I)
+ {
+ $I->executeCommand('config:validate -c php_config/codeception.php --no-ansi', false);
+ $I->dontSeeInShellOutput('ConfigurationException');
+ $I->seeInShellOutput('tests => tests');
+ $I->seeInShellOutput('FROM_PHP');
+ }
+
+ public function discoversAndValidatesPhpSuiteConfig(CliTester $I)
+ {
+ $I->executeCommand('config:validate Sample -c php_config/codeception.php --no-ansi', false);
+ $I->dontSeeInShellOutput('ConfigurationException');
+ $I->seeInShellOutput('Asserts');
+ }
+
+ public function phpConfigWinsOverYamlOnDiscovery(CliTester $I)
+ {
+ $I->executeCommand('config:validate -c php_config --no-ansi', false);
+ $I->seeInShellOutput('FROM_PHP');
+ $I->dontSeeInShellOutput('FROM_YAML');
+ }
+
+ public function reportsInvalidPhpConfigCleanly(CliTester $I)
+ {
+ $I->executeCommand('config:validate -c php_config/codeception_invalid.php --no-ansi', false);
+ $I->seeInShellOutput('must return an array');
+ $I->seeInShellOutput('codeception_invalid.php');
+ }
+
+ public function runsTestsThroughPhpConfig(CliTester $I)
+ {
+ $I->executeCommand('build -c php_config/codeception.php --no-ansi', false);
+ $I->executeCommand('run Sample -c php_config/codeception.php --no-ansi', false);
+ $I->seeInShellOutput('OK (1 test');
+ $I->dontSeeInShellOutput('ConfigurationException');
+ }
+
+ public function phpEnvConfigWinsOverYaml(CliTester $I)
+ {
+ $I->executeCommand('config:validate Sample -c php_config/codeception.php --no-ansi', false);
+ $I->seeInShellOutput('ENV_FROM_PHP');
+ $I->dontSeeInShellOutput('ENV_FROM_YAML');
+ }
+
+ public function reportsWrongBuilderTypeCleanly(CliTester $I)
+ {
+ $I->executeCommand('config:validate -c php_config/codeception_wrongtype.php --no-ansi', false);
+ $I->seeInShellOutput('must return a Codeception\Config\GlobalConfig instance');
+ $I->seeInShellOutput('SuiteConfig');
+ }
+
+ public function swallowsOutputEmittedByConfigFile(CliTester $I)
+ {
+ $I->executeCommand('config:validate -c php_config/codeception_echo.php --no-ansi', false);
+ $I->dontSeeInShellOutput('LEAK_OUTPUT_MARKER');
+ $I->seeInShellOutput('FROM_PHP');
+ }
+
+ public function supportsCrossFormatExtends(CliTester $I)
+ {
+ $I->executeCommand('config:validate -c php_config/codeception_extends.php --no-ansi', false);
+ $I->dontSeeInShellOutput('ConfigurationException');
+ $I->seeInShellOutput('fromPreset');
+ }
+
+ public function rejectsCrossTypeExtendsPreset(CliTester $I)
+ {
+ $I->executeCommand('config:validate -c php_config/codeception_badextends.php --no-ansi', false);
+ $I->seeInShellOutput('got Codeception\Config\SuiteConfig');
+ }
+
+ public function reportsLoadedConfigFile(CliTester $I)
+ {
+ $I->executeCommand('config:validate -c php_config/codeception.php --no-ansi', false);
+ $I->seeInShellOutput('Loaded config file');
+ $I->seeInShellOutput('codeception.php');
+ }
+
+ public function migratesYamlProjectToPhpAndRuns(CliTester $I)
+ {
+ $I->executeCommand('config:to-php php_migrate --no-ansi', false);
+ $I->seeInShellOutput('Created');
+ $I->seeFileFound('Sample.suite.php', 'php_migrate/tests');
+ $I->seeFileFound('codeception.php', 'php_migrate');
+ $I->seeInThisFile('GlobalConfig::create()');
+ $I->seeInThisFile('->merge(');
+ $I->dontSeeInThisFile('->env(');
+
+ $I->executeCommand('build -c php_migrate/codeception.php --no-ansi', false);
+ $I->executeCommand('run Sample -c php_migrate/codeception.php --no-ansi', false);
+ $I->seeInShellOutput('OK (1 test');
+ $I->dontSeeInShellOutput('ConfigurationException');
+ }
+
+ public function migratesDistOnlyProject(CliTester $I)
+ {
+ $I->executeCommand('config:to-php php_dist_only --dry-run --no-ansi', false);
+ $I->dontSeeInShellOutput('Global config not found');
+ $I->seeInShellOutput('GlobalConfig::create()');
+ $I->seeInShellOutput('SuiteConfig::create()');
+ }
+
+ public function generateEnvironmentRefusesToShadowExistingYaml(CliTester $I)
+ {
+ $I->amInPath('php_config');
+ $I->executeCommand('generate:environment legacy --no-ansi', false);
+ $I->seeInShellOutput('shadow');
+ $I->dontSeeFileFound('legacy.php', 'tests/_envs');
+ }
+
+ public function inlineSuiteShadowsSuiteFile(CliTester $I)
+ {
+ $I->executeCommand('config:validate Sample -c php_config/codeception_singlefile.php --no-ansi', false);
+ $I->dontSeeInShellOutput('ConfigurationException');
+ $I->seeInShellOutput('INLINE_WINS');
+ }
+
+ public function migrationDryRunPrintsBuilderWithoutWriting(CliTester $I)
+ {
+ $I->executeCommand('config:to-php php_config --dry-run --no-ansi', false);
+ $I->seeInShellOutput('GlobalConfig::create()');
+ $I->seeInShellOutput("->namespace('PhpConfig')");
+ }
+}
diff --git a/tests/data/claypit/php_config/codeception.php b/tests/data/claypit/php_config/codeception.php
new file mode 100644
index 0000000000..45293d48ea
--- /dev/null
+++ b/tests/data/claypit/php_config/codeception.php
@@ -0,0 +1,18 @@
+namespace('PhpConfig')
+ ->supportNamespace('Support')
+ ->paths(
+ tests: 'tests',
+ output: 'tests/_output',
+ data: 'tests/_data',
+ support: 'tests/_support',
+ envs: 'tests/_envs',
+ )
+ ->settings(colors: false, lint: false)
+ ->merge(['marker' => 'FROM_PHP']);
diff --git a/tests/data/claypit/php_config/codeception.yml b/tests/data/claypit/php_config/codeception.yml
new file mode 100644
index 0000000000..4e4bcd8d08
--- /dev/null
+++ b/tests/data/claypit/php_config/codeception.yml
@@ -0,0 +1,11 @@
+namespace: PhpConfig
+support_namespace: Support
+paths:
+ tests: tests
+ output: tests/_output
+ data: tests/_data
+ support: tests/_support
+settings:
+ colors: false
+ lint: false
+marker: FROM_YAML
diff --git a/tests/data/claypit/php_config/codeception_badextends.php b/tests/data/claypit/php_config/codeception_badextends.php
new file mode 100644
index 0000000000..a3288a2cff
--- /dev/null
+++ b/tests/data/claypit/php_config/codeception_badextends.php
@@ -0,0 +1,17 @@
+namespace('PhpConfig')
+ ->supportNamespace('Support')
+ ->paths(
+ tests: 'tests',
+ output: 'tests/_output',
+ data: 'tests/_data',
+ support: 'tests/_support',
+ )
+ ->settings(colors: false, lint: false)
+ ->extends('codeception_wrongtype.php');
diff --git a/tests/data/claypit/php_config/codeception_echo.php b/tests/data/claypit/php_config/codeception_echo.php
new file mode 100644
index 0000000000..2ff3856a3c
--- /dev/null
+++ b/tests/data/claypit/php_config/codeception_echo.php
@@ -0,0 +1,19 @@
+namespace('PhpConfig')
+ ->supportNamespace('Support')
+ ->paths(
+ tests: 'tests',
+ output: 'tests/_output',
+ data: 'tests/_data',
+ support: 'tests/_support',
+ )
+ ->settings(colors: false, lint: false)
+ ->merge(['marker' => 'FROM_PHP']);
diff --git a/tests/data/claypit/php_config/codeception_extends.php b/tests/data/claypit/php_config/codeception_extends.php
new file mode 100644
index 0000000000..dcaa3aac94
--- /dev/null
+++ b/tests/data/claypit/php_config/codeception_extends.php
@@ -0,0 +1,17 @@
+namespace('PhpConfig')
+ ->supportNamespace('Support')
+ ->paths(
+ tests: 'tests',
+ output: 'tests/_output',
+ data: 'tests/_data',
+ support: 'tests/_support',
+ )
+ ->settings(colors: false, lint: false)
+ ->extends('preset_base.yml');
diff --git a/tests/data/claypit/php_config/codeception_invalid.php b/tests/data/claypit/php_config/codeception_invalid.php
new file mode 100644
index 0000000000..188daf9ed0
--- /dev/null
+++ b/tests/data/claypit/php_config/codeception_invalid.php
@@ -0,0 +1,3 @@
+namespace('PhpConfig')
+ ->supportNamespace('Support')
+ ->paths(
+ tests: 'tests',
+ output: 'tests/_output',
+ data: 'tests/_data',
+ support: 'tests/_support',
+ )
+ ->settings(colors: false, lint: false)
+ ->suite('Sample', SuiteConfig::create()
+ ->actor('SampleTester')
+ ->module('Asserts')
+ ->merge(['inline_marker' => 'INLINE_WINS']));
diff --git a/tests/data/claypit/php_config/codeception_wrongtype.php b/tests/data/claypit/php_config/codeception_wrongtype.php
new file mode 100644
index 0000000000..540c83dbb0
--- /dev/null
+++ b/tests/data/claypit/php_config/codeception_wrongtype.php
@@ -0,0 +1,7 @@
+actor('Nope');
diff --git a/tests/data/claypit/php_config/preset_base.yml b/tests/data/claypit/php_config/preset_base.yml
new file mode 100644
index 0000000000..703d01c7fc
--- /dev/null
+++ b/tests/data/claypit/php_config/preset_base.yml
@@ -0,0 +1,3 @@
+groups:
+ fromPreset:
+ - tests/PresetGroup
diff --git a/tests/data/claypit/php_config/tests/Sample.suite.php b/tests/data/claypit/php_config/tests/Sample.suite.php
new file mode 100644
index 0000000000..28b42a71e2
--- /dev/null
+++ b/tests/data/claypit/php_config/tests/Sample.suite.php
@@ -0,0 +1,9 @@
+actor('SampleTester')
+ ->module('Asserts');
diff --git a/tests/data/claypit/php_config/tests/Sample/SampleCest.php b/tests/data/claypit/php_config/tests/Sample/SampleCest.php
new file mode 100644
index 0000000000..8819565e77
--- /dev/null
+++ b/tests/data/claypit/php_config/tests/Sample/SampleCest.php
@@ -0,0 +1,15 @@
+assertTrue(true);
+ }
+}
diff --git a/tests/data/claypit/php_config/tests/_envs/legacy.yml b/tests/data/claypit/php_config/tests/_envs/legacy.yml
new file mode 100644
index 0000000000..3f2027a3b1
--- /dev/null
+++ b/tests/data/claypit/php_config/tests/_envs/legacy.yml
@@ -0,0 +1 @@
+env_marker: LEGACY_YAML_ONLY
diff --git a/tests/data/claypit/php_config/tests/_envs/staging.php b/tests/data/claypit/php_config/tests/_envs/staging.php
new file mode 100644
index 0000000000..b842053e75
--- /dev/null
+++ b/tests/data/claypit/php_config/tests/_envs/staging.php
@@ -0,0 +1,8 @@
+merge(['env_marker' => 'ENV_FROM_PHP']);
diff --git a/tests/data/claypit/php_config/tests/_envs/staging.yml b/tests/data/claypit/php_config/tests/_envs/staging.yml
new file mode 100644
index 0000000000..794fe69523
--- /dev/null
+++ b/tests/data/claypit/php_config/tests/_envs/staging.yml
@@ -0,0 +1 @@
+env_marker: ENV_FROM_YAML
diff --git a/tests/data/claypit/php_dist_only/codeception.dist.yml b/tests/data/claypit/php_dist_only/codeception.dist.yml
new file mode 100644
index 0000000000..ad7c5e9352
--- /dev/null
+++ b/tests/data/claypit/php_dist_only/codeception.dist.yml
@@ -0,0 +1,9 @@
+namespace: DistOnly
+support_namespace: Support
+paths:
+ tests: tests
+ output: tests/_output
+ data: tests/_data
+ support: tests/_support
+settings:
+ colors: false
diff --git a/tests/data/claypit/php_dist_only/tests/Sample.suite.yml b/tests/data/claypit/php_dist_only/tests/Sample.suite.yml
new file mode 100644
index 0000000000..04395dbc55
--- /dev/null
+++ b/tests/data/claypit/php_dist_only/tests/Sample.suite.yml
@@ -0,0 +1,4 @@
+actor: SampleTester
+modules:
+ enabled:
+ - Asserts
diff --git a/tests/data/claypit/php_migrate/codeception.yml b/tests/data/claypit/php_migrate/codeception.yml
new file mode 100644
index 0000000000..74286675e7
--- /dev/null
+++ b/tests/data/claypit/php_migrate/codeception.yml
@@ -0,0 +1,14 @@
+namespace: Migrated
+support_namespace: Support
+paths:
+ tests: tests
+ output: tests/_output
+ data: tests/_data
+ support: tests/_support
+settings:
+ colors: false
+ shuffle: true
+env:
+ dev:
+ groups:
+ fast: [tests/Sample]
diff --git a/tests/data/claypit/php_migrate/tests/Sample.suite.yml b/tests/data/claypit/php_migrate/tests/Sample.suite.yml
new file mode 100644
index 0000000000..04395dbc55
--- /dev/null
+++ b/tests/data/claypit/php_migrate/tests/Sample.suite.yml
@@ -0,0 +1,4 @@
+actor: SampleTester
+modules:
+ enabled:
+ - Asserts
diff --git a/tests/data/claypit/php_migrate/tests/Sample/MigratedCest.php b/tests/data/claypit/php_migrate/tests/Sample/MigratedCest.php
new file mode 100644
index 0000000000..9f06acfc66
--- /dev/null
+++ b/tests/data/claypit/php_migrate/tests/Sample/MigratedCest.php
@@ -0,0 +1,15 @@
+assertTrue(true);
+ }
+}
diff --git a/tests/unit/Codeception/Config/ConfigDriftTest.php b/tests/unit/Codeception/Config/ConfigDriftTest.php
new file mode 100644
index 0000000000..db7500d843
--- /dev/null
+++ b/tests/unit/Codeception/Config/ConfigDriftTest.php
@@ -0,0 +1,101 @@
+ 'actorSuffix',
+ 'support_namespace' => 'supportNamespace',
+ 'namespace' => 'namespace',
+ 'include' => 'include',
+ 'paths' => 'paths',
+ 'extends' => 'extends',
+ 'suites' => 'suite',
+ 'modules' => 'module',
+ 'extensions' => 'extension',
+ 'groups' => 'groups',
+ 'bootstrap' => 'bootstrap',
+ 'settings' => 'settings',
+ 'coverage' => 'coverage',
+ 'params' => 'params',
+ 'gherkin' => 'gherkin',
+ ];
+
+ private const SUITE_KEY_METHOD = [
+ 'actor' => 'actor',
+ 'modules' => 'module',
+ 'step_decorators' => 'stepDecorators',
+ 'path' => 'path',
+ 'extends' => 'extends',
+ 'namespace' => 'namespace',
+ 'groups' => 'groups',
+ 'formats' => 'formats',
+ 'shuffle' => 'shuffle',
+ 'extensions' => 'extension',
+ 'error_level' => 'errorLevel',
+ 'convert_deprecations_to_exceptions' => 'convertDeprecationsToExceptions',
+ ];
+
+ public function testEveryGlobalKeyHasABuilderMethod(): void
+ {
+ foreach (array_keys(Configuration::$defaultConfig) as $key) {
+ $this->assertArrayHasKey($key, self::GLOBAL_KEY_METHOD, "GlobalConfig has no method mapped for '{$key}'");
+ $this->assertTrue($this->hasMethod(GlobalConfig::class, self::GLOBAL_KEY_METHOD[$key]));
+ }
+ }
+
+ public function testEverySuiteKeyHasABuilderMethod(): void
+ {
+ foreach (array_keys(Configuration::$defaultSuiteSettings) as $key) {
+ $this->assertArrayHasKey($key, self::SUITE_KEY_METHOD, "SuiteConfig has no method mapped for '{$key}'");
+ $this->assertTrue($this->hasMethod(SuiteConfig::class, self::SUITE_KEY_METHOD[$key]));
+ }
+ }
+
+ public function testSettingsMethodCoversEverySettingKey(): void
+ {
+ $covered = [];
+ foreach ((new ReflectionMethod(GlobalConfig::class, 'settings'))->getParameters() as $parameter) {
+ $covered[] = strtolower((string) preg_replace('/([a-z0-9])([A-Z])/', '$1_$2', $parameter->getName()));
+ }
+
+ foreach (array_keys(Configuration::$defaultConfig['settings']) as $key) {
+ $this->assertContains($key, $covered, "settings() has no named parameter for '{$key}'");
+ }
+ }
+
+ public function testPhpConfigFileSettingsMapCoversEverySettingKey(): void
+ {
+ $map = (new ReflectionClassConstant(PhpConfigFile::class, 'SETTINGS_MAP'))->getValue();
+
+ foreach (array_keys(Configuration::$defaultConfig['settings']) as $key) {
+ $this->assertArrayHasKey($key, $map, "PhpConfigFile::SETTINGS_MAP has no entry for setting '{$key}'");
+ }
+
+ $params = [];
+ foreach ((new ReflectionMethod(GlobalConfig::class, 'settings'))->getParameters() as $parameter) {
+ $params[$parameter->getName()] = true;
+ }
+ foreach ($map as $snake => $camel) {
+ $this->assertArrayHasKey($camel, $params, "SETTINGS_MAP maps '{$snake}' to unknown settings() parameter '{$camel}'");
+ }
+ }
+
+ private function hasMethod(string $class, string $method): bool
+ {
+ return method_exists($class, $method) || method_exists(AbstractConfigBuilder::class, $method);
+ }
+}
diff --git a/tests/unit/Codeception/Config/GlobalConfigTest.php b/tests/unit/Codeception/Config/GlobalConfigTest.php
new file mode 100644
index 0000000000..cae2d5425b
--- /dev/null
+++ b/tests/unit/Codeception/Config/GlobalConfigTest.php
@@ -0,0 +1,121 @@
+assertInstanceOf(ConfigInterface::class, GlobalConfig::create());
+ }
+
+ public function testBuildsExpectedArray(): void
+ {
+ $config = GlobalConfig::create()
+ ->namespace('App\\Tests')
+ ->supportNamespace('Support')
+ ->paths(tests: 'tests', output: 'tests/_output')
+ ->actorSuffix('Tester')
+ ->extension(RunFailed::class)
+ ->module('Db', ['dsn' => 'sqlite::memory:'])
+ ->module('Asserts')
+ ->settings(shuffle: true, colors: false, reportUselessTests: true)
+ ->params('.env', '.env.test')
+ ->toArray();
+
+ $this->assertSame('App\\Tests', $config['namespace']);
+ $this->assertSame('Support', $config['support_namespace']);
+ $this->assertSame(['tests' => 'tests', 'output' => 'tests/_output'], $config['paths']);
+ $this->assertSame('Tester', $config['actor_suffix']);
+ $this->assertSame([RunFailed::class], $config['extensions']['enabled']);
+ $this->assertSame([['Db' => ['dsn' => 'sqlite::memory:']], 'Asserts'], $config['modules']['enabled']);
+ $this->assertSame(['shuffle' => true, 'colors' => false, 'report_useless_tests' => true], $config['settings']);
+ $this->assertSame(['.env', '.env.test'], $config['params']);
+ }
+
+ public function testSettingsCamelCaseMapsToSnakeCase(): void
+ {
+ $config = GlobalConfig::create()
+ ->settings(memoryLimit: '1G', backupGlobals: false, beStrictAboutChangesToGlobalState: true)
+ ->toArray();
+
+ $this->assertSame([
+ 'memory_limit' => '1G',
+ 'backup_globals' => false,
+ 'be_strict_about_changes_to_global_state' => true,
+ ], $config['settings']);
+ }
+
+ public function testSettingsOmitsNullArguments(): void
+ {
+ $config = GlobalConfig::create()->settings(shuffle: true)->toArray();
+
+ $this->assertSame(['shuffle' => true], $config['settings']);
+ }
+
+ public function testModuleConfigAndCommands(): void
+ {
+ $config = GlobalConfig::create()
+ ->moduleConfig('Db', ['populate' => true])
+ ->commands('My\\Command', 'My\\Other')
+ ->toArray();
+
+ $this->assertSame(['Db' => ['populate' => true]], $config['modules']['config']);
+ $this->assertSame(['My\\Command', 'My\\Other'], $config['extensions']['commands']);
+ }
+
+ public function testInlineSuiteIsNormalizedToArray(): void
+ {
+ $config = GlobalConfig::create()
+ ->suite('Unit', SuiteConfig::create()->actor('UnitTester')->module('Asserts'))
+ ->toArray();
+
+ $this->assertSame(
+ ['actor' => 'UnitTester', 'modules' => ['enabled' => ['Asserts']]],
+ $config['suites']['Unit']
+ );
+ }
+
+ public function testInlineParamMapIsAllowed(): void
+ {
+ $config = GlobalConfig::create()->params(['token' => 'abc'], '.env')->toArray();
+
+ $this->assertSame([['token' => 'abc'], '.env'], $config['params']);
+ }
+
+ public function testOnlySetKeysAreEmitted(): void
+ {
+ $this->assertSame(['namespace' => 'X'], GlobalConfig::create()->namespace('X')->toArray());
+ }
+
+ public function testMergeOverridesWinLast(): void
+ {
+ $config = GlobalConfig::create()
+ ->namespace('A')
+ ->merge(['namespace' => 'B', 'custom' => 123])
+ ->toArray();
+
+ $this->assertSame('B', $config['namespace']);
+ $this->assertSame(123, $config['custom']);
+ }
+
+ public function testExtensionWithConfig(): void
+ {
+ $config = GlobalConfig::create()->extension('My\\Ext', ['opt' => 1])->toArray();
+
+ $this->assertSame(['My\\Ext'], $config['extensions']['enabled']);
+ $this->assertSame(['My\\Ext' => ['opt' => 1]], $config['extensions']['config']);
+ }
+
+ public function testEmptySuiteNameIsRejected(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ GlobalConfig::create()->suite('', []);
+ }
+}
diff --git a/tests/unit/Codeception/Config/ParamsTest.php b/tests/unit/Codeception/Config/ParamsTest.php
new file mode 100644
index 0000000000..4e1ca075d1
--- /dev/null
+++ b/tests/unit/Codeception/Config/ParamsTest.php
@@ -0,0 +1,43 @@
+property = new ReflectionProperty(Configuration::class, 'params');
+ $this->original = $this->property->getValue();
+ }
+
+ protected function tearDown(): void
+ {
+ $this->property->setValue(null, $this->original);
+ }
+
+ public function testGetReturnsLoadedParam(): void
+ {
+ $this->property->setValue(null, ['TOKEN' => 'abc']);
+
+ $this->assertSame('abc', Params::get('TOKEN'));
+ $this->assertSame('fallback', Params::get('MISSING', 'fallback'));
+ }
+
+ public function testGetThrowsWhenParamsNotLoaded(): void
+ {
+ $this->property->setValue(null, null);
+
+ $this->expectException(ConfigurationException::class);
+ $this->expectExceptionMessageMatches('/Params are not loaded/');
+ Params::get('TOKEN');
+ }
+}
diff --git a/tests/unit/Codeception/Config/PhpConfigFileTest.php b/tests/unit/Codeception/Config/PhpConfigFileTest.php
new file mode 100644
index 0000000000..ade4d89547
--- /dev/null
+++ b/tests/unit/Codeception/Config/PhpConfigFileTest.php
@@ -0,0 +1,155 @@
+ 'App\\Tests',
+ 'paths' => ['tests' => 'tests', 'output' => 'tests/_output'],
+ 'actor_suffix' => 'Tester',
+ 'settings' => ['shuffle' => true, 'colors' => false],
+ 'modules' => ['enabled' => ['Asserts', ['Db' => ['dsn' => 'sqlite::memory:']]], 'config' => ['Db' => ['populate' => true]]],
+ 'extensions' => ['enabled' => ['Codeception\\Extension\\RunFailed'], 'commands' => ['My\\Command']],
+ 'suites' => ['Unit' => ['actor' => 'UnitTester', 'modules' => ['enabled' => ['Asserts']]]],
+ 'groups' => ['slow' => ['tests/slow']],
+ ];
+
+ $loaded = $this->load((new PhpConfigFile())->renderGlobal($config));
+
+ $this->assertInstanceOf(GlobalConfig::class, $loaded);
+ $this->assertEquals($config, $loaded->toArray());
+ }
+
+ public function testSuiteRoundTripProducesEqualArray(): void
+ {
+ $config = [
+ 'actor' => 'FunctionalTester',
+ 'modules' => ['enabled' => [['Symfony' => ['app_path' => 'src']], ['Doctrine' => ['depends' => 'Symfony']]]],
+ 'step_decorators' => null,
+ 'error_level' => 'E_ALL & ~E_DEPRECATED',
+ ];
+
+ $loaded = $this->load((new PhpConfigFile())->renderSuite($config));
+
+ $this->assertInstanceOf(SuiteConfig::class, $loaded);
+ $this->assertEquals($config, $loaded->toArray());
+ }
+
+ public function testUnknownKeysRouteThroughMerge(): void
+ {
+ $source = (new PhpConfigFile())->renderGlobal(['reporters' => ['report' => 'Custom']]);
+
+ $this->assertStringContainsString('->merge(', $source);
+ $this->assertEquals(['reporters' => ['report' => 'Custom']], $this->load($source)->toArray());
+ }
+
+ public function testWholeValueParamBecomesGetenvInGlobal(): void
+ {
+ $source = (new PhpConfigFile())->renderGlobal(['modules' => ['enabled' => [['Db' => ['dsn' => '%DB_DSN%']]]]]);
+
+ $this->assertStringContainsString("getenv('DB_DSN')", $source);
+ }
+
+ public function testWholeValueParamBecomesParamsGetInSuite(): void
+ {
+ $renderer = new PhpConfigFile();
+ $source = $renderer->renderSuite(['modules' => ['enabled' => [['Db' => ['dsn' => '%DB_DSN%']]]]]);
+
+ $this->assertStringContainsString("\\Codeception\\Config\\Params::get('DB_DSN')", $source);
+ $this->assertSame([], $renderer->warnings());
+ }
+
+ public function testEmbeddedParamIsReported(): void
+ {
+ $renderer = new PhpConfigFile();
+ $renderer->renderSuite(['modules' => ['enabled' => [['Db' => ['dsn' => 'mysql://%HOST%/db']]]]]);
+
+ $this->assertNotEmpty($renderer->warnings());
+ }
+
+ public function testDottedParamNameIsReportedNotSilentlyDropped(): void
+ {
+ $renderer = new PhpConfigFile();
+ $source = $renderer->renderSuite(['modules' => ['enabled' => [['Db' => ['dsn' => '%db.host%']]]]]);
+
+ $this->assertStringContainsString('%db.host%', $source, 'kept verbatim');
+ $this->assertNotEmpty($renderer->warnings(), 'and the user is warned');
+ }
+
+ public function testSuiteOnlyKeysInGlobalRouteThroughMerge(): void
+ {
+ $config = [
+ 'env' => ['staging' => ['modules' => ['config' => ['Db' => ['dsn' => 'x']]]]],
+ 'formats' => ['Custom'],
+ ];
+
+ $loaded = $this->load((new PhpConfigFile())->renderGlobal($config));
+
+ $this->assertInstanceOf(GlobalConfig::class, $loaded);
+ $this->assertEquals($config, $loaded->toArray());
+ }
+
+ public function testGlobalOnlyKeysInSuiteRouteThroughMerge(): void
+ {
+ $config = [
+ 'gherkin' => ['contexts' => ['default' => ['App\\Context']]],
+ 'params' => ['.env'],
+ ];
+
+ $loaded = $this->load((new PhpConfigFile())->renderSuite($config));
+
+ $this->assertInstanceOf(SuiteConfig::class, $loaded);
+ $this->assertEquals($config, $loaded->toArray());
+ }
+
+ public function testModulesDisabledIsPreserved(): void
+ {
+ $config = ['modules' => ['enabled' => ['X'], 'disabled' => ['Y']]];
+
+ $loaded = $this->load((new PhpConfigFile())->renderGlobal($config));
+
+ $this->assertSame(['X'], $loaded->toArray()['modules']['enabled']);
+ $this->assertSame(['Y'], $loaded->toArray()['modules']['disabled']);
+ }
+
+ public function testNullModuleBodyRendersBareModule(): void
+ {
+ $loaded = $this->load((new PhpConfigFile())->renderSuite(['modules' => ['enabled' => [['X' => null]]]]));
+
+ $this->assertSame(['X'], $loaded->toArray()['modules']['enabled']);
+ }
+
+ public function testMultiModuleListEntryKeepsEveryModule(): void
+ {
+ $config = ['modules' => ['enabled' => [['Db' => ['a' => 1], 'Redis' => ['b' => 2]]]]];
+
+ $loaded = $this->load((new PhpConfigFile())->renderSuite($config));
+
+ $this->assertSame(
+ [['Db' => ['a' => 1]], ['Redis' => ['b' => 2]]],
+ $loaded->toArray()['modules']['enabled']
+ );
+ }
+
+ private function load(string $source): ConfigInterface
+ {
+ $file = codecept_output_dir() . 'render_' . uniqid() . '.php';
+ file_put_contents($file, $source);
+ try {
+ $result = (static fn () => require $file)();
+ } finally {
+ @unlink($file);
+ }
+ $this->assertInstanceOf(ConfigInterface::class, $result);
+ return $result;
+ }
+}
diff --git a/tests/unit/Codeception/Config/SuiteConfigTest.php b/tests/unit/Codeception/Config/SuiteConfigTest.php
new file mode 100644
index 0000000000..2c5a48451e
--- /dev/null
+++ b/tests/unit/Codeception/Config/SuiteConfigTest.php
@@ -0,0 +1,93 @@
+assertInstanceOf(ConfigInterface::class, SuiteConfig::create());
+ }
+
+ public function testBuildsExpectedArray(): void
+ {
+ $config = SuiteConfig::create()
+ ->actor('FunctionalTester')
+ ->module('Asserts')
+ ->module('Symfony', ['app_path' => 'src', 'environment' => 'test'])
+ ->module('Doctrine', ['cleanup' => true], depends: 'Symfony')
+ ->stepDecorators(null)
+ ->toArray();
+
+ $this->assertSame('FunctionalTester', $config['actor']);
+ $this->assertSame([
+ 'Asserts',
+ ['Symfony' => ['app_path' => 'src', 'environment' => 'test']],
+ ['Doctrine' => ['cleanup' => true, 'depends' => 'Symfony']],
+ ], $config['modules']['enabled']);
+ $this->assertNull($config['step_decorators']);
+ }
+
+ public function testDependsAcceptsList(): void
+ {
+ $config = SuiteConfig::create()
+ ->module('Doctrine', depends: ['Symfony', 'Db'])
+ ->toArray();
+
+ $this->assertSame([['Doctrine' => ['depends' => ['Symfony', 'Db']]]], $config['modules']['enabled']);
+ }
+
+ public function testDependsConflictIsRejected(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ SuiteConfig::create()->module('Doctrine', ['depends' => 'A'], depends: 'B');
+ }
+
+ public function testErrorLevelAcceptsNativeConstant(): void
+ {
+ $config = SuiteConfig::create()->errorLevel(E_ALL & ~E_DEPRECATED)->toArray();
+
+ $this->assertSame(E_ALL & ~E_DEPRECATED, $config['error_level']);
+ }
+
+ public function testShuffleErrorLevelAndDeprecations(): void
+ {
+ $config = SuiteConfig::create()
+ ->shuffle()
+ ->errorLevel('E_ALL')
+ ->convertDeprecationsToExceptions()
+ ->toArray();
+
+ $this->assertTrue($config['shuffle']);
+ $this->assertSame('E_ALL', $config['error_level']);
+ $this->assertTrue($config['convert_deprecations_to_exceptions']);
+ }
+
+ public function testEnvOverlayIsNormalized(): void
+ {
+ $config = SuiteConfig::create()
+ ->actor('X')
+ ->env('staging', SuiteConfig::create()->module('Db', ['dsn' => 'stage']))
+ ->toArray();
+
+ $this->assertSame(
+ ['modules' => ['enabled' => [['Db' => ['dsn' => 'stage']]]]],
+ $config['env']['staging']
+ );
+ }
+
+ public function testMergeMergesRawKeys(): void
+ {
+ $config = SuiteConfig::create()
+ ->actor('X')
+ ->merge(['groups' => ['g' => ['a']]])
+ ->toArray();
+
+ $this->assertSame('X', $config['actor']);
+ $this->assertSame(['g' => ['a']], $config['groups']);
+ }
+}