From bec42077f7bdf1fd74b85dd8a3208b496408d9cc Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Sat, 14 Sep 2024 17:22:33 +1000 Subject: [PATCH] Added `customize` command and updated `--no-install` instructions. --- CustomizeCommand.php | 75 +-- Plugin.php | 22 +- README.md | 466 +++++++++++------- customize.php | 3 +- rector.php | 4 - .../{expected => post_install}/.ignorecontent | 0 .../{expected => post_install}/composer.json | 0 .../{expected => post_install}/composer.lock | 0 .../{expected => post_install}/.ignorecontent | 0 .../{expected => post_install}/composer.json | 0 .../{expected => post_install}/composer.lock | 0 .../{expected => post_install}/.ignorecontent | 0 .../{expected => post_install}/composer.json | 0 .../{expected => post_install}/composer.lock | 0 .../no_install/1_before_install/composer.json | 18 + .../1_before_install}/customize.php | 4 +- .../no_install/2_post_install/.ignorecontent | 2 + .../no_install/2_post_install/composer.json | 18 + .../no_install/2_post_install/composer.lock | 1 + .../no_install/2_post_install/customize.php | 78 +++ .../3_post_customize/.ignorecontent | 2 + .../composer.json | 3 +- .../no_install/3_post_customize/composer.lock | 1 + .../Fixtures/no_install/base/composer.json | 18 +- .../Fixtures/no_install/base/customize.php | 12 +- .../no_install_sub_dir/base/composer.json | 24 - .../no_install_sub_dir/expected/composer.json | 10 - .../CreateProjectCommandNoInstallTest.php | 79 --- ...dInstallTest.php => CreateProjectTest.php} | 48 +- .../phpunit/Functional/CustomizerTestCase.php | 86 ++-- tests/phpunit/Traits/ReflectionTrait.php | 102 ---- tests/phpunit/Unit/ArrayUnsetDeepTest.php | 2 +- tests/phpunit/Unit/FilesTest.php | 16 +- tests/phpunit/Unit/SelfTest.php | 3 - 34 files changed, 565 insertions(+), 532 deletions(-) rename tests/phpunit/Fixtures/install/{expected => post_install}/.ignorecontent (100%) rename tests/phpunit/Fixtures/install/{expected => post_install}/composer.json (100%) rename tests/phpunit/Fixtures/install/{expected => post_install}/composer.lock (100%) rename tests/phpunit/Fixtures/install_additional_cleanup/{expected => post_install}/.ignorecontent (100%) rename tests/phpunit/Fixtures/install_additional_cleanup/{expected => post_install}/composer.json (100%) rename tests/phpunit/Fixtures/install_additional_cleanup/{expected => post_install}/composer.lock (100%) rename tests/phpunit/Fixtures/install_no_config_file/{expected => post_install}/.ignorecontent (100%) rename tests/phpunit/Fixtures/install_no_config_file/{expected => post_install}/composer.json (100%) rename tests/phpunit/Fixtures/install_no_config_file/{expected => post_install}/composer.lock (100%) create mode 100644 tests/phpunit/Fixtures/no_install/1_before_install/composer.json rename tests/phpunit/Fixtures/{no_install_sub_dir/base => no_install/1_before_install}/customize.php (93%) create mode 100644 tests/phpunit/Fixtures/no_install/2_post_install/.ignorecontent create mode 100644 tests/phpunit/Fixtures/no_install/2_post_install/composer.json create mode 100644 tests/phpunit/Fixtures/no_install/2_post_install/composer.lock create mode 100644 tests/phpunit/Fixtures/no_install/2_post_install/customize.php create mode 100644 tests/phpunit/Fixtures/no_install/3_post_customize/.ignorecontent rename tests/phpunit/Fixtures/no_install/{expected => 3_post_customize}/composer.json (81%) create mode 100644 tests/phpunit/Fixtures/no_install/3_post_customize/composer.lock delete mode 100644 tests/phpunit/Fixtures/no_install_sub_dir/base/composer.json delete mode 100644 tests/phpunit/Fixtures/no_install_sub_dir/expected/composer.json delete mode 100644 tests/phpunit/Functional/CreateProjectCommandNoInstallTest.php rename tests/phpunit/Functional/{CreateProjectCommandInstallTest.php => CreateProjectTest.php} (52%) delete mode 100644 tests/phpunit/Traits/ReflectionTrait.php diff --git a/CustomizeCommand.php b/CustomizeCommand.php index 4e16a1b..928e129 100644 --- a/CustomizeCommand.php +++ b/CustomizeCommand.php @@ -17,36 +17,23 @@ /** * Customize the project based on the answers provided by the user. * + * The Customizer allows template project authors to ask users questions during + * the `composer create-project` command and then update the newly created + * project based on the received answers. + * * This is a single-file Symfony Console Command class designed to work without - * any additional dependencies (apart from dependencies provided by Composer) - * during the `composer create-project` command ran with the `--no-install`. - * It provides a way to ask questions and process answers to customize - * user's project started from your scaffold project. + * any additional dependencies (apart from dependencies provided by Composer). * - * It also supports passing answers as a JSON string via the `--answers` option + * It supports passing answers as a JSON string via the `--answers` option * or the `CUSTOMIZER_ANSWERS` environment variable. * - * If you are a scaffold project maintainer, and want to allow customisations - * to your user's project without installing dependencies, you would need - * to copy this class to your project, adjust the namespace, and implement the - * `questions()` method. - * - * If, however, you do not want to support `--no-install` mode, you should use - * this project as a dev dependency of your scaffold project and simply provide - * a configuration file with questions and processing callbacks. - * - * Please keep this link in your project to help others find this tool. - * Thank you! - * - * @see https://github.com/AlexSkrypnyk/customizer - * * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) * @SuppressWarnings(PHPMD.TooManyPublicMethods) */ class CustomizeCommand extends BaseCommand { /** - * Defines the file name for an optional external configuration file. + * Defines the file name for a configuration file. */ const CONFIG_FILE = 'customize.php'; @@ -121,7 +108,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->io->definitionList( ['QUESTIONS' => 'ANSWERS'], new TableSeparator(), - ...array_map(static fn($q, $a): array => [$q => $a], array_keys($answers), array_column($answers, 'answer')) + ...array_map(static fn($q, $a): array => [$q => $a], array_keys($answers), array_values($answers)) ); if (!$this->io->confirm($this->message('proceed'))) { @@ -145,7 +132,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int } /** - * Collect, validate, ask questions and return the answers. + * Ask questions and return the answers. + * + * Before asking questions, the method will discover the answers from the + * environment and then ask the questions. * * @return array * The answers to the questions as an associative array: @@ -231,6 +221,7 @@ protected function processAnswers(array $answers): void { */ protected function cleanupSelf(): void { if (!empty($this->configClass)) { + // Check if the config class has a cleanup method. if (method_exists($this->configClass, 'cleanup') && !is_callable([$this->configClass, 'cleanup'])) { throw new \RuntimeException(sprintf('Optional method `cleanup()` exists in the config class %s but is not callable', $this->configClass)); } @@ -245,16 +236,6 @@ protected function cleanupSelf(): void { $json = $this->readComposerJson($this->cwd . '/composer.json'); - $is_dependency = ( - !empty($json['require']) - && is_array($json['require']) - && isset($json['require']['alexskrypnyk/customizer']) - ) || ( - !empty($json['require-dev']) - && is_array($json['require-dev']) - && isset($json['require-dev']['alexskrypnyk/customizer']) - ); - static::arrayUnsetDeep($json, ['autoload', 'classmap'], basename(__FILE__), FALSE); static::arrayUnsetDeep($json, ['scripts', 'customize']); static::arrayUnsetDeep($json, ['scripts', 'post-create-project-cmd'], '@customize'); @@ -274,26 +255,12 @@ protected function cleanupSelf(): void { if (strcmp(serialize($this->composerjsonData), serialize($json)) !== 0) { $this->writeComposerJson($this->cwd . '/composer.json', $json); - // We can only update the composer.lock file if the Customizer was not run - // after the Composer dependencies were installed and the Customizer - // was not installed as a dependency because the files will be removed - // and this process will no longer have required dependencies. - // For a Customizer installed as a dependency, the user should run - // `composer update` manually (or through a plugin) after the Customizer - // is finished. - if ($this->isComposerDependenciesInstalled && !$is_dependency) { + if ($this->isComposerDependenciesInstalled) { $this->io->writeLn('Updating composer.lock file after customization.'); static::passthru('composer update --quiet --no-interaction --no-progress'); } } - // Find and remove the command file. - $finder = static::finder($this->cwd)->files()->name(basename(__FILE__)); - $file = iterator_to_array($finder->getIterator(), FALSE)[0] ?? NULL; - if ($file) { - $this->fs->remove($file->getRealPath()); - } - // Find and remove the configuration file. $finder = static::finder($this->cwd)->files()->name(self::CONFIG_FILE); $file = iterator_to_array($finder->getIterator(), FALSE)[0] ?? NULL; @@ -363,7 +330,7 @@ protected function init(string $cwd, InputInterface $input, OutputInterface $out // Initialize the IO. // - // Convert the answers (if provided) to an input stream to be used for + // Convert the answers (if provided) to an input stream to be used for the // interactive prompts. $answers = getenv('CUSTOMIZER_ANSWERS'); $answers = $answers ?: $input->getOption('answers'); @@ -431,12 +398,12 @@ protected function loadConfigClass(string $file_name, string $cwd): ?string { return $class_name; } - // ============================================================================ + // =========================================================================== // UTILITY METHODS // // Note that these methods are static and public so that they could be used - // in the configuration class as well. - // ============================================================================ + // from within the configuration class as well. + // =========================================================================== /** * Run a command. @@ -480,7 +447,7 @@ public function debug(string $message, string ...$args): void { * Finder instance. */ public static function finder(string $dir, ?array $exclude = NULL): Finder { - $exclude = $exclude ?? ['.git', '.idea', 'vendor', 'node_modules']; + $exclude = $exclude ?? ['.git', '.idea', '.vscode', 'vendor', 'node_modules']; return Finder::create()->ignoreVCS(TRUE)->ignoreDotFiles(FALSE)->exclude($exclude)->in($dir); } @@ -538,7 +505,7 @@ public static function writeComposerJson(string $file, array $data): void { * Replace a whole line or only the occurrence. * @param array|null $exclude * Directories to exclude. - * Defaults to ['.git', '.idea', 'vendor', 'node_modules']. + * Defaults to ['.git', '.idea', '.vscode', 'vendor', 'node_modules']. */ public static function replaceInPath(string $path, string $search, string $replace, bool $replace_line = FALSE, ?array $exclude = NULL): void { $dir = dirname($path); @@ -592,7 +559,7 @@ public static function replaceInPath(string $path, string $search, string $repla * @param string $end * End marker. */ - public static function replaceInPathBetween(string $path, string $search, string $replace, string $start, string $end): void { + public static function replaceInPathBetweenMarkers(string $path, string $search, string $replace, string $start, string $end): void { $search = empty($search) ? '.*' : preg_quote($search, '/'); $replace = empty($replace) ? '$1' : preg_quote($replace, '/'); diff --git a/Plugin.php b/Plugin.php index 16e5569..af01e2b 100644 --- a/Plugin.php +++ b/Plugin.php @@ -10,6 +10,8 @@ use Composer\Installer\PackageEvent; use Composer\Installer\PackageEvents; use Composer\IO\IOInterface; +use Composer\Plugin\Capability\CommandProvider; +use Composer\Plugin\Capable; use Composer\Plugin\PluginInterface; /** @@ -22,7 +24,7 @@ * `scripts['post-create-project-cmd']` explicitly, but this means that this * package can no longer be easily included in the project. */ -class Plugin implements PluginInterface, EventSubscriberInterface { +class Plugin implements PluginInterface, EventSubscriberInterface, CommandProvider, Capable { /** * {@inheritdoc} @@ -49,6 +51,24 @@ public function deactivate(Composer $composer, IOInterface $io) { public function uninstall(Composer $composer, IOInterface $io) { } + /** + * {@inheritdoc} + */ + public function getCapabilities(): array { + return [ + CommandProvider::class => static::class, + ]; + } + + /** + * {@inheritdoc} + */ + public function getCommands(): array { + return [ + new CustomizeCommand(), + ]; + } + /** * {@inheritdoc} */ diff --git a/README.md b/README.md index 6712ea4..ae60242 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,8 @@ --- The Customizer allows template project authors to ask users questions during -the `composer create-project` command and then update the code base based on -their answers. +the `composer create-project` command and then update the newly created project +based on the received answers. ## TL;DR @@ -34,31 +34,16 @@ composer create-project alexskrypnyk/template-project-example my-project ## Features -- Can be included as a dependency -- Can be used without dependencies to support `composer create-project --no-install` -- Questions and processing logic are defined in a standalone file -- Provides a set of helpers for processing answers -- Provides a test harness that can be used in the template project to test - questions and processing logic +- Simple installation into template project +- Runs customization on `composer create-project` +- Runs customization on `composer create-project --no-install` via `composer customize` command +- Configuration file for questions and processing logic +- Test harness for the template project to test questions and processing logic +- No additional dependencies for minimal footprint ## Installation -Customizer can be installed into the template project in two ways: - -1. As a Composer dependency: easier to manage, but does not work with `composer create-project --no-install` -2. As a standalone class: harder to manage, but works with `composer create-project --no-install` - -### As a Composer dependency - -When creating projects from other template projects, users typically -use `composer create-project` (without the `--no-install`), which installs all -required dependencies. This means that Customizer can be used as a dependency, -allowing template project authors to focus on the questions and processing -logic without managing the Customizer's code itself. - -1. Add the following to your `composer.json` file ( - see [this](tests/phpunit/Fixtures/plugin/composer.json) example): - +1. Add to the template project as a Composer dependency: ```json "require-dev": { "alexskrypnyk/customizer": "^1.0" @@ -69,64 +54,19 @@ logic without managing the Customizer's code itself. } } ``` -2. Create `customize.php` file with questions and processing logic relevant - to your template project and place it in anywhere in your project. - These entries will be removed by the Customizer after your project's users run the `composer create-project` command. -See the [Configuration](#configuration) section below for more information. - -### As a standalone class - -There may be cases where template project authors want to ensure customization -takes place even if the user doesn't install dependencies. In this situation, -the Customizer class needs to be stored within the template project so that -Composer can access the code without installing dependencies. - -The Customizer provides a single file that can be copied to your project and -only relies on Composer. - -1. Copy the `CustomizeCommand.php` file to the root, `src` or any other - directory of your project. -2. Adjust the namespace within the class. -3. Add the following to your `composer.json` file ( - see [this](tests/phpunit/Fixtures/command/composer.json) example): - -```json -"autoload": { - "classmap": [ - "src/CustomizeCommand.php" - ] -}, -"scripts": { - "customize": [ - "YourNamespace\\Customizer\\CustomizeCommand" - ], - "post-create-project-cmd": [ - "@customize" - ] -} -``` -Make sure to adjust the path in the `classmap` and update -`YourNamespace\\Customizer\\CustomizeCommand` with the correct namespace. - -These entries will be removed by the Customizer after your project's users run -the `composer create-project` command. - -4. Create `customize.php` file with questions and processing logic relevant +2. Create `customize.php` file with questions and processing logic relevant to your template project and place it in anywhere in your project. See the [Configuration](#configuration) section below for more information. ## Usage example -After the installation into the template project, the Customizer will be -triggered automatically after a user runs the `composer create-project` -command. - -It will ask the user a series of questions, and will process the answers to -customize their instance of the template project. +When your users run the `composer create-project` command, the Customizer will +ask them questions and process the answers to customize their instance of the +template project. Run the command below to create a new project from the [template project example](https://github.com/AlexSkrypnyk/template-project-example) and see the Customizer in action: @@ -135,34 +75,86 @@ and see the Customizer in action: composer create-project alexskrypnyk/template-project-example my-project ``` -The demonstration questions provided in the [`customize.php`](https://github.com/AlexSkrypnyk/template-project-example/blob/main/customize.php) -file will ask you to provide a package name, description, and license. -The answers are then processed by updating the `composer.json` file and -replacing the package name in other project files. +In this example, the [demonstration questions](https://github.com/AlexSkrypnyk/template-project-example/blob/main/customize.php) +will ask you to provide a **package name**, **description**, and +**license type**. The answers are then processed by updating +the `composer.json` file and replacing the package name in other project files. + +### `--no-install` + +Your users may run the `composer create-project --no-install` command if they +want to adjust the project before installing dependencies, for example. +Customizer will not run in this case as it is not being installed yet and +it's dependencies entries will stay in the `composer.json` file. + +The user will have to run `composer customize` manually to run the +Customizer. It could be useful to let your users know about this command +in the project's README file. ## Configuration -The template project authors can configure the Customizer, including defining -questions and processing logic, by providing a an arbitrary class (with any -namespace) in a `customize.php` file. +You can configure how the Customizer, including defining questions and +processing logic, by providing an arbitrary class (with any namespace) in a +`customize.php` file. -The class has to implement `public static` methods to perform the configuration. +The class has to implement `public static` methods : +- [`questions()`](#questions) - defines questions; required +- [`process()`](#process) - defines processing logic based on received answers; required +- [`cleanup()`](#cleanup) - defines processing logic for the `composer.json` file; optional +- [`messages()`](#messages) - optional method to overwrite messages seen by the user; optional ### `questions()` -Define questions and their processing callbacks. Questions will be asked -in the order they are defined. Questions can use answers from previous -questions received so far. +Defines **questions**, their **discovery** and **validation** callbacks. +Questions will be asked in the order they are defined. Questions can use answers +from previous questions received so far. -Answers will be processed in the order they are defined. Process callbacks -have access to all answers and Customizer's class public properties and methods. +The **discovery** callback is optional and runs before the question is asked. It +can be used to discover the default answer based on the current state of the +project. The discovered value is passed to the question callback. It can be an +anonymous function or a method of the configuration class +named `discover`. -If a question does not have a `processAnswers()` callback explicitly specified, a static -method prefixed with `processAnswers` and a camel-cased question title will be called. -If the method does not exist, there will be no processing. +The **validation** callback should return the validated answer or throw an +exception with a message to be shown to the user. This uses inbuilt +SymfonyStyle's [`ask()`](https://symfony.com/doc/current/components/console/helpers/questionhelper.html#asking-the-user-for-information) +method for asking questions. [`customize.php`](customize.php) has an example of the `questions()` method. +Note that while the Customizer examples use SymfonyStyle's [`ask()`](https://symfony.com/doc/current/components/console/helpers/questionhelper.html#asking-the-user-for-information) +method, you can build your own question asking logic using any other TUI +interaction methods. For example, you can use [Laravel Prompts](https://github.com/laravel/prompts). + +### `process()` + +Defines processing logic for all answers. This method will be called after all +answers are received and the user confirms the intended changes. It has access +to all answers and Customizer's class public properties and methods. + +All file manipulations should be done within this method. + +[`customize.php`](customize.php) has an example of the `process()` method. + +### `cleanup()` + +Defines the `cleanup()` method after all files were processed but before all +dependencies are updated. + +[`customize.php`](customize.php) has an example of the `cleanup()` method. + +### `messages()` + +Defines overrides for the Customizer's messages shown to the user. + +[`customize.php`](customize.php) has an example of the `messages()` method. +messages provided by the Customizer. + +### Example configuration + +
+Click to expand an example configuration customize.php file + ```php > + * An associative array of questions with question title as a key and the + * value of array with the following keys: + * - question: Required question callback function used to ask the question. + * The callback receives the following arguments: + * - discovered: A value discovered by the discover callback or NULL. + * - answers: An associative array of all answers received so far. + * - command: The CustomizeCommand object. + * - discover: Optional callback function used to discover the value from + * the environment. Can be an anonymous function or a method of this class + * as discover. If not provided, empty string will + * be passed to the question callback. The callback receives the following + * arguments: + * - command: The CustomizeCommand object. + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + public static function questions(CustomizeCommand $c): array { // This an example of questions that can be asked to customize the project. // You can adjust this method to ask questions that are relevant to your // project. @@ -180,123 +214,154 @@ public static function questions(CustomizeCommand $c): array { // You may remove all the questions below and replace them with your own. return [ 'Name' => [ + // The discover callback function is used to discover the value from the + // environment. In this case, we use the current directory name + // and the GITHUB_ORG environment variable to generate the package name. + 'discover' => static function (CustomizeCommand $c): string { + $name = basename((string) getcwd()); + $org = getenv('GITHUB_ORG') ?: 'acme'; + + return $org . '/' . $name; + }, // The question callback function defines how the question is asked. // In this case, we ask the user to provide a package name as a string. - 'question' => static fn(array $answers, CustomizeCommand $c): mixed => $c->io->ask('Package name', NULL, static function (string $value): string { + // The discovery callback is used to provide a default value. + // The question callback provides a capability to validate the answer + // before it can be accepted by providing a validation callback. + 'question' => static fn(string $discovered, array $answers, CustomizeCommand $c): mixed => $c->io->ask('Package name', $discovered, static function (string $value): string { // This is a validation callback that checks if the package name is - // valid. If not, an exception is thrown with a message shown to the - // user. + // valid. If not, an \InvalidArgumentException exception is thrown + // with a message shown to the user. if (!preg_match('/^[a-z0-9_.-]+\/[a-z0-9_.-]+$/', $value)) { throw new \InvalidArgumentException(sprintf('The package name "%s" is invalid, it should be lowercase and have a vendor name, a forward slash, and a package name.', $value)); } return $value; }), - // The `processAnswers()` callback function defines how the answer is processed. - // The processing takes place only after all answers are received and - // the user confirms the intended changes. - 'processAnswers' => static function (string $title, string $answer, array $answers, CustomizeCommand $c): void { - $name = is_string($c->composerjsonData['name'] ?? NULL) ? $c->composerjsonData['name'] : ''; - // Update the package data. - $c->composerjsonData['name'] = $answer; - // Write the updated composer.json file. - CustomizeCommand::writeComposerJson($c->cwd . '/composer.json', $c->composerjsonData); - // Replace the package name in the project files. - $c->replaceInPath($c->cwd, $name, $answer); - }, ], 'Description' => [ - // For this question, we are using an answer from the previous question + // For this question, we use an answer from the previous question // in the title of the question. - 'question' => static fn(array $answers, CustomizeCommand $c): mixed => $c->io->ask(sprintf('Description for %s', $answers['Name'])), - 'processAnswers' => static function (string $title, string $answer, array $answers, CustomizeCommand $c): void { - $description = is_string($c->composerjsonData['description'] ?? NULL) ? $c->composerjsonData['description'] : ''; - $c->composerjsonData['description'] = $answer; - CustomizeCommand::writeComposerJson($c->cwd . '/composer.json', $c->composerjsonData); - $c->replaceInPath($c->cwd, $description, $answer); - }, + 'question' => static fn(string $discovered, array $answers, CustomizeCommand $c): mixed => $c->io->ask(sprintf('Description for %s', $answers['Name'])), ], 'License' => [ - // For this question, we are using a pre-defined list of options. - // For processing, we are using a separate method named 'processLicense' + // For this question, we use a pre-defined list of options. + // For discovery, we use a separate method named 'discoverLicense' // (only for the demonstration purposes; it could have been an // anonymous function). - 'question' => static fn(array $answers, CustomizeCommand $c): mixed => $c->io->choice('License type', [ - 'MIT', - 'GPL-3.0-or-later', - 'Apache-2.0', - ], 'GPL-3.0-or-later'), + 'question' => static fn(string $discovered, array $answers, CustomizeCommand $c): mixed => $c->io->choice('License type', + [ + 'MIT', + 'GPL-3.0-or-later', + 'Apache-2.0', + ], + // Note that the default value is the value discovered by the + // 'discoverLicense' method. If the discovery did not return a value, + // the default value of 'GPL-3.0-or-later' is used. + empty($discovered) ? 'GPL-3.0-or-later' : $discovered + ), ], ]; } - public static function processLicense(string $title, string $answer, array $answers, CustomizeCommand $c): void { - $c->composerjsonData['license'] = $answer; - CustomizeCommand::writeComposerJson($c->cwd . '/composer.json', $c->composerjsonData); + /** + * A callback to discover the `License` value from the environment. + * + * This is an example of discovery function as a class method. + * + * @param \AlexSkrypnyk\Customizer\CustomizeCommand $c + * The Customizer instance. + */ + public static function discoverLicense(CustomizeCommand $c): string { + return isset($c->composerjsonData['license']) && is_string($c->composerjsonData['license']) ? $c->composerjsonData['license'] : ''; } -} -``` - -### `cleanupSelf()` - -Using the `cleanupSelf()` method, the template project authors can additionally -process the `composer.json` file content before all dependencies are updated. -This runs after all answers are received and the user confirms -the intended changes. - -Use `$composerjson = [];` to prevent dependencies updates by the Customizer. -This essentially means that you are managing that process outside of this -method. + /** + * A required callback to process all answers. + * + * This method is called after all questions have been answered and a user + * has confirmed the intent to proceed with the customization. + * + * Note that any manipulation of the composer.json file should be done here + * and then written back to the file system. + * + * @param array $answers + * Gathered answers. + * @param \AlexSkrypnyk\Customizer\CustomizeCommand $c + * The Customizer instance. + */ + public static function process(array $answers, CustomizeCommand $c): void { + $c->debug('Updating composer configuration'); + $json = $c->readComposerJson($c->composerjson); + $json['name'] = $answers['Name']; + $json['description'] = $answers['Description']; + $json['license'] = $answers['License']; + $c->writeComposerJson($c->composerjson, $json); + + $c->debug('Removing an arbitrary file.'); + $files = $c->finder($c->cwd)->files()->name('LICENSE'); + foreach ($files as $file) { + $c->fs->remove($file->getRealPath()); + } + } -```php -/** - * A callback to process cleanup. - * - * @param array $composerjson - * The composer.json file content passed by reference. - * @param \AlexSkrypnyk\Customizer\CustomizeCommand $c - * The Customizer instance. - */ -public static function cleanupSelf(array &$composerjson, CustomizeCommand $c): void { - // Here you can remove any sections from the composer.json file that are not - // needed for the project before all dependencies are updated. - // - // You can also additionally process files. -} -``` + /** + * Cleanup after the customization. + * + * By the time this method is called, all the necessary changes have been made + * to the project. + * + * The Customizer will remove itself from the project and will update the + * composer.json as required. This method allows to alter that process as + * needed and, if necessary, cancel the original self-cleanup. + * + * @param \AlexSkrypnyk\Customizer\CustomizeCommand $c + * The CustomizeCommand object. + * + * @return bool + * Return FALSE to skip the further self-cleanup. Returning TRUE will + * proceed with the self-cleanup. + */ + public static function cleanup(CustomizeCommand $c): bool { + if ($c->isComposerDependenciesInstalled) { + $c->debug('Add an example flag to composer.json.'); + $json = $c->readComposerJson($c->composerjson); + $json['extra'] = is_array($json['extra']) ? $json['extra'] : []; + $json['extra']['customizer'] = TRUE; + $c->writeComposerJson($c->composerjson, $json); + } -### `messages()` + return TRUE; + } -Using the `messages()` method, the template project authors can overwrite -messages provided by the Customizer. + /** + * Override some of the messages displayed to the user by Customizer. + * + * @param \AlexSkrypnyk\Customizer\CustomizeCommand $c + * The Customizer instance. + * + * @return array> + * An associative array of messages with message name as key and the message + * test as a string or an array of strings. + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public static function messages(CustomizeCommand $c): array { + return [ + // This is an example of a custom message that overrides the default + // message with name `welcome`. + 'title' => 'Welcome to the "{{ package.name }}" project customizer', + ]; + } -```php -public static function messages(CustomizeCommand $c): array { - return [ - // This is an example of a custom message that overrides the default - // message with name `welcome`. - 'title' => 'Welcome to the {{ package.name }} project customizer', - ]; } ``` - -### Advanced configuration - -In case when a template repository authors want to make the Customizer to be -_truly_ drop-in single-file solution (installation [option 2](#as-a-standalone-class) -without `customize.php` file), they can define the questions and processing -logic in the `CustomizeCommand.php` file itself. In this case, `customize.php` -will not be required (but is still supported). - -Note that if the `customize.php` file is present in the project, the questions -defined in the `CustomizeCommand.php` file will be ignored in favour of the -questions provided in the `customize.php` file. +
## Helpers The Customizer provides a few helpers to make processing answers easier. -These are available as properties and methods of the `$c` instance +These are available as properties and methods of the Customizer `$c` instance passed to the processing callbacks: - `cwd` - current working directory. @@ -309,7 +374,8 @@ passed to the processing callbacks: - `writeComposerJson()` - Write the contents of the array to the `composer.json` file. - `replaceInPath()` - Replace a string in a file or all files in a directory. -- `replaceInPathBetween()` - Replace a string in a file or all files in a directory between two markers. +- `replaceInPathBetweenMarkers()` - Replace a string in a file or all files in + a directory between two markers. - `uncommentLine()` - Uncomment a line in a file or all files in a directory. - `arrayUnsetDeep()` - Unset a fully or partially matched value in a nested array, removing empty arrays. @@ -341,20 +407,48 @@ command to enable debugging with XDebug. ### Automated functional tests -This project uses [automated functional tests](tests/phpunit/Functional) to -check that `composer create-project` asks the questions and processes the -answers correctly. +The Customizer provides a [test harness]](tests/phpunit/Functional) to help you +test your questions and processing with ease. + +The template project authors can use the same test harness to test their own +questions and processing logic: + +1. Setup PHPUnit in your template project to run tests. +2. Inherit your classes from [`CustomizerTestCase.php`](tests/phpunit/Functional/CustomizerTestCase.php) (this file is + included into distribution when you add Customizer to your template project). +3. Create a directory in your project with the name `tests/phpunit/Fixtures/` + and place your test fixtures there. If you use data providers, you can + create a sub-directory with the name of the data set within the provider. +4. Add tests as _base_/_expected_ directory structures and assert for the + expected results. + +See examples within the [template project example](https://github.com/AlexSkrypnyk/template-project-example/blob/main/tests/). + +### Comparing fixture directories + +The base test class [`CustomizerTestCase.php`](tests/phpunit/Functional/CustomizerTestCase.php) provides +the `assertFixtureDirectoryEqualsSut()` method to compare a directory under +test with the expected results. + +The method uses _base_ and _expected_ directories to compare the results: +_base_ is used as a state of the project you are testing before the +customization ran, and _expected_ is used as an expected result, which will be +compared to the actual result after the customization. + +Because the projects can have dependencies added during `composer install` and +other files that are not related to the customization, the method allows you to +specify the list of files to ignore during the comparison using +`.gitignore`-like syntax with the addition to ignore content changes but still +assess the file presence. -You can setup PHPUnit in your template project to run these tests. Once done, -use [`CustomizerTestCase.php`](tests/phpunit/Functional/CustomizerTestCase.php) -as a base class for your tests. See this example within the -[template project example](https://github.com/AlexSkrypnyk/template-project-example/blob/main/tests/CreateProjectTest.php). +See the description in `assertFixtureDirectoryEqualsSut()` for more information. ## Maintenance - composer install - composer lint - composer test + composer install # Install dependencies. + composer lint # Check coding standards. + composer lint-fix # Fix coding standards. + composer test # Run tests. --- _This repository was created using the [getscaffold.dev](https://getscaffold.dev/) project scaffold template_ diff --git a/customize.php b/customize.php index 5fa258c..ab5c4c9 100644 --- a/customize.php +++ b/customize.php @@ -109,8 +109,7 @@ public static function questions(CustomizeCommand $c): array { /** * A callback to discover the `License` value from the environment. * - * This is an example callback, and it can be safely removed if this question - * is not needed. + * This is an example of discovery function as a class method. * * @param \AlexSkrypnyk\Customizer\CustomizeCommand $c * The Customizer instance. diff --git a/rector.php b/rector.php index c0d0ea4..9e7772d 100644 --- a/rector.php +++ b/rector.php @@ -15,9 +15,7 @@ use Rector\CodeQuality\Rector\ClassMethod\InlineArrayReturnAssignRector; use Rector\CodeQuality\Rector\Empty_\SimplifyEmptyCheckOnEmptyArrayRector; use Rector\CodingStyle\Rector\ClassMethod\NewlineBeforeNewAssignSetRector; -use Rector\CodingStyle\Rector\FuncCall\ArraySpreadInsteadOfArrayMergeRector; use Rector\CodingStyle\Rector\FuncCall\CountArrayToEmptyArrayComparisonRector; -use Rector\CodingStyle\Rector\PostInc\PostIncDecToPreIncDecRector; use Rector\CodingStyle\Rector\Stmt\NewlineAfterStatementRector; use Rector\Config\RectorConfig; use Rector\DeadCode\Rector\If_\RemoveAlwaysTrueIfConditionRector; @@ -46,13 +44,11 @@ $rectorConfig->skip([ // Rules added by Rector's rule sets. - ArraySpreadInsteadOfArrayMergeRector::class, CountArrayToEmptyArrayComparisonRector::class, DisallowedEmptyRuleFixerRector::class, InlineArrayReturnAssignRector::class, NewlineAfterStatementRector::class, NewlineBeforeNewAssignSetRector::class, - PostIncDecToPreIncDecRector::class, RemoveAlwaysTrueIfConditionRector::class, SimplifyEmptyCheckOnEmptyArrayRector::class, // Dependencies. diff --git a/tests/phpunit/Fixtures/install/expected/.ignorecontent b/tests/phpunit/Fixtures/install/post_install/.ignorecontent similarity index 100% rename from tests/phpunit/Fixtures/install/expected/.ignorecontent rename to tests/phpunit/Fixtures/install/post_install/.ignorecontent diff --git a/tests/phpunit/Fixtures/install/expected/composer.json b/tests/phpunit/Fixtures/install/post_install/composer.json similarity index 100% rename from tests/phpunit/Fixtures/install/expected/composer.json rename to tests/phpunit/Fixtures/install/post_install/composer.json diff --git a/tests/phpunit/Fixtures/install/expected/composer.lock b/tests/phpunit/Fixtures/install/post_install/composer.lock similarity index 100% rename from tests/phpunit/Fixtures/install/expected/composer.lock rename to tests/phpunit/Fixtures/install/post_install/composer.lock diff --git a/tests/phpunit/Fixtures/install_additional_cleanup/expected/.ignorecontent b/tests/phpunit/Fixtures/install_additional_cleanup/post_install/.ignorecontent similarity index 100% rename from tests/phpunit/Fixtures/install_additional_cleanup/expected/.ignorecontent rename to tests/phpunit/Fixtures/install_additional_cleanup/post_install/.ignorecontent diff --git a/tests/phpunit/Fixtures/install_additional_cleanup/expected/composer.json b/tests/phpunit/Fixtures/install_additional_cleanup/post_install/composer.json similarity index 100% rename from tests/phpunit/Fixtures/install_additional_cleanup/expected/composer.json rename to tests/phpunit/Fixtures/install_additional_cleanup/post_install/composer.json diff --git a/tests/phpunit/Fixtures/install_additional_cleanup/expected/composer.lock b/tests/phpunit/Fixtures/install_additional_cleanup/post_install/composer.lock similarity index 100% rename from tests/phpunit/Fixtures/install_additional_cleanup/expected/composer.lock rename to tests/phpunit/Fixtures/install_additional_cleanup/post_install/composer.lock diff --git a/tests/phpunit/Fixtures/install_no_config_file/expected/.ignorecontent b/tests/phpunit/Fixtures/install_no_config_file/post_install/.ignorecontent similarity index 100% rename from tests/phpunit/Fixtures/install_no_config_file/expected/.ignorecontent rename to tests/phpunit/Fixtures/install_no_config_file/post_install/.ignorecontent diff --git a/tests/phpunit/Fixtures/install_no_config_file/expected/composer.json b/tests/phpunit/Fixtures/install_no_config_file/post_install/composer.json similarity index 100% rename from tests/phpunit/Fixtures/install_no_config_file/expected/composer.json rename to tests/phpunit/Fixtures/install_no_config_file/post_install/composer.json diff --git a/tests/phpunit/Fixtures/install_no_config_file/expected/composer.lock b/tests/phpunit/Fixtures/install_no_config_file/post_install/composer.lock similarity index 100% rename from tests/phpunit/Fixtures/install_no_config_file/expected/composer.lock rename to tests/phpunit/Fixtures/install_no_config_file/post_install/composer.lock diff --git a/tests/phpunit/Fixtures/no_install/1_before_install/composer.json b/tests/phpunit/Fixtures/no_install/1_before_install/composer.json new file mode 100644 index 0000000..8720161 --- /dev/null +++ b/tests/phpunit/Fixtures/no_install/1_before_install/composer.json @@ -0,0 +1,18 @@ +{ + "name": "yourorg/yourtempaltepackage", + "description": "Your template package description", + "type": "project", + "license": "proprietary", + "require": { + "php": ">=8.2", + "monolog/monolog": "^2.0" + }, + "require-dev": { + "alexskrypnyk/customizer": "*" + }, + "config": { + "allow-plugins": { + "alexskrypnyk/customizer": true + } + } +} diff --git a/tests/phpunit/Fixtures/no_install_sub_dir/base/customize.php b/tests/phpunit/Fixtures/no_install/1_before_install/customize.php similarity index 93% rename from tests/phpunit/Fixtures/no_install_sub_dir/base/customize.php rename to tests/phpunit/Fixtures/no_install/1_before_install/customize.php index 50f6e2b..f6501a1 100644 --- a/tests/phpunit/Fixtures/no_install_sub_dir/base/customize.php +++ b/tests/phpunit/Fixtures/no_install/1_before_install/customize.php @@ -67,10 +67,8 @@ public static function process(array $answers, CustomizeCommand $c): void { public static function cleanup(CustomizeCommand $c): bool { if ($c->isComposerDependenciesInstalled) { - $c->debug('Add an example flag to composer.json.'); $json = $c->readComposerJson($c->composerjson); - $json['extra'] = is_array($json['extra']) ? $json['extra'] : []; - $json['extra']['customizer'] = TRUE; + $json['homepage'] = 'https://example.com'; $c->writeComposerJson($c->composerjson, $json); } diff --git a/tests/phpunit/Fixtures/no_install/2_post_install/.ignorecontent b/tests/phpunit/Fixtures/no_install/2_post_install/.ignorecontent new file mode 100644 index 0000000..98610ec --- /dev/null +++ b/tests/phpunit/Fixtures/no_install/2_post_install/.ignorecontent @@ -0,0 +1,2 @@ +vendor/ +^composer.lock diff --git a/tests/phpunit/Fixtures/no_install/2_post_install/composer.json b/tests/phpunit/Fixtures/no_install/2_post_install/composer.json new file mode 100644 index 0000000..8720161 --- /dev/null +++ b/tests/phpunit/Fixtures/no_install/2_post_install/composer.json @@ -0,0 +1,18 @@ +{ + "name": "yourorg/yourtempaltepackage", + "description": "Your template package description", + "type": "project", + "license": "proprietary", + "require": { + "php": ">=8.2", + "monolog/monolog": "^2.0" + }, + "require-dev": { + "alexskrypnyk/customizer": "*" + }, + "config": { + "allow-plugins": { + "alexskrypnyk/customizer": true + } + } +} diff --git a/tests/phpunit/Fixtures/no_install/2_post_install/composer.lock b/tests/phpunit/Fixtures/no_install/2_post_install/composer.lock new file mode 100644 index 0000000..d349f0c --- /dev/null +++ b/tests/phpunit/Fixtures/no_install/2_post_install/composer.lock @@ -0,0 +1 @@ +IGNORECONTENT diff --git a/tests/phpunit/Fixtures/no_install/2_post_install/customize.php b/tests/phpunit/Fixtures/no_install/2_post_install/customize.php new file mode 100644 index 0000000..f6501a1 --- /dev/null +++ b/tests/phpunit/Fixtures/no_install/2_post_install/customize.php @@ -0,0 +1,78 @@ + [ + 'discover' => static function (CustomizeCommand $c): string { + $name = basename((string) getcwd()); + $org = getenv('GITHUB_ORG') ?: 'acme'; + + return $org . '/' . $name; + }, + 'question' => static fn(string $discovered, array $answers, CustomizeCommand $c): mixed => $c->io->ask('Package name', $discovered, static function (string $value): string { + if (!preg_match('/^[a-z0-9_.-]+\/[a-z0-9_.-]+$/', $value)) { + throw new \InvalidArgumentException(sprintf('The package name "%s" is invalid, it should be lowercase and have a vendor name, a forward slash, and a package name.', $value)); + } + + return $value; + }), + ], + 'Description' => [ + 'question' => static fn(string $discovered, array $answers, CustomizeCommand $c): mixed => $c->io->ask(sprintf('Description for %s', $answers['Name'])), + ], + 'License' => [ + 'question' => static fn(string $discovered, array $answers, CustomizeCommand $c): mixed => $c->io->choice('License type', + [ + 'MIT', + 'GPL-3.0-or-later', + 'Apache-2.0', + ], + empty($discovered) ? 'GPL-3.0-or-later' : $discovered + ), + ], + ]; + } + + public static function discoverLicense(CustomizeCommand $c): string { + return isset($c->composerjsonData['license']) && is_string($c->composerjsonData['license']) ? $c->composerjsonData['license'] : ''; + } + + public static function process(array $answers, CustomizeCommand $c): void { + $c->debug('Updating composer configuration'); + $json = $c->readComposerJson($c->composerjson); + $json['name'] = $answers['Name']; + $json['description'] = $answers['Description']; + $json['license'] = $answers['License']; + $c->writeComposerJson($c->composerjson, $json); + + $c->debug('Removing an arbitrary file.'); + $files = $c->finder($c->cwd)->files()->name('LICENSE'); + foreach ($files as $file) { + $c->fs->remove($file->getRealPath()); + } + } + + public static function cleanup(CustomizeCommand $c): bool { + if ($c->isComposerDependenciesInstalled) { + $json = $c->readComposerJson($c->composerjson); + $json['homepage'] = 'https://example.com'; + $c->writeComposerJson($c->composerjson, $json); + } + + return TRUE; + } + +} diff --git a/tests/phpunit/Fixtures/no_install/3_post_customize/.ignorecontent b/tests/phpunit/Fixtures/no_install/3_post_customize/.ignorecontent new file mode 100644 index 0000000..98610ec --- /dev/null +++ b/tests/phpunit/Fixtures/no_install/3_post_customize/.ignorecontent @@ -0,0 +1,2 @@ +vendor/ +^composer.lock diff --git a/tests/phpunit/Fixtures/no_install/expected/composer.json b/tests/phpunit/Fixtures/no_install/3_post_customize/composer.json similarity index 81% rename from tests/phpunit/Fixtures/no_install/expected/composer.json rename to tests/phpunit/Fixtures/no_install/3_post_customize/composer.json index c9da9a7..9b83e9f 100644 --- a/tests/phpunit/Fixtures/no_install/expected/composer.json +++ b/tests/phpunit/Fixtures/no_install/3_post_customize/composer.json @@ -6,5 +6,6 @@ "require": { "php": ">=8.2", "monolog/monolog": "^2.0" - } + }, + "homepage": "https://example.com" } diff --git a/tests/phpunit/Fixtures/no_install/3_post_customize/composer.lock b/tests/phpunit/Fixtures/no_install/3_post_customize/composer.lock new file mode 100644 index 0000000..d349f0c --- /dev/null +++ b/tests/phpunit/Fixtures/no_install/3_post_customize/composer.lock @@ -0,0 +1 @@ +IGNORECONTENT diff --git a/tests/phpunit/Fixtures/no_install/base/composer.json b/tests/phpunit/Fixtures/no_install/base/composer.json index 03751d8..8720161 100644 --- a/tests/phpunit/Fixtures/no_install/base/composer.json +++ b/tests/phpunit/Fixtures/no_install/base/composer.json @@ -7,18 +7,12 @@ "php": ">=8.2", "monolog/monolog": "^2.0" }, - "minimum-stability": "dev", - "autoload": { - "classmap": [ - "CustomizeCommand.php" - ] + "require-dev": { + "alexskrypnyk/customizer": "*" }, - "scripts": { - "post-create-project-cmd": [ - "@customize" - ], - "customize": [ - "AlexSkrypnyk\\Customizer\\CustomizeCommand" - ] + "config": { + "allow-plugins": { + "alexskrypnyk/customizer": true + } } } diff --git a/tests/phpunit/Fixtures/no_install/base/customize.php b/tests/phpunit/Fixtures/no_install/base/customize.php index 22c1e69..f6501a1 100644 --- a/tests/phpunit/Fixtures/no_install/base/customize.php +++ b/tests/phpunit/Fixtures/no_install/base/customize.php @@ -66,13 +66,13 @@ public static function process(array $answers, CustomizeCommand $c): void { } public static function cleanup(CustomizeCommand $c): bool { - return TRUE; - } + if ($c->isComposerDependenciesInstalled) { + $json = $c->readComposerJson($c->composerjson); + $json['homepage'] = 'https://example.com'; + $c->writeComposerJson($c->composerjson, $json); + } - public static function messages(CustomizeCommand $c): array { - return [ - 'title' => 'Greetings from the customizer for the "{{ package.name }}" project', - ]; + return TRUE; } } diff --git a/tests/phpunit/Fixtures/no_install_sub_dir/base/composer.json b/tests/phpunit/Fixtures/no_install_sub_dir/base/composer.json deleted file mode 100644 index 03751d8..0000000 --- a/tests/phpunit/Fixtures/no_install_sub_dir/base/composer.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "yourorg/yourtempaltepackage", - "description": "Your template package description", - "type": "project", - "license": "proprietary", - "require": { - "php": ">=8.2", - "monolog/monolog": "^2.0" - }, - "minimum-stability": "dev", - "autoload": { - "classmap": [ - "CustomizeCommand.php" - ] - }, - "scripts": { - "post-create-project-cmd": [ - "@customize" - ], - "customize": [ - "AlexSkrypnyk\\Customizer\\CustomizeCommand" - ] - } -} diff --git a/tests/phpunit/Fixtures/no_install_sub_dir/expected/composer.json b/tests/phpunit/Fixtures/no_install_sub_dir/expected/composer.json deleted file mode 100644 index c9da9a7..0000000 --- a/tests/phpunit/Fixtures/no_install_sub_dir/expected/composer.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "testorg/testpackage", - "description": "Test description", - "type": "project", - "license": "MIT", - "require": { - "php": ">=8.2", - "monolog/monolog": "^2.0" - } -} diff --git a/tests/phpunit/Functional/CreateProjectCommandNoInstallTest.php b/tests/phpunit/Functional/CreateProjectCommandNoInstallTest.php deleted file mode 100644 index 68df937..0000000 --- a/tests/phpunit/Functional/CreateProjectCommandNoInstallTest.php +++ /dev/null @@ -1,79 +0,0 @@ -runComposerCreateProject(['--no-install' => TRUE]); - - // Custom welcome message. - $this->assertComposerCommandSuccessOutputContains('Greetings from the customizer for the "yourorg/yourtempaltepackage" project',); - $this->assertComposerCommandSuccessOutputContains('Project was customized'); - - $this->assertFixtureDirectoriesEqual(); - } - - #[RunInSeparateProcess] - public function testNoInstallSubDir(): void { - // Move the command stub pre-created in setUp() to the 'src' directory. - $this->fs->mkdir(static::$repo . DIRECTORY_SEPARATOR . 'src'); - $this->fs->rename( - static::$repo . DIRECTORY_SEPARATOR . 'CustomizeCommand.php', - static::$repo . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'CustomizeCommand.php' - ); - - $json = CustomizeCommand::readComposerJson(static::$repo . DIRECTORY_SEPARATOR . 'composer.json'); - $json['autoload'] = is_array($json['autoload']) ? $json['autoload'] : []; - $json['autoload']['classmap'] = ['src/CustomizeCommand.php']; - CustomizeCommand::writeComposerJson(static::$repo . DIRECTORY_SEPARATOR . 'composer.json', $json); - - CustomizerTestCase::customizerSetAnswers([ - 'testorg/testpackage', - 'Test description', - 'MIT', - self::TUI_ANSWER_NOTHING, - ]); - - $this->runComposerCreateProject(['--no-install' => TRUE]); - - $this->assertComposerCommandSuccessOutputContains('Welcome to the "yourorg/yourtempaltepackage" project customizer'); - $this->assertComposerCommandSuccessOutputContains('Project was customized'); - - $this->assertFixtureDirectoriesEqual(); - } - -} diff --git a/tests/phpunit/Functional/CreateProjectCommandInstallTest.php b/tests/phpunit/Functional/CreateProjectTest.php similarity index 52% rename from tests/phpunit/Functional/CreateProjectCommandInstallTest.php rename to tests/phpunit/Functional/CreateProjectTest.php index 3c0a485..405c2fd 100644 --- a/tests/phpunit/Functional/CreateProjectCommandInstallTest.php +++ b/tests/phpunit/Functional/CreateProjectTest.php @@ -13,10 +13,12 @@ * Test Customizer as a single-file drop-in during `composer create-project`. */ #[CoversClass(CustomizeCommand::class)] -#[Group('install')] -class CreateProjectCommandInstallTest extends CustomizerTestCase { +#[Group('create-project')] +class CreateProjectTest extends CustomizerTestCase { #[RunInSeparateProcess] + #[Group('install')] + #[Group('wip1')] public function testInstall(): void { static::customizerSetAnswers([ 'testorg/testpackage', @@ -28,14 +30,21 @@ public function testInstall(): void { $this->runComposerCreateProject(); // Custom welcome message. - $this->assertComposerCommandSuccessOutputContains('Greetings from the customizer for the "yourorg/yourtempaltepackage" project',); + $this->assertComposerCommandSuccessOutputContains('Greetings from the customizer for the "yourorg/yourtempaltepackage" project'); + $this->assertComposerCommandSuccessOutputContains('Name'); + $this->assertComposerCommandSuccessOutputContains('testorg/testpackage'); + $this->assertComposerCommandSuccessOutputContains('Description'); + $this->assertComposerCommandSuccessOutputContains('Test description'); + $this->assertComposerCommandSuccessOutputContains('License'); + $this->assertComposerCommandSuccessOutputContains('MIT'); $this->assertComposerCommandSuccessOutputContains('Project was customized'); - $this->assertFixtureDirectoriesEqual(); + $this->assertFixtureDirectoryEqualsSut('post_install'); $this->assertComposerLockUpToDate(); } #[RunInSeparateProcess] + #[Group('install')] public function testInstallAdditionalCleanup(): void { static::customizerSetAnswers([ 'testorg/testpackage', @@ -49,18 +58,45 @@ public function testInstallAdditionalCleanup(): void { $this->assertComposerCommandSuccessOutputContains('Welcome to the "yourorg/yourtempaltepackage" project customizer'); $this->assertComposerCommandSuccessOutputContains('Project was customized'); - $this->assertFixtureDirectoriesEqual(); + $this->assertFixtureDirectoryEqualsSut('post_install'); $this->assertComposerLockUpToDate(); } #[RunInSeparateProcess] + #[Group('install')] public function testInstallNoConfigFile(): void { $this->runComposerCreateProject(); $this->assertComposerCommandSuccessOutputContains('Welcome to the "yourorg/yourtempaltepackage" project customizer'); $this->assertComposerCommandSuccessOutputContains('No questions were found. No changes were made'); - $this->assertFixtureDirectoriesEqual(); + $this->assertFixtureDirectoryEqualsSut('post_install'); + $this->assertComposerLockUpToDate(); + } + + #[RunInSeparateProcess] + #[Group('no-install')] + public function testNoInstall(): void { + static::customizerSetAnswers([ + 'testorg/testpackage', + 'Test description', + 'MIT', + self::TUI_ANSWER_NOTHING, + ]); + + $this->runComposerCreateProject(['--no-install' => TRUE]); + $this->assertFixtureDirectoryEqualsSut('1_before_install'); + + $this->tester->run(['command' => 'install']); + $this->assertComposerLockUpToDate(); + $this->assertFixtureDirectoryEqualsSut('2_post_install'); + + $this->tester->run(['command' => 'customize']); + + $this->assertComposerCommandSuccessOutputContains('Welcome to the "yourorg/yourtempaltepackage" project customizer'); + $this->assertComposerCommandSuccessOutputContains('Project was customized'); + + $this->assertFixtureDirectoryEqualsSut('3_post_customize'); $this->assertComposerLockUpToDate(); } diff --git a/tests/phpunit/Functional/CustomizerTestCase.php b/tests/phpunit/Functional/CustomizerTestCase.php index 0b4ca5f..8fc1ac9 100644 --- a/tests/phpunit/Functional/CustomizerTestCase.php +++ b/tests/phpunit/Functional/CustomizerTestCase.php @@ -93,9 +93,9 @@ protected function setUp(): void { $this->initLocations((string) getcwd()); - // Projects using this project through a plugin must have this - // repository added to their composer.json to be able to download it - // during the test. + // Template project using the Customizer through a plugin must have this + // repository added to their `composer.json` during the test to be able to + // download it. $json = CustomizeCommand::readComposerJson(static::$repo . '/composer.json'); $json['minimum-stability'] = 'dev'; $json['repositories'] = [ @@ -118,6 +118,11 @@ protected function setUp(): void { * Initialize the Composer command tester. */ protected function initComposerTester(): void { + // @see https://github.com/composer/composer/issues/12107 + if (!defined('STDIN')) { + define('STDIN', fopen('php://stdin', 'r')); + } + $application = new Application(); $application->setAutoExit(FALSE); $application->setCatchExceptions(FALSE); @@ -130,8 +135,8 @@ protected function initComposerTester(): void { // Composer autoload uses per-project Composer binary, if the // `composer/composer` is included in the project as a dependency. // - // When the test runs and creates SUT, the Composer binary used is - // from the SUT's `vendor` directory. The Customizer may remove the + // When a test creates SUT, the Composer binary used is from the SUT's + // `vendor` directory. The Customizer may remove the // `vendor/composer/composer` directory as a part of the cleanup, resulting // in the Composer autoloader having an empty path to the Composer binary. // @@ -196,11 +201,6 @@ protected function initLocations(string $cwd, ?callable $cb = NULL): void { $this->fs->mirror(static::$fixtures . DIRECTORY_SEPARATOR . 'base', static::$repo); } - // Create an empty command file in the 'system under test' to replicate a - // real scenario during test where the file is manually copied into a real - // project and then removed by the command after customization runs. - $this->fs->touch(static::$repo . DIRECTORY_SEPARATOR . 'CustomizeCommand.php'); - if ($cb !== NULL && is_callable($cb) && $cb instanceof \Closure) { // @phpstan-ignore-next-line \Closure::bind($cb, $this, self::class)(); @@ -289,9 +289,10 @@ protected static function customizerSetAnswers(array $answers): void { protected function runComposerCreateProject(array $options = []): void { $defaults = [ 'command' => 'create-project', - 'package' => $this->packageName, 'directory' => static::$sut, + 'package' => $this->packageName, 'version' => '@dev', + // Use a fixture of the template repository as a source for the project. '--repository' => [ json_encode([ 'type' => 'path', @@ -373,8 +374,29 @@ protected function assertComposerCommandSuccessOutputContains(string|array $stri /** * Assert that fixtures directories are equal. */ - protected function assertFixtureDirectoriesEqual(): void { - $this->assertDirectoriesEqual(static::$fixtures . DIRECTORY_SEPARATOR . 'expected', static::$sut); + protected function assertFixtureDirectoryEqualsSut(string $expected): void { + $this->assertDirectoriesEqual(static::$fixtures . DIRECTORY_SEPARATOR . $expected, static::$sut, static function (string $content, \SplFileInfo $file) : string { + // Remove compose.json overrides added by the static::setUp(). + if ($file->getBasename() === 'composer.json') { + $data = json_decode($content, TRUE); + if (!is_array($data)) { + return $content; + } + unset($data['minimum-stability']); + if (isset($data['repositories'])) { + foreach ($data['repositories'] as $key => $repository) { + if ($repository['type'] === 'path' && $repository['url'] === static::$root) { + unset($data['repositories'][$key]); + if (empty($data['repositories'])) { + unset($data['repositories']); + } + } + } + } + $content = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . PHP_EOL; + } + return $content; + }); } /** @@ -417,15 +439,14 @@ protected function assertFixtureDirectoriesEqual(): void { * The first directory. * @param string $dir2 * The second directory. - * - * @throws \PHPUnit\Framework\AssertionFailedError - * If the directories are not equal. + * @param callable|null $match_content + * A callback to modify the content of the files before comparison. */ - protected function assertDirectoriesEqual(string $dir1, string $dir2): void { + protected function assertDirectoriesEqual(string $dir1, string $dir2, ?callable $match_content = NULL): void { $rules_file = $dir1 . DIRECTORY_SEPARATOR . '.ignorecontent'; // Initialize the rules arrays: skip, presence, include, and global. - $rules = ['skip' => ['.ignorecontent'], 'content' => [], 'include' => [], 'global' => []]; + $rules = ['skip' => ['.ignorecontent'], 'ignore_content' => [], 'include' => [], 'global' => []]; // Parse the .ignorecontent file. if (file_exists($rules_file)) { @@ -444,7 +465,7 @@ protected function assertDirectoriesEqual(string $dir1, string $dir2): void { $rules['include'][] = $line[1] === '^' ? substr($line, 2) : substr($line, 1); } elseif ($line[0] === '^') { - $rules['content'][] = substr($line, 1); + $rules['ignore_content'][] = substr($line, 1); } elseif (!str_contains($line, DIRECTORY_SEPARATOR)) { // Treat patterns without slashes as global patterns. @@ -476,7 +497,7 @@ protected function assertDirectoriesEqual(string $dir1, string $dir2): void { }; // Get the files in the directories. - $get_files = static function (string $dir, array $rules, callable $match_path): array { + $get_files = static function (string $dir, array $rules, callable $match_path, ?callable $match_content): array { $files = []; $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS)); foreach ($iterator as $file) { @@ -510,21 +531,28 @@ protected function assertDirectoriesEqual(string $dir1, string $dir2): void { } } - $is_content = FALSE; + $is_ignore_content = FALSE; if (!$is_included) { - foreach ($rules['content'] as $pattern) { + foreach ($rules['ignore_content'] as $pattern) { if ($match_path($path, $pattern, $is_directory)) { - $is_content = TRUE; + $is_ignore_content = TRUE; break; } } } - if ($is_content) { - $files[$path] = 'content'; + if ($is_ignore_content) { + $files[$path] = 'ignore_content'; + } + elseif ($is_directory) { + $files[$path] = 'ignore_content'; } else { - $files[$path] = $is_directory ? 'content' : md5_file($file->getPathname()); + $content = file_get_contents($file->getPathname()); + if (is_callable($match_content)) { + $content = $match_content($content, $file); + } + $files[$path] = md5($content); } } ksort($files); @@ -532,8 +560,8 @@ protected function assertDirectoriesEqual(string $dir1, string $dir2): void { return $files; }; - $dir1_files = $get_files($dir1, $rules, $match_path); - $dir2_files = $get_files($dir2, $rules, $match_path); + $dir1_files = $get_files($dir1, $rules, $match_path, $match_content); + $dir2_files = $get_files($dir2, $rules, $match_path, $match_content); // Allow updating the test fixtures. if (getenv('UPDATE_TEST_FIXTURES')) { @@ -560,7 +588,7 @@ protected function assertDirectoriesEqual(string $dir1, string $dir2): void { // Compare files where content is not ignored. foreach ($dir1_files as $file => $hash) { - if (isset($dir2_files[$file]) && $hash !== $dir2_files[$file] && !in_array($file, $rules['content'])) { + if (isset($dir2_files[$file]) && $hash !== $dir2_files[$file] && !in_array($file, $rules['ignore_content'])) { $diffs['different_files'][] = $file; } } diff --git a/tests/phpunit/Traits/ReflectionTrait.php b/tests/phpunit/Traits/ReflectionTrait.php deleted file mode 100644 index 4b3814e..0000000 --- a/tests/phpunit/Traits/ReflectionTrait.php +++ /dev/null @@ -1,102 +0,0 @@ -hasMethod($name)) { - throw new \InvalidArgumentException(sprintf('Method %s does not exist', $name)); - } - - $method = $class->getMethod($name); - - $original_accessibility = $method->isPublic(); - - // Set method accessibility to true, so it can be invoked. - $method->setAccessible(TRUE); - - // If the method is static, we won't pass an object instance to invokeArgs() - // Otherwise, we ensure to pass the object instance. - $invoke_object = $method->isStatic() ? NULL : (is_object($object) ? $object : NULL); - - // Ensure we have an object for non-static methods. - if (!$method->isStatic() && $invoke_object === NULL) { - throw new \InvalidArgumentException("An object instance is required for non-static methods"); - } - - $result = $method->invokeArgs($invoke_object, $args); - - // Reset the method's accessibility to its original state. - $method->setAccessible($original_accessibility); - - return $result; - } - - /** - * Set protected property value. - * - * @param object $object - * Object to set the value on. - * @param string $property - * Property name to set the value. Property should exists in the object. - * @param mixed $value - * Value to set to the property. - */ - protected static function setProtectedValue($object, $property, mixed $value): void { - $class = new \ReflectionClass($object::class); - $property = $class->getProperty($property); - $property->setAccessible(TRUE); - - $property->setValue($object, $value); - } - - /** - * Get protected value from the object. - * - * @param object $object - * Object to set the value on. - * @param string $property - * Property name to get the value. Property should exists in the object. - * - * @return mixed - * Protected property value. - */ - protected static function getProtectedValue($object, $property) { - $class = new \ReflectionClass($object::class); - $property = $class->getProperty($property); - $property->setAccessible(TRUE); - - return $property->getValue($class); - } - -} diff --git a/tests/phpunit/Unit/ArrayUnsetDeepTest.php b/tests/phpunit/Unit/ArrayUnsetDeepTest.php index a4c770e..020b27b 100644 --- a/tests/phpunit/Unit/ArrayUnsetDeepTest.php +++ b/tests/phpunit/Unit/ArrayUnsetDeepTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace AlexSkrypnyk\Customizer\Tests\Functional; +namespace AlexSkrypnyk\Customizer\Tests\Unit; use AlexSkrypnyk\Customizer\CustomizeCommand; use PHPUnit\Framework\Attributes\CoversClass; diff --git a/tests/phpunit/Unit/FilesTest.php b/tests/phpunit/Unit/FilesTest.php index bf6ee5b..204250d 100644 --- a/tests/phpunit/Unit/FilesTest.php +++ b/tests/phpunit/Unit/FilesTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace AlexSkrypnyk\Customizer\Tests\Functional; +namespace AlexSkrypnyk\Customizer\Tests\Unit; use AlexSkrypnyk\Customizer\CustomizeCommand; -use AlexSkrypnyk\Customizer\Tests\Traits\ReflectionTrait; +use AlexSkrypnyk\Customizer\Tests\Functional\CustomizerTestCase; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; @@ -17,8 +17,6 @@ #[Group('unit')] class FilesTest extends CustomizerTestCase { - use ReflectionTrait; - /** * {@inheritdoc} */ @@ -223,11 +221,11 @@ public static function dataProviderReplaceInPath(): array { ]; } - #[DataProvider('dataProviderReplaceInPathBetween')] - public function testReplaceInPathBetween(string $path, array $before, string $search, string $replace, string $start, string $end, array $after): void { + #[DataProvider('dataProviderreplaceInPathBetweenMarkers')] + public function testreplaceInPathBetweenMarkers(string $path, array $before, string $search, string $replace, string $start, string $end, array $after): void { $this->createFileTree(static::$sut, $before); - CustomizeCommand::replaceInPathBetween( + CustomizeCommand::replaceInPathBetweenMarkers( static::$sut . DIRECTORY_SEPARATOR . $path, $search, $replace, @@ -239,12 +237,12 @@ public function testReplaceInPathBetween(string $path, array $before, string $se } /** - * Data provider for testReplaceInPathBetween. + * Data provider for testreplaceInPathBetweenMarkers. * * @return array * The data. */ - public static function dataProviderReplaceInPathBetween(): array { + public static function dataProviderreplaceInPathBetweenMarkers(): array { return [ // Single file, only word, using file, substring. [ diff --git a/tests/phpunit/Unit/SelfTest.php b/tests/phpunit/Unit/SelfTest.php index 7277aa7..995951b 100644 --- a/tests/phpunit/Unit/SelfTest.php +++ b/tests/phpunit/Unit/SelfTest.php @@ -6,7 +6,6 @@ use AlexSkrypnyk\Customizer\CustomizeCommand; use AlexSkrypnyk\Customizer\Tests\Functional\CustomizerTestCase; -use AlexSkrypnyk\Customizer\Tests\Traits\ReflectionTrait; use PHPUnit\Framework\AssertionFailedError; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; @@ -25,8 +24,6 @@ #[Group('unit')] class SelfTest extends CustomizerTestCase { - use ReflectionTrait; - /** * {@inheritdoc} */