diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 39bab8b..ce0151b 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -15,7 +15,7 @@ permissions: jobs: code-quality: - name: Run Code Quality Checks (PHP ${{ matrix.php }}, Deployer ${{ matrix.deployer }}) + name: Code Quality (PHP ${{ matrix.php }}, Deployer 8) runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -23,16 +23,7 @@ jobs: checks: write # needed for cs2pr to post inline annotations strategy: matrix: - include: - - { php: '8.2', deployer: '7.3' } - - { php: '8.2', deployer: '7.4' } - - { php: '8.2', deployer: '7.5' } - - { php: '8.3', deployer: '7.3' } - - { php: '8.3', deployer: '7.4' } - - { php: '8.3', deployer: '7.5' } - - { php: '8.3', deployer: '8.0' } - - { php: '8.4', deployer: '8.0' } - - { php: '8.5', deployer: '8.0' } + php: ['8.3', '8.4', '8.5'] fail-fast: false steps: @@ -52,13 +43,6 @@ jobs: - name: Validate composer.json run: composer validate --strict - - name: Set Deployer version constraint - env: - DEPLOYER_VERSION: ${{ matrix.deployer }} - run: composer require --no-update deployer/deployer:"${DEPLOYER_VERSION}.*" - - # Install dependencies with caching — uses `composer update` (highest) so the pinned - # deployer constraint above is actually resolved. - name: Install Composer dependencies uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # v4.0.0 with: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5875878..23c4fd5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -18,18 +18,9 @@ jobs: timeout-minutes: 10 strategy: matrix: - include: - - { php: '8.2', deployer: '7.3' } - - { php: '8.2', deployer: '7.4' } - - { php: '8.2', deployer: '7.5' } - - { php: '8.3', deployer: '7.3' } - - { php: '8.3', deployer: '7.4' } - - { php: '8.3', deployer: '7.5' } - - { php: '8.3', deployer: '8.0' } - - { php: '8.4', deployer: '8.0' } - - { php: '8.5', deployer: '8.0' } + php: ['8.3', '8.4', '8.5'] fail-fast: false - name: Tests for Deployer ${{ matrix.deployer }} on PHP ${{ matrix.php }} + name: Tests for Deployer 8 on PHP ${{ matrix.php }} steps: - name: Checkout repository @@ -44,23 +35,12 @@ jobs: php-version: ${{ matrix.php }} tools: composer:v2 - # Pin the Deployer version constraint before installing, so the install - # step resolves the right version (and fails fast on invalid combos like - # PHP 8.2 + Deployer 8 which requires PHP ^8.3). - - name: Set Deployer version constraint - env: - DEPLOYER_VERSION: ${{ matrix.deployer }} - run: composer require --no-update deployer/deployer:"${DEPLOYER_VERSION}.*" - - # Install dependencies with caching — uses `composer update` (highest) - # so the pinned deployer constraint above is actually resolved. - name: Install Composer dependencies uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # v4.0.0 with: dependency-versions: highest composer-options: '--prefer-dist --no-progress' - # Run PHPStan so we check compatibility with the specified Deployer version. - name: Run PHPStan run: composer run-script phpstan diff --git a/CHANGELOG.md b/CHANGELOG.md index dd4b300..29b4826 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Breaking Changes +- PHP 8.3 or newer and Deployer 8 are now required. Deployer 7 support and compatibility code were removed. +- Use Deployer's `quote()` directly instead of the removed `Utils::quote()` wrapper. - **`Files` path resolution**: Paths passed to `Files::pushFiles()` / `Files::pullFiles()` that begin with `/` are now treated as absolute rather than project-relative. Relative paths continue to resolve against `current_path` (local) or `release_or_current_path` (remote). @@ -12,6 +14,7 @@ ### Added +- Local and remote WP-CLI and database commands can use runtimes supplied by `gaambo/deployer-utils`, including DDEV. - `Files::pushFile()` and `Files::pullFile()` helpers for transferring individual files with the same path resolution semantics as directory transfers. - `Files::resolvePath()` helper to resolve a path against a base path, leaving absolute and `~`-relative paths unchanged. @@ -21,6 +24,16 @@ - Database dump paths are now resolved consistently against `deploy_path` for WP-CLI export/import and file transfers, instead of being interpreted relative to different working directories. +### Changed + +- Implemented [gaambo/deployer-utils#1](https://github.com/gaambo/deployer-utils/issues/1) by moving generic Composer, + npm, file, rsync, utility, localhost, runtime, test-support, and file-backup behavior to `gaambo/deployer-utils`. +- WordPress tasks and recipes now use the shared helpers directly. Existing `Gaambo\DeployerWordpress` helper classes + remain as silent deprecated wrappers. +- `files:push` and `files:pull` remain WordPress aggregate tasks. `files:backup:remote` and `files:backup:local` now load + from the shared package through the explicit utils task file. +- Composer now uses the public `gaambo/deployer-utils:^0.1.1` release. + ## v4.0.1 - 2026-04-28 - Update composer packages diff --git a/README.md b/README.md index 96d296b..e0d54cf 100644 --- a/README.md +++ b/README.md @@ -44,10 +44,12 @@ custom theme/plugin builds, and complex setups including Bedrock and multisite. 4. Test on staging first, then deploy to production 🚀 +This package uses the public `gaambo/deployer-utils:^0.1.1` release. + ## Requirements -- PHP + [Composer](https://getcomposer.org) -- [Deployer](https://deployer.org) (automatically installed) +- PHP 8.3 or newer and [Composer](https://getcomposer.org) +- [Deployer 8](https://deployer.org) (automatically installed) - WordPress installation - *nix OS (Linux/macOS or [WSL](https://docs.microsoft.com/en-us/windows/wsl/install-win10) on Windows) - `rsync` installed @@ -64,11 +66,7 @@ Example recipes (in `examples/`) provide configuration starting points. The libr vanilla, Composer, subdirectory, Bedrock, multisite) by making all paths and directories configurable. Check the example recipes and task source files for available options. -### Localhost context - -Version 4 introduces a proper `Localhost` context. This ensures that when tasks run on your local machine -(like building assets or backing up the local database), they use configuration values specifically defined for -localhost, rather than falling back to global or remote values. +### Localhost Context Configure localhost in `deploy.php`: @@ -81,11 +79,14 @@ localhost() ->set('backup_path', __DIR__ . '/data/backups'); ``` -The `Localhost` utility class (`Gaambo\DeployerWordpress\Localhost`) handles switching context automatically in tasks. - `deploy_path` is the project root and is used for private data such as relative database dump paths. `current_path` is the WordPress root. The previous `project_path` example setting was unused and has been removed. +Local and remote WP-CLI database commands honor runtimes configured on their source hosts and map database dump paths +into them. See the +[Deployer Utils README](https://github.com/gaambo/deployer-utils#readme) for generic localhost, runtime, Composer, npm, +file, and rsync APIs. The old `Gaambo\DeployerWordpress` helper names remain as deprecated wrappers for migration. + ### wp-config.php (Recommendation) Keep `wp-config.php` in git and deploy it. Extract environment-specific config (database credentials, `WP_DEBUG`) into @@ -173,6 +174,7 @@ set('packages', [ **File Tasks** (`tasks/files.php`) - `files:push` / `files:pull` - Sync all files (combines wp, uploads, plugins, themes, packages) +- `files:backup:remote` / `files:backup:local` - Back up all files through the shared utils tasks **WordPress Core** (`tasks/wp.php`) @@ -218,16 +220,4 @@ The library includes a comprehensive test suite with unit, integration, and func - Functional tests use a mocked environment to verify rsync commands and file operations without real remote connections. -The library supports both Deployer v7 and v8. Test against both before submitting (note: Deployer v7 requires PHP 8.2 or 8.3 — it is incompatible with PHP 8.4+): - -```bash -# Switch to Deployer v7 -composer require --no-update deployer/deployer:"7.5.*" -composer update deployer/deployer --with-dependencies -composer precommit - -# Switch back to v8 -composer require --no-update deployer/deployer:"^7.3 || ^8.0" -composer update deployer/deployer --with-dependencies -composer precommit -``` +CI tests Deployer 8 on PHP 8.3, 8.4, and 8.5. diff --git a/composer.json b/composer.json index ce21428..ec53198 100644 --- a/composer.json +++ b/composer.json @@ -14,13 +14,13 @@ "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^10.5", "squizlabs/php_codesniffer": "^3.11 || ^4.0", - "symfony/console": "^6.4 || ^7.4.0 || ^8.0.0", - "symfony/process": "^6.4 || ^7.4.0 || ^8.0.0" + "symfony/console": "^7.4.0 || ^8.0.0", + "symfony/process": "^7.4.0 || ^8.0.0" }, "require": { - "php": "^8.2", - "composer-runtime-api": "^2.0", - "deployer/deployer": "^7.3 || ^8.0" + "php": "^8.3", + "deployer/deployer": "^8.0", + "gaambo/deployer-utils": "^0.2.0" }, "autoload": { "psr-4": { @@ -30,7 +30,7 @@ "autoload-dev": { "psr-4": { "Gaambo\\DeployerWordpress\\Tests\\": "tests/", - "Gaambo\\DeployerWordpress\\PHPStan\\": "phpstan/", + "Gaambo\\DeployerUtils\\Tests\\": "vendor/gaambo/deployer-utils/tests/", "Deployer\\": "vendor/deployer/deployer/src/" } }, @@ -62,9 +62,7 @@ }, "config": { "allow-plugins": false, - "preferred-install": { - "*": "dist" - }, + "preferred-install": "dist", "policy": { "advisories": { "audit": "report" diff --git a/composer.lock b/composer.lock index 4d46d44..a8dd4fc 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ebdcbae99c91467f362967558ee2aad1", + "content-hash": "15236184d27664009356425d3fc3aa6d", "packages": [ { "name": "deployer/deployer", @@ -71,6 +71,109 @@ ], "time": "2026-05-18T16:59:34+00:00" }, + { + "name": "gaambo/deployer-utils", + "version": "v0.2.0", + "source": { + "type": "git", + "url": "https://github.com/gaambo/deployer-utils.git", + "reference": "4ce1f58925a3167f112b9cb6579c979cd7988445" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/gaambo/deployer-utils/zipball/4ce1f58925a3167f112b9cb6579c979cd7988445", + "reference": "4ce1f58925a3167f112b9cb6579c979cd7988445", + "shasum": "" + }, + "require": { + "deployer/deployer": "^8.0", + "php": "^8.3" + }, + "require-dev": { + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^10.5", + "squizlabs/php_codesniffer": "^3.11 || ^4.0", + "symfony/console": "^7.4.0 || ^8.0.0", + "symfony/process": "^7.4.0 || ^8.0.0" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Gaambo\\DeployerUtils\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Gaambo\\DeployerUtils\\Tests\\": "tests/", + "Deployer\\": "vendor/deployer/deployer/src/" + } + }, + "scripts": { + "phpcs": [ + "vendor/bin/phpcs -s" + ], + "phpcs:fix": [ + "vendor/bin/phpcbf" + ], + "phpstan": [ + "vendor/bin/phpstan analyse --memory-limit 1G" + ], + "lint": [ + "find . -type f -name '*.php' -not -path './vendor/*' -exec php -l {} \\;" + ], + "phpunit": [ + "DO_NOT_TRACK=true vendor/bin/phpunit" + ], + "tests:unit": [ + "DO_NOT_TRACK=true vendor/bin/phpunit --testsuite Unit" + ], + "tests:unit:coverage": [ + "DO_NOT_TRACK=true XDEBUG_MODE=coverage vendor/bin/phpunit --testsuite Unit --coverage-text" + ], + "tests:integration": [ + "DO_NOT_TRACK=true vendor/bin/phpunit --testsuite Integration" + ], + "tests:integration:coverage": [ + "DO_NOT_TRACK=true XDEBUG_MODE=coverage vendor/bin/phpunit --testsuite Integration --coverage-text" + ], + "tests:functional": [ + "DO_NOT_TRACK=true vendor/bin/phpunit --testsuite Functional" + ], + "tests:functional:coverage": [ + "DO_NOT_TRACK=true XDEBUG_MODE=coverage vendor/bin/phpunit --testsuite Functional --coverage-text" + ], + "tests": [ + "@tests:unit", + "@tests:integration", + "@tests:functional" + ], + "tests:coverage": [ + "DO_NOT_TRACK=true XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html=tests/coverage" + ], + "precommit": [ + "@lint", + "@phpcs", + "@phpstan", + "@composer validate", + "@tests" + ] + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabian Todt", + "email": "fabian@fabiantodt.at" + } + ], + "description": "Shared utilities for Deployer recipe packages", + "homepage": "https://github.com/gaambo/deployer-utils", + "time": "2026-09-18T10:45:53+00:00" + }, { "name": "maml/maml", "version": "v3.2.0", @@ -2688,19 +2791,20 @@ }, { "name": "squizlabs/php_codesniffer", - "version": "4.0.1", + "version": "4.0.4", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "0525c73950de35ded110cffafb9892946d7771b5" + "reference": "bbdc3d0532623e21838b7041a4364383a8126f96" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0525c73950de35ded110cffafb9892946d7771b5", - "reference": "0525c73950de35ded110cffafb9892946d7771b5", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/bbdc3d0532623e21838b7041a4364383a8126f96", + "reference": "bbdc3d0532623e21838b7041a4364383a8126f96", "shasum": "" }, "require": { + "ext-libxml": "*", "ext-simplexml": "*", "ext-tokenizer": "*", "ext-xmlwriter": "*", @@ -2709,6 +2813,10 @@ "require-dev": { "phpunit/phpunit": "^8.4.0 || ^9.3.4 || ^10.5.32 || 11.3.3 - 11.5.28 || ^11.5.31" }, + "suggest": { + "ext-iconv": "For accurate character length calculation when the checked files contain multi-byte characters.", + "ext-pcntl": "For parallel processing support via the --parallel CLI option." + }, "bin": [ "bin/phpcbf", "bin/phpcs" @@ -2763,7 +2871,7 @@ "type": "thanks_dev" } ], - "time": "2025-11-10T16:43:36+00:00" + "time": "2026-08-06T02:45:27+00:00" }, { "name": "theseer/tokenizer", @@ -2822,8 +2930,7 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": "^8.2", - "composer-runtime-api": "^2.0" + "php": "^8.3" }, "platform-dev": {}, "plugin-api-version": "2.9.0" diff --git a/examples/bedrock/deploy.php b/examples/bedrock/deploy.php index 4c105d0..c08bac5 100644 --- a/examples/bedrock/deploy.php +++ b/examples/bedrock/deploy.php @@ -1,6 +1,6 @@ getIdentifier(); - - $isV8 = version_compare( - InstalledVersions::getVersion('deployer/deployer') ?? '0.0.0', - '8.0.0', - '>=' - ); - - // argument.unknown only appears when analysing against v7 - // argument.type only appears when analysing against v8 - if ($identifier === 'argument.unknown' && $isV8) { - return false; - } - if ($identifier === 'argument.type' && !$isV8) { - return false; - } - if ($identifier !== 'argument.unknown' && $identifier !== 'argument.type') { - return false; - } - - if (!$node instanceof FuncCall || !$node->name instanceof Name) { - return false; - } - - $name = $node->name->toString(); - return $name === 'runLocally' || $name === 'Deployer\\runLocally'; - } -} diff --git a/recipes/bedrock.php b/recipes/bedrock.php index 58ce979..55e0202 100644 --- a/recipes/bedrock.php +++ b/recipes/bedrock.php @@ -8,8 +8,8 @@ namespace Gaambo\DeployerWordpress\Recipes\Bedrock; -use Gaambo\DeployerWordpress\Composer; -use Gaambo\DeployerWordpress\Rsync; +use Gaambo\DeployerUtils\Composer; +use Gaambo\DeployerUtils\Rsync; use function Deployer\add; use function Deployer\after; diff --git a/recipes/common.php b/recipes/common.php index 948fec5..2e82297 100644 --- a/recipes/common.php +++ b/recipes/common.php @@ -10,14 +10,14 @@ use Deployer\Deployer; use Deployer\Exception\ConfigurationException; use Deployer\Host\Host; -use Gaambo\DeployerWordpress\Composer; -use Gaambo\DeployerWordpress\Localhost; -use Gaambo\DeployerWordpress\Utils; use Gaambo\DeployerWordpress\WPCLI; +use Gaambo\DeployerUtils\Composer; +use Gaambo\DeployerUtils\Localhost; use function Deployer\after; use function Deployer\cd; use function Deployer\commandExist; +use function Deployer\quote; use function Deployer\currentHost; use function Deployer\get; use function Deployer\has; @@ -143,9 +143,7 @@ return $host; }); -// if you want to further define options for rsyncing files -// just look at the source in `Files.php` and `Rsync.php` -// and use the Rsync::buildOptionsArray and Files::push/pull methods +// See the gaambo/deployer-utils README for file and rsync configuration. set('wp/dir', ''); // relative to document root // config files which should be protected - add to shared_files as well set('wp/configFiles', ['wp-config.php', 'wp-config-local.php']); @@ -213,7 +211,7 @@ set('writable_dirs', ['{{uploads/dir}}']); // The default rsync config -// used by all *:push/*:pull tasks and in `src/utils/rsync.php:buildOptionsArray` +// used by all *:push/*:pull tasks and Rsync::buildOptionsArray() set('rsync', function () { $config = [ 'exclude' => [], // do NOT exclude .deployfilter files - remote should be aware of them @@ -243,7 +241,6 @@ return $config; }); -// https://github.com/deployphp/deployer/issues/3139 set('rsync_src', __DIR__); set('release_name', function () { @@ -294,7 +291,7 @@ ]; // Save metainfo about release. - $json = Utils::quote(json_encode($metainfo)); + $json = quote(json_encode($metainfo)); run("echo $json >> .dep/releases_log"); }); diff --git a/src/Composer.php b/src/Composer.php index 67b27ad..6205b57 100644 --- a/src/Composer.php +++ b/src/Composer.php @@ -2,83 +2,7 @@ namespace Gaambo\DeployerWordpress; -use function Deployer\run; - -class Composer +/** @deprecated Use \Gaambo\DeployerUtils\Composer. */ +class Composer extends \Gaambo\DeployerUtils\Composer { - private const INSTALLER_DOWNLOAD = 'https://getcomposer.org/installer'; - - /** - * Run default composer command - * Uses Deployers run function - * - * @param string $path Path in which to run composer command - * @return string Output of the command - */ - public static function runDefault(string $path): string - { - return self::runCommand($path, '{{composer_action}}', '{{composer_options}}'); - } - - /** - * Run any composer command with verbosity flags - * Uses Deployers run function - * - * @param string $path Path in which to run composer command - * @param string $command Composer command to run - * @param string $arguments Command-line arguments to be passed to composer - * @return string Output of the command - */ - public static function runCommand(string $path, string $command, string $arguments = ''): string - { - $verbosityArgument = Utils::getVerbosityArgument(); - - $runCommand = "cd $path && {{bin/composer}} $command"; - if ($arguments !== '') { - $runCommand .= " $arguments"; - } - if ($verbosityArgument !== '') { - $runCommand .= " $verbosityArgument"; - } - - return run($runCommand); - } - - /** - * Run a composer script - * Uses Deployers run function - * - * @param string $path Path in which to run script - * @param string $script Script-name defined in composer.json to be run - * @param string $arguments Command-line arguments to be passed to composer - * @return string Output of the command - */ - public static function runScript(string $path, string $script, string $arguments = ''): string - { - return self::runCommand($path, "run-script $script", $arguments); - } - - /** - * Install composer binary - * @param string $installPath Path where to install the binary - * @param string $binaryName Name for the binary file - * @param bool $sudo Whether to use sudo for moving the binary - * @return string Path to installed and moved binary file - */ - public static function install( - string $installPath, - string $binaryName = 'composer.phar', - bool $sudo = false - ): string { - $sudoPrefix = $sudo ? 'sudo ' : ''; - - run($sudoPrefix . "mkdir -p $installPath"); - run($sudoPrefix . "cd $installPath && curl -sS " . self::INSTALLER_DOWNLOAD . " | {{bin/php}}"); - - if ($binaryName !== 'composer.phar') { - run($sudoPrefix . "mv $installPath/composer.phar $installPath/$binaryName"); - } - - return "$installPath/$binaryName"; - } } diff --git a/src/Files.php b/src/Files.php index 7154340..d863fbb 100644 --- a/src/Files.php +++ b/src/Files.php @@ -2,161 +2,7 @@ namespace Gaambo\DeployerWordpress; -use function Deployer\download; -use function Deployer\get; -use function Deployer\run; -use function Deployer\runLocally; -use function Deployer\upload; - -/** - * @phpstan-import-type RsyncOptions from Rsync - */ -class Files +/** @deprecated Use \Gaambo\DeployerUtils\Files. */ +class Files extends \Gaambo\DeployerUtils\Files { - /** - * Resolve a path against a base path. - * - * Absolute paths (starting with "/") and home-relative paths (starting with "~") - * are returned unchanged. Relative paths are resolved against the given base path. - * - * This maybe a naive check, but it should be good enough for our use cases. - * - * @param string $path Path to resolve. - * @param string $basePath Base path for relative paths. - * @return string Resolved path. - */ - public static function resolvePath(string $path, string $basePath): string - { - if (str_starts_with($path, '/') || str_starts_with($path, '~')) { - return $path; - } - - return rtrim($basePath, '/') . '/' . $path; - } - - /** - * Push files from local to remote. - * - * Relative paths are resolved against localhosts current_path (local) or the - * remote hosts release_or_current_path (remote). Absolute and home-relative - * paths are used unchanged. - * - * @param string $localPath Local path to push from. - * @param string $remotePath Remote path to push to. - * @param RsyncOptions $rsyncOptions Rsync options array - * @return void - */ - public static function pushFiles(string $localPath, string $remotePath, array $rsyncOptions = []): void - { - $localBasePath = Localhost::getConfig('current_path'); - $remoteBasePath = get('release_or_current_path'); - - $localPath = self::resolvePath($localPath, $localBasePath); - $remotePath = self::resolvePath($remotePath, $remoteBasePath); - - run("mkdir -p $remotePath"); // Always ensure remote directory exists. - upload($localPath . '/', $remotePath . '/', ['options' => $rsyncOptions]); - } - - /** - * Pull files from remote to local. - * - * Relative paths are resolved against the remote hosts release_or_current_path - * (remote) or localhosts current_path (local). Absolute and home-relative - * paths are used unchanged. - * - * @param string $remotePath Remote path to pull from. - * @param string $localPath Local path to pull to. - * @param RsyncOptions $rsyncOptions Rsync options array - * @return void - */ - public static function pullFiles(string $remotePath, string $localPath, array $rsyncOptions = []): void - { - $localBasePath = Localhost::getConfig('current_path'); - $remoteBasePath = get('release_or_current_path'); - - $localPath = self::resolvePath($localPath, $localBasePath); - $remotePath = self::resolvePath($remotePath, $remoteBasePath); - - runLocally("mkdir -p $localPath"); // Always ensure local directory exists. - download($remotePath . '/', $localPath . '/', ['options' => $rsyncOptions]); - } - - /** - * Push a single file from local to remote. - * - * Relative paths are resolved against localhosts current_path (local) or the - * remote hosts release_or_current_path (remote). Absolute and home-relative - * paths are used unchanged. - * - * @param string $localPath Local file path to push. - * @param string $remotePath Remote file path to push to. - * @param RsyncOptions $rsyncOptions Rsync options array - * @return void - */ - public static function pushFile(string $localPath, string $remotePath, array $rsyncOptions = []): void - { - $localBasePath = Localhost::getConfig('current_path'); - $remoteBasePath = get('release_or_current_path'); - - $localPath = self::resolvePath($localPath, $localBasePath); - $remotePath = self::resolvePath($remotePath, $remoteBasePath); - - run('mkdir -p ' . dirname($remotePath)); // Always ensure remote directory exists. - upload($localPath, $remotePath, ['options' => $rsyncOptions]); - } - - /** - * Pull a single file from remote to local. - * - * Relative paths are resolved against the remote hosts release_or_current_path - * (remote) or localhosts current_path (local). Absolute and home-relative - * paths are used unchanged. - * - * @param string $remotePath Remote file path to pull. - * @param string $localPath Local file path to pull to. - * @param RsyncOptions $rsyncOptions Rsync options array - * @return void - */ - public static function pullFile(string $remotePath, string $localPath, array $rsyncOptions = []): void - { - $localBasePath = Localhost::getConfig('current_path'); - $remoteBasePath = get('release_or_current_path'); - - $localPath = self::resolvePath($localPath, $localBasePath); - $remotePath = self::resolvePath($remotePath, $remoteBasePath); - - runLocally('mkdir -p ' . dirname($localPath)); // Always ensure local directory exists. - download($remotePath, $localPath, ['options' => $rsyncOptions]); - } - - /** - * Zip files into a backup zip - * - * @param string $dir Directory to zip - * Can have a trailing slash, which backups the contents of the directory, - * if not it backups the directory into the zip - * @param string $backupDir Directory in which to store the zip - * @param string $filename Filename of the zip file - gets prefixed to a datetime - * @return string The full path ($backupDir + full filename) to the created zip - */ - public static function zipFiles(string $dir, string $backupDir, string $filename): string - { - $backupFilename = $filename . '_' . date('Y-m-d_H-i-s') . '.zip'; - $backupPath = "$backupDir/$backupFilename"; - run("mkdir -p $backupDir"); - - // dir can have a trailing slash (which means, backup only the content of the specified directory) - if (str_ends_with($dir, '/')) { - // Add everything from directory to zip, but exclude previous backups - run("cd $dir && zip -r $backupFilename . {{zip_options}} && mv $backupFilename $backupPath"); - } else { - $parentDir = dirname($dir); - $dir = basename($dir); - // Add dir itself to zip, but exclude previous backups - run("cd $parentDir && zip -r $backupFilename $dir {{zip_options}} && mv $backupFilename $backupPath"); - } - - return $backupPath; - } } diff --git a/src/Localhost.php b/src/Localhost.php index f24b5dd..5cda769 100644 --- a/src/Localhost.php +++ b/src/Localhost.php @@ -2,87 +2,7 @@ namespace Gaambo\DeployerWordpress; -use Deployer\Deployer; -use Deployer\Host\Host; -use Deployer\Task\Context; -use Gaambo\DeployerWordpress\Utils; - -use function Deployer\on; -use function Deployer\runLocally; - -/** - * Localhost utility class - * Provides methods to get localhost configuration values - */ -class Localhost +/** @deprecated Use \Gaambo\DeployerUtils\Localhost. */ +class Localhost extends \Gaambo\DeployerUtils\Localhost { - /** - * Get a localhost configuration value - * @param string $key The configuration key to get - * @return mixed The configuration value - */ - public static function getConfig(string $key): mixed - { - // Switch to the localhost config, so all get() calls use that context. - // Useful for dynamic calculated values. - Context::push(new Context(self::get())); - $value = self::get()->get($key); - Context::pop(); - return $value; - } - - /** - * Get the (single) defined localhost host - * - * @return Host Localhost host - */ - public static function get(): Host - { - $localhost = Deployer::get()->hosts->get('localhost'); - return $localhost; - } - - /** - * A wrapper around runLocally() which parses variables from the local host. - * The problem with runLocally() is that it parses values from the global context which - * does not use our Localhost instance. - * - * @param string $command Command to run on localhost. - * @param array{ - * cwd?:string|null, - * timeout?:int|null, - * idleTimeout?:int|null, - * env?:array|null, - * secrets?:array|null, - * nothrow?:bool, - * forceOutput?:bool, - * shell?:string|null - * }|null $options On v8 extracted to named parameters; on v7 passed as array. - * @return string - */ - public static function run(string $command, ?array $options = null): string - { - $result = null; - on(self::get(), function () use ($command, $options, &$result) { - $runOpts = $options ?? []; - if (Utils::isDeployerVersion('>=', '8.0.0')) { - // v8: runLocally uses named parameters (argument.unknown suppressed by DeployerVersionCompatExtension). - $result = runLocally( - $command, - cwd: $runOpts['cwd'] ?? null, - timeout: $runOpts['timeout'] ?? null, - idleTimeout: $runOpts['idleTimeout'] ?? null, - secrets: $runOpts['secrets'] ?? null, - env: $runOpts['env'] ?? null, - forceOutput: $runOpts['forceOutput'] ?? false, - nothrow: $runOpts['nothrow'] ?? false, - shell: $runOpts['shell'] ?? null, - ); - } else { - // v7 compat: runLocally accepted an options array. Remove when v7 support is dropped. - $result = runLocally($command, $runOpts); - } - }); - return $result; - } } diff --git a/src/NPM.php b/src/NPM.php index f7fce45..b5356bb 100644 --- a/src/NPM.php +++ b/src/NPM.php @@ -2,60 +2,7 @@ namespace Gaambo\DeployerWordpress; -use function Deployer\has; -use function Deployer\run; -use function Deployer\test; - -class NPM +/** @deprecated Use \Gaambo\DeployerUtils\NPM. */ +class NPM extends \Gaambo\DeployerUtils\NPM { - /** - * Run a npm script - * Uses Deployers run function - * - * @param string $path Path in which to run script - * @param string $script Script-name defined in package.json to be run - * @param string $arguments Command-line arguments to be passed to npm - * @return string Output of the command - */ - public static function runScript(string $path, string $script, string $arguments = ''): string - { - return self::runCommand($path, "run-script $script", $arguments); - } - - /** - * Run any npm command - * Uses Deployers run function - * - * @param string $path Path in which to run npm command - * @param string $action NPM action to be run - * @param string $arguments Command-line arguments to be passed to npm - * @return string Output of the command - */ - public static function runCommand(string $path, string $action, string $arguments = ''): string - { - $verbosityArgument = Utils::getVerbosityArgument(); - $verbosityArgument = str_replace('v', 'd', $verbosityArgument); // npm takes d for verbosity argument - - $command = "cd $path && {{bin/npm}} $action"; - if ($arguments !== '') { - $command .= " $arguments"; - } - if ($verbosityArgument !== '') { - $command .= " $verbosityArgument"; - } - - return run($command); - } - - /** - * Run npm install. - * - * @param string $path Path in which to run npm install - * @param string $arguments Command-line arguments to be passed to npm - * @return string Output of the command - */ - public static function runInstall(string $path, string $arguments = ''): string - { - return self::runCommand($path, 'install', $arguments); - } } diff --git a/src/Rsync.php b/src/Rsync.php index 69007cf..3c5c30f 100644 --- a/src/Rsync.php +++ b/src/Rsync.php @@ -2,232 +2,7 @@ namespace Gaambo\DeployerWordpress; -use function Deployer\get; - -/** - * @phpstan-type RsyncConfig array{ - * exclude?: array, - * 'exclude-file'?: string|false, - * include?: array, - * 'include-file'?: string|false, - * filter?: array, - * 'filter-file'?: string|false, - * 'filter-perdir'?: string|false, - * options?: array - * } - * - * @phpstan-type RsyncOptions list - * - */ -class Rsync +/** @deprecated Use \Gaambo\DeployerUtils\Rsync. */ +class Rsync extends \Gaambo\DeployerUtils\Rsync { - public const DEFAULT_CONFIG = [ - 'exclude' => [ - '.git', - 'deploy.php', - ], - 'exclude-file' => false, - 'include' => [], - 'include-file' => false, - 'filter' => [], - 'filter-file' => false, - 'filter-perdir' => false, - 'options' => ['delete-after'], // needed so deployfilter files are send and delete is checked afterward - ]; - - /** - * Builds a comprehensive array of rsync command options - * by merging default (set in `rsync` config) and custom configurations. - * Takes a full config array and builds the different command-line arguments to be passed to rsync - * Combines all config-build functions of this file. - * - * @example - * // Using default configuration - * $options = buildRsyncOptions(); - * // Returns ['--delete-after', '--exclude=.git', '--exclude=deploy.php'] - * - * @example - * // With custom configuration - * $options = buildRsyncOptions([ - * 'exclude' => ['.git', 'node_modules'], - * 'include' => ['dist'], - * 'options' => ['archive', 'verbose'] - * ]); - * // Returns ['--archive', '--verbose', '--include=dist', '--exclude=.git', '--exclude=node_modules'] - * - * @param RsyncConfig $config Custom rsync configuration. - * @return RsyncOptions Array of formatted rsync command options with empty values filtered out - */ - public static function buildOptionsArray(array $config = []): array - { - // Get default config or use fallback - $defaultConfig = get('rsync'); - if (!$defaultConfig || !is_array($defaultConfig)) { - $defaultConfig = [...self::DEFAULT_CONFIG]; - } - - $options = array_key_exists('options', $config) ? $config['options'] : ($defaultConfig['options'] ?? []); - $options = Utils::parseStringArray((array) $options); - $exclude = array_key_exists('exclude', $config) ? $config['exclude'] : ($defaultConfig['exclude'] ?? []); - $exclude = Utils::parseStringArray((array) $exclude); - $excludeFile = array_key_exists('exclude-file', $config) ? - $config['exclude-file'] : ($defaultConfig['exclude-file'] ?? null); - $excludeFile = Utils::parseStringOrNull($excludeFile); - $include = array_key_exists('include', $config) ? - $config['include'] : ($defaultConfig['include'] ?? []); - $include = Utils::parseStringArray((array) $include); - $includeFile = array_key_exists('include-file', $config) ? - $config['include-file'] : ($defaultConfig['include-file'] ?? null); - $includeFile = Utils::parseStringOrNull($includeFile); - $filter = array_key_exists('filter', $config) ? $config['filter'] : ($defaultConfig['filter'] ?? []); - $filter = Utils::parseStringArray((array) $filter); - $filterFile = array_key_exists('filter-file', $config) ? - $config['filter-file'] : ($defaultConfig['filter-file'] ?? null); - $filterFile = Utils::parseStringOrNull($filterFile); - $filterPerDir = array_key_exists('filter-perdir', $config) ? - $config['filter-perdir'] : ($defaultConfig['filter-perdir'] ?? null); - $filterPerDir = Utils::parseStringOrNull($filterPerDir); - - // Build options - $options = self::buildOptions($options); - $includes = self::buildIncludes($include, $includeFile); - $excludes = self::buildExcludes($exclude, $excludeFile); - $filters = self::buildFilter($filter, $filterFile, $filterPerDir); - - // remove empty strings because they break rsync - // because Rsync class uses escapeshellarg - return array_filter([...$options, ...$includes, ...$excludes, ...$filters]); - } - - /** - * Builds an array of rsync option arguments by prefixing each option with '--'. - * Does not use escapeshellarg, because Rsync class from deployer uses it and that would destroy the options. - * - * @example - * // Returns ['--archive', '--verbose', '--compress'] - * buildOptions(['archive', 'verbose', 'compress']); - * - * @example - * // Returns ['--dry-run', '--itemize-changes'] - * buildOptions(['dry-run', 'itemize-changes']); - * - * @param list $options Array of rsync option names without the '--' prefix - * (e.g. ['archive', 'verbose', 'compress']) - * - * @return list Array of formatted option arguments where each element - * has the format '--{option_name}' - */ - public static function buildOptions(array $options): array - { - $optionsStrings = []; - foreach ($options as $option) { - $optionsStrings[] = '--' . $option; - } - - return $optionsStrings; - } - - /** - * Builds an array of rsync include arguments based on provided include patterns and include file. - * Does not use escapeshellarg, because Rsync class from deployer uses it and that would destroy the options - * @example - * // Returns ['--include=*.php', '--include=/config/'] - * buildIncludes(['*.php', '/config/']); - * - * @example - * // Returns ['--include=*.php', '--include-from=/path/to/include-list.txt'] - * buildIncludes(['*.php'], '/path/to/include-list.txt'); - * - * @param list $includes Array of include patterns to be applied (e.g. ["*.php", "/config/"]) - * @param string|null $includeFile Path to a file containing include patterns (e.g. "/path/to/include-list.txt") - * - * @return list Array where each element is one of: - * - '--include={include_pattern}' for each entry in $includes - * - '--include-from={include_file_path}' if $includeFile is valid - */ - public static function buildIncludes(array $includes = [], ?string $includeFile = null): array - { - $includesStrings = []; - foreach ($includes as $include) { - $includesStrings[] = '--include=' . $include; - } - - if (!empty($includeFile) && file_exists($includeFile) && is_file($includeFile) && is_readable($includeFile)) { - $includesStrings[] = '--include-from=' . $includeFile; - } - - return $includesStrings; - } - - /** - * Builds an array of rsync exclude arguments based on provided exclude patterns and exclude file. - * Does not use escapeshellarg, because Rsync class from deployer uses it and that would destroy the options - * - * @example - * // Returns ['--exclude=*.tmp', '--exclude=/cache/'] - * buildExcludes(['*.tmp', '/cache/']); - * - * @example - * // Returns ['--exclude=*.tmp', '--exclude-from=/path/to/exclude-list.txt'] - * buildExcludes(['*.tmp'], '/path/to/exclude-list.txt'); - * - * @param list $excludes Array of exclude patterns to be applied (e.g. ["*.tmp", "/cache/"]) - * @param string|null $excludeFile Path to a file containing exclude patterns (e.g. "/path/to/exclude-list.txt") - * - * @return list Array where each element is one of: - * - '--exclude={exclude_pattern}' for each entry in $excludes - * - '--exclude-from={exclude_file_path}' if $excludeFile is valid - */ - public static function buildExcludes(array $excludes = [], ?string $excludeFile = null): array - { - $excludesStrings = []; - foreach ($excludes as $exclude) { - $excludesStrings[] = '--exclude=' . $exclude; - } - - if (!empty($excludeFile) && file_exists($excludeFile) && is_file($excludeFile) && is_readable($excludeFile)) { - $excludesStrings[] = '--exclude-from=' . $excludeFile; - } - - return $excludesStrings; - } - - /** - * Builds an array of rsync filter arguments based on provided filters and filter files. - * Does not use escapeshellarg, because Rsync class from deployer uses it and that would destroy the options - * - * @example - * // Returns ['--filter=exclude=/tmp', '--filter=include=/var'] - * buildFilter(['exclude=/tmp', 'include=/var']); - * - * @example - * // Returns ['--filter=exclude=/tmp', '--filter=merge /path/to/rules.txt', '--filter=dir-merge .rsync-filter'] - * buildFilter(['exclude=/tmp'], '/path/to/rules.txt', '.rsync-filter'); - * - * @param list $filters Array of filter patterns to be applied (e.g. ["exclude=/path", "include=/other"]) - * @param string|null $filterFile Path to a filter file to be merged (e.g. "/path/to/filter-rules.txt") - * @param string|null $filterPerDir Name of per-directory filter files to be merged (e.g. ".rsync-filter") - * - * @return list Array where each element is one of: - * - '--filter={filter_pattern}' for each entry in $filters - * - '--filter=merge {filter_file_path}' if $filterFile is valid - * - '--filter=dir-merge {filter_per_dir}' if $filterPerDir is provided - */ - public static function buildFilter( - array $filters = [], - ?string $filterFile = null, - ?string $filterPerDir = null - ): array { - $filtersStrings = []; - foreach ($filters as $filter) { - $filtersStrings[] = '--filter=' . $filter; - } - if (!empty($filterFile) && file_exists($filterFile) && is_file($filterFile) && is_readable($filterFile)) { - $filtersStrings[] = "--filter=merge " . $filterFile; - } - if (!empty($filterPerDir)) { - $filtersStrings[] = "--filter=dir-merge " . $filterPerDir; - } - return $filtersStrings; - } } diff --git a/src/Utils.php b/src/Utils.php index 9dab5c6..48564c0 100644 --- a/src/Utils.php +++ b/src/Utils.php @@ -2,98 +2,7 @@ namespace Gaambo\DeployerWordpress; -use Composer\InstalledVersions; - -use function Deployer\output; -use function Deployer\quote as deployerQuote; // Deployer v8 -use function Deployer\Support\escape_shell_argument; - -// Deployer v7 - -class Utils +/** @deprecated Use \Gaambo\DeployerUtils\Utils. */ +class Utils extends \Gaambo\DeployerUtils\Utils { - /** @var array Cached results keyed by "$operator$version". */ - private static array $versionCache = []; - - /** - * Check the installed deployer/deployer version against a given constraint. - * - * Example: Utils::isDeployerVersion('>=', '8.0.0') - * - * @param string $operator A version_compare operator: '>=', '>', '==', '<', '<=', '!=' - * @param string $version Version string to compare against, e.g. '8.0.0' - */ - public static function isDeployerVersion(string $operator, string $version): bool - { - $key = $operator . $version; - if (!array_key_exists($key, self::$versionCache)) { - self::$versionCache[$key] = version_compare( - InstalledVersions::getVersion('deployer/deployer') ?? '0.0.0', - $version, - $operator - ); - } - return self::$versionCache[$key]; - } - - /** - * Get the verbosity argument based on Deployer's output verbosity - * - * @return string - */ - public static function getVerbosityArgument(): string - { - $outputInterface = output(); - $verbosityArgument = ''; - - if ($outputInterface->isVerbose()) { - $verbosityArgument = '-v'; - } - if ($outputInterface->isVeryVerbose()) { - $verbosityArgument = '-vv'; - } - if ($outputInterface->isDebug()) { - $verbosityArgument = '-vvv'; - } - - return $verbosityArgument; - } - - /** - * Parses an array of mixed values and returns an array of strings. - * @param array $array - * @return array - */ - public static function parseStringArray(array $array): array - { - return array_values( - array_filter( - array_map( - fn($value) => is_string($value) ? $value : null, - $array - ), - ) - ); - } - - /** - * Parses a mixed value and returns a string or null if the value is not a string. - * @param mixed $string - * @return string|null - */ - public static function parseStringOrNull(mixed $string): ?string - { - return is_string($string) ? $string : null; - } - - /** - * Deployer v7/v8 compat: v7 uses Deployer\Support\escape_shell_argument, v8 uses Deployer\quote. - */ - public static function quote(string $arg): string - { - if (self::isDeployerVersion('>=', '8.0.0')) { - return deployerQuote($arg); - } - return escape_shell_argument($arg); - } } diff --git a/src/WPCLI.php b/src/WPCLI.php index 5d4579a..2b1709e 100644 --- a/src/WPCLI.php +++ b/src/WPCLI.php @@ -2,6 +2,9 @@ namespace Gaambo\DeployerWordpress; +use Gaambo\DeployerUtils\Localhost; +use Gaambo\DeployerUtils\Runtime\Runtime; + use function Deployer\run; /** @@ -13,23 +16,27 @@ class WPCLI private const BINARY_DOWNLOAD = 'https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar'; /** - * Run a WP CLI command + * Run a WP CLI command through the current source host's runtime. + * * @param string $command The command to run (without wp prefix) * @param string|null $path The path to run the command in (defaults to {{release_or_current_path}}) * @param string $arguments Additional arguments to pass to WP-CLI + * @param array $runtimePaths Source-host paths to map into the configured runtime * @return void */ public static function runCommand( string $command, ?string $path = '{{release_or_current_path}}', - string $arguments = '' + string $arguments = '', + array $runtimePaths = [] ): void { - $cmd = "{{bin/wp}} $command $arguments"; - if ($path) { - run("cd $path && $cmd"); - } else { - run($cmd); + foreach ($runtimePaths as $sourcePath) { + $runtimePath = Runtime::path($sourcePath); + $command = str_replace($sourcePath, $runtimePath, $command); + $arguments = str_replace($sourcePath, $runtimePath, $arguments); } + + Runtime::run("{{bin/wp}} $command $arguments", ['cwd' => $path]); } /** @@ -37,19 +44,18 @@ public static function runCommand( * @param string $command The command to run (without wp prefix) * @param string|null $path The path to run the command in (defaults to {{current_path}}) * @param string $arguments Additional arguments to pass to WP-CLI + * @param array $runtimePaths Host paths to map into the configured runtime. * @return void */ public static function runCommandLocally( string $command, ?string $path = '{{current_path}}', - string $arguments = '' + string $arguments = '', + array $runtimePaths = [] ): void { - $localWp = Localhost::getConfig('bin/wp'); - if ($path) { - Localhost::run("cd $path && $localWp $command $arguments"); - } else { - Localhost::run("$localWp $command $arguments"); - } + Localhost::within(function () use ($command, $path, $arguments, $runtimePaths): void { + self::runCommand($command, $path, $arguments, $runtimePaths); + }); } /** diff --git a/stubs/deployer-v7.php b/stubs/deployer-v7.php deleted file mode 100644 index d1dbfbb..0000000 --- a/stubs/deployer-v7.php +++ /dev/null @@ -1,31 +0,0 @@ -desc('Create backup of remote database and download locally'); @@ -75,7 +79,8 @@ Localhost::run("mkdir -p $resolvedLocalDumpPath"); WPCLI::runCommandLocally( "db export $resolvedLocalDumpPath/$dumpFile --add-drop-table", - Localhost::getConfig('current_path') + Localhost::getConfig('current_path'), + runtimePaths: ["$resolvedLocalDumpPath/$dumpFile"] ); Files::pushFile("$resolvedLocalDumpPath/$dumpFile", "$resolvedRemoteDumpPath/$dumpFile"); @@ -104,7 +109,8 @@ $localUrl = Localhost::getConfig('public_url'); $remoteUrl = get('public_url'); - WPCLI::runCommand("db import $resolvedRemoteDumpPath/$dumpFile"); + $remoteDumpFile = "$resolvedRemoteDumpPath/$dumpFile"; + WPCLI::runCommand("db import $remoteDumpFile", runtimePaths: [$remoteDumpFile]); if (get('wp/multisite')) { WPCLI::runCommand("search-replace $localUrl $remoteUrl --network --all-tables"); @@ -151,7 +157,8 @@ $remoteUrl = get('public_url'); WPCLI::runCommandLocally( "db import $resolvedLocalDumpPath/$dumpFile", - Localhost::getConfig('current_path') + Localhost::getConfig('current_path'), + runtimePaths: ["$resolvedLocalDumpPath/$dumpFile"] ); if (get('wp/multisite')) { diff --git a/tasks/files.php b/tasks/files.php index ab9d55d..e00741d 100644 --- a/tasks/files.php +++ b/tasks/files.php @@ -13,12 +13,14 @@ namespace Gaambo\DeployerWordpress\Tasks; -use Gaambo\DeployerWordpress\Files; -use Gaambo\DeployerWordpress\Localhost; - -use function Deployer\download; use function Deployer\task; +$utilsFilesTasks = dirname(__DIR__, 3) . '/gaambo/deployer-utils/tasks/files.php'; +if (!file_exists($utilsFilesTasks)) { + $utilsFilesTasks = __DIR__ . '/../vendor/gaambo/deployer-utils/tasks/files.php'; +} +require $utilsFilesTasks; + require_once __DIR__ . '/mu-plugins.php'; require_once __DIR__ . '/packages.php'; require_once __DIR__ . '/plugins.php'; @@ -49,47 +51,3 @@ */ task('files:pull', ['wp:pull', 'uploads:pull', 'plugins:pull', 'mu-plugins:pull', 'themes:pull', 'packages:pull']) ->desc('Pull all files from remote to local'); - -/** - * Backup all files on remote host - * - * Creates a zip backup of remote WordPress files and downloads it locally. - * - * Configuration: - * - backup_path: Path for storing backups (required on both local and remote) - * - release_path: Path to WordPress installation on remote - * - * Example: - * dep files:backup:remote prod - */ -task('files:backup:remote', function () { - $backupFile = Files::zipFiles( - '{{release_or_current_path}}/', - '{{backup_path}}', - 'backup_files' - ); - $localBackupPath = Localhost::getConfig('backup_path'); - download($backupFile, "$localBackupPath/"); -})->desc('Backup remote files and download locally'); - -/** - * Backup all files on local host - * - * Creates a zip backup of local WordPress files. - * - * Configuration: - * - backup_path: Path for storing backups (local) - * - current_path: Path to WordPress installation on local - * - * Example: - * dep files:backup:local prod - */ -task('files:backup:local', function () { - $localPath = Localhost::getConfig('current_path'); - $localBackupPath = Localhost::getConfig('backup_path'); - Files::zipFiles( - "$localPath/", - $localBackupPath, - 'backup_files' - ); -})->once()->desc('Backup local files'); diff --git a/tasks/languages.php b/tasks/languages.php index a78db04..10d6387 100644 --- a/tasks/languages.php +++ b/tasks/languages.php @@ -12,9 +12,9 @@ namespace Gaambo\DeployerWordpress\Tasks; -use Gaambo\DeployerWordpress\Files; -use Gaambo\DeployerWordpress\Localhost; -use Gaambo\DeployerWordpress\Rsync; +use Gaambo\DeployerUtils\Files; +use Gaambo\DeployerUtils\Localhost; +use Gaambo\DeployerUtils\Rsync; use function Deployer\download; use function Deployer\get; diff --git a/tasks/mu-plugins.php b/tasks/mu-plugins.php index 68422ee..dd718e3 100644 --- a/tasks/mu-plugins.php +++ b/tasks/mu-plugins.php @@ -13,10 +13,10 @@ namespace Gaambo\DeployerWordpress\Tasks; -use Gaambo\DeployerWordpress\Composer; -use Gaambo\DeployerWordpress\Files; -use Gaambo\DeployerWordpress\Localhost; -use Gaambo\DeployerWordpress\Rsync; +use Gaambo\DeployerUtils\Composer; +use Gaambo\DeployerUtils\Files; +use Gaambo\DeployerUtils\Localhost; +use Gaambo\DeployerUtils\Rsync; use function Deployer\download; use function Deployer\get; diff --git a/tasks/packages.php b/tasks/packages.php index f120fb9..b3981a5 100644 --- a/tasks/packages.php +++ b/tasks/packages.php @@ -15,11 +15,11 @@ namespace Gaambo\DeployerWordpress\Tasks; -use Gaambo\DeployerWordpress\Composer; -use Gaambo\DeployerWordpress\Files; -use Gaambo\DeployerWordpress\Localhost; -use Gaambo\DeployerWordpress\NPM; -use Gaambo\DeployerWordpress\Rsync; +use Gaambo\DeployerUtils\Composer; +use Gaambo\DeployerUtils\Files; +use Gaambo\DeployerUtils\Localhost; +use Gaambo\DeployerUtils\NPM; +use Gaambo\DeployerUtils\Rsync; use function Deployer\download; use function Deployer\get; diff --git a/tasks/plugins.php b/tasks/plugins.php index fea7703..c2f227c 100644 --- a/tasks/plugins.php +++ b/tasks/plugins.php @@ -12,9 +12,9 @@ namespace Gaambo\DeployerWordpress\Tasks; -use Gaambo\DeployerWordpress\Files; -use Gaambo\DeployerWordpress\Localhost; -use Gaambo\DeployerWordpress\Rsync; +use Gaambo\DeployerUtils\Files; +use Gaambo\DeployerUtils\Localhost; +use Gaambo\DeployerUtils\Rsync; use function Deployer\download; use function Deployer\get; diff --git a/tasks/themes.php b/tasks/themes.php index 559c91a..416302a 100644 --- a/tasks/themes.php +++ b/tasks/themes.php @@ -14,11 +14,11 @@ namespace Gaambo\DeployerWordpress\Tasks; -use Gaambo\DeployerWordpress\Composer; -use Gaambo\DeployerWordpress\Files; -use Gaambo\DeployerWordpress\Localhost; -use Gaambo\DeployerWordpress\NPM; -use Gaambo\DeployerWordpress\Rsync; +use Gaambo\DeployerUtils\Composer; +use Gaambo\DeployerUtils\Files; +use Gaambo\DeployerUtils\Localhost; +use Gaambo\DeployerUtils\NPM; +use Gaambo\DeployerUtils\Rsync; use function Deployer\download; use function Deployer\get; diff --git a/tasks/uploads.php b/tasks/uploads.php index b406819..71485af 100644 --- a/tasks/uploads.php +++ b/tasks/uploads.php @@ -12,9 +12,9 @@ namespace Gaambo\DeployerWordpress\Tasks; -use Gaambo\DeployerWordpress\Files; -use Gaambo\DeployerWordpress\Localhost; -use Gaambo\DeployerWordpress\Rsync; +use Gaambo\DeployerUtils\Files; +use Gaambo\DeployerUtils\Localhost; +use Gaambo\DeployerUtils\Rsync; use function Deployer\download; use function Deployer\get; diff --git a/tasks/wp.php b/tasks/wp.php index 4968a87..fd54597 100644 --- a/tasks/wp.php +++ b/tasks/wp.php @@ -15,10 +15,10 @@ namespace Gaambo\DeployerWordpress\Tasks; use RuntimeException; -use Gaambo\DeployerWordpress\Files; -use Gaambo\DeployerWordpress\Localhost; -use Gaambo\DeployerWordpress\Rsync; use Gaambo\DeployerWordpress\WPCLI; +use Gaambo\DeployerUtils\Files; +use Gaambo\DeployerUtils\Localhost; +use Gaambo\DeployerUtils\Rsync; use function Deployer\get; use function Deployer\task; diff --git a/tests/Fixtures/rsync/exclude.txt b/tests/Fixtures/rsync/exclude.txt deleted file mode 100644 index 9dd43f3..0000000 --- a/tests/Fixtures/rsync/exclude.txt +++ /dev/null @@ -1,3 +0,0 @@ -*.log -*.tmp -.DS_Store \ No newline at end of file diff --git a/tests/Fixtures/rsync/filter.txt b/tests/Fixtures/rsync/filter.txt deleted file mode 100644 index 365dd27..0000000 --- a/tests/Fixtures/rsync/filter.txt +++ /dev/null @@ -1,3 +0,0 @@ -+ / -+ /wp-content/ -- /wp-content/uploads/* \ No newline at end of file diff --git a/tests/Fixtures/rsync/include.txt b/tests/Fixtures/rsync/include.txt deleted file mode 100644 index b0f0249..0000000 --- a/tests/Fixtures/rsync/include.txt +++ /dev/null @@ -1,3 +0,0 @@ -*.php -*.js -*.css \ No newline at end of file diff --git a/tests/Functional/FunctionalTestCase.php b/tests/Functional/FunctionalTestCase.php index f77c69d..b529e8a 100644 --- a/tests/Functional/FunctionalTestCase.php +++ b/tests/Functional/FunctionalTestCase.php @@ -2,278 +2,42 @@ namespace Gaambo\DeployerWordpress\Tests\Functional; -use Deployer\Component\ProcessRunner\ProcessRunner; -use Deployer\Component\Ssh\Client; -use Deployer\Deployer; -use Deployer\Executor\Server; -use Deployer\Host\Host; -use Deployer\Host\Localhost; -use Deployer\Utility\Rsync; -use Exception; -use Gaambo\DeployerWordpress\Utils; -use PHPUnit\Framework\MockObject\MockObject; -use PHPUnit\Framework\TestCase; -use Symfony\Component\Console\Application; -use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Tester\ApplicationTester; +use Deployer\Ssh\RunParams; +use Gaambo\DeployerUtils\Tests\Functional\FunctionalTestCase as BaseFunctionalTestCase; -/** - * Base class for functional tests that need to test Deployer tasks. - * - * This class provides: - * 1. A temporary test environment with local and remote directories - * 2. Configured local and remote hosts - * 3. Mocked SSH client and process runner for testing commands - * 4. A LocalRsync implementation for testing file transfers - */ -abstract class FunctionalTestCase extends TestCase +use function Deployer\quote; + +abstract class FunctionalTestCase extends BaseFunctionalTestCase { protected const RECIPE_PATH = __DIR__ . '/../Fixtures/recipes/common.php'; - protected Deployer $deployer; - protected ApplicationTester $tester; - // Test environment paths - protected Host $localHost; - protected Host $remoteHost; - protected string $testDir; - protected string $localDir; protected string $localDocRootDir; - protected string $remoteDir; protected string $remoteReleaseDir; - // Mocked services - protected Client|MockObject $sshClient; - protected ProcessRunner $originalProcessRunner; - - // If a child test needs to mock rsync to simulate a failed download, use this. - protected ProcessRunner|MockObject $mockedRunner; - protected Rsync|MockObject|null $rsyncMock = null; - - /** - * Accumulated map of command patterns to callables/return values for the mocked process runner. - * - * @var array - */ - private array $mockedCommands = []; - protected function setUp(): void { parent::setUp(); - // 1. Set up test environment directories - $this->createTestDirectories(); - - // 2. Initialize Deployer and Application - $console = new Application(); - $console->setAutoExit(false); - $this->tester = new ApplicationTester($console); - $this->deployer = new Deployer($console); - $this->deployer->importer->import(static::RECIPE_PATH); - $this->deployer->init(); - - // 3. Configure hosts - $this->setUpHosts(); - - // 4. Set up mocked services - $this->setUpMockedServices(); - } - - /** - * Creates a unique test directory structure for this test run - */ - private function createTestDirectories(): void - { - $this->testDir = __TEMP_DIR__ . '/' . uniqid(); - // These are the deploy_path. - $this->localDir = $this->testDir . '/local'; - $this->remoteDir = $this->testDir . '/remote'; - - // Fixed directory, no symlinks. Just like in example deploy.yml - $this->localDocRootDir = $this->localDir . '/public_html'; - $this->remoteReleaseDir = $this->remoteDir . '/public_html'; - - mkdir($this->localDir, 0755, true); - mkdir($this->localDocRootDir, 0755, true); - mkdir($this->remoteDir, 0755, true); - mkdir($this->remoteReleaseDir, 0755, true); - } - - /** - * Sets up local and remote hosts with their paths - */ - private function setUpHosts(): void - { - // Local host setup - // This mirrors the setup in examples/simple/deploy.php - $this->localHost = new Localhost(); - $this->localHost->set('deploy_path', $this->localDir); - $this->localHost->set('current_path', $this->localDocRootDir); + $this->localDocRootDir = $this->localCurrentDir; + $this->remoteReleaseDir = $this->remoteCurrentDir; $this->localHost->set('dbdump_path', $this->localDir . '/data/db_dumps'); - $this->localHost->set('backup_path', $this->localDir . '/data/backups'); $this->localHost->set('bin/wp', 'wp'); - $this->localHost->set('bin/php', 'php'); - - // Remote host setup (using Localhost for testing so rsync runs on the same host) - $this->remoteHost = new Localhost('testremote'); - $this->remoteHost->set('deploy_path', $this->remoteDir); - $this->remoteHost->set('current_path', $this->remoteReleaseDir); $this->remoteHost->set('dbdump_path', $this->remoteDir . '/data/db_dumps'); - $this->remoteHost->set('backup_path', $this->remoteDir . '/data/backups'); $this->remoteHost->set('bin/wp', 'wp'); - $this->remoteHost->set('bin/php', 'php'); - - // Register hosts with Deployer - $this->deployer->hosts->set('localhost', $this->localHost); - $this->deployer->hosts->set('testremote', $this->remoteHost); - } - - /** - * Creates mock objects for SSH and process running - * And register services in Deployer container - */ - protected function setUpMockedServices(): void - { - $this->sshClient = $this->createMock(Client::class); - $this->deployer['sshClient'] = $this->sshClient; - // Server is not needed and throws errors in dev. - $this->deployer['server'] = $this->createMock(Server::class); - - // Create a mock runner but don't use it by default. - // Can't store originalProcessRunner here already, because it needs to dynamically resolve from the container later. - $this->mockedRunner = $this->createMock(ProcessRunner::class); - } - - /** - * Helper to mock specific commands while passing others through. - * - * Multiple calls accumulate mocked commands; later patterns for the same - * command take precedence. - * - * @param array $commandsToMock Map of command patterns to callables or return values - */ - protected function mockCommands(array $commandsToMock, ?string $hostAlias = null): void - { - $this->mockedCommands = array_merge($this->mockedCommands, $commandsToMock); - - // Set up the mock to handle specific commands - $this->mockedRunner->expects($this->any()) - ->method('run') - ->willReturnCallback(function ($host, $command, $options = []) use ($hostAlias) { - // Check if this command should be mocked - if (!$hostAlias || $host->getAlias() === $hostAlias) { - foreach ($this->mockedCommands as $pattern => $handler) { - if (str_contains($command, $pattern)) { - return is_callable($handler) ? $handler($host, $command, $options) : $handler; - } - } - } - - // If not mocked, pass through to original runner - return $this->originalProcessRunner->run($host, $command, $options); - }); - - // Use a closure factory, because the original runner needs dependencies. - // Deployer v7/v8 compat: constructor changed from ProcessRunner($pop, $logger) to ProcessRunner($logger). - $this->deployer['processRunner'] = function ($c) { - $this->originalProcessRunner = Utils::isDeployerVersion('>=', '8.0.0') - ? new ProcessRunner($c['logger']) // v8 - : new ProcessRunner($c['pop'], $c['logger']); // v7 - return $this->mockedRunner; - }; - } - - protected function tearDown(): void - { - $this->removeDirectory($this->testDir); - parent::tearDown(); - } - - protected function removeDirectory(string $dir): void - { - if (!is_dir($dir)) { - return; - } - - $files = array_diff(scandir($dir), ['.', '..']); - foreach ($files as $file) { - $path = "$dir/$file"; - is_dir($path) ? $this->removeDirectory($path) : unlink($path); - } - - rmdir($dir); - } - - /** - * Helper method to recursively copy a directory - */ - protected function copyDirectory(string $source, string $destination): void - { - if (!is_dir($source)) { - return; - } - - foreach ( - $iterator = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($source, \RecursiveDirectoryIterator::SKIP_DOTS), - \RecursiveIteratorIterator::SELF_FIRST - ) as $item - ) { - $relativePath = substr($item->getPathname(), strlen($source) + 1); - $targetPath = $destination . DIRECTORY_SEPARATOR . $relativePath; - - if ($item->isDir()) { - mkdir($targetPath); - } else { - copy($item, $targetPath); - } - } } - /** - * Returns the full path to a fixture file - */ - protected function getFixturePath(string $path): string + protected function runCwd(RunParams $options): ?string { - return __FIXTURES__ . '/' . $path; + return $options->cwd; } - /** - * Runs a deployer task with the given arguments - */ - protected function dep(string $task, ?string $host = 'testremote', array $args = []): int + protected function runShell(RunParams $options): string { - $input = [$task]; - if (!empty($host)) { - $input['selector'] = [$host]; - } - $input['--file'] = static::RECIPE_PATH; - $input = array_merge($input, $args); - return $this->tester->run($input, [ - 'verbosity' => OutputInterface::VERBOSITY_VERBOSE, - 'interactive' => false, - ]); + return $options->shell; } - /** - * Helper method to mock rsync failures. Use $checkSource and $checkDestination if you want to mock only specific up-/download. - * - * @param string|null $checkSource When set, the source in the download call will be compared against and only these downloads will fail. - * @param string|null $checkDestination When set, the destination in the download call will be compared against and only these downloads will fail. - */ - protected function mockRsyncFailure(?string $checkSource = null, ?string $checkDestination = null): void + protected function ddevShell(string $path): string { - $this->rsyncMock = $this->createMock(Rsync::class); - $this->deployer['rsync'] = $this->rsyncMock; - $this->rsyncMock->expects($this->once()) - ->method('call') - ->willReturnCallback(function (Host $host, $source, $destination, $options) use ($checkSource, $checkDestination) { - if ($checkSource && $source !== $checkSource) { - return; - } - if ($checkDestination && $destination !== $checkSource) { - return; - } - throw new Exception('Download failed'); - }); + return 'ddev exec --dir ' . quote($path) . ' bash -s'; } } diff --git a/tests/Functional/Tasks/DatabaseTasksFunctionalTest.php b/tests/Functional/Tasks/DatabaseTasksFunctionalTest.php index 9e44eb7..8867965 100644 --- a/tests/Functional/Tasks/DatabaseTasksFunctionalTest.php +++ b/tests/Functional/Tasks/DatabaseTasksFunctionalTest.php @@ -3,9 +3,11 @@ namespace Gaambo\DeployerWordpress\Tests\Functional\Tasks; use Gaambo\DeployerWordpress\Tests\Functional\FunctionalTestCase; +use Gaambo\DeployerUtils\Runtime\DdevRuntime; use RuntimeException; use function Deployer\set; +use function Gaambo\DeployerUtils\runtime; class DatabaseTasksFunctionalTest extends FunctionalTestCase { @@ -46,11 +48,11 @@ public function testDbRemoteBackup(): void protected function mockSuccessfulDbExport(string $wpBinary = 'wp'): void { $this->mockCommands([ - "$wpBinary db export" => function ($host, $command) use ($wpBinary) { + "$wpBinary db export" => function ($host, $command, $options) use ($wpBinary) { $wpWorkingPath = $host->getAlias() === 'localhost' ? $this->localHost->get('current_path') : $this->remoteHost->get('release_or_current_path'); - $this->assertStringStartsWith("cd $wpWorkingPath &&", $command); + $this->assertSame($wpWorkingPath, $this->runCwd($options)); if (preg_match('/db export (.*?db_backup-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}\.sql)(?:\s|$)/', $command, $matches)) { $dumpFile = $matches[1]; @@ -207,9 +209,8 @@ public function testDbRemoteBackupWithInvalidDumpPath(): void $result = $this->dep('db:remote:backup'); $this->assertNotEquals(0, $result, 'Task should fail with invalid dump path'); $output = $this->tester->getDisplay(); - // Deployer v7/v8 compat: v7 says "Task db:remote:backup failed", v8 says "error in ...". $this->assertStringContainsString('db:remote:backup', $output); - $this->assertMatchesRegularExpression('/failed|error/i', $output, 'Output should indicate failure (v7: "failed", v8: "error")'); + $this->assertStringContainsString('error', strtolower($output)); // Verify no dump files were created $remoteDumpFiles = glob($this->remoteDir . '/dumps/db_backup-*.sql'); @@ -361,9 +362,8 @@ public function testDbLocalBackupWithInvalidDumpPath(): void $result = $this->dep('db:local:backup'); $this->assertNotEquals(0, $result, 'Task should fail with invalid dump path'); $output = $this->tester->getDisplay(); - // Deployer v7/v8 compat: v7 says "Task db:local:backup failed", v8 says "error in ...". $this->assertStringContainsString('db:local:backup', $output); - $this->assertMatchesRegularExpression('/failed|error/i', $output, 'Output should indicate failure (v7: "failed", v8: "error")'); + $this->assertStringContainsString('error', strtolower($output)); // Verify no dump files were created $localDumpFiles = glob($this->localDir . '/dumps/db_backup-*.sql'); @@ -402,11 +402,11 @@ public function testDbRemoteImport(): void protected function mockSuccessfulDbImport(): void { $this->mockCommands([ - 'wp db import' => function ($host, $command) { + 'wp db import' => function ($host, $command, $options) { $wpWorkingPath = $host->getAlias() === 'localhost' ? $this->localHost->get('current_path') : $this->remoteHost->get('release_or_current_path'); - $this->assertStringStartsWith("cd $wpWorkingPath &&", $command); + $this->assertSame($wpWorkingPath, $this->runCwd($options)); return 'Database imported successfully'; }, @@ -883,6 +883,101 @@ public function testDbRemoteImportHandleMultisiteWithUrlReplaceError(): void $this->assertNotEquals(0, $result, 'Task should fail when URL replacement fails'); } + public function testDbLocalBackupWithDdevRuntime(): void + { + $this->localHost->set('runtime', runtime(DdevRuntime::class)); + $this->mockCommands([ + 'wp db export' => function ($host, $command, $options) { + $this->assertStringStartsWith('wp db export /var/www/html/dumps/', $command); + $this->assertSame($this->localDir, $this->runCwd($options)); + $this->assertSame($this->ddevShell('/var/www/html/current'), $this->runShell($options)); + preg_match('/db export \/var\/www\/html\/dumps\/(db_backup-[^ ]+\.sql)/', $command, $matches); + copy($this->getFixturePath('database/dump.sql'), $this->localDir . '/dumps/' . $matches[1]); + return 'Database exported successfully'; + }, + ]); + + $result = $this->dep('db:local:backup'); + + $this->assertSame(0, $result); + $this->assertCount(1, glob($this->remoteDir . '/dumps/db_backup-*.sql')); + } + + public function testDbRemoteBackupWithDdevRuntimeMapsDumpPath(): void + { + $this->remoteHost->set('runtime', runtime(DdevRuntime::class)); + $this->mockCommands([ + 'wp db export' => function ($executionHost, $command, $options) { + $this->assertStringStartsWith('wp db export /var/www/html/dumps/', $command); + $this->assertSame($this->remoteDir, $this->runCwd($options)); + $this->assertSame($this->ddevShell('/var/www/html/current'), $this->runShell($options)); + preg_match('/db export \/var\/www\/html\/dumps\/(db_backup-[^ ]+\.sql)/', $command, $matches); + copy($this->getFixturePath('database/dump.sql'), $this->remoteDir . '/dumps/' . $matches[1]); + return 'Database exported successfully'; + }, + ], 'testremote:ddev'); + + $result = $this->dep('db:remote:backup'); + + $this->assertSame(0, $result); + $this->assertCount(1, glob($this->localDir . '/dumps/db_backup-*.sql')); + } + + public function testDbLocalImportWithDdevRuntimeMapsOnlyDumpPath(): void + { + $this->localHost->set('runtime', runtime(DdevRuntime::class)); + $this->localHost->set('public_url', 'http://localhost'); + $this->remoteHost->set('public_url', 'https://example.com'); + $this->localHost->set('uploads/dir', '/local/uploads'); + $this->remoteHost->set('uploads/dir', '/remote/uploads'); + $dumpFile = $this->localDir . '/dumps/db_backup.sql'; + copy($this->getFixturePath('database/dump.sql'), $dumpFile); + set('dbdump/file', 'db_backup.sql'); + + $commands = []; + $this->mockCommands([ + 'wp ' => function ($host, $command, $options) use (&$commands) { + $commands[] = $command; + $this->assertSame($this->localDir, $this->runCwd($options)); + $this->assertSame($this->ddevShell('/var/www/html/current'), $this->runShell($options)); + return ''; + }, + ]); + + $result = $this->dep('db:local:import'); + + $this->assertSame(0, $result); + $this->assertFileDoesNotExist($dumpFile); + $this->assertStringContainsString('wp db import /var/www/html/dumps/db_backup.sql', $commands[0]); + $this->assertContains('wp search-replace /remote/uploads /local/uploads ', $commands); + } + + public function testDbRemoteImportWithDdevRuntimeMapsOnlyDumpPath(): void + { + $this->remoteHost->set('runtime', runtime(DdevRuntime::class)); + $this->localHost->set('public_url', 'http://localhost'); + $this->remoteHost->set('public_url', 'https://example.com'); + $dumpFile = $this->remoteDir . '/dumps/db_backup.sql'; + copy($this->getFixturePath('database/dump.sql'), $dumpFile); + set('dbdump/file', 'db_backup.sql'); + $commands = []; + $this->mockCommands([ + 'wp ' => function ($executionHost, $command, $options) use (&$commands) { + $commands[] = $command; + $this->assertSame($this->remoteDir, $this->runCwd($options)); + $this->assertSame($this->ddevShell('/var/www/html/current'), $this->runShell($options)); + return ''; + }, + ], 'testremote:ddev'); + + $result = $this->dep('db:remote:import'); + + $this->assertSame(0, $result); + $this->assertFileDoesNotExist($dumpFile); + $this->assertStringContainsString('wp db import /var/www/html/dumps/db_backup.sql', $commands[0]); + $this->assertContains('wp search-replace http://localhost https://example.com ', $commands); + } + protected function setUp(): void { parent::setUp(); diff --git a/tests/Integration/ComposerIntegrationTest.php b/tests/Integration/ComposerIntegrationTest.php deleted file mode 100644 index a77fdc5..0000000 --- a/tests/Integration/ComposerIntegrationTest.php +++ /dev/null @@ -1,230 +0,0 @@ -processRunnerMock = $this->createMock(ProcessRunner::class); - $this->sshClientMock = $this->createMock(Client::class); - $this->outputMock = $this->createMock(OutputInterface::class); - - // Set them in the Deployer container - $this->deployer['processRunner'] = $this->processRunnerMock; - $this->deployer['sshClient'] = $this->sshClientMock; - $this->deployer['output'] = $this->outputMock; - - // Set default composer configuration - $this->deployer->config->set('composer_action', 'install'); - $this->deployer->config->set('composer_options', '--no-dev --no-interaction'); - $this->deployer->config->set('bin/composer', 'composer'); - $this->deployer->config->set('bin/php', 'php'); - $this->deployer->config->set('deploy_path', '/var/www'); - } - - public function testRunDefault(): void - { - $path = '/var/www/html'; - $expectedCommand = "cd $path && composer install --no-dev --no-interaction"; - - $this->processRunnerMock - ->expects($this->once()) - ->method('run') - ->with($this->host, $expectedCommand) - ->willReturn('Composer output'); - - $result = Composer::runDefault($path); - $this->assertEquals('Composer output', $result); - } - - public function testRunCommand(): void - { - $path = '/var/www/html'; - $command = 'require'; - $arguments = 'package/name:1.0.0'; - $expectedCommand = "cd $path && composer require package/name:1.0.0"; - - $this->processRunnerMock - ->expects($this->once()) - ->method('run') - ->with($this->host, $expectedCommand) - ->willReturn('Composer output'); - - $result = Composer::runCommand($path, $command, $arguments); - $this->assertEquals('Composer output', $result); - } - - public function testRunScript(): void - { - $path = '/var/www/html'; - $script = 'post-install-cmd'; - $arguments = '--env=prod'; - $expectedCommand = "cd $path && composer run-script post-install-cmd --env=prod"; - - $this->processRunnerMock - ->expects($this->once()) - ->method('run') - ->with($this->host, $expectedCommand) - ->willReturn('Script output'); - - $result = Composer::runScript($path, $script, $arguments); - $this->assertEquals('Script output', $result); - } - - public function testInstall(): void - { - $installPath = '/usr/local/bin'; - $binaryName = 'composer'; - $expectedCommands = [ - "sudo mkdir -p $installPath", - "sudo cd $installPath && curl -sS https://getcomposer.org/installer | php", - "sudo mv $installPath/composer.phar $installPath/$binaryName" - ]; - - // Set up expectations for each command in sequence - $this->processRunnerMock - ->expects($this->exactly(3)) - ->method('run') - ->willReturnCallback(function ($host, $command) use ($expectedCommands) { - static $index = 0; - $this->assertEquals($expectedCommands[$index], $command); - $index++; - return 'Installation output'; - }); - - $result = Composer::install($installPath, $binaryName, true); - $this->assertEquals("$installPath/$binaryName", $result); - } - - public function testInstallWithoutSudo(): void - { - $installPath = '/usr/local/bin'; - $binaryName = 'composer'; - $expectedCommands = [ - "mkdir -p $installPath", - "cd $installPath && curl -sS https://getcomposer.org/installer | php", - "mv $installPath/composer.phar $installPath/$binaryName" - ]; - - // Set up expectations for each command in sequence - $this->processRunnerMock - ->expects($this->exactly(3)) - ->method('run') - ->willReturnCallback(function ($host, $command) use ($expectedCommands) { - static $index = 0; - $this->assertEquals($expectedCommands[$index], $command); - $index++; - return 'Installation output'; - }); - - $result = Composer::install($installPath, $binaryName, false); - $this->assertEquals("$installPath/$binaryName", $result); - } - - public function testInstallWithDefaultBinaryName(): void - { - $installPath = '/usr/local/bin'; - $expectedCommands = [ - "mkdir -p $installPath", - "cd $installPath && curl -sS https://getcomposer.org/installer | php" - ]; - - // Set up expectations for each command in sequence - $this->processRunnerMock - ->expects($this->exactly(2)) - ->method('run') - ->willReturnCallback(function ($host, $command) use ($expectedCommands) { - static $index = 0; - $this->assertEquals($expectedCommands[$index], $command); - $index++; - return 'Installation output'; - }); - - $result = Composer::install($installPath); - $this->assertEquals("$installPath/composer.phar", $result); - } - - /** - * @dataProvider verbosityProvider - */ - public function testRunCommandWithVerbosity(bool $isVerbose, bool $isVeryVerbose, bool $isDebug, string $verbosityFlag): void - { - $path = '/var/www/html'; - $command = 'require'; - $arguments = 'package/name:1.0.0'; - - $this->outputMock->method('isVerbose')->willReturn($isVerbose); - $this->outputMock->method('isVeryVerbose')->willReturn($isVeryVerbose); - $this->outputMock->method('isDebug')->willReturn($isDebug); - - $expectedCommand = "cd $path && composer require package/name:1.0.0 $verbosityFlag"; - - $this->processRunnerMock - ->expects($this->once()) - ->method('run') - ->with($this->host, $expectedCommand) - ->willReturn('Composer output'); - - $result = Composer::runCommand($path, $command, $arguments); - $this->assertEquals('Composer output', $result); - } - - public static function verbosityProvider(): array - { - return [ - 'verbose' => [true, false, false, '-v'], - 'very verbose' => [false, true, false, '-vv'], - 'debug' => [false, false, true, '-vvv'], - ]; - } - - public function testRunCommandWithCustomComposerBinary(): void - { - $path = '/var/www/html'; - $action = 'install'; - $arguments = '--no-dev'; - $expectedCommand = "cd $path && /usr/local/bin/composer $action $arguments"; - - // Set custom composer binary path - $this->deployer->config->set('bin/composer', '/usr/local/bin/composer'); - - $this->processRunnerMock - ->expects($this->once()) - ->method('run') - ->with($this->host, $expectedCommand) - ->willReturn('Composer output'); - - $result = Composer::runCommand($path, $action, $arguments); - $this->assertEquals('Composer output', $result); - } - - public function testRunCommandWithInvalidComposerBinary(): void - { - $path = '/var/www/html'; - $action = 'install'; - $arguments = '--no-dev'; - $expectedCommand = "cd $path && composer $action $arguments"; - - // Set invalid composer binary (should fall back to default) - $this->deployer->config->set('bin/composer', null); - - $this->expectException(ConfigurationException::class); - $this->expectExceptionMessage('Config option "bin/composer" does not exist'); - $result = Composer::runCommand($path, $action, $arguments); - } -} diff --git a/tests/Integration/FilesIntegrationTest.php b/tests/Integration/FilesIntegrationTest.php deleted file mode 100644 index 35d395b..0000000 --- a/tests/Integration/FilesIntegrationTest.php +++ /dev/null @@ -1,407 +0,0 @@ -processRunnerMock = $this->createMock(ProcessRunner::class); - $this->sshClientMock = $this->createMock(Client::class); - $this->outputMock = $this->createMock(OutputInterface::class); - $this->rsyncMock = $this->createMock(\Deployer\Utility\Rsync::class); - - // Set them in the Deployer container - $this->deployer['processRunner'] = $this->processRunnerMock; - $this->deployer['sshClient'] = $this->sshClientMock; - $this->deployer['output'] = $this->outputMock; - $this->deployer['rsync'] = $this->rsyncMock; - - // Set default configuration - $this->host->set('current_path', '/var/www/current'); - $this->host->set('release_or_current_path', '/var/www/current'); - $this->host->set('zip_options', '--exclude=*.zip'); - } - - public function testPushFiles(): void - { - $localPath = 'wp-content/themes/my-theme'; - $remotePath = 'wp-content/themes'; - $rsyncOptions = ['--exclude=*.log']; - - // Set up expectations for upload - $this->rsyncMock - ->expects($this->once()) - ->method('call') - ->with( - $this->host, - '/var/www/current/wp-content/themes/my-theme/', - '/var/www/current/wp-content/themes/', - ['options' => $rsyncOptions] - ); - - Files::pushFiles($localPath, $remotePath, $rsyncOptions); - } - - public function testPullFiles(): void - { - $remotePath = 'wp-content/uploads'; - $localPath = 'wp-content/uploads'; - $rsyncOptions = ['--exclude=*.tmp']; - - // Set up expectations for download - $this->rsyncMock - ->expects($this->once()) - ->method('call') - ->with( - $this->host, - '/var/www/current/wp-content/uploads/', - '/var/www/current/wp-content/uploads/', - ['options' => $rsyncOptions] - ); - - Files::pullFiles($remotePath, $localPath, $rsyncOptions); - } - - public function testPushFilesWithAbsolutePath(): void - { - $localPath = '/tmp/themes/my-theme'; - $remotePath = '/tmp/themes'; - $rsyncOptions = ['--exclude=*.log']; - - $this->processRunnerMock - ->expects($this->once()) - ->method('run') - ->with($this->anything(), 'mkdir -p /tmp/themes'); - - $this->rsyncMock - ->expects($this->once()) - ->method('call') - ->with( - $this->host, - '/tmp/themes/my-theme/', - '/tmp/themes/', - ['options' => $rsyncOptions] - ); - - Files::pushFiles($localPath, $remotePath, $rsyncOptions); - } - - public function testPushFilesWithHomeRelativePath(): void - { - $localPath = '~/themes/my-theme'; - $remotePath = '~/themes'; - $rsyncOptions = ['--exclude=*.log']; - - $this->processRunnerMock - ->expects($this->once()) - ->method('run') - ->with($this->anything(), 'mkdir -p ~/themes'); - - $this->rsyncMock - ->expects($this->once()) - ->method('call') - ->with( - $this->host, - '~/themes/my-theme/', - '~/themes/', - ['options' => $rsyncOptions] - ); - - Files::pushFiles($localPath, $remotePath, $rsyncOptions); - } - - public function testPullFilesWithAbsolutePath(): void - { - $remotePath = '/tmp/uploads'; - $localPath = '/tmp/uploads'; - $rsyncOptions = ['--exclude=*.tmp']; - - $this->processRunnerMock - ->expects($this->once()) - ->method('run') - ->with($this->anything(), 'mkdir -p /tmp/uploads'); - - $this->rsyncMock - ->expects($this->once()) - ->method('call') - ->with( - $this->host, - '/tmp/uploads/', - '/tmp/uploads/', - ['options' => $rsyncOptions] - ); - - Files::pullFiles($remotePath, $localPath, $rsyncOptions); - } - - public function testPullFilesWithHomeRelativePath(): void - { - $remotePath = '~/uploads'; - $localPath = '~/uploads'; - $rsyncOptions = ['--exclude=*.tmp']; - - $this->processRunnerMock - ->expects($this->once()) - ->method('run') - ->with($this->anything(), 'mkdir -p ~/uploads'); - - $this->rsyncMock - ->expects($this->once()) - ->method('call') - ->with( - $this->host, - '~/uploads/', - '~/uploads/', - ['options' => $rsyncOptions] - ); - - Files::pullFiles($remotePath, $localPath, $rsyncOptions); - } - - public function testPushFileWithRelativePath(): void - { - $localPath = 'data/db_dumps/db_backup.sql'; - $remotePath = 'data/db_dumps/db_backup.sql'; - $rsyncOptions = ['--checksum']; - - $this->processRunnerMock - ->expects($this->once()) - ->method('run') - ->with($this->anything(), 'mkdir -p /var/www/current/data/db_dumps'); - - $this->rsyncMock - ->expects($this->once()) - ->method('call') - ->with( - $this->host, - '/var/www/current/data/db_dumps/db_backup.sql', - '/var/www/current/data/db_dumps/db_backup.sql', - ['options' => $rsyncOptions] - ); - - Files::pushFile($localPath, $remotePath, $rsyncOptions); - } - - public function testPushFileWithAbsolutePath(): void - { - $localPath = '/tmp/db_backup.sql'; - $remotePath = '/tmp/db_backup.sql'; - $rsyncOptions = ['--checksum']; - - $this->processRunnerMock - ->expects($this->once()) - ->method('run') - ->with($this->anything(), 'mkdir -p /tmp'); - - $this->rsyncMock - ->expects($this->once()) - ->method('call') - ->with( - $this->host, - '/tmp/db_backup.sql', - '/tmp/db_backup.sql', - ['options' => $rsyncOptions] - ); - - Files::pushFile($localPath, $remotePath, $rsyncOptions); - } - - public function testPushFileWithHomeRelativePath(): void - { - $localPath = '~/data/db_dumps/db_backup.sql'; - $remotePath = '~/data/db_dumps/db_backup.sql'; - $rsyncOptions = ['--checksum']; - - $this->processRunnerMock - ->expects($this->once()) - ->method('run') - ->with($this->anything(), 'mkdir -p ~/data/db_dumps'); - - $this->rsyncMock - ->expects($this->once()) - ->method('call') - ->with( - $this->host, - '~/data/db_dumps/db_backup.sql', - '~/data/db_dumps/db_backup.sql', - ['options' => $rsyncOptions] - ); - - Files::pushFile($localPath, $remotePath, $rsyncOptions); - } - - public function testPullFileWithRelativePath(): void - { - $remotePath = 'data/db_dumps/db_backup.sql'; - $localPath = 'data/db_dumps/db_backup.sql'; - $rsyncOptions = ['--checksum']; - - $this->processRunnerMock - ->expects($this->once()) - ->method('run') - ->with($this->anything(), 'mkdir -p /var/www/current/data/db_dumps'); - - $this->rsyncMock - ->expects($this->once()) - ->method('call') - ->with( - $this->host, - '/var/www/current/data/db_dumps/db_backup.sql', - '/var/www/current/data/db_dumps/db_backup.sql', - ['options' => $rsyncOptions] - ); - - Files::pullFile($remotePath, $localPath, $rsyncOptions); - } - - public function testPullFileWithAbsolutePath(): void - { - $remotePath = '/tmp/db_backup.sql'; - $localPath = '/tmp/db_backup.sql'; - $rsyncOptions = ['--checksum']; - - $this->processRunnerMock - ->expects($this->once()) - ->method('run') - ->with($this->anything(), 'mkdir -p /tmp'); - - $this->rsyncMock - ->expects($this->once()) - ->method('call') - ->with( - $this->host, - '/tmp/db_backup.sql', - '/tmp/db_backup.sql', - ['options' => $rsyncOptions] - ); - - Files::pullFile($remotePath, $localPath, $rsyncOptions); - } - - public function testPullFileWithHomeRelativePath(): void - { - $remotePath = '~/data/db_dumps/db_backup.sql'; - $localPath = '~/data/db_dumps/db_backup.sql'; - $rsyncOptions = ['--checksum']; - - $this->processRunnerMock - ->expects($this->once()) - ->method('run') - ->with($this->anything(), 'mkdir -p ~/data/db_dumps'); - - $this->rsyncMock - ->expects($this->once()) - ->method('call') - ->with( - $this->host, - '~/data/db_dumps/db_backup.sql', - '~/data/db_dumps/db_backup.sql', - ['options' => $rsyncOptions] - ); - - Files::pullFile($remotePath, $localPath, $rsyncOptions); - } - - public function testResolvePath(): void - { - $basePath = '/var/www/current'; - - $this->assertEquals( - '/var/www/current/data/db_dumps', - Files::resolvePath('data/db_dumps', $basePath) - ); - $this->assertEquals( - '/var/www/current/data/db_dumps/', - Files::resolvePath('data/db_dumps/', $basePath) - ); - $this->assertEquals( - '/tmp/db_dumps', - Files::resolvePath('/tmp/db_dumps', $basePath) - ); - $this->assertEquals( - '~/db_dumps', - Files::resolvePath('~/db_dumps', $basePath) - ); - $this->assertEquals( - '/var/www/current/', - Files::resolvePath('', $basePath) - ); - } - - public function testZipFilesWithTrailingSlash(): void - { - $dir = '/var/www/current/wp-content/uploads/'; - $backupDir = '/var/www/backups'; - $filename = 'uploads'; - - // Set up expectations for zip command - $this->processRunnerMock - ->expects($this->exactly(2)) - ->method('run') - ->willReturnCallback(function ($host, $command) use ($backupDir, $filename) { - static $callNumber = 0; - $callNumber++; - - switch ($callNumber) { - case 1: - $this->assertEquals("mkdir -p $backupDir", $command); - return 'Mkdir output'; - case 2: - $this->assertStringContainsString("cd /var/www/current/wp-content/uploads/ && zip -r ", $command); - $this->assertStringContainsString(" . --exclude=*.zip && mv ", $command); - $this->assertStringContainsString(" $backupDir/", $command); - return 'Zip output'; - } - }); - - $result = Files::zipFiles($dir, $backupDir, $filename); - $this->assertStringContainsString("$backupDir/{$filename}_", $result); - $this->assertStringEndsWith('.zip', $result); - } - - public function testZipFilesWithoutTrailingSlash(): void - { - $dir = '/var/www/current/wp-content/uploads'; - $backupDir = '/var/www/backups'; - $filename = 'uploads'; - - // Set up expectations for zip command - $this->processRunnerMock - ->expects($this->exactly(2)) - ->method('run') - ->willReturnCallback(function ($host, $command) use ($backupDir, $filename) { - static $callNumber = 0; - $callNumber++; - - switch ($callNumber) { - case 1: - $this->assertEquals("mkdir -p $backupDir", $command); - return 'Mkdir output'; - case 2: - $this->assertStringContainsString("cd /var/www/current/wp-content && zip -r ", $command); - $this->assertStringContainsString(" uploads --exclude=*.zip && mv ", $command); - $this->assertStringContainsString(" $backupDir/", $command); - return 'Zip output'; - } - }); - - $result = Files::zipFiles($dir, $backupDir, $filename); - $this->assertStringContainsString("$backupDir/{$filename}_", $result); - $this->assertStringEndsWith('.zip', $result); - } -} diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php index 30235b7..8faf5ca 100644 --- a/tests/Integration/IntegrationTestCase.php +++ b/tests/Integration/IntegrationTestCase.php @@ -2,59 +2,13 @@ namespace Gaambo\DeployerWordpress\Tests\Integration; -use Deployer\Deployer; -use Deployer\Host\Host; -use Deployer\Host\Localhost; -use Deployer\Task\Context; -use PHPUnit\Framework\MockObject\MockObject; -use PHPUnit\Framework\TestCase; -use Symfony\Component\Console\Application; -use Symfony\Component\Console\Input\Input; -use Symfony\Component\Console\Output\Output; +use Gaambo\DeployerUtils\Tests\Integration\IntegrationTestCase as BaseIntegrationTestCase; -abstract class IntegrationTestCase extends TestCase +abstract class IntegrationTestCase extends BaseIntegrationTestCase { - protected Deployer $deployer; - protected MockObject|Input $input; - protected MockObject|Output $output; - protected Host $host; - protected function setUp(): void { parent::setUp(); - - // Create a new Deployer instance - $console = new Application(); - $this->deployer = new Deployer($console); - - // Create mocked Input and Output - $this->input = $this->createMock(Input::class); - $this->output = $this->createMock(Output::class); - - // Set up the mocked components in Deployer - $this->deployer['input'] = $this->input; - $this->deployer['output'] = $this->output; - - // Create a localhost instance for testing - $this->host = new Localhost(); - $this->host->set('deploy_path', '/var/www'); $this->host->set('bin/wp', 'wp'); - $this->host->set('current_path', '/var/www/current'); - $this->host->set('release_or_current_path', '/var/www/current'); - $this->deployer->hosts->set('localhost', $this->host); - - // Push a new context with our host - Context::push(new Context($this->host)); - } - - protected function tearDown(): void - { - // Pop the context to clean up - Context::pop(); - - // Clean up the Deployer instance - unset($this->deployer); - - parent::tearDown(); } } diff --git a/tests/Integration/LocalhostIntegrationTest.php b/tests/Integration/LocalhostIntegrationTest.php deleted file mode 100644 index fb44007..0000000 --- a/tests/Integration/LocalhostIntegrationTest.php +++ /dev/null @@ -1,68 +0,0 @@ -host->set('test_key', 'test_value'); - - // Test getting configuration - $value = Localhost::getConfig('test_key'); - $this->assertEquals('test_value', $value); - } - - public function testGetConfigWithNonExistentKey(): void - { - // Test getting non-existent configuration - $value = Localhost::getConfig('non_existent_key'); - $this->assertNull($value); - } - - public function testGetConfigWithDynamicValue(): void - { - $this->host->set('test_key', function () { - return 'test_value'; - }); - // Test getting configuration - $value = Localhost::getConfig('test_key'); - $this->assertEquals('test_value', $value); - } - - public function testCorrectlyUsesLocalhostContextWithDynamicValues(): void - { - $this->host->set('test_key', 'test_value'); - - $this->host->set('dynamic_key', function () { - return \Deployer\get('test_key'); - }); - $remoteHost = new \Deployer\Host\Localhost('testremote'); - $remoteHost->set('test_key', 'remote_value'); - // Push a new context with the remote host - Context::push(new Context($remoteHost)); - - $value = Localhost::getConfig('dynamic_key'); - $this->assertEquals('test_value', $value); - - Context::pop(); - } - - public function testGet(): void - { - // Test getting localhost instance - $host = Localhost::get(); - - // Verify it's the same instance we set up in IntegrationTestCase - $this->assertSame($this->host, $host); - - // Verify it has the expected configuration - $this->assertEquals('/var/www', $host->get('deploy_path')); - $this->assertEquals('wp', $host->get('bin/wp')); - $this->assertEquals('/var/www/current', $host->get('release_or_current_path')); - } -} diff --git a/tests/Integration/NPMIntegrationTest.php b/tests/Integration/NPMIntegrationTest.php deleted file mode 100644 index 3bd2171..0000000 --- a/tests/Integration/NPMIntegrationTest.php +++ /dev/null @@ -1,227 +0,0 @@ -processRunnerMock = $this->createMock(ProcessRunner::class); - $this->sshClientMock = $this->createMock(Client::class); - $this->outputMock = $this->createMock(OutputInterface::class); - - // Set them in the Deployer container - $this->deployer['processRunner'] = $this->processRunnerMock; - $this->deployer['sshClient'] = $this->sshClientMock; - $this->deployer['output'] = $this->outputMock; - - // Set default npm configuration - $this->deployer->config->set('bin/npm', 'npm'); - } - - public function testRunScript(): void - { - $path = '/var/www/html'; - $script = 'build'; - $arguments = '--env=production'; - $expectedCommand = "cd $path && npm run-script $script $arguments"; - - $this->processRunnerMock - ->expects($this->once()) - ->method('run') - ->with($this->host, $expectedCommand) - ->willReturn('NPM output'); - - $result = NPM::runScript($path, $script, $arguments); - $this->assertEquals('NPM output', $result); - $this->addToAssertionCount(1); // Count the mock expectation as an assertion - } - - public function testRunCommand(): void - { - $path = '/var/www/html'; - $action = 'install'; - $arguments = '--save-dev package-name'; - $expectedCommand = "cd $path && npm $action $arguments"; - - $this->processRunnerMock - ->expects($this->once()) - ->method('run') - ->with($this->host, $expectedCommand) - ->willReturn('NPM output'); - - $result = NPM::runCommand($path, $action, $arguments); - $this->assertEquals('NPM output', $result); - $this->addToAssertionCount(1); // Count the mock expectation as an assertion - } - - public function testRunInstall(): void - { - $path = '/var/www/html'; - $arguments = '--save-dev'; - $expectedCommand = "cd $path && npm install $arguments"; - - $this->processRunnerMock - ->expects($this->once()) - ->method('run') - ->with($this->host, $expectedCommand) - ->willReturn('NPM output'); - - $result = NPM::runInstall($path, $arguments); - $this->assertEquals('NPM output', $result); - $this->addToAssertionCount(1); // Count the mock expectation as an assertion - } - - /** - * @dataProvider verbosityProvider - */ - public function testRunCommandWithVerbosity(bool $isVerbose, bool $isVeryVerbose, bool $isDebug, string $verbosityFlag): void - { - $path = '/var/www/html'; - $action = 'install'; - $arguments = '--save-dev'; - - $this->outputMock->method('isVerbose')->willReturn($isVerbose); - $this->outputMock->method('isVeryVerbose')->willReturn($isVeryVerbose); - $this->outputMock->method('isDebug')->willReturn($isDebug); - - $expectedCommand = "cd $path && npm $action $arguments $verbosityFlag"; - - $this->processRunnerMock - ->expects($this->once()) - ->method('run') - ->with($this->host, $expectedCommand) - ->willReturn('NPM output'); - - $result = NPM::runCommand($path, $action, $arguments); - $this->assertEquals('NPM output', $result); - $this->addToAssertionCount(1); // Count the mock expectation as an assertion - } - - public static function verbosityProvider(): array - { - return [ - 'verbose' => [true, false, false, '-d'], - 'very verbose' => [false, true, false, '-dd'], - 'debug' => [false, false, true, '-ddd'], - ]; - } - - public function testRunCommandWithComplexArguments(): void - { - $path = '/var/www/html'; - $action = 'install'; - $arguments = '--save-dev "package@1.0.0" --save-exact --no-audit --legacy-peer-deps'; - $expectedCommand = "cd $path && npm $action $arguments"; - - $this->processRunnerMock - ->expects($this->once()) - ->method('run') - ->with($this->host, $expectedCommand) - ->willReturn('NPM output'); - - $result = NPM::runCommand($path, $action, $arguments); - $this->assertEquals('NPM output', $result); - } - - public function testRunCommandWithSpecialCharactersInPath(): void - { - $path = '/var/www/html with spaces'; - $action = 'install'; - $arguments = '--save-dev'; - $expectedCommand = "cd $path && npm $action $arguments"; - - $this->processRunnerMock - ->expects($this->once()) - ->method('run') - ->with($this->host, $expectedCommand) - ->willReturn('NPM output'); - - $result = NPM::runCommand($path, $action, $arguments); - $this->assertEquals('NPM output', $result); - } - - public function testRunCommandWithVeryLongPath(): void - { - // Create a path that's 255 characters long (common filesystem limit) - $path = str_repeat('a', 200) . '/path/to/project'; - $action = 'install'; - $arguments = '--save-dev'; - $expectedCommand = "cd $path && npm $action $arguments"; - - $this->processRunnerMock - ->expects($this->once()) - ->method('run') - ->with($this->host, $expectedCommand) - ->willReturn('NPM output'); - - $result = NPM::runCommand($path, $action, $arguments); - $this->assertEquals('NPM output', $result); - } - - public function testRunCommandWithComplexScriptName(): void - { - $path = '/var/www/html'; - $script = 'build:production:minify'; - $arguments = '--env=prod'; - $expectedCommand = "cd $path && npm run-script $script $arguments"; - - $this->processRunnerMock - ->expects($this->once()) - ->method('run') - ->with($this->host, $expectedCommand) - ->willReturn('NPM output'); - - $result = NPM::runScript($path, $script, $arguments); - $this->assertEquals('NPM output', $result); - } - - public function testRunCommandWithCustomNpmBinary(): void - { - $path = '/var/www/html'; - $action = 'install'; - $arguments = '--save-dev'; - $expectedCommand = "cd $path && /usr/local/bin/npm $action $arguments"; - - // Set custom npm binary path - $this->deployer->config->set('bin/npm', '/usr/local/bin/npm'); - - $this->processRunnerMock - ->expects($this->once()) - ->method('run') - ->with($this->host, $expectedCommand) - ->willReturn('NPM output'); - - $result = NPM::runCommand($path, $action, $arguments); - $this->assertEquals('NPM output', $result); - } - - public function testRunCommandWithInvalidNpmBinary(): void - { - $path = '/var/www/html'; - $action = 'install'; - $arguments = '--save-dev'; - $expectedCommand = "cd $path && npm $action $arguments"; - - // Set invalid npm binary (should fall back to default) - $this->deployer->config->set('bin/npm', null); - - $this->expectException(ConfigurationException::class); - $this->expectExceptionMessage('Config option "bin/npm" does not exist'); - $result = NPM::runCommand($path, $action, $arguments); - } -} diff --git a/tests/Integration/RsyncIntegrationTest.php b/tests/Integration/RsyncIntegrationTest.php deleted file mode 100644 index 8a70952..0000000 --- a/tests/Integration/RsyncIntegrationTest.php +++ /dev/null @@ -1,397 +0,0 @@ -fixturesDir = __FIXTURES__ . '/rsync'; - - // Set default rsync configuration - $this->deployer->config->set('rsync', [ - 'exclude' => [ - '.git', - 'deploy.php', - ], - 'exclude-file' => false, - 'include' => [], - 'include-file' => false, - 'filter' => [], - 'filter-file' => false, - 'filter-perdir' => false, - 'options' => ['delete-after'], // needed so deployfilter files are send and delete is checked afterwards - ]); - } - - public function testBuildOptionsWithArray(): void - { - $options = ['delete-after', 'recursive', 'verbose']; - $result = Rsync::buildOptions($options); - - $this->assertCount(3, $result); - $this->assertContains('--delete-after', $result); - $this->assertContains('--recursive', $result); - $this->assertContains('--verbose', $result); - } - - public function testBuildOptionsWithEmptyArray(): void - { - $result = Rsync::buildOptions([]); - $this->assertEmpty($result); - } - - public function testBuildOptionsWithSpecialCharacters(): void - { - $options = ['delete after', 'recursive-update', 'verbose=true']; - $result = Rsync::buildOptions($options); - - $this->assertCount(3, $result); - $this->assertContains('--delete after', $result); - $this->assertContains('--recursive-update', $result); - $this->assertContains('--verbose=true', $result); - } - - public function testBuildOptionsArrayWithCustomConfig(): void - { - $config = [ - 'exclude' => ['*.log', '*.tmp'], - 'exclude-file' => $this->fixturesDir . '/exclude.txt', - 'include' => ['*.php', '*.js'], - 'include-file' => $this->fixturesDir . '/include.txt', - 'filter' => ['+ /wp-content/'], - 'filter-file' => $this->fixturesDir . '/filter.txt', - 'filter-perdir' => '.deployfilter', - 'options' => ['delete-after', 'recursive'] - ]; - - $result = Rsync::buildOptionsArray($config); - - $this->assertContains('--exclude=*.log', $result); - $this->assertContains('--exclude=*.tmp', $result); - $this->assertContains('--exclude-from=' . $config['exclude-file'], $result); - $this->assertContains('--include=*.php', $result); - $this->assertContains('--include=*.js', $result); - $this->assertContains('--include-from=' . $config['include-file'], $result); - $this->assertContains('--filter=+ /wp-content/', $result); - $this->assertContains('--filter=merge ' . $config['filter-file'], $result); - $this->assertContains('--filter=dir-merge ' . $config['filter-perdir'], $result); - $this->assertContains('--delete-after', $result); - $this->assertContains('--recursive', $result); - } - - public function testBuildOptionsArrayWithDefaults(): void - { - $result = Rsync::buildOptionsArray(); - - // Default config should include these - $this->assertContains('--exclude=.git', $result); - $this->assertContains('--exclude=deploy.php', $result); - $this->assertContains('--delete-after', $result); - - // These should not be present in default config - $this->assertNotContains('--include=', $result); - $this->assertNotContains('--include-from=', $result); - $this->assertNotContains('--filter=', $result); - $this->assertNotContains('--filter=merge', $result); - $this->assertNotContains('--filter=dir-merge', $result); - } - - public function testBuildOptionsArrayWithEmptyConfig(): void - { - $result = Rsync::buildOptionsArray([]); - - // Should still include defaults - $this->assertContains('--exclude=.git', $result); - $this->assertContains('--exclude=deploy.php', $result); - $this->assertContains('--delete-after', $result); - } - - public function testBuildOptionsArrayWithNoDeployerConfig(): void - { - // Remove the rsync config - $this->deployer->config->set('rsync', null); - - $result = Rsync::buildOptionsArray(); - - // Should use hardcoded defaults - $this->assertContains('--exclude=.git', $result); - $this->assertContains('--exclude=deploy.php', $result); - $this->assertContains('--delete-after', $result); - } - - public function testBuildOptionsArrayWithInvalidDeployerConfig(): void - { - // Set an invalid config - $this->deployer->config->set('rsync', 'invalid'); - - $result = Rsync::buildOptionsArray(); - - // Should use hardcoded defaults - $this->assertContains('--exclude=.git', $result); - $this->assertContains('--exclude=deploy.php', $result); - $this->assertContains('--delete-after', $result); - } - - public function testBuildOptionsArrayWithInvalidFilePaths(): void - { - $config = [ - 'exclude-file' => '/nonexistent/exclude.txt', - 'include-file' => '/nonexistent/include.txt', - 'filter-file' => '/nonexistent/filter.txt' - ]; - - $result = Rsync::buildOptionsArray($config); - - // Invalid files should be ignored - $this->assertNotContains('--exclude-from=/nonexistent/exclude.txt', $result); - $this->assertNotContains('--include-from=/nonexistent/include.txt', $result); - $this->assertNotContains('--filter=merge /nonexistent/filter.txt', $result); - } - - public function testBuildOptionsArrayFiltersEmptyStrings(): void - { - $config = [ - 'options' => ['', 'delete-after', '', 'recursive', ''], - 'exclude' => ['', '.git', ''], - 'include' => ['', '*.php', ''], - 'filter' => ['', '+ /wp-content/', ''] - ]; - - $result = Rsync::buildOptionsArray($config); - - // Check that non-empty values are present - $this->assertContains('--delete-after', $result); - $this->assertContains('--recursive', $result); - $this->assertContains('--exclude=.git', $result); - $this->assertContains('--include=*.php', $result); - $this->assertContains('--filter=+ /wp-content/', $result); - - // Check that no empty values are present - $this->assertNotContains('', $result, "Empty string found in result"); - $this->assertNotContains('--exclude=', $result, "Empty exclude found in result"); - $this->assertNotContains('--include=', $result, "Empty include found in result"); - $this->assertNotContains('--filter=', $result, "Empty filter found in result"); - } - - public function testBuildOptionsArrayWithComplexPaths(): void - { - $config = [ - 'exclude' => [ - 'path/with spaces/file.txt', - 'path/with/special@chars', - 'path/with/backslash\\file.txt', - 'path/with/dollar$file.txt', - 'path/with/asterisk*file.txt', - 'path/with/question?file.txt' - ], - 'include' => [ - 'path/with/quotes"file.txt', - 'path/with/single\'quote.txt', - 'path/with/backtick`file.txt' - ] - ]; - - $result = Rsync::buildOptionsArray($config); - - // Test spaces in paths - $this->assertContains('--exclude=path/with spaces/file.txt', $result); - - // Test special characters - $this->assertContains('--exclude=path/with/special@chars', $result); - $this->assertContains('--exclude=path/with/backslash\\file.txt', $result); - $this->assertContains('--exclude=path/with/dollar$file.txt', $result); - $this->assertContains('--exclude=path/with/asterisk*file.txt', $result); - $this->assertContains('--exclude=path/with/question?file.txt', $result); - - // Test quotes and backticks - $this->assertContains('--include=path/with/quotes"file.txt', $result); - $this->assertContains('--include=path/with/single\'quote.txt', $result); - $this->assertContains('--include=path/with/backtick`file.txt', $result); - } - - public function testBuildOptionsArrayWithRelativePaths(): void - { - $config = [ - 'exclude' => [ - './local/path', - '../parent/path', - '../../root/path', - '././nested/./path', - '.././mixed/../path' - ], - 'include' => [ - '~/home/path', - '~user/home/path' - ] - ]; - - $result = Rsync::buildOptionsArray($config); - - // Test relative paths - $this->assertContains('--exclude=./local/path', $result); - $this->assertContains('--exclude=../parent/path', $result); - $this->assertContains('--exclude=../../root/path', $result); - - // Test mixed relative paths - $this->assertContains('--exclude=././nested/./path', $result); - $this->assertContains('--exclude=.././mixed/../path', $result); - - // Test home directory paths - $this->assertContains('--include=~/home/path', $result); - $this->assertContains('--include=~user/home/path', $result); - } - - public function testBuildOptionsArrayWithEmptyAndWhitespaceValues(): void - { - $config = [ - 'options' => ['', ' ', ' ', "\t", "\n", 'valid-option'], - 'exclude' => ['', ' ', ' ', "\t", "\n", 'valid-exclude'], - 'include' => ['', ' ', ' ', "\t", "\n", 'valid-include'], - 'filter' => ['', ' ', ' ', "\t", "\n", 'valid-filter'] - ]; - - $result = Rsync::buildOptionsArray($config); - - // Check that non-empty values are present - $this->assertContains('--valid-option', $result); - $this->assertContains('--exclude=valid-exclude', $result); - $this->assertContains('--include=valid-include', $result); - $this->assertContains('--filter=valid-filter', $result); - - // Check that no empty values are present - $this->assertNotContains('', $result, "Empty string found in result"); - $this->assertNotContains('--exclude=', $result, "Empty exclude found in result"); - $this->assertNotContains('--include=', $result, "Empty include found in result"); - $this->assertNotContains('--filter=', $result, "Empty filter found in result"); - - // Check that defaults are not present (user values should override) - $this->assertNotContains('--delete-after', $result); - $this->assertNotContains('--exclude=.git', $result); - $this->assertNotContains('--exclude=deploy.php', $result); - } - - public function testBuildOptionsArrayWithVeryLongPaths(): void - { - // Create a path that's 255 characters long (common filesystem limit) - $longPath = str_repeat('a', 200) . '/path/to/file.txt'; - - $config = [ - 'exclude' => [$longPath], - 'include' => [$longPath], - 'filter' => ['+ ' . $longPath] - ]; - - $result = Rsync::buildOptionsArray($config); - - $this->assertContains('--exclude=' . $longPath, $result); - $this->assertContains('--include=' . $longPath, $result); - $this->assertContains('--filter=+ ' . $longPath, $result); - } - - public function testBuildOptionsArrayWithUnicodeCharacters(): void - { - $config = [ - 'exclude' => [ - 'path/with/unicode/测试.txt', - 'path/with/unicode/über.txt', - 'path/with/unicode/🎉.txt' - ], - 'include' => [ - 'path/with/unicode/日本語.txt', - 'path/with/unicode/русский.txt' - ] - ]; - - $result = Rsync::buildOptionsArray($config); - - $this->assertContains('--exclude=path/with/unicode/测试.txt', $result); - $this->assertContains('--exclude=path/with/unicode/über.txt', $result); - $this->assertContains('--exclude=path/with/unicode/🎉.txt', $result); - $this->assertContains('--include=path/with/unicode/日本語.txt', $result); - $this->assertContains('--include=path/with/unicode/русский.txt', $result); - } - - public function testBuildOptionsArrayWithInvalidConfigurationTypes(): void - { - $config = [ - 'exclude' => 'not-an-array', - 'include' => 123, - 'filter' => true, - 'options' => null, - 'exclude-file' => ['should-be-string'], - 'include-file' => 456, - 'filter-file' => false, - 'filter-perdir' => ['should-be-string'] - ]; - - $result = Rsync::buildOptionsArray($config); - - $this->assertContains('--exclude=not-an-array', $result); - $this->assertNotContains('--exclude=deploy.php', $result); // Not default value - $this->assertNotContains('--delete-after', $result); // Not default value - $this->assertNotContains('--exclude-file=should-be-string', $result); - $this->assertNotContains('--filter=dir-merge should-be-string', $result); - } - - public function testBuildOptionsArrayWithEmptyConfigurationValues(): void - { - $config = [ - 'exclude' => [], - 'include' => [], - 'filter' => [], - 'options' => [] - ]; - - $result = Rsync::buildOptionsArray($config); - - // Empty arrays should replace defaults - $this->assertNotContains('--exclude=.git', $result); - $this->assertNotContains('--exclude=deploy.php', $result); - $this->assertNotContains('--delete-after', $result); - } - - public function testBuildOptionsArrayWithPartialConfiguration(): void - { - $config = [ - 'exclude' => ['custom.txt'], - 'options' => ['recursive'] - ]; - - $result = Rsync::buildOptionsArray($config); - - // Should use custom values over defaults - $this->assertContains('--exclude=custom.txt', $result); - $this->assertNotContains('--exclude=.git', $result); - $this->assertNotContains('--exclude=deploy.php', $result); - $this->assertContains('--recursive', $result); - $this->assertNotContains('--delete-after', $result); - } - - public function testBuildOptionsArrayWithOverriddenDefaults(): void - { - $config = [ - 'exclude' => ['custom.txt'], - 'options' => ['recursive'] - ]; - - // Override default config - $this->deployer->config->set('rsync', [ - 'exclude' => ['default.txt'], - 'options' => ['verbose'] - ]); - - $result = Rsync::buildOptionsArray($config); - - // Should use custom values over defaults - $this->assertContains('--exclude=custom.txt', $result); - $this->assertNotContains('--exclude=default.txt', $result); - $this->assertContains('--recursive', $result); - $this->assertNotContains('--verbose', $result); - } -} diff --git a/tests/Integration/Tasks/TaskRegistrationTestCase.php b/tests/Integration/Tasks/TaskRegistrationTestCase.php index 3ce50de..73ec95a 100644 --- a/tests/Integration/Tasks/TaskRegistrationTestCase.php +++ b/tests/Integration/Tasks/TaskRegistrationTestCase.php @@ -2,40 +2,8 @@ namespace Gaambo\DeployerWordpress\Tests\Integration\Tasks; -use Deployer\Task\GroupTask; -use Gaambo\DeployerWordpress\Tests\Integration\IntegrationTestCase; +use Gaambo\DeployerUtils\Tests\Integration\Tasks\TaskRegistrationTestCase as BaseTaskRegistrationTestCase; -abstract class TaskRegistrationTestCase extends IntegrationTestCase +abstract class TaskRegistrationTestCase extends BaseTaskRegistrationTestCase { - abstract protected static function loadTasks(): void; - - protected function setUp(): void - { - parent::setUp(); - // Require on each run, so they are registered again on the new Deployer instance. - static::loadTasks(); - } - - protected function assertTaskExists(string $taskName): void - { - $this->assertTrue( - $this->deployer->tasks->has($taskName), - "Task '$taskName' should be registered" - ); - } - - /** - * @param string $taskName - * @param string[] $expectedDependencies - * @return void - */ - protected function assertTaskDependencies(string $taskName, array $expectedDependencies): void - { - $task = $this->deployer->tasks->get($taskName); - $this->assertInstanceOf(GroupTask::class, $task); - $this->assertEquals($expectedDependencies, $task->getGroup()); - } - - abstract protected function testTasksAreRegistered(): void; - abstract protected function testTaskDependencies(): void; } diff --git a/tests/Integration/UtilsIntegrationTest.php b/tests/Integration/UtilsIntegrationTest.php deleted file mode 100644 index 5c5ac2d..0000000 --- a/tests/Integration/UtilsIntegrationTest.php +++ /dev/null @@ -1,44 +0,0 @@ -outputMock = $this->createMock(OutputInterface::class); - $this->deployer['output'] = $this->outputMock; - } - - /** - * @dataProvider verbosityProvider - */ - public function testGetVerbosityArgument(bool $isVerbose, bool $isVeryVerbose, bool $isDebug, string $expectedArgument): void - { - $this->outputMock->method('isVerbose')->willReturn($isVerbose); - $this->outputMock->method('isVeryVerbose')->willReturn($isVeryVerbose); - $this->outputMock->method('isDebug')->willReturn($isDebug); - - $result = Utils::getVerbosityArgument(); - $this->assertEquals($expectedArgument, $result); - } - - public static function verbosityProvider(): array - { - return [ - 'normal output' => [false, false, false, ''], - 'verbose output' => [true, false, false, '-v'], - 'very verbose output' => [false, true, false, '-vv'], - 'debug output' => [false, false, true, '-vvv'], - ]; - } -} \ No newline at end of file diff --git a/tests/Integration/WpCliIntegrationTest.php b/tests/Integration/WpCliIntegrationTest.php index 1a7da24..078fc8d 100644 --- a/tests/Integration/WpCliIntegrationTest.php +++ b/tests/Integration/WpCliIntegrationTest.php @@ -2,20 +2,31 @@ namespace Gaambo\DeployerWordpress\Tests\Integration; +use Deployer\ProcessRunner\ProcessRunner; +use Deployer\Host\Host; +use Deployer\Host\Localhost as DeployerLocalhost; +use Deployer\Ssh\RunParams; +use Deployer\Ssh\SshClient; +use Deployer\Task\Context; use Gaambo\DeployerWordpress\WPCLI; +use Gaambo\DeployerUtils\Runtime\DdevRuntime; use PHPUnit\Framework\MockObject\MockObject; +use function Gaambo\DeployerUtils\runtime; + class WpCliIntegrationTest extends IntegrationTestCase { private MockObject $processRunnerMock; + private MockObject $sshClientMock; protected function setUp(): void { parent::setUp(); - // Set up the process runner mock - $this->processRunnerMock = $this->createMock(\Deployer\Component\ProcessRunner\ProcessRunner::class); + $this->processRunnerMock = $this->createMock(ProcessRunner::class); $this->deployer['processRunner'] = $this->processRunnerMock; + $this->sshClientMock = $this->createMock(SshClient::class); + $this->deployer['sshClient'] = $this->sshClientMock; } public function testRunCommand(): void @@ -23,13 +34,8 @@ public function testRunCommand(): void $path = '/var/www/html'; $command = 'post list'; $arguments = '--format=table'; - $expectedCommand = "cd $path && wp post list --format=table"; - - $this->processRunnerMock - ->expects($this->once()) - ->method('run') - ->with($this->host, $expectedCommand) - ->willReturn('WP-CLI output'); + $expectedCommand = 'wp post list --format=table'; + $this->expectLocalWpCommand($expectedCommand, $path); WPCLI::runCommand($command, $path, $arguments); } @@ -40,24 +46,20 @@ public function testRunCommandWithoutPath(): void $arguments = '--format=table'; $expectedCommand = "wp post list --format=table"; - $this->processRunnerMock - ->expects($this->once()) - ->method('run') - ->with($this->host, $expectedCommand) - ->willReturn('WP-CLI output'); + $this->expectLocalWpCommand($expectedCommand, null); WPCLI::runCommand($command, null, $arguments); } public function testRunCommandLocally(): void { - $expectedCommand = 'cd /var/www/current && wp post list --format=table '; - // Note: We can't test for the host object because runLocally creates a new host instance. + $expectedCommand = 'wp post list --format=table '; $this->processRunnerMock ->expects($this->once()) ->method('run') - ->willReturnCallback(function ($host, $command) use ($expectedCommand) { + ->willReturnCallback(function ($host, $command, $options) use ($expectedCommand) { $this->assertEquals($expectedCommand, $command); + $this->assertSame('/var/www/current', $this->runCwd($options)); return ''; }); @@ -79,6 +81,144 @@ public function testRunCommandLocallyWithoutPath(): void WPCLI::runCommandLocally('post list --format=table', false); } + public function testRunCommandLocallyMapsOnlyExplicitRuntimePaths(): void + { + $this->host->set('runtime', runtime(DdevRuntime::class)); + $hostDumpPath = '/var/www/data/dumps/site.sql'; + + $this->processRunnerMock + ->expects($this->once()) + ->method('run') + ->willReturnCallback(function ($host, $command, $options) { + $this->assertSame( + 'wp db import /var/www/html/data/dumps/site.sql --source=/var/www/uploads', + $command + ); + $this->assertSame('/var/www', $this->runCwd($options)); + $this->assertSame($this->ddevShell('/var/www/html/current'), $this->runShell($options)); + return ''; + }); + + WPCLI::runCommandLocally( + "db import $hostDumpPath", + '/var/www/current', + '--source=/var/www/uploads', + runtimePaths: [$hostDumpPath] + ); + } + + public function testRunCommandLocallyUsesLocalhostFromAnotherTaskContext(): void + { + $this->host->set('runtime', runtime(DdevRuntime::class)); + $remoteHost = new Host('mi6'); + $remoteHost->set('deploy_path', '/remote/project'); + $remoteHost->set('runtime', runtime(DdevRuntime::class)); + Context::push(new Context($remoteHost)); + + try { + $this->processRunnerMock + ->expects($this->once()) + ->method('run') + ->willReturnCallback(function ($host, $command, $options) { + $this->assertSame('wp db import /var/www/html/data/dump.sql ', $command); + $this->assertSame('/var/www', $this->runCwd($options)); + $this->assertSame($this->ddevShell('/var/www/html/current'), $this->runShell($options)); + return ''; + }); + + WPCLI::runCommandLocally( + 'db import /var/www/data/dump.sql', + '/var/www/current', + runtimePaths: ['/var/www/data/dump.sql'] + ); + } finally { + Context::pop(); + } + } + + public function testRunCommandUsesRemoteSourceHostWithoutRuntime(): void + { + $remoteSourceHost = $this->remoteSourceHost(); + $this->sshClientMock + ->expects($this->once()) + ->method('run') + ->willReturnCallback(function ($executionHost, $command, RunParams $options) use ($remoteSourceHost) { + $this->assertSame($remoteSourceHost, $executionHost); + $this->assertSame('wp post list --format=json', $command); + $this->assertSame('/srv/www/current', $this->runCwd($options)); + return ''; + }); + + $this->onHost( + $remoteSourceHost, + fn() => WPCLI::runCommand('post list', arguments: '--format=json') + ); + } + + public function testRunCommandUsesDdevOnRemoteSourceHostAndMapsExplicitPaths(): void + { + $remoteSourceHost = $this->remoteSourceHost(); + $remoteSourceHost->set('runtime', runtime('ddev')); + $sourceDumpFile = '/srv/www/data/dumps/site.sql'; + $this->sshClientMock + ->expects($this->once()) + ->method('run') + ->willReturnCallback(function ($executionHost, $command, RunParams $options) use ($remoteSourceHost) { + $this->assertNotSame($remoteSourceHost, $executionHost); + $this->assertSame('production:ddev', $executionHost->getAlias()); + $this->assertSame('wp db export /var/www/html/data/dumps/site.sql ', $command); + $this->assertSame('', $this->runCwd($options)); + $this->assertSame( + $this->remoteDdevShell('/srv/www', '/var/www/html/current'), + $this->runShell($options) + ); + return ''; + }); + + $this->onHost( + $remoteSourceHost, + fn() => WPCLI::runCommand( + "db export $sourceDumpFile", + runtimePaths: [$sourceDumpFile] + ) + ); + } + + public function testRunCommandResolvesWpBinaryFromRemoteDdevWorkingPath(): void + { + $remoteSourceHost = $this->remoteSourceHost(); + $remoteSourceHost->set('runtime', runtime('ddev')); + $remoteSourceHost->set('bin/php', '/usr/bin/php'); + $remoteSourceHost->set('bin/wp', function () { + if (\Deployer\test('[ -f {{deploy_path}}/.dep/wp-cli.phar ]')) { + return '{{bin/php}} {{deploy_path}}/.dep/wp-cli.phar'; + } + + return 'wp'; + }); + $this->sshClientMock + ->expects($this->exactly(2)) + ->method('run') + ->willReturnCallback(function ($executionHost, $command, RunParams $options) { + $this->assertSame('production:ddev', $executionHost->getAlias()); + $this->assertSame( + $this->remoteDdevShell('/srv/www', '/var/www/html/current'), + $this->runShell($options) + ); + if (str_starts_with($command, 'if [ -f /var/www/html/.dep/wp-cli.phar ]; then echo +')) { + $this->assertSame('/var/www/html', $this->runCwd($options)); + preg_match('/echo (\+\w+); fi$/', $command, $matches); + return $matches[1]; + } + + $this->assertSame('/usr/bin/php /var/www/html/.dep/wp-cli.phar --info ', $command); + $this->assertSame('', $this->runCwd($options)); + return ''; + }); + + $this->onHost($remoteSourceHost, fn() => WPCLI::runCommand('--info')); + } + public function testInstall(): void { $installPath = '/usr/local/bin'; @@ -157,13 +297,8 @@ public function testRunCommandWithComplexArguments(): void $path = '/var/www/html'; $command = 'post create'; $arguments = '--post_title="Complex Title with Spaces" --post_content="Content with \'quotes\' and \"double quotes\"" --post_status=draft'; - $expectedCommand = "cd $path && wp $command $arguments"; - - $this->processRunnerMock - ->expects($this->once()) - ->method('run') - ->with($this->host, $expectedCommand) - ->willReturn('WP-CLI output'); + $expectedCommand = "wp $command $arguments"; + $this->expectLocalWpCommand($expectedCommand, $path); WPCLI::runCommand($command, $path, $arguments); } @@ -173,13 +308,8 @@ public function testRunCommandWithSpecialCharactersInPath(): void $path = '/var/www/html with spaces'; $command = 'post list'; $arguments = '--format=table'; - $expectedCommand = "cd $path && wp $command $arguments"; - - $this->processRunnerMock - ->expects($this->once()) - ->method('run') - ->with($this->host, $expectedCommand) - ->willReturn('WP-CLI output'); + $expectedCommand = "wp $command $arguments"; + $this->expectLocalWpCommand($expectedCommand, $path); WPCLI::runCommand($command, $path, $arguments); } @@ -190,13 +320,8 @@ public function testRunCommandWithVeryLongPath(): void $path = str_repeat('a', 200) . '/path/to/wordpress'; $command = 'post list'; $arguments = '--format=table'; - $expectedCommand = "cd $path && wp $command $arguments"; - - $this->processRunnerMock - ->expects($this->once()) - ->method('run') - ->with($this->host, $expectedCommand) - ->willReturn('WP-CLI output'); + $expectedCommand = "wp $command $arguments"; + $this->expectLocalWpCommand($expectedCommand, $path); WPCLI::runCommand($command, $path, $arguments); } @@ -206,13 +331,8 @@ public function testRunCommandWithComplexCommand(): void $path = '/var/www/html'; $command = 'post create --post_type=page --post_status=publish --post_title="Home Page" --post_content="Welcome to our site"'; $arguments = '--meta_input=\'{"_wp_page_template":"page-home.php"}\''; - $expectedCommand = "cd $path && wp $command $arguments"; - - $this->processRunnerMock - ->expects($this->once()) - ->method('run') - ->with($this->host, $expectedCommand) - ->willReturn('WP-CLI output'); + $expectedCommand = "wp $command $arguments"; + $this->expectLocalWpCommand($expectedCommand, $path); WPCLI::runCommand($command, $path, $arguments); } @@ -222,13 +342,14 @@ public function testRunCommandLocallyWithComplexPath(): void $path = '/var/www/html with spaces and special chars @#$%'; $command = 'post list'; $arguments = '--format=table'; - $expectedCommand = "cd $path && wp $command $arguments"; + $expectedCommand = "wp $command $arguments"; $this->processRunnerMock ->expects($this->once()) ->method('run') - ->willReturnCallback(function ($host, $command) use ($expectedCommand) { + ->willReturnCallback(function ($host, $command, $options) use ($expectedCommand, $path) { $this->assertEquals($expectedCommand, $command); + $this->assertSame($path, $this->runCwd($options)); return ''; }); @@ -240,17 +361,13 @@ public function testRunCommandWithCustomWpBinary(): void $path = '/var/www/html'; $command = 'post list'; $arguments = '--format=table'; - $expectedCommand = "cd $path && /usr/local/bin/wp $command $arguments"; + $expectedCommand = "/usr/local/bin/wp $command $arguments"; // Set custom wp binary path $this->deployer->config->set('bin/wp', '/usr/local/bin/wp'); $this->host->config()->set('bin/wp', '/usr/local/bin/wp'); - $this->processRunnerMock - ->expects($this->once()) - ->method('run') - ->with($this->host, $expectedCommand) - ->willReturn('WP-CLI output'); + $this->expectLocalWpCommand($expectedCommand, $path); WPCLI::runCommand($command, $path, $arguments); } @@ -260,7 +377,7 @@ public function testRunCommandLocallyWithCustomWpBinary(): void $path = '/var/www/html'; $command = 'post list'; $arguments = '--format=table'; - $expectedCommand = "cd $path && /usr/local/bin/wp $command $arguments"; + $expectedCommand = "/usr/local/bin/wp $command $arguments"; // Set custom wp binary path for localhost $this->deployer->config->set('localhost.bin/wp', '/usr/local/bin/wp'); @@ -269,8 +386,9 @@ public function testRunCommandLocallyWithCustomWpBinary(): void $this->processRunnerMock ->expects($this->once()) ->method('run') - ->willReturnCallback(function ($host, $command) use ($expectedCommand) { + ->willReturnCallback(function ($host, $command, $options) use ($expectedCommand, $path) { $this->assertEquals($expectedCommand, $command); + $this->assertSame($path, $this->runCwd($options)); return ''; }); @@ -282,16 +400,12 @@ public function testRunCommandWithInvalidWpBinary(): void $path = '/var/www/html'; $command = 'post list'; $arguments = '--format=table'; - $expectedCommand = "cd $path && wp $command $arguments"; + $expectedCommand = "wp $command $arguments"; // Set invalid wp binary (should fall back to default) $this->deployer->config->set('bin/wp', null); - $this->processRunnerMock - ->expects($this->once()) - ->method('run') - ->with($this->host, $expectedCommand) - ->willReturn('WP-CLI output'); + $this->expectLocalWpCommand($expectedCommand, $path); WPCLI::runCommand($command, $path, $arguments); } @@ -301,7 +415,7 @@ public function testRunCommandLocallyWithInvalidWpBinary(): void $path = '/var/www/html'; $command = 'post list'; $arguments = '--format=table'; - $expectedCommand = "cd $path && wp $command $arguments"; + $expectedCommand = "wp $command $arguments"; // Set invalid wp binary for localhost (should fall back to default) $this->deployer->config->set('localhost.bin/wp', null); @@ -309,8 +423,9 @@ public function testRunCommandLocallyWithInvalidWpBinary(): void $this->processRunnerMock ->expects($this->once()) ->method('run') - ->willReturnCallback(function ($host, $command) use ($expectedCommand) { + ->willReturnCallback(function ($host, $command, $options) use ($expectedCommand, $path) { $this->assertEquals($expectedCommand, $command); + $this->assertSame($path, $this->runCwd($options)); return ''; }); @@ -343,4 +458,47 @@ public function testInstallWithCustomBinaryPath(): void $result = WPCLI::install($installPath, $binaryName, true); $this->assertEquals("$installPath/$binaryName", $result); } + + private function expectLocalWpCommand(string $expectedCommand, ?string $expectedCwd): void + { + $this->processRunnerMock + ->expects($this->once()) + ->method('run') + ->willReturnCallback(function ( + $executionHost, + $command, + RunParams $options + ) use ( + $expectedCommand, + $expectedCwd + ) { + $this->assertInstanceOf(DeployerLocalhost::class, $executionHost); + $this->assertSame($expectedCommand, $command); + $this->assertSame($expectedCwd, $this->runCwd($options)); + return 'WP-CLI output'; + }); + } + + private function remoteSourceHost(): Host + { + $sourceHost = new Host('production'); + $sourceHost->setHostname('example.com'); + $sourceHost->setRemoteUser('deploy'); + $sourceHost->set('deploy_path', '/srv/www'); + $sourceHost->set('current_path', '/srv/www/current'); + $sourceHost->set('release_or_current_path', '/srv/www/current'); + $sourceHost->set('bin/wp', 'wp'); + + return $sourceHost; + } + + private function onHost(Host $sourceHost, callable $callback): mixed + { + Context::push(new Context($sourceHost)); + try { + return $callback(); + } finally { + Context::pop(); + } + } } diff --git a/tests/Unit/CompatibilityWrappersTest.php b/tests/Unit/CompatibilityWrappersTest.php new file mode 100644 index 0000000..cfc15a5 --- /dev/null +++ b/tests/Unit/CompatibilityWrappersTest.php @@ -0,0 +1,22 @@ + \Gaambo\DeployerUtils\Composer::class, + \Gaambo\DeployerWordpress\Files::class => \Gaambo\DeployerUtils\Files::class, + \Gaambo\DeployerWordpress\Localhost::class => \Gaambo\DeployerUtils\Localhost::class, + \Gaambo\DeployerWordpress\NPM::class => \Gaambo\DeployerUtils\NPM::class, + \Gaambo\DeployerWordpress\Rsync::class => \Gaambo\DeployerUtils\Rsync::class, + \Gaambo\DeployerWordpress\Utils::class => \Gaambo\DeployerUtils\Utils::class, + ]; + + foreach ($wrappers as $wrapper => $sharedClass) { + $this->assertTrue(is_subclass_of($wrapper, $sharedClass)); + } + } +} diff --git a/tests/Unit/RsyncTest.php b/tests/Unit/RsyncTest.php deleted file mode 100644 index 73a9b56..0000000 --- a/tests/Unit/RsyncTest.php +++ /dev/null @@ -1,150 +0,0 @@ -fixturesDir = __FIXTURES__ . '/rsync'; - } - - public function testBuildExcludesWithExistingFile() - { - $excludes = ['*.log', '*.tmp', '.DS_Store']; - $excludeFile = $this->fixturesDir . '/exclude.txt'; - - $result = Rsync::buildExcludes($excludes, $excludeFile); - - $this->assertContains('--exclude=*.log', $result); - $this->assertContains('--exclude=*.tmp', $result); - $this->assertContains('--exclude=.DS_Store', $result); - $this->assertContains('--exclude-from=' . $excludeFile, $result); - } - - public function testBuildExcludesWithNonExistingFile() - { - $excludes = ['*.log', '*.tmp', '.DS_Store']; - $excludeFile = '/path/to/nonexistent.txt'; - - $result = Rsync::buildExcludes($excludes, $excludeFile); - - $this->assertContains('--exclude=*.log', $result); - $this->assertContains('--exclude=*.tmp', $result); - $this->assertContains('--exclude=.DS_Store', $result); - $this->assertNotContains('--exclude-from=' . $excludeFile, $result); - } - - public function testBuildIncludesWithExistingFile() - { - $includes = ['*.php', '*.js', '*.css']; - $includeFile = $this->fixturesDir . '/include.txt'; - - $result = Rsync::buildIncludes($includes, $includeFile); - - $this->assertContains('--include=*.php', $result); - $this->assertContains('--include=*.js', $result); - $this->assertContains('--include=*.css', $result); - $this->assertContains('--include-from=' . $includeFile, $result); - } - - public function testBuildIncludesWithNonExistingFile() - { - $includes = ['*.php', '*.js', '*.css']; - $includeFile = '/path/to/nonexistent.txt'; - - $result = Rsync::buildIncludes($includes, $includeFile); - - $this->assertContains('--include=*.php', $result); - $this->assertContains('--include=*.js', $result); - $this->assertContains('--include=*.css', $result); - $this->assertNotContains('--include-from=' . $includeFile, $result); - } - - public function testBuildFilterWithExistingFile() - { - $filters = ['+ /wp-content/', '- /wp-content/uploads/*']; - $filterFile = $this->fixturesDir . '/filter.txt'; - $filterPerDir = '.deployfilter'; - - $result = Rsync::buildFilter($filters, $filterFile, $filterPerDir); - - $this->assertContains('--filter=+ /wp-content/', $result); - $this->assertContains('--filter=- /wp-content/uploads/*', $result); - $this->assertContains('--filter=merge ' . $filterFile, $result); - $this->assertContains('--filter=dir-merge ' . $filterPerDir, $result); - } - - public function testBuildFilterWithNonExistingFile() - { - $filters = ['+ /wp-content/', '- /wp-content/uploads/*']; - $filterFile = '/path/to/nonexistent.txt'; - $filterPerDir = '.deployfilter'; - - $result = Rsync::buildFilter($filters, $filterFile, $filterPerDir); - - $this->assertContains('--filter=+ /wp-content/', $result); - $this->assertContains('--filter=- /wp-content/uploads/*', $result); - $this->assertNotContains('--filter=merge ' . $filterFile, $result); - $this->assertContains('--filter=dir-merge ' . $filterPerDir, $result); - } - - public function testBuildExcludesWithArray(): void - { - $excludes = ['*.log', '*.tmp', '.DS_Store']; - $result = Rsync::buildExcludes($excludes); - - $this->assertCount(3, $result); - $this->assertContains('--exclude=*.log', $result); - $this->assertContains('--exclude=*.tmp', $result); - $this->assertContains('--exclude=.DS_Store', $result); - } - - public function testBuildExcludesWithEmptyArray(): void - { - $result = Rsync::buildExcludes([]); - $this->assertEmpty($result); - } - - public function testBuildIncludesWithArray(): void - { - $includes = ['*.php', '*.js', '*.css']; - $result = Rsync::buildIncludes($includes); - - $this->assertCount(3, $result); - $this->assertContains('--include=*.php', $result); - $this->assertContains('--include=*.js', $result); - $this->assertContains('--include=*.css', $result); - } - - public function testBuildIncludesWithEmptyArray(): void - { - $result = Rsync::buildIncludes([]); - $this->assertEmpty($result); - } - - public function testBuildFilterWithArray(): void - { - $filters = [ - '+ /wp-content/', - '- /wp-content/uploads/*' - ]; - $result = Rsync::buildFilter($filters); - - $this->assertCount(2, $result); - $this->assertContains('--filter=+ /wp-content/', $result); - $this->assertContains('--filter=- /wp-content/uploads/*', $result); - } - - public function testBuildFilterWithEmptyArray(): void - { - $result = Rsync::buildFilter([]); - $this->assertEmpty($result); - } -} diff --git a/tests/Unit/UnitTestCase.php b/tests/Unit/UnitTestCase.php index 49faa1e..4aab858 100644 --- a/tests/Unit/UnitTestCase.php +++ b/tests/Unit/UnitTestCase.php @@ -2,12 +2,8 @@ namespace Gaambo\DeployerWordpress\Tests\Unit; -use PHPUnit\Framework\TestCase as BaseTestCase; +use Gaambo\DeployerUtils\Tests\Unit\UnitTestCase as BaseUnitTestCase; -abstract class UnitTestCase extends BaseTestCase +abstract class UnitTestCase extends BaseUnitTestCase { - protected function setUp(): void - { - parent::setUp(); - } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 5dd0854..bd925c6 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -2,37 +2,19 @@ require __DIR__ . '/../vendor/autoload.php'; -// Somehow deployer removes its autoload after releasing. -// see https://github.com/deployphp/deployer/commit/76fadcd887eb22e37ffe92c5e05964ce43c9cfe5 -// And also add Deployer PSR-4 autoload-dev to composer.json -// These are automatically loaded by Deployer when running the dep binary. -// In deployer v8 these are added as autoload-files, therefore load conditionally. -if(!function_exists('Deployer\Support\array_flatten')){ +if (!function_exists('Deployer\Support\array_flatten')) { require __DIR__ . '/../vendor/deployer/deployer/src/Support/helpers.php'; } -if(!function_exists('Deployer\host')) { +if (!function_exists('Deployer\host')) { require __DIR__ . '/../vendor/deployer/deployer/src/functions.php'; } set_include_path(__DIR__ . '/../vendor/deployer/deployer' . PATH_SEPARATOR . get_include_path()); -// Deployer v7/v8 compat: alias moved/renamed classes under their v7 names so test files -// can use a single import that works with both versions. -// ProcessRunner: v7 Deployer\Component\ProcessRunner\ProcessRunner → v8 Deployer\ProcessRunner\ProcessRunner -if (!class_exists('Deployer\Component\ProcessRunner\ProcessRunner') && class_exists('Deployer\ProcessRunner\ProcessRunner')) { - class_alias('Deployer\ProcessRunner\ProcessRunner', 'Deployer\Component\ProcessRunner\ProcessRunner'); -} -// Ssh Client: v7 Deployer\Component\Ssh\Client → v8 Deployer\Ssh\SshClient (renamed + moved) -if (!class_exists('Deployer\Component\Ssh\Client') && class_exists('Deployer\Ssh\SshClient')) { - class_alias('Deployer\Ssh\SshClient', 'Deployer\Component\Ssh\Client'); -} - -// Set up test environment putenv('DEPLOYER_LOCAL_WORKER=true'); define('__FIXTURES__', __DIR__ . '/Fixtures'); define('__TEMP_DIR__', sys_get_temp_dir() . '/deployer-wordpress'); -// Create temp directory if it doesn't exist if (!file_exists(__TEMP_DIR__)) { mkdir(__TEMP_DIR__, 0755, true); }