diff --git a/.gitattributes b/.gitattributes index 5e43d025..79c814f5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,4 +6,8 @@ .github export-ignore CONTRIBUTING.md export-ignore -phpcs.xml.dist export-ignore \ No newline at end of file +phpcs.xml.dist export-ignore +phpunit.xml.dist export-ignore + +# Ignore directories for distribution archives. +/tests/ export-ignore diff --git a/.github/workflows/integrationtest.yml b/.github/workflows/integrationtest.yml index 0c772663..a2ddc968 100644 --- a/.github/workflows/integrationtest.yml +++ b/.github/workflows/integrationtest.yml @@ -18,126 +18,32 @@ concurrency: jobs: test: - runs-on: ubuntu-latest + runs-on: "${{ matrix.os }}" strategy: matrix: - php: ['5.4', '5.5', '5.6', '7.0', '7.1', '7.2', '7.3', '7.4', '8.0', '8.1'] - phpcs_version: ['dev-master'] - phpcompat: ['composer'] - experimental: [false] - - include: - # Ensure a "highest" PHP/PHPCS build combination for PHPCS 2.x is included. - - php: '5.4' - phpcs_version: '2.9.2' - phpcompat: 'composer' - experimental: false - - # Complement the matrix with build against the lowest supported PHPCS version - # for each PHP version. - - php: '8.1' - # Lowest PHPCS version on which PHP 8.1 is supported. - phpcs_version: '3.6.1' - phpcompat: 'composer' - experimental: false - - php: '8.0' - # Lowest PHPCS version on which PHP 8.0 is supported. - phpcs_version: '3.5.7' - phpcompat: 'composer' - experimental: false - - php: '7.4' - # Lowest PHPCS version on which PHP 7.4 is supported. - phpcs_version: '3.5.0' - phpcompat: 'composer' - experimental: false - - php: '7.3' - # Lowest PHPCS version on which PHP 7.3 is supported. - phpcs_version: '3.3.1' - phpcompat: 'composer' - experimental: false - - php: '7.2' - # Lowest PHPCS version on which PHP 7.2 is supported. - phpcs_version: '2.9.2' - phpcompat: 'composer' - experimental: false - - php: '7.1' - phpcs_version: '2.0.0' - phpcompat: '^7.0' - experimental: false - - php: '7.0' - phpcs_version: '2.0.0' - phpcompat: '^7.0' - experimental: false - - php: '5.6' - phpcs_version: '2.0.0' - phpcompat: '^7.0' - experimental: false - - php: '5.5' - phpcs_version: '2.0.0' - phpcompat: '^7.0' - experimental: false - - php: '5.4' - phpcs_version: '2.0.0' - phpcompat: '^7.0' - experimental: false - - # Additional builds against arbitrary interim PHPCS versions. - - php: '7.3' - phpcs_version: '3.5.3' - phpcompat: 'composer' - experimental: false - - php: '7.2' - phpcs_version: '3.2.3' - phpcompat: 'composer' - experimental: false - - php: '7.1' - phpcs_version: '3.1.1' - phpcompat: 'composer' - experimental: false - - php: '7.0' - phpcs_version: '3.4.2' - phpcompat: 'composer' - experimental: false - - php: '7.0' - phpcs_version: '2.2.0' - phpcompat: '^8.0' - experimental: false - - php: '5.6' - phpcs_version: '3.0.2' - phpcompat: 'composer' - experimental: false - - php: '5.6' - phpcs_version: '2.4.0' - phpcompat: 'composer' - experimental: false - - php: '5.5' - phpcs_version: '2.6.1' - phpcompat: 'composer' - experimental: false - - php: '5.4' - phpcs_version: '3.5.3' - phpcompat: 'composer' - experimental: false - - php: '5.4' - phpcs_version: '2.8.1' - phpcompat: 'composer' - experimental: false - - # Experimental builds. These are allowed to fail. - - php: '8.2' - phpcs_version: 'dev-master' - phpcompat: 'composer' - experimental: true - - - php: '8.1' - phpcs_version: '4.0.x-dev as 3.9.99' - phpcompat: 'composer' - experimental: true - - name: "Integration test: PHP ${{ matrix.php }} - PHPCS ${{ matrix.phpcs_version }}" - - continue-on-error: ${{ matrix.experimental }} + php: + - '5.4' + - '5.5' + - '5.6' + - '7.0' + - '7.1' + - '7.2' + - '7.3' + - '7.4' + - '8.0' + - '8.1' + - '8.2' + composer: + - 'v1' + - 'v2' + os: + - 'ubuntu-latest' + - 'windows-latest' + + name: "Integration test" + + continue-on-error: ${{ matrix.php == '8.2' }} steps: - name: Checkout code @@ -147,41 +53,27 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - ini-values: error_reporting=-1, display_errors=On + ini-values: zend.assertions=1, error_reporting=-1, display_errors=On + tools: "composer:${{ matrix.composer }}" coverage: none - - name: 'Composer: set PHPCS version for tests' - run: composer require --no-update --no-scripts squizlabs/php_codesniffer:"${{ matrix.phpcs_version }}" --no-interaction - - # Install PHPCompatibility 7.x/8.x for PHPCS < 2.3. - - name: 'Composer: set PHPCompatibility version for tests (PHPCS < 2.3)' - if: ${{ matrix.phpcompat != 'composer' }} - run: composer require --dev --no-update --no-scripts phpcompatibility/php-compatibility:"${{ matrix.phpcompat }}" --no-interaction + - name: "Conditionally disable tls (Composer 1.x/Windows/PHP 5.4)" + if: ${{ matrix.os == 'windows-latest' && matrix.composer == 'v1' && matrix.php == '5.4' }} + run: composer config -- disable-tls true # Install dependencies and handle caching in one go. # @link https://github.com/marketplace/actions/install-composer-dependencies - - name: 'Install Composer dependencies' + - name: Install Composer dependencies + if: ${{ matrix.php != '8.2' }} uses: "ramsey/composer-install@v2" with: - composer-options: --no-scripts --optimize-autoloader - - # Rename the PHPCompatibility directory as PHPCompatibility 7.x wasn't fully compatible with Composer yet. - - name: 'Rename the PHPCompatibility directory (PHPCS < 2.2)' - if: ${{ matrix.phpcompat == '^7.0' }} - run: mv ./vendor/phpcompatibility/php-compatibility ./vendor/phpcompatibility/PHPCompatibility - - - name: 'Install standards' - run: composer install-codestandards + composer-options: '--optimize-autoloader' - - name: 'Show installed standards' - run: vendor/bin/phpcs -i - - # Test that an external standard has been registered correctly by running it against the codebase on PHPCS < 2.3. - - name: 'Test the PHPCompatibility standard was installed succesfully (PHPCS < 2.3)' - if: ${{ matrix.phpcompat != 'composer' }} - run: ./vendor/bin/phpcs -ps ./src/ --standard=PHPCompatibility --sniffs=PHPCompatibility.PHP.DeprecatedFunctions --runtime-set testVersion ${{ matrix.php }} + - name: Install Composer dependencies + if: ${{ matrix.php == '8.2' }} + uses: "ramsey/composer-install@v2" + with: + composer-options: '--ignore-platform-reqs --optimize-autoloader' - # Test that an external standard has been registered correctly by running it against the codebase. - - name: 'Test the PHPCompatibility standard was installed succesfully (PHPCS >= 2.3)' - if: ${{ matrix.phpcompat == 'composer' }} - run: ./vendor/bin/phpcs -ps ./src/ --standard=PHPCompatibility --sniffs=PHPCompatibility.FunctionUse.RemovedFunctions --runtime-set testVersion ${{ matrix.php }} + - name: Run integration tests + run: vendor/bin/phpunit --no-coverage diff --git a/.github/workflows/quicktest.yml b/.github/workflows/quicktest.yml index 1b88771a..5bf8f2fa 100644 --- a/.github/workflows/quicktest.yml +++ b/.github/workflows/quicktest.yml @@ -20,28 +20,22 @@ jobs: # This is a much quicker test which only runs the integration tests against a limited set of # supported PHP/PHPCS combinations. quicktest: - runs-on: ubuntu-latest + runs-on: "${{ matrix.os }}" strategy: matrix: - include: - - php: 'latest' - phpcs_version: 'dev-master' - phpcompat: 'composer' - - php: '7.3' - phpcs_version: '2.9.2' - phpcompat: 'composer' - - php: '7.1' - phpcs_version: '3.3.1' - phpcompat: 'composer' - - php: '5.6' - phpcs_version: '2.6.0' - phpcompat: 'composer' - - php: '5.4' - phpcs_version: '2.0.0' - phpcompat: '^7.0' + php: + - '5.4' + - '7.2' + - 'latest' + composer: + - 'v1' + - 'v2' + os: + - 'ubuntu-latest' + - 'windows-latest' - name: "Quick test: PHP ${{ matrix.php }} - PHPCS ${{ matrix.phpcs_version }}" + name: "Quick test" steps: - name: Checkout code @@ -51,41 +45,20 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - ini-values: error_reporting=-1, display_errors=On + ini-values: zend.assertions=1, error_reporting=-1, display_errors=On + tools: "composer:${{ matrix.composer }}" coverage: none - - name: 'Composer: set PHPCS version for tests' - run: composer require --no-update --no-scripts squizlabs/php_codesniffer:"${{ matrix.phpcs_version }}" --no-interaction - - # Install PHPCompatibility 7.x/8.x for PHPCS < 2.3. - - name: 'Composer: set PHPCompatibility version for tests (PHPCS < 2.3)' - if: ${{ matrix.phpcompat != 'composer' }} - run: composer require --dev --no-update --no-scripts phpcompatibility/php-compatibility:"${{ matrix.phpcompat }}" --no-interaction + - name: "Conditionally disable tls (Composer 1.x/Windows/PHP 5.4)" + if: ${{ matrix.os == 'windows-latest' && matrix.composer == 'v1' && matrix.php == '5.4' }} + run: composer config -- disable-tls true # Install dependencies and handle caching in one go. # @link https://github.com/marketplace/actions/install-composer-dependencies - - name: 'Install Composer dependencies' + - name: Install Composer dependencies uses: "ramsey/composer-install@v2" with: - composer-options: --no-scripts --optimize-autoloader - - # Rename the PHPCompatibility directory as PHPCompatibility 7.x wasn't fully compatible with Composer yet. - - name: 'Rename the PHPCompatibility directory (PHPCS < 2.2)' - if: ${{ matrix.phpcompat == '^7.0' }} - run: mv ./vendor/phpcompatibility/php-compatibility ./vendor/phpcompatibility/PHPCompatibility - - - name: 'Install standards' - run: composer install-codestandards - - - name: 'Show installed standards' - run: vendor/bin/phpcs -i - - # Test that an external standard has been registered correctly by running it against the codebase on PHPCS < 2.3. - - name: 'Test the PHPCompatibility standard was installed succesfully (PHPCS < 2.3)' - if: ${{ matrix.phpcompat != 'composer' }} - run: ./vendor/bin/phpcs -ps ./src/ --standard=PHPCompatibility --sniffs=PHPCompatibility.PHP.DeprecatedFunctions --runtime-set testVersion ${{ matrix.php }} + composer-options: '--optimize-autoloader' - # Test that an external standard has been registered correctly by running it against the codebase. - - name: 'Test the PHPCompatibility standard was installed succesfully (PHPCS >= 2.3)' - if: ${{ matrix.phpcompat == 'composer' }} - run: ./vendor/bin/phpcs -ps ./src/ --standard=PHPCompatibility --sniffs=PHPCompatibility.FunctionUse.RemovedFunctions --runtime-set testVersion ${{ matrix.php }} + - name: Run integration tests + run: vendor/bin/phpunit --no-coverage diff --git a/.gitignore b/.gitignore index c5f3d254..568b9ede 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ vendor/ composer.lock .phpcs.xml phpcs.xml +.phpunit.result.cache +phpunit.xml +/build/ +/tests/artifact/*.zip diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6056ec53..5c4b8d99 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,7 +23,9 @@ Even better: You could submit a pull request with a fix / new feature! 1. Search our repository for open or closed [pull requests][prs] that relate to your submission. You don't want to duplicate effort. -2. You may merge the pull request in once you have the sign-off of two other +2. All pull requests are expected to be accompanied by tests which cover the change. + +3. You may merge the pull request in once you have the sign-off of two other developers, or if you do not have permission to do that, you may request the second reviewer to merge it for you. @@ -39,6 +41,7 @@ These tools fall into two categories: PHP and non-PHP. The PHP specific tools used by this build are: +- [PHPUnit][] and the [PHPUnit Polyfills][] for the integration tests. - [PHP_CodeSniffer][] to verify PHP code complies with the [PSR12][] standard. - [PHPCompatibility][] to verify that code is written in a PHP cross-version compatible manner. - [PHP-Parallel-Lint][] to check against parse errors in PHP files. @@ -57,6 +60,8 @@ can be downloaded suitable for your operating system from their [releases page][ Alternatively, these tools can be run using `docker run`, through the Docker images provided by [Pipeline-Component][]. +[PHPUnit]: https://phpunit.de/ +[PHPUnit Polyfills]: https://github.com/Yoast/PHPUnit-Polyfills/ [PHP_CodeSniffer]: https://github.com/squizlabs/PHP_CodeSniffer [PHPCompatibility]: https://github.com/PHPCompatibility/PHPCompatibility [PHP-Parallel-Lint]: https://github.com/php-parallel-lint/PHP-Parallel-Lint @@ -64,6 +69,39 @@ images provided by [Pipeline-Component][]. [PSR12]: https://www.php-fig.org/psr/psr-12/ [releases page]: https://github.com/fabpot/local-php-security-checker/releases/ +#### Automated testing + +This package includes a test setup for automated testing on all supported PHP versions +using [PHPUnit][] with the [PHPUnit Polyfills][]. +This means that tests can be written for the latest version of PHPUnit +(9.x at the time of writing) and still be run on all PHPUnit versions needed to test +all supported PHP versions (PHPUnit 4.x - 9.x). + +The tests can be run both via a Composer installed version of PHPUnit, as well as using +a PHPUnit PHAR file, however, whichever way you run the tests, you will always need to +make sure that `composer install` has been run on the repository to make sure the +PHPUnit Polyfills are available. + +**Note**: _as these tests run Composer and other CLI commands they will be slow to run._ + +To run the tests locally: +1. Run `composer install` +2. Run the tests either using a PHPUnit PHAR file or by calling `composer test`. + +In case the test setup has trouble locating your `composer.phar` file: + +1. Copy the `phpunit.xml.dist` file to `phpunit.xml`. + +2. Edit the `phpunit.xml` file and add the following, replacing the value with the applicable path to Composer for your local machine: + ```xml + + + + ``` + **Note**: this setting also allows for locally testing with different versions of Composer. + You could, for instance, have multiple Composer Phar files locally, `composer1.phar`, `composer2.1.phar`, `composer2.2.phar`. + By changing the path in the value of this `env` setting, you can switch which version will be used in the tests. + ### Non-PHP The non-PHP specific tools used by this build are: diff --git a/composer.json b/composer.json index 545a9cfc..ba8cf7c0 100644 --- a/composer.json +++ b/composer.json @@ -32,9 +32,12 @@ "squizlabs/php_codesniffer": "^2.0 || ^3.1.0 || ^4.0" }, "require-dev": { + "ext-json": "*", + "ext-zip": "*", "composer/composer": "*", "phpcompatibility/php-compatibility": "^9.0", - "php-parallel-lint/php-parallel-lint": "^1.3.1" + "php-parallel-lint/php-parallel-lint": "^1.3.1", + "yoast/phpunit-polyfills": "^1.0" }, "minimum-stability": "dev", "prefer-stable": true, @@ -43,6 +46,11 @@ "Dealerdirect\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\": "src/" } }, + "autoload-dev": { + "psr-4": { + "Dealerdirect\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Tests\\": "tests/" + } + }, "extra": { "class": "Dealerdirect\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin" }, @@ -52,6 +60,12 @@ ], "lint": [ "@php ./vendor/php-parallel-lint/php-parallel-lint/parallel-lint . -e php --exclude vendor --exclude .git" + ], + "test": [ + "@php ./vendor/phpunit/phpunit/phpunit --no-coverage" + ], + "coverage": [ + "@php ./vendor/phpunit/phpunit/phpunit" ] } } diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 2a73914f..493a03ea 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -9,7 +9,8 @@ . - */.github/* + */.git* + */build/* */vendor/* @@ -21,6 +22,19 @@ - + + + + + + + + */tests/bootstrap\.php$ + + + + + */tests/ + diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 00000000..7ac1a77c --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,34 @@ + + + + + + tests/IntegrationTest/ + + + + + + + + + + ./src/ + + + + + + + + diff --git a/tests/CreateComposerZipArtifacts.php b/tests/CreateComposerZipArtifacts.php new file mode 100644 index 00000000..202cdefe --- /dev/null +++ b/tests/CreateComposerZipArtifacts.php @@ -0,0 +1,249 @@ + 'composer.lock', + 'phpcs.xml.dist' => 'phpcs.xml.dist', + 'phpunit.xml.dist' => 'phpunit.xml.dist', + 'phpunit.xml' => 'phpunit.xml', + ); + + /** + * List of file extensions for files which should be excluded from the zip archives. + * + * @var array + */ + private $excludedExtensions = array( + 'md' => 'md', + 'bak' => 'bak', + 'orig' => 'orig', + ); + + /** + * List of top-level directories which should be excluded from the zip archives. + * + * Note: no need to list directories starting with a `.` as those will always be filtered out. + * + * @var array + */ + private $excludedDirs = array( + 'bin' => 'bin', + 'build' => 'build', // PHPUnit code coverage directory. + 'tests' => 'tests', + 'vendor' => 'vendor', + ); + + /** + * Constructor. + * + * @param string $artifactDir Full path to the directory to place the sipped artifacts in. + */ + public function __construct($artifactDir) + { + // Make sure the directory has a trailing slash. + $this->artifactDir = rtrim($artifactDir, '/') . '/'; + } + + /** + * Delete all zip artifacts from the artifacts directory. + * + * @return void + */ + public function clearOldArtifacts() + { + $di = new DirectoryIterator($this->artifactDir); + foreach ($di as $fileinfo) { + if ($fileinfo->isFile() && $fileinfo->getExtension() === 'zip') { + @unlink($fileinfo->getPathname()); + } + } + } + + /** + * Create a zip file of the *current* state of the plugin to be passed to Composer as an artifact. + * + * @param string $source Path to the directory to package up. + * @param string $version Version number to use for the package. + * + * @return void + */ + public function createPluginArtifact($source, $version) + { + $fileName = "dealerdirect-phpcodesniffer-composer-installer-{$version}.zip"; + $this->createZipArtifact($source, \ZIP_ARTIFACT_DIR . $fileName, $version); + } + + /** + * Create a zip package artifact for each test fixture. + * + * @param string $source The source directory where the fixtures can be found. + * Each subdirectory of this directory will be treated as a + * package to be zipped up. + * + * @return void + */ + public function createFixtureArtifacts($source) + { + $di = new DirectoryIterator($source); + foreach ($di as $fileinfo) { + if ($fileinfo->isDot() || $fileinfo->isDir() === false) { + continue; + } + + $sourcePath = $fileinfo->getRealPath(); + $composerFile = $sourcePath . '/composer.json'; + if (file_exists($composerFile) === false) { + throw new RuntimeException( + sprintf( + 'Each fixture MUST contain a composer.json file. File not found in %s', + $composerFile + ) + ); + } + + $config = json_decode(file_get_contents($composerFile), true); + if (isset($config['name']) === false) { + throw new RuntimeException( + sprintf('The fixture composer.json file is missing the "name" for the package in %s', $composerFile) + ); + } + + $targetVersion = self::FIXTURE_VERSION; + if (isset($config['version'])) { + $targetVersion = $config['version']; + } + + $package = $config['name']; + $targetFile = str_replace('/', '-', $package) . "-{$targetVersion}.zip"; + $targetPath = $this->artifactDir . $targetFile; + + $this->createZipArtifact($sourcePath, $targetPath, $targetVersion); + } + } + + /** + * Create a zip file of an arbitrary directory and package it for use by Composer. + * + * Inspired by https://github.com/composer/package-versions-deprecated/blob/c6522afe5540d5fc46675043d3ed5a45a740b27c/test/PackageVersionsTest/E2EInstallerTest.php#L262-L301 + * + * @param string $source Path to the directory to package up. + * @param string $target Path to the file where to save the zip. + * @param string $version Version number to use for the package. + * + * @return void + */ + private function createZipArtifact($source, $target, $version) + { + if (file_exists($target) === true) { + @unlink($target); + } + + $zip = new ZipArchive(); + $zip->open($target, ZipArchive::CREATE); + + $directoryIterator = new RecursiveDirectoryIterator( + realpath($source), + RecursiveDirectoryIterator::SKIP_DOTS + ); + + $filteredFileIterator = new RecursiveIteratorIterator( + new RecursiveCallbackFilterIterator( + $directoryIterator, + function (SplFileInfo $file, $key, RecursiveDirectoryIterator $iterator) { + $subPathName = $iterator->getSubPathname(); + $extension = $file->getExtension(); + + return (isset($this->excludedFiles[$subPathName]) === false) + && isset($this->excludedExtensions[$extension]) === false + && isset($this->excludedDirs[$subPathName]) === false + && $subPathName[0] !== '.'; // Not a .dot-file. + } + ), + RecursiveIteratorIterator::LEAVES_ONLY + ); + + foreach ($filteredFileIterator as $file) { + if ($file->isFile() === false) { + continue; + } + + /* + * DO NOT REMOVE! + * While this block may seem unnecessary, adding an arbitrary version number in the composer.json + * file **IS** necessary for Composer installs via a repo artifact to actual work. + * This does not seem to be documented in the Composer documentation, but if the + * version is not declared in the composer.json of the artifact, the install will fail + * with a "Package ... has no version defined." exception. + */ + if ($file->getFilename() === 'composer.json') { + $contents = json_decode(file_get_contents($file->getRealPath()), true); + $contents['version'] = $version; + + $zip->addFromString( + 'composer.json', + json_encode($contents, \JSON_UNESCAPED_SLASHES | \JSON_PRETTY_PRINT) + ); + + continue; + } + + $zip->addFile( + $file->getRealPath(), + str_replace('\\', '/', substr($file->getRealPath(), strlen(realpath($source)) + 1)) + ); + } + + $zip->close(); + } +} diff --git a/tests/DebugTestListener.php b/tests/DebugTestListener.php new file mode 100644 index 00000000..226644b6 --- /dev/null +++ b/tests/DebugTestListener.php @@ -0,0 +1,66 @@ +getName(), \PHP_EOL, self::$debugLog; + } + } + + /** + * Add information to the debug log. + * + * @param string The information to add to the log. + * + * @return void + */ + public static function debugLog($str) + { + self::$debugLog .= $str . \PHP_EOL; + } +} diff --git a/tests/IntegrationTest/BaseLineTest.php b/tests/IntegrationTest/BaseLineTest.php new file mode 100644 index 00000000..8c635346 --- /dev/null +++ b/tests/IntegrationTest/BaseLineTest.php @@ -0,0 +1,167 @@ + 'phpcs-composer-installer/baseline-test', + 'require-dev' => array( + 'squizlabs/php_codesniffer' => null, + 'dealerdirect/phpcodesniffer-composer-installer' => '*', + ), + ); + + /** + * Set up test environment before each test. + */ + protected function set_up() + { + $this->createTestEnvironment(); + } + + /** + * Clean up after each test. + */ + protected function tear_down() + { + $this->removeTestEnvironment(); + } + + /** + * Baseline test for a Composer GLOBAL install. + * + * @dataProvider dataBaseLine + * + * @param string $phpcsVersion PHPCS version to use in this test. + * This version is randomly selected from the PHPCS versions compatible + * with the PHP version used in the test. + * @param array $expectedStnds List of the standards which are expected to be registered. + * + * @return void + */ + public function testBaseLineGlobal($phpcsVersion, $expectedStnds) + { + $config = $this->composerConfig; + $config['require-dev']['squizlabs/php_codesniffer'] = $phpcsVersion; + + $this->writeComposerJsonFile($config, static::$tempGlobalPath); + $this->assertComposerValidates(static::$tempGlobalPath); + + // Make sure the plugin runs. + $expectedStdOut = $this->willPluginOutputShow() ? 'Running PHPCodeSniffer Composer Installer' : null; + $this->assertExecute( + 'composer global install -v --no-ansi', + 0, // Expected exit code. + $expectedStdOut, // Expected stdout. + null, // No stderr expectation. + 'Failed to install dependencies.' + ); + + $result = $this->executeCliCommand('"vendor/bin/phpcs" -i', static::$tempGlobalPath); + $this->assertSame(0, $result['exitcode'], 'Exitcode for phpcs -i did not match 0'); + $this->assertSame( + $expectedStnds, + $this->standardsPhraseToArray($result['stdout']), + 'Installed standards do not match the expected standards.' + ); + + // Make sure the CodeSniffer.conf file does not get created when no external standards are found. + $this->assertFileDoesNotExist( + static::$tempGlobalPath . '/vendor/squizlabs/php_codesniffer/CodeSniffer.conf' + ); + } + + /** + * Baseline test for a Composer LOCAL install. + * + * @dataProvider dataBaseLine + * + * @param string $phpcsVersion PHPCS version to use in this test. + * This version is randomly selected from the PHPCS versions compatible + * with the PHP version used in the test. + * @param array $expectedStnds List of the standards which are expected to be registered. + * + * @return void + */ + public function testBaseLineLocal($phpcsVersion, $expectedStnds) + { + $config = $this->composerConfig; + $config['require-dev']['squizlabs/php_codesniffer'] = $phpcsVersion; + + $this->writeComposerJsonFile($config, static::$tempLocalPath); + $this->assertComposerValidates(static::$tempLocalPath); + + // Make sure the plugin runs. + $expectedStdOut = $this->willPluginOutputShow() ? 'Running PHPCodeSniffer Composer Installer' : null; + $this->assertExecute( + sprintf('composer install -v --no-ansi --working-dir=%s', escapeshellarg(static::$tempLocalPath)), + 0, // Expected exit code. + $expectedStdOut, // Expected stdout. + null, // No stderr expectation. + 'Failed to install dependencies.' + ); + + $result = $this->executeCliCommand('"vendor/bin/phpcs" -i', static::$tempLocalPath); + $this->assertSame(0, $result['exitcode'], 'Exitcode for phpcs -i did not match 0'); + $this->assertSame( + $expectedStnds, + $this->standardsPhraseToArray($result['stdout']), + 'Installed standards do not match the expected standards.' + ); + + // Make sure the CodeSniffer.conf file does not get created when no external standards are found. + $this->assertFileDoesNotExist( + static::$tempLocalPath . '/vendor/squizlabs/php_codesniffer/CodeSniffer.conf' + ); + } + + /** + * Data provider. + * + * Note: PHPCS does not display the names of the standards in a fixed order, so the order in which standards + * get displayed may differ depending on the machine/OS on which the tests get run. + * With that in mind, the verification that the PHPCS native standards are the only recognized standards + * is done using a regex instead of an exact match. + * Also see: https://github.com/squizlabs/PHP_CodeSniffer/pull/3539 + * + * @return array + */ + public function dataBaseLine() + { + // Get two PHPCS versions suitable for this PHP version + `master` + PHPCS 4.x dev. + $versions = PHPCSVersions::get(2, true, true); + + $data = array(); + foreach ($versions as $version) { + $data["phpcs $version"] = array( + 'phpcsVersion' => $version, + 'expectedStnds' => PHPCSVersions::getStandards($version), + ); + } + + return $data; + } +} diff --git a/tests/IntegrationTest/RegisterExternalStandardsTest.php b/tests/IntegrationTest/RegisterExternalStandardsTest.php new file mode 100644 index 00000000..e2841dd6 --- /dev/null +++ b/tests/IntegrationTest/RegisterExternalStandardsTest.php @@ -0,0 +1,191 @@ + 'phpcs-composer-installer/register-external-stnds-one-stnd', + 'require-dev' => array( + 'squizlabs/php_codesniffer' => null, + 'phpcs-composer-installer/dummy-subdir' => '*', + 'dealerdirect/phpcodesniffer-composer-installer' => '*', + ), + ); + + /** + * Set up test environment before each test. + */ + protected function set_up() + { + $this->createTestEnvironment(); + } + + /** + * Clean up after each test. + */ + protected function tear_down() + { + $this->removeTestEnvironment(); + } + + /** + * Test registering one external standard for a Composer GLOBAL install. + * + * @dataProvider dataRegisterOneStandard + * + * @param string $phpcsVersion PHPCS version to use in this test. + * This version is randomly selected from the PHPCS versions compatible + * with the PHP version used in the test. + * + * @return void + */ + public function testRegisterOneStandardGlobal($phpcsVersion) + { + $config = $this->configOneStandard; + $config['require-dev']['squizlabs/php_codesniffer'] = $phpcsVersion; + + $this->writeComposerJsonFile($config, static::$tempGlobalPath); + $this->assertComposerValidates(static::$tempGlobalPath); + + // Install the dependencies. + $this->assertExecute( + 'composer global install --no-plugins', + 0, // Expected exit code. + null, // No stdout expectation. + null, // No stderr expectation. + 'Failed to install dependencies.' + ); + + // Verify that the standard registers correctly. + $installResult = $this->executeCliCommand('composer global install-codestandards --no-ansi'); + $this->assertSame(0, $installResult['exitcode'], 'Exitcode for install-codestandards did not match 0'); + + $this->assertMatchesRegularExpression( + '`^PHP CodeSniffer Config installed_paths set to [^\s]+/dummy-subdir$`', + trim($installResult['stdout']), + 'Installing the standards failed.' + ); + + // Make sure the CodeSniffer.conf file has been created. + $this->assertFileExists( + static::$tempGlobalPath . '/vendor/squizlabs/php_codesniffer/CodeSniffer.conf' + ); + + // Verify that PHPCS sees the external standard. + $this->assertExecute( + '"vendor/bin/phpcs" -i', + 0, // Expected exit code. + 'and DummySubDir', // Expected stdout. + '', // Empty stderr expectation. + 'Running phpcs -i failed.', + static::$tempGlobalPath + ); + + // Verify that PHPCS can with the external standard set as the standard. + $phpcsCommand = '"vendor/bin/phpcs" --standard=DummySubDir -e'; + $phpcsResult = $this->executeCliCommand($phpcsCommand, static::$tempGlobalPath); + + $this->assertSame(0, $phpcsResult['exitcode'], 'Exitcode for PHPCS explain did not match 0'); + $this->assertMatchesRegularExpression( + '`DummySubDir \(1 sniffs?\)\s+[-]+\s+DummySubDir\.Demo\.Demo(?:[\r\n]+|$)`', + $phpcsResult['stdout'], + 'Output of the PHPCS explain command did not match the expectation.' + ); + } + + /** + * Test registering one external standard for a Composer LOCAL install. + * + * @dataProvider dataRegisterOneStandard + * + * @param string $phpcsVersion PHPCS version to use in this test. + * This version is randomly selected from the PHPCS versions compatible + * with the PHP version used in the test. + * + * @return void + */ + public function testRegisterOneStandardLocal($phpcsVersion) + { + $config = $this->configOneStandard; + $config['require-dev']['squizlabs/php_codesniffer'] = $phpcsVersion; + + $this->writeComposerJsonFile($config, static::$tempLocalPath); + $this->assertComposerValidates(static::$tempLocalPath); + + // Install the dependencies. + $this->assertExecute( + sprintf('composer install --no-plugins --working-dir=%s', escapeshellarg(static::$tempLocalPath)), + 0, // Expected exit code. + null, // No stdout expectation. + null, // No stderr expectation. + 'Failed to install dependencies.' + ); + + // Verify that the standard registers correctly. + $installCommand = sprintf( + 'composer install-codestandards --no-ansi --working-dir=%s', + escapeshellarg(static::$tempLocalPath) + ); + $installResult = $this->executeCliCommand($installCommand); + $this->assertSame(0, $installResult['exitcode'], 'Exitcode for install-codestandards did not match 0'); + + $this->assertMatchesRegularExpression( + '`^PHP CodeSniffer Config installed_paths set to [^\s]+/dummy-subdir$`', + trim($installResult['stdout']), + 'Installing the standards failed.' + ); + + // Make sure the CodeSniffer.conf file has been created. + $this->assertFileExists( + static::$tempLocalPath . '/vendor/squizlabs/php_codesniffer/CodeSniffer.conf' + ); + + // Verify that PHPCS sees the external standard. + $this->assertExecute( + '"vendor/bin/phpcs" -i', + 0, // Expected exit code. + 'and DummySubDir', // Expected stdout. + '', // Empty stderr expectation. + 'Running phpcs -i failed.', + static::$tempLocalPath + ); + + // Verify that PHPCS can with the external standard set as the standard. + $phpcsCommand = '"vendor/bin/phpcs" --standard=DummySubDir -e'; + $phpcsResult = $this->executeCliCommand($phpcsCommand, static::$tempLocalPath); + + $this->assertSame(0, $phpcsResult['exitcode'], 'Exitcode for PHPCS explain did not match 0'); + $this->assertMatchesRegularExpression( + '`DummySubDir \(1 sniffs?\)\s+[-]+\s+DummySubDir\.Demo\.Demo(?:[\r\n]+|$)`', + $phpcsResult['stdout'], + 'Output of the PHPCS explain command did not match the expectation.' + ); + } + + /** + * Data provider. + * + * @return array + */ + public function dataRegisterOneStandard() + { + // Get two PHPCS versions suitable for this PHP version + `master` + PHPCS 4.x dev. + $versions = PHPCSVersions::get(2, true, true); + return PHPCSVersions::toDataprovider($versions); + } +} diff --git a/tests/PHPCSVersions.php b/tests/PHPCSVersions.php new file mode 100644 index 00000000..51b65842 --- /dev/null +++ b/tests/PHPCSVersions.php @@ -0,0 +1,423 @@ + '2.0.0', + '2.1.0' => '2.1.0', + '2.2.0' => '2.2.0', + '2.3.0' => '2.3.0', + '2.3.1' => '2.3.1', + '2.3.2' => '2.3.2', + '2.3.3' => '2.3.3', + '2.3.4' => '2.3.4', + '2.4.0' => '2.4.0', + '2.5.0' => '2.5.0', + '2.5.1' => '2.5.1', + '2.6.0' => '2.6.0', + '2.6.1' => '2.6.1', + '2.6.2' => '2.6.2', + '2.7.0' => '2.7.0', + '2.7.1' => '2.7.1', + '2.8.0' => '2.8.0', + '2.8.1' => '2.8.1', + '2.9.0' => '2.9.0', + '2.9.1' => '2.9.1', + '2.9.2' => '2.9.2', + '3.1.0' => '3.1.0', + '3.1.1' => '3.1.1', + '3.2.0' => '3.2.0', + '3.2.1' => '3.2.1', + '3.2.2' => '3.2.2', + '3.2.3' => '3.2.3', + '3.3.0' => '3.3.0', + '3.3.1' => '3.3.1', + '3.3.2' => '3.3.2', + '3.4.0' => '3.4.0', + '3.4.1' => '3.4.1', + '3.4.2' => '3.4.2', + '3.5.0' => '3.5.0', + '3.5.1' => '3.5.1', + '3.5.2' => '3.5.2', + '3.5.3' => '3.5.3', + '3.5.4' => '3.5.4', + '3.5.5' => '3.5.5', + '3.5.6' => '3.5.6', + '3.5.7' => '3.5.7', + '3.5.8' => '3.5.8', + '3.6.0' => '3.6.0', + '3.6.1' => '3.6.1', + '3.6.2' => '3.6.2', + ); + + /** + * Retrieve an array with a specific number of PHPCS versions valid for the current PHP version. + * + * @param int $number Number of PHPCS versions to retrieve (excluding master/next major). + * Defaults to `0` = all supported versions for the current PHP version. + * When a non-0 value is passed, a random selection of versions supported + * by the current PHP version will be returned. + * @param bool $addMaster Whether or not `dev-master` should be added to the version array (providing + * it supports the current PHP version). + * Defaults to `false`. + * @param bool $addNextMajor Whether or not the development branch for the next PHPCS major should be + * added to the version array (providing it supports the current PHP version). + * Defaults to `false`. + * Note: if `true`, the version will be returned in a Composer usable format. + * + * @return array Numerically indexed array with PHPCS version identifiers as values. + */ + public static function get($number = 0, $addMaster = false, $addNextMajor = false) + { + if (is_int($number) === false || $number < 0) { + throw new RuntimeException('The number parameter must be a positive integer.'); + } + + $versions = self::getSupportedVersions(); + + $selection = array_values($versions); + if ($number !== 0 && empty($versions) === false) { + $number = min($number, count($versions)); + $selection = (array) array_rand($versions, $number); + } + + if ($addMaster === true) { + $selection[] = self::MASTER; + } + + if ($addNextMajor === true && self::isNextMajorSupported()) { + $selection[] = self::NEXT_MAJOR; + } + + return $selection; + } + + /** + * Retrieve an array of the highest and lowest PHPCS versions valid for the current PHP version. + * + * @param bool $addMaster Whether or not `dev-master` should be added to the version array (providing + * it supports the current PHP version). + * Defaults to `false`. + * @param bool $addNextMajor Whether or not the development branch for the next PHPCS major should be + * added to the version array (providing it supports the current PHP version). + * Defaults to `false`. + * Note: if `true`, the version will be returned in a Composer usable format. + * + * @return array Numerically indexed array with PHPCS version identifiers as values. + */ + public static function getHighLow($addMaster = false, $addNextMajor = false) + { + $versions = self::getSupportedVersions(); + $selection = array(); + + if (empty($versions) === false) { + $selection[] = min($versions); + $selection[] = max($versions); + } + + if ($addMaster === true) { + $selection[] = self::MASTER; + } + + if ($addNextMajor === true && self::isNextMajorSupported()) { + $selection[] = self::NEXT_MAJOR; + } + + return $selection; + } + + /** + * Retrieve an array of the highest and lowest supported PHPCS versions for each PHPCS major + * (valid for the current PHP version). + * + * @param bool $addMaster Whether or not `dev-master` should be added to the version array (providing + * it supports the current PHP version). + * Defaults to `false`. + * @param bool $addNextMajor Whether or not the development branch for the next PHPCS major should be + * added to the version array (providing it supports the current PHP version). + * Defaults to `false`. + * Note: if `true`, the version will be returned in a Composer usable format. + * + * @return array Numerically indexed array with PHPCS version identifiers as values. + */ + public static function getHighLowEachMajor($addMaster = false, $addNextMajor = false) + { + $versions = self::getSupportedVersions(); + $versions2 = array(); + $versions3 = array(); + + if (empty($versions) === false) { + $versions2 = array_filter( + $versions, + function ($v) { + return $v[0] === '2'; + } + ); + $versions3 = array_filter( + $versions, + function ($v) { + return $v[0] === '3'; + } + ); + } + + $selection = array(); + if (empty($versions2) === false) { + $selection[] = min($versions2); + $selection[] = max($versions2); + } + + if (empty($versions3) === false) { + $selection[] = min($versions3); + $selection[] = max($versions3); + } + + if ($addMaster === true) { + $selection[] = self::MASTER; + } + + if ($addNextMajor === true && self::isNextMajorSupported()) { + $selection[] = self::NEXT_MAJOR; + } + + return $selection; + } + + /** + * Get a random PHPCS version which is valid for the current PHP version. + * + * @param bool $inclMaster Whether or not `dev-master` should be included in the array to pick + * the version from (providing it supports the current PHP version). + * Defaults to `false`. + * @param bool $inclNextMajor Whether or not the development branch for the next PHPCS major should be included + * in the array to pick the version (providing it supports the current PHP version). + * Defaults to `false`. + * Note: if `true`, the version will be returned in a Composer usable format. + * + * @return string + */ + public static function getRandom($inclMaster = false, $inclNextMajor = false) + { + $versions = self::getSupportedVersions(); + + if ($inclMaster === true) { + $versions[self::MASTER] = self::MASTER; + } + + if ($inclNextMajor === true && self::isNextMajorSupported()) { + $versions[self::NEXT_MAJOR] = self::NEXT_MAJOR; + } + + return array_rand($versions); + } + + /** + * Convert a versions array to an array suitable for use as a PHPUnit dataprovider. + * + * @param array $versions Array with PHPCS version numbers as values. + * + * @return array Array of PHPCS version identifiers in a format usable for a test data provider. + */ + public static function toDataprovider($versions) + { + if (is_array($versions) === false || $versions === array()) { + throw new RuntimeException('The versions parameter must be a non-empty array.'); + } + + $data = array(); + foreach ($versions as $version) { + $data['phpcs ' . $version] = array( + 'phpcsVersion' => $version, + ); + } + + return $data; + } + + /** + * Retrieve an array with PHPCS versions valid for the current PHP version. + * + * @return array Array with PHPCS version identifiers as both keys and values. + */ + public static function getSupportedVersions() + { + /* + * Adjust the list of available versions based on the PHP version on which the tests are run. + */ + switch (\CLI_PHP_MINOR) { + case '5.3': + $versions = array_filter( + self::$allPhpcsVersions, + function ($version) { + // PHPCS 2.9.2 is the highest version still supporting PHP 5.3. + return version_compare($version, '2.9.2', '<='); + } + ); + break; + + case '7.2': + $versions = array_filter( + self::$allPhpcsVersions, + function ($version) { + // PHPCS 2.9.2 is the first PHPCS version with runtime support for PHP 7.2. + return version_compare($version, '2.9.2', '>='); + } + ); + break; + + case '7.3': + $versions = array_filter( + self::$allPhpcsVersions, + function ($version) { + // PHPCS 3.3.1 is the first PHPCS version with runtime support for PHP 7.3. + return version_compare($version, '3.3.1', '>='); + } + ); + break; + + case '7.4': + $versions = array_filter( + self::$allPhpcsVersions, + function ($version) { + // PHPCS 3.5.0 is the first PHPCS version with runtime support for PHP 7.4. + return version_compare($version, '3.5.0', '>='); + } + ); + break; + + case '8.0': + $versions = array_filter( + self::$allPhpcsVersions, + function ($version) { + // PHPCS 3.5.7 is the first PHPCS version with runtime support for PHP 8.0. + return version_compare($version, '3.5.7', '>='); + } + ); + break; + + case '8.1': + $versions = array_filter( + self::$allPhpcsVersions, + function ($version) { + // PHPCS 3.6.1 is the first PHPCS version with runtime support for PHP 8.1. + return version_compare($version, '3.6.1', '>='); + } + ); + break; + + case '8.2': + /* + * At this point in time, it is unclear as of which PHPCS version PHP 8.2 will be supported. + * In other words: tests should only use dev-master/4.x when on PHP 8.2 for the time being. + */ + $versions = array(); + break; + + default: + $versions = self::$allPhpcsVersions; + break; + } + + return $versions; + } + + /** + * Determine if the current PHP version is supported on the "next major" branch of PHPCS. + * + * @return bool + */ + public static function isNextMajorSupported() + { + return version_compare(\CLI_PHP_MINOR, '7.2', '>='); + } + + /** + * Retrieve an array of the PHPCS native standards which are included in a particular PHPCS version. + * + * @param string $version PHPCS version number. + * + * @return array Numerically indexed array of standards, natural sort applied. + */ + public static function getStandards($version) + { + if ( + is_string($version) === false + || (isset(self::$allPhpcsVersions[$version]) === false + && $version !== self::MASTER + && $version !== self::NEXT_MAJOR) + ) { + throw new RuntimeException('The version parameter must be a valid PHPCS version number as a string.'); + } + + $standards = array( + 'PEAR', + 'PSR1', + 'PSR2', + 'Squiz', + 'Zend', + ); + + if ($version !== self::NEXT_MAJOR) { + // The MySource standard is available in PHPCS 2.x and 3.x, but will be removed in 4.0. + $standards[] = 'MySource'; + } + + if ( + $version !== self::MASTER + && $version !== self::NEXT_MAJOR + && version_compare($version, '3.0.0', '<') + ) { + // The PHPCS standard was available in PHPCS 2.x, but has been removed in 3.0. + $standards[] = 'PHPCS'; + } + + if ( + $version === self::MASTER + || $version === self::NEXT_MAJOR + || version_compare($version, '3.3.0', '>=') + ) { + // The PSR12 standard is available since PHPCS 3.3.0. + $standards[] = 'PSR12'; + } + + sort($standards, \SORT_NATURAL); + + return $standards; + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 00000000..57b00c04 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,582 @@ + 'local', + 'tempGlobalPath' => 'global', + ); + + foreach ($subDirs as $property => $subDir) { + $path = static::$tempDir . '/' . $subDir; + if (mkdir($path, 0766, true) === false || is_dir($path) === false) { + throw new RuntimeException("Failed to create the $path directory for the test"); + } + + static::${$property} = $path; + } + + putenv('COMPOSER_HOME=' . static::$tempGlobalPath); + } + + public static function removeTestEnvironment() + { + if (file_exists(static::$tempDir) === true) { + // Remove temp directory, including all files. + if (static::onWindows() === true) { + // Windows. + exec(sprintf('rd /s /q %s', escapeshellarg(static::$tempDir)), $output, $exitCode); + } else { + exec(sprintf('rm -rf %s', escapeshellarg(static::$tempDir)), $output, $exitCode); + } + + if ($exitCode !== 0) { + throw new RuntimeException( + 'Failed to remove the temp directory created for the test: ' . \PHP_EOL . 'Error: ' . $output + ); + } + + clearstatcache(); + } + + putenv('COMPOSER_HOME'); + } + + + /* ***** CUSTOM ASSERTIONS ***** */ + + /** + * Assert that a composer.json file is valid for use in the tests. + * + * @param string $workingDir The working directory in which to execute the command. + * @param string $file The file to execute the command on. + * By default the command will execute on the `composer.json` file + * in the current or working directory. + * + * @return void + * + * @throws RuntimeException When either passed argument is not a string. + * @throws RuntimeException When both arguments are passed as Composer can only handle one. + */ + public function assertComposerValidates($workingDir = '', $file = '') + { + if (is_string($workingDir) === false) { + throw new RuntimeException('Working directory must be a string.'); + } + + if (is_string($file) === false) { + throw new RuntimeException('File must be a string.'); + } + + if ($workingDir !== '' && $file !== '') { + throw new RuntimeException( + 'Pass either the working directory OR a file name. Composer does not handle both in the same command.' + ); + } + + $command = 'composer validate --no-check-all --no-check-publish --no-check-lock --no-ansi'; + $stderr = '%s is valid'; + $message = 'Provided Composer configuration is not valid.'; + + if ($workingDir !== '') { + $command .= sprintf(' --working-dir=%s', escapeshellarg($workingDir)); + $stderr = sprintf($stderr, 'composer.json'); + $message .= ' Working directory: ' . $workingDir; + } + + if ($file !== '') { + $command .= ' ' . escapeshellarg($file); + $stderr = sprintf($stderr, $file); + $message .= ' File: ' . $file; + } + + $this->assertExecute( + $command, + 0, // Expected exit code. + null, // Expected stdout. + $stderr, // Expected stderr. + $message + ); + } + + /** + * Assert that a command when executed meets certain expectations for exit code and output. + * + * Note: the stdout and stderr assertions will verify that the passed expectation is a **substring** + * of the actual output using `assertStringContainsString()`. + * + * The stdout and stderr assertions will disregard potential color codes in the actual output + * when no color codes are included in the expectation. + * + * If more specific assertions are needed, use the `TestCase::executeCliCommand()` directly and + * apply assertions to the results from that function call. + * + * @param string $command The CLI command to execute. + * @param int|null $expectedExitCode Optional. The expected exit code for the command. + * @param string|null $expectedStdOut Optional. The expected command output to stdout. + * @param string|null $expectedStdErr Optional. The expected command output to stderr. + * @param string $message Optional. Message to display when an assertion fails. + * @param string|null $workingDir Optional. The directory in which to execute the command. + * Defaults to `null` = the working directory of the current PHP process. + * Note: if the command itself already contains a "working directory" argument, + * this parameter will normally not need to be passed. + * + * @return void + * + * @throws RuntimeException When neither $expectedExitCode, $expectedStdOut or $expectedStdErr are passed. + */ + public function assertExecute( + $command, + $expectedExitCode = null, + $expectedStdOut = null, + $expectedStdErr = null, + $message = '', + $workingDir = null + ) { + if ($expectedExitCode === null && $expectedStdOut === null && $expectedStdErr === null) { + throw new RuntimeException('At least one expectation has to be set for the executed command.'); + } + + $result = $this->executeCliCommand($command, $workingDir); + + if (is_string($expectedStdOut)) { + $msg = 'stdOut did not contain the expected output. ' . $message; + + if ($expectedStdOut === '') { + $this->assertSame($expectedStdOut, $result['stdout'], $msg); + } else { + $stdout = $this->maybeStripColors($expectedStdOut, $result['stdout']); + $this->assertStringContainsString($expectedStdOut, $stdout, $msg); + } + } + + if (is_string($expectedStdErr)) { + $msg = 'stdErr did not contain the expected output. ' . $message; + + if ($expectedStdErr === '') { + $this->assertSame($expectedStdErr, $result['stderr'], $msg); + } else { + $stderr = $this->maybeStripColors($expectedStdErr, $result['stderr']); + $this->assertStringContainsString($expectedStdErr, $stderr, $msg); + } + } + + if (is_int($expectedExitCode)) { + $msg = 'Exit code did not match expected code. ' . $message; + $this->assertSame($expectedExitCode, $result['exitcode'], $msg); + } + } + + + /* ***** HELPER METHODS ***** */ + + /** + * Determine whether or not the tests are being run on Windows. + * + * @return bool + */ + protected static function onWindows() + { + return stripos(\PHP_OS, 'WIN') === 0; + } + + /** + * Determine whether output expectations can be set for a typical Composer `install`/`update` run. + * + * Composer 1.x on Windows with PHP 5.5 DOES run the plugin, but doesn't consistently show this in the logs. + * This method can be used to still test output expectations in _most_ cases, without failing the tests + * in the rare case they won't show. + * + * It is recommended to only add a call to this method to a test when it has been proven + * to fail without it. + * + * @return bool + */ + protected function willPluginOutputShow() + { + return ((\CLI_PHP_MINOR === '5.5' + && $this->onWindows() === true + && strpos(\COMPOSER_VERSION, '1') === 0) === false); + } + + /** + * Create a composer.json file based on a given configuration. + * + * @param array $config Composer configuration as an array. + * @param string $directory Location to write the resulting `composer.json` file to (without trailing slash). + * + * @return void + * + * @throws RuntimeException When either of the passed parameters are of the wrong data type. + * @throws RuntimeException When the provided configuration is invalid. + * @throws RuntimeException When the configuration could not be written to a file. + */ + protected static function writeComposerJsonFile($config, $directory) + { + if (is_array($config) === false || $config === array()) { + throw new RuntimeException('Configuration must be a non-empty array.'); + } + + if (is_string($directory) === false || $directory === '') { + throw new RuntimeException('Directory must be a non-empty string.'); + } + + // Inject artifact for this plugin and some dummy standards. + if (isset($config['repositories']) === false) { + $config['repositories'][] = array( + 'type' => 'artifact', + 'url' => \ZIP_ARTIFACT_DIR, + ); + } + + // Inject ability to run the plugin via a script. + if (isset($config['scripts']['install-codestandards']) === false) { + $config['scripts']['install-codestandards'] = array( + 'Dealerdirect\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin::run', + ); + } + + // Inject permission for this plugin to run (Composer 2.2 compat). + if (isset($config['config']['allow-plugins']['dealerdirect/phpcodesniffer-composer-installer']) === false) { + $config['config']['allow-plugins']['dealerdirect/phpcodesniffer-composer-installer'] = true; + } + + /* + * Disable TLS when on Windows with Composer 1.x and PHP 5.4. + * @link https://github.com/composer/composer/issues/10495 + */ + if (static::onWindows() === true && \CLI_PHP_MINOR === '5.4' && strpos(\COMPOSER_VERSION, '1') === 0) { + $config['config']['disable-tls'] = true; + } + + $encoded = json_encode($config, \JSON_UNESCAPED_SLASHES | \JSON_PRETTY_PRINT); + if (json_last_error() !== \JSON_ERROR_NONE || $encoded === false) { + throw new RuntimeException('Provided configuration can not be encoded to valid JSON'); + } + + $written = file_put_contents($directory . '/composer.json', $encoded); + + if ($written === false) { + throw new RuntimeException('Failed to create the composer.json file in the temp directory for the test'); + } + + // Add debug information to the test listener which will be displayed in case the test fails. + DebugTestListener::debugLog( + '---------------------------------------' . \PHP_EOL + . 'composer.json: ' . \PHP_EOL + . $encoded . \PHP_EOL + . '---------------------------------------' . \PHP_EOL + ); + } + + /** + * Helper function for CLI commands. + * + * This function stabilizes the CLI command for the purpose of these tests when the + * tests are run in a non-isolated environment with multiple installed PHP versions + * and multiple installed Composer versions. + * + * This prevents the system default PHP version being used instead of the PHP version + * which was used to initiate the test run. + * Similarly, this prevents the system default Composer version being used instead of the + * target Composer version for this test run. + * + * @param string $command The command to stabilize. + * @param string|null $workingDir Optional. The directory in which the command will be executed. + * Defaults to `null` = the working directory of the current PHP process. + * + * @return string + * + * @throws RuntimeException When the passed command is not a string. + */ + protected static function stabilizeCommand($command, $workingDir = null) + { + if (is_string($command) === false) { + throw new RuntimeException('Command must be a string.'); + } + + if (strpos($command, 'vendor/bin/phpcs') !== false) { + $phpcsCommand = static::getPhpcsCommand($workingDir); + if (strpos($command, 'vendor/bin/phpcs') === 0) { + $command = '"' . \PHP_BINARY . '" ' . $phpcsCommand . substr($command, 16); + } + + if (strpos($command, '"vendor/bin/phpcs"') === 0) { + $command = '"' . \PHP_BINARY . '" ' . $phpcsCommand . substr($command, 18); + } + + if (strpos($command, ' vendor/bin/phpcs ') !== false) { + $command = str_replace(' vendor/bin/phpcs ', ' ' . $phpcsCommand . ' ', $command); + } + + if (strpos($command, ' "vendor/bin/phpcs" ') !== false) { + $command = str_replace(' "vendor/bin/phpcs" ', ' ' . $phpcsCommand . ' ', $command); + } + } + + if (strpos($command, 'php composer.phar ') !== false) { + $command = str_replace('php composer.phar ', '"' . \PHP_BINARY . '" "' . \COMPOSER_PHAR . '" ', $command); + } + + if (strpos($command, 'php ') === 0) { + $command = '"' . \PHP_BINARY . '" ' . substr($command, 3); + } + + if (strpos($command, ' php ') !== false) { + $command = str_replace(' php ', ' "' . \PHP_BINARY . '" ', $command); + } + + if (strpos($command, 'composer ') !== false) { + $command = str_replace('composer ', '"' . \PHP_BINARY . '" "' . \COMPOSER_PHAR . '" ', $command); + } + + // Make sure the `--no-interaction` flag is set for all Composer commands to prevent tests hanging. + if (strpos($command, '"' . \COMPOSER_PHAR . '"') !== false && strpos($command, ' --no-interaction') === false) { + $command = str_replace('"' . \COMPOSER_PHAR . '"', '"' . \COMPOSER_PHAR . '" --no-interaction', $command); + } + + /* + * If the command will be run on Windows in combination with PHP < 8.0, wrap it in an extra set of quotes. + * Note: it is unclear what changes in PHP 8.0, but the quotes will now suddenly break things. + * Ref: https://www.php.net/manual/en/function.proc-open.php#example-3331 + */ + if (static::onWindows() === true && substr(\CLI_PHP_MINOR, 0, 1) < 8) { + $command = '"' . $command . '"'; + } + + return $command; + } + + /** + * Retrieve the command to use to run PHPCS. + * + * @param string|null $workingDir Optional. The directory in which the command will be executed. + * Defaults to `null` = the working directory of the current PHP process. + * + * @return string + */ + protected static function getPhpcsCommand($workingDir = null) + { + $command = '"vendor/squizlabs/php_codesniffer/bin/phpcs"'; // PHPCS 3.x. + + if (is_string($workingDir) && file_exists($workingDir . '/vendor/squizlabs/php_codesniffer/scripts/phpcs')) { + // PHPCS 2.x. + $command = '"vendor/squizlabs/php_codesniffer/scripts/phpcs"'; + } + + return $command; + } + + /** + * Helper function to execute a CLI command. + * + * @param string $command The CLI command to execute. + * @param string|null $workingDir Optional. The directory in which to execute the command. + * Defaults to `null` = the working directory of the current PHP process. + * Note: if the command itself already contains a "working directory" argument, + * this parameter will normally not need to be passed. + * + * @return array Format: + * 'exitcode' int The exit code from the command. + * 'stdout' string The output send to stdout. + * 'stderr' string The output send to stderr. + * + * @throws RuntimeException When the passed arguments do not comply. + * @throws RuntimeException When no resource could be obtained to execute the command. + */ + public static function executeCliCommand($command, $workingDir = null) + { + if (is_string($command) === false || $command === '') { + throw new RuntimeException('Command must be a non-empty string.'); + } + + if (is_null($workingDir) === false && (is_string($workingDir) === false || $workingDir === '')) { + throw new RuntimeException('Working directory must be a non-empty string or null.'); + } + + $command = static::stabilizeCommand($command, $workingDir); + $descriptorspec = array( + 0 => array("pipe", "r"), // stdin + 1 => array("pipe", "w"), // stdout + 2 => array("pipe", "w"), // stderr + ); + + $process = proc_open($command, $descriptorspec, $pipes, $workingDir); + + if (is_resource($process) === false) { + throw new RuntimeException('Could not obtain a resource with proc_open() to execute the command.'); + } + + $result = array(); + fclose($pipes[0]); + + $result['stdout'] = stream_get_contents($pipes[1]); + fclose($pipes[1]); + + $result['stderr'] = stream_get_contents($pipes[2]); + fclose($pipes[2]); + + $result['exitcode'] = proc_close($process); + + // Add debug information to the test listener which will be displayed in case the test fails. + DebugTestListener::debugLog( + '---------------------------------------' . \PHP_EOL + . 'Command: ' . $command . \PHP_EOL + . 'Output: ' . var_export($result, true) . \PHP_EOL + . '---------------------------------------' . \PHP_EOL + ); + + return $result; + } + + /** + * Helper function which strips potential CLI colour codes from the actual output + * when the expected output does not contain any colour codes. + * + * @param string $expected Expected output. + * @param string $actual Actual output. + * + * @return string Actual output, potentially stripped of colour codes. + * + * @throws RuntimeException When either passed argument is not a string. + */ + protected function maybeStripColors($expected, $actual) + { + if (is_string($expected) === false) { + throw new RuntimeException('Expected output must be a string.'); + } + + if (is_string($actual) === false) { + throw new RuntimeException('Actual output must be a string.'); + } + + if ($expected === '') { + // Nothing to do. + return $actual; + } + + if ( + (strpos($expected, "\033") === false && strpos($actual, "\033") !== false) + || (strpos($expected, "\x1b") === false && strpos($actual, "\x1b") !== false) + ) { + $actual = preg_replace('`(?:\\\\033|\\\\x1b)\\\\[[0-9]+(;[0-9]*)[A-Za-z]`', '', $actual); + } + + return $actual; + } + + /** + * Helper function to create a file which can be used to run PHPCS against. + * + * @param string $path The full path, including filename, to write the file to. + * @param string $contents Optional. The ccntents for the file. + * Defaults to a simple `echo 'Hello world';` PHP file. + * + * @return void + * + * @throws RuntimeException When either passed argument is not a string. + * @throws RuntimeException When the file could not be created. + */ + protected function createFile($path, $contents = '') + { + if (is_string($path) === false || $path === '') { + throw new RuntimeException('Path must be a non-empty string.'); + } + + if (is_string($contents) === false) { + throw new RuntimeException('Contents must be a string.'); + } + + if ($contents === '') { + $contents = <<<'PHP' +clearOldArtifacts(); + $zipCreator->createPluginArtifact(dirname(__DIR__), \PLUGIN_ARTIFACT_VERSION); + $zipCreator->createFixtureArtifacts(__DIR__ . '/fixtures/'); + unset($zipCreator); +} else { + echo 'Please enable the zip extension before running the tests.'; + die(1); +} + +/* + * Set a few constants for use throughout the tests. + */ + +define('CLI_PHP_MINOR', substr(\PHP_VERSION, 0, strpos(\PHP_VERSION, '.', 2))); + +if (\getenv('COMPOSER_PHAR') !== false) { + define('COMPOSER_PHAR', getenv('COMPOSER_PHAR')); +} elseif (strpos(strtoupper(\PHP_OS), 'WIN') === 0) { + // Windows. + exec('where composer.phar', $output, $exitcode); + if ($exitcode === 0 && empty($output) === false) { + define('COMPOSER_PHAR', trim(implode('', $output))); + } +} else { + exec('which composer.phar', $output, $exitcode); + if ($exitcode === 0 && empty($output) === false) { + define('COMPOSER_PHAR', trim(implode('', $output))); + } +} + +if (defined('COMPOSER_PHAR') === false) { + echo 'Please add a configuration to your local phpunit.xml' + . ' overload file before running the tests.' . \PHP_EOL + . 'The value should point to the local Composer phar file you want to use for the tests.'; + die(1); +} + +// Get the version of Composer being used. +$command = '"' . \PHP_BINARY . '" "' . \COMPOSER_PHAR . '" --version --no-ansi --no-interaction'; +$lastLine = exec($command, $output, $exitcode); +if ($exitcode === 0 && preg_match('`Composer version ([^\s]+)`', $lastLine, $matches) === 1) { + define('COMPOSER_VERSION', $matches[1]); +} else { + echo 'Could not determine the version of Composer being used.'; + die(1); +} diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 00000000..6a470872 --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,42 @@ +# Test fixtures + +The subdirectories in this folder contain "fake" PHPCS standards. + +As more and more external PHPCS standards include a `require` for this plugin, these test fixtures should be used in the integration tests instead of _real_ PHPCS standards. + +Using these fixtures will make creating tests more straight-forward as: +* It should prevent issues with the plugin version as used in the tests (version of the created zip artifact) not matching the version constraint for the plugin in a _real_ standard, which would result in Composer downloading from Packagist instead of using the artifact package created for use in the tests. +* It means we don't need to keep track of the version history of external standards in regards to: + - whether or not the external standard uses the correct project `type` in their `composer.json`. + - whether or not they `require` the plugin. + - whether or not they comply with the PHPCS naming conventions. + - whether or not the standard is compatible with PHPCS 2.x/3.x/4.x. + - etc... + +Each subdirectory in this `fixtures` directory will be zipped up and placed in the `artifact` subdirectory ahead of running the tests, making them available to all tests. + +The artifact version of each fake standard will always be `1.0.0`, unless otherwise indicated. +Setting a different version for a fake standard can be achieved by explicitly setting the `version` in the `composer.json` file of the fake standard. + +Any particular test can use one or more of these fake standards. + +Notes: +* The "fake" standards DO need to comply with the naming conventions from PHPCS, which means that the name of a standard as set in the `ruleset.xml` file MUST be the same as the name of the directory containing the `ruleset.xml` file. + So the `ruleset.xml` file for a standard called `Dummy` MUST be in a (sub)directory named `Dummy`. +* A "fake" standard will normally consist of a `composer.json` file in the fake project root and one or more `ruleset.xml` files. +* If the "fake" standard `require`s the plugin, it should do so with `'*'` as the version constraint. +* The "fake" standards generally do NOT need to contain any sniffs or actual rules in the ruleset (unless the standard will be used in a test for running PHPCS). + +It is recommended to add a short description of the situation which can be tested with each fixture to the below list. + +### Package name: `phpcs-composer-installer/dummy-subdir` + +**Description:** +An external PHPCS standard with the `ruleset.xml` file in a subdirectory ("normal" standard setup). + +| Characteristics | Notes | +|--------------------------|--------------------------------------------------------------------------------------------------| +| **Standard(s):** | `DummySubDir` | +| **Includes sniff(s):** | :heavy_checkmark: One sniff - `DummySubDir.Demo.Demo` - which is PHPCS cross-version compatible. | +| **Requires the plugin:** | :x: | + diff --git a/tests/fixtures/dummy-subdir/DummySubDir/Sniffs/Demo/DemoSniff.php b/tests/fixtures/dummy-subdir/DummySubDir/Sniffs/Demo/DemoSniff.php new file mode 100644 index 00000000..f18b7583 --- /dev/null +++ b/tests/fixtures/dummy-subdir/DummySubDir/Sniffs/Demo/DemoSniff.php @@ -0,0 +1,46 @@ + + + + Dummy PHPCS standard for testing. + diff --git a/tests/fixtures/dummy-subdir/composer.json b/tests/fixtures/dummy-subdir/composer.json new file mode 100644 index 00000000..8d082365 --- /dev/null +++ b/tests/fixtures/dummy-subdir/composer.json @@ -0,0 +1,10 @@ +{ + "name" : "phpcs-composer-installer/dummy-subdir", + "description" : "Dummy PHPCS standard with subdirectory ruleset for use in the tests.", + "type" : "phpcodesniffer-standard", + "license" : "MIT", + "require" : { + "php" : ">=5.4", + "squizlabs/php_codesniffer" : "*" + } +}