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']); + } +}