diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e39991a..2df6464 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,17 +1,76 @@ # Copilot Instructions for WP GitHub Updater Package +## πŸ“‹ Documentation Policy + +**CRITICAL: ALL documentation must be consolidated in these three files ONLY:** + +1. **README.md** - User-facing documentation, installation, usage examples, API reference +2. **CHANGELOG.md** - Version history, changes, bug fixes, new features +3. **.github/copilot-instructions.md** - This file, development guidelines and architecture + +**🚫 NEVER create separate documentation files:** +- ❌ Do NOT create files like `docs/GUIDE.md`, `docs/TESTING.md`, `TROUBLESHOOTING.md`, etc. +- ❌ Do NOT create a `docs/` directory +- ❌ Do NOT split documentation into multiple MD files + +**βœ… Instead:** +- Add user documentation to appropriate sections in README.md +- Add development/architecture notes to this copilot-instructions.md file +- Add version changes to CHANGELOG.md +- Use inline code comments for complex logic explanation + +**Rationale:** Centralized documentation is easier to maintain, search, and keep in sync with code changes. + +--- + ## Architecture Overview This is a **reusable Composer package** that provides WordPress plugin update functionality from GitHub releases. The package is designed to be integrated into various WordPress plugins, not as a standalone plugin. ## Core Technologies -- **PHP 8.0+**: Modern PHP with strict typing, union types, and nullable parameters +- **PHP 8.2+**: Modern PHP with strict typing, union types, and nullable parameters - **WordPress 6.0+**: WordPress update system integration via hooks and filters - **PSR-4 Autoloading**: Namespace-based class loading for better organization - **GitHub API v3**: REST API integration for release management - **Composer Package**: Distributed via Packagist as `silverassist/wp-github-updater` +## πŸ”’ PHPUnit Version Policy - CRITICAL + +**MANDATORY: PHPUnit MUST remain at version 9.6.x** + +### Rationale +- **WordPress Ecosystem Standard**: PHPUnit 9.6 is the most widely used version across WordPress projects +- **WordPress Coding Standards**: The official [WordPress Coding Standards](https://github.com/WordPress/WordPress-Coding-Standards) uses PHPUnit 9.x +- **Yoast PHPUnit Polyfills**: Version 4.x supports PHPUnit 7.5-9.x, 11.x, and 12.x, but **NOT 10.x** +- **Consumer Compatibility**: Most projects depending on this package use PHPUnit 9.6 + +### Version Constraints +```json +{ + "require-dev": { + "phpunit/phpunit": "^9.6", + "yoast/phpunit-polyfills": "^4.0" + } +} +``` + +### What NOT to do +- ❌ Do NOT upgrade PHPUnit to version 10.x (not supported by Yoast PHPUnit Polyfills) +- ❌ Do NOT upgrade to PHPUnit 11.x or 12.x (breaks compatibility with most WordPress projects) +- ❌ Do NOT accept Dependabot PRs that upgrade PHPUnit beyond 9.x + +### Configuration Files +- **dependabot.yml**: Configured to ignore PHPUnit major version updates +- **composer.json**: Locked to `^9.6` version constraint +- **phpunit.xml**: Uses PHPUnit 9.6 schema reference + +### When to Reconsider +Only upgrade PHPUnit when: +1. WordPress Coding Standards officially adopts a newer version +2. Majority of WordPress ecosystem projects have migrated +3. All dependent projects confirm compatibility + ## Project Structure ``` @@ -44,6 +103,7 @@ wp-github-updater/ - Cache duration and WordPress requirements - AJAX endpoints and nonce configuration - Plugin data parsing from WordPress headers +- **Custom temporary directory configuration** (v1.1.3+) ### Updater Class **Purpose**: Core WordPress integration and GitHub API communication @@ -52,8 +112,11 @@ wp-github-updater/ - GitHub API communication with caching - Plugin update checking and information display - Download error handling and recovery -- AJAX endpoints for manual update checks +- AJAX endpoints for manual update checks and notice dismissal - Markdown-to-HTML conversion for changelogs +- **Enhanced temporary file management with PCLZIP error resolution** (v1.1.3+) +- **WordPress admin notices for manual update checks** (v1.1.3+) +- **Dismissible admin notices with AJAX handling** (v1.1.4+) ### Security - **Nonce verification** for AJAX requests @@ -90,6 +153,34 @@ class UpdaterConfig { \__("Update available", "wp-github-updater"); ``` +### Temporary File Management Strategy +**New in v1.1.3**: Enhanced handling to resolve `PCLZIP_ERR_MISSING_FILE (-4)` errors + +#### Multi-Tier Fallback System +The package implements a robust 6-tier fallback system for temporary file creation: + +1. **Custom temporary directory** - User-specified via `custom_temp_dir` option +2. **WordPress uploads directory** - Most reliable, within web root +3. **WP_CONTENT_DIR/temp** - Auto-created if needed +4. **WP_TEMP_DIR** - If defined in wp-config.php +5. **System temporary directory** - OS default /tmp or equivalent +6. **Manual file creation** - Last resort fallback + +#### Configuration Options +```php +$config = new UpdaterConfig($pluginFile, $githubRepo, [ + "custom_temp_dir" => WP_CONTENT_DIR . "/temp", // Custom directory + // or + "custom_temp_dir" => wp_upload_dir()["basedir"] . "/temp", // Uploads subdirectory +]); +``` + +#### WordPress Configuration Alternative +```php +// In wp-config.php +define('WP_TEMP_DIR', ABSPATH . 'wp-content/temp'); +``` + ### Coding Standards - **WordPress Coding Standards**: Full compliance with WordPress PHP standards @@ -137,13 +228,41 @@ class Updater { } public function checkForUpdate($transient) { - // WordPress update check integration + // WordPress update check integration using isUpdateAvailable() method } public function pluginInfo($result, string $action, object $args) { // Plugin information display } + public function manualVersionCheck(): void { + // AJAX handler for manual version checks with admin notice creation + } + + public function dismissUpdateNotice(): void { + // AJAX handler for dismissing admin notices (v1.1.4+) + } + + public function showUpdateNotice(): void { + // WordPress admin notice display for available updates (v1.1.4+) + } + + public function maybeFixDownload($result, string $package, object $upgrader, array $hook_extra) { + // Enhanced download handling with PCLZIP error resolution (v1.1.3+) + } + + private function createSecureTempFile(string $package) { + // Multi-tier temporary file creation with fallback strategies (v1.1.3+) + } + + public function isUpdateAvailable(): bool { + // Centralized update availability check (refactored in v1.1.4+) + } + + public function getLatestVersion(): string|false { + // Public API method for external access (v1.1.2+) + } + // ... other methods } ``` @@ -277,6 +396,7 @@ $config = new UpdaterConfig([ "github_username" => "username", "github_repo" => "repository", "text_domain" => "my-plugin-domain", // Consumer's text domain + "custom_temp_dir" => WP_CONTENT_DIR . "/temp", // Optional: Custom temp directory (v1.1.3+) // ... other options ]); @@ -288,6 +408,9 @@ $updater = new Updater($config); - Provide fallback download mechanisms for GitHub API failures - Cache GitHub API responses to avoid rate limiting - Handle PCLZIP errors with alternative download methods +- **PCLZIP_ERR_MISSING_FILE Resolution** (v1.1.3+): Multi-tier temporary file creation to avoid /tmp permission issues +- Cache GitHub API responses to avoid rate limiting +- Handle PCLZIP errors with alternative download methods ### Version Management - Follow semantic versioning (MAJOR.MINOR.PATCH) diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d9a5bbd --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,50 @@ +version: 2 +updates: + # Composer dependency updates + - package-ecosystem: "composer" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + timezone: "America/Mexico_City" + labels: + - "dependencies" + - "composer" + - "automated" + open-pull-requests-limit: 10 + # Ignore PHPUnit major version updates + # Keep at 9.x for WordPress compatibility + ignore: + - dependency-name: "phpunit/phpunit" + update-types: ["version-update:semver-major"] + groups: + composer-updates: + patterns: + - "*" + update-types: + - "minor" + - "patch" + + # GitHub Actions dependency updates + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + timezone: "America/Mexico_City" + labels: + - "dependencies" + - "github-actions" + - "automated" + open-pull-requests-limit: 10 + + # Group all GitHub Actions updates together + groups: + github-actions-updates: + patterns: + - "*" + update-types: + - "minor" + - "patch" diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 4b6ca3c..63a3a81 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -13,24 +13,48 @@ on: jobs: build-and-release: + permissions: + contents: write runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f # v2.35.5 with: - php-version: "8.1" - extensions: mbstring, xml, ctype, json, tokenizer + php-version: "8.2" + extensions: mbstring, xml, ctype, json, tokenizer, mysqli coverage: none + - name: Setup MySQL + run: | + sudo systemctl start mysql.service + mysql -e "CREATE DATABASE IF NOT EXISTS wordpress_test;" -uroot -proot + mysql -e "CREATE USER IF NOT EXISTS 'wp_test'@'localhost' IDENTIFIED BY 'wp_test';" -uroot -proot + mysql -e "GRANT ALL PRIVILEGES ON wordpress_test.* TO 'wp_test'@'localhost';" -uroot -proot + mysql -e "FLUSH PRIVILEGES;" -uroot -proot + echo "βœ… MySQL database configured" + + - name: Install Subversion (required for WordPress Test Suite) + run: | + sudo apt-get update + sudo apt-get install -y subversion + echo "βœ… Subversion installed" + - name: Install Composer dependencies run: | composer install --optimize-autoloader --no-interaction echo "βœ… Composer dependencies installed successfully (including dev dependencies for testing)" + - name: Install WordPress Test Suite + run: | + echo "πŸ“¦ Installing WordPress Test Suite..." + chmod +x scripts/install-wp-tests.sh + bash scripts/install-wp-tests.sh wordpress_test wp_test wp_test localhost latest + echo "βœ… WordPress Test Suite installed" + - name: Extract version from tag or input id: version run: | @@ -76,14 +100,14 @@ jobs: - name: Run tests run: | - echo "πŸ§ͺ Running PHPUnit tests..." + echo "πŸ§ͺ Running PHPUnit tests with WordPress Test Suite..." echo "Working directory: $(pwd)" echo "Checking for phpunit.xml:" ls -la phpunit.xml || echo "phpunit.xml not found" echo "Checking tests directory:" ls -la tests/ ./vendor/bin/phpunit --configuration=phpunit.xml - echo "βœ… All tests passed" + echo "βœ… All tests passed (including WordPress integration tests)" - name: Run code quality checks run: | @@ -138,7 +162,7 @@ jobs: - **Version**: $VERSION - **Namespace**: SilverAssist\\WpGithubUpdater - **License**: PolyForm Noncommercial 1.0.0 - - **PHP Version**: 8.0+ + - **PHP Version**: 8.2+ - **WordPress Version**: 6.0+ ## πŸš€ Installation via Composer @@ -177,7 +201,7 @@ jobs: - **API Docs**: Comprehensive PHPDoc documentation ## πŸ”§ Requirements - - PHP 8.0 or higher + - PHP 8.2 or higher - WordPress 6.0 or higher - Composer for package management - GitHub repository with releases for updates @@ -217,7 +241,7 @@ jobs: \`\`\` ## Requirements - - PHP 8.0+ + - PHP 8.2+ - WordPress 6.0+ - Composer - GitHub repository with releases @@ -258,7 +282,7 @@ jobs: EOF - name: Create GitHub Release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2 with: tag_name: ${{ steps.version.outputs.tag }} name: "WP GitHub Updater v${{ steps.version.outputs.version }}" diff --git a/CHANGELOG.md b/CHANGELOG.md index ea6eb3e..b576de1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,112 @@ # Changelog +## [1.2.1] - 2025-10-11 + +### Changed +- **PHP Version Requirement**: Lowered minimum PHP version from 8.3 to 8.2 for broader compatibility +- **Documentation Updates**: Updated all documentation, examples, and tests to reflect PHP 8.2 requirement +- **Workflow Updates**: Updated GitHub Actions workflow to reflect PHP 8.2 requirement + +### Testing +- **51 Tests Passing**: All tests verified with PHP 8.2 compatibility +- **Test Fixtures Updated**: Updated all test fixtures and expectations for PHP 8.2 + +## [1.2.0] - 2025-10-10 + +### Changed +- **Documentation Consolidation**: Centralized all documentation into README.md, CHANGELOG.md, and .github/copilot-instructions.md +- **Removed Separate Documentation Files**: Eliminated `docs/` directory to maintain simpler, more maintainable documentation structure +- **Testing Documentation**: Moved comprehensive testing guide to README.md Development section +- **Troubleshooting Guide**: Integrated troubleshooting information directly into README.md + +### Testing +- **51 Tests, 130 Assertions**: Complete test suite with 100% pass rate +- **Real GitHub API Integration Tests**: 9 tests making actual HTTP requests to production repositories +- **Test Coverage**: Unit tests (3), Integration tests (22), WordPress tests (26) +- **Performance Verification**: Caching performance tests confirm < 10ms for cached API calls + +## [1.1.5] - 2025-10-10 +### Fixed +- **PCLZIP_ERR_MISSING_FILE (-4) Resolution**: Complete rewrite of `upgrader_pre_download` filter to properly handle all download scenarios +- **Download Filter Return Values**: Fixed critical issue where filter could return invalid types causing WordPress to fail with PCLZIP errors +- **Better Plugin Detection**: Added robust verification to ensure filter only intercepts downloads for the correct plugin +- **Enhanced Error Handling**: Comprehensive error messages for all failure points in the download process +- **File Verification**: Added multiple validation checks (file size, readability, existence) before returning downloaded file to WordPress + +### Changed +- **Stricter Filter Logic**: `maybeFixDownload()` now returns `false` to let WordPress handle downloads that aren't for our plugin +- **Safety Checks**: Added verification of `hook_extra` data to ensure we only process downloads for our specific plugin +- **Improved Documentation**: Enhanced PHPDoc comments explaining critical return value requirements for WordPress compatibility +- **Download Process**: Better handling of HTTP response codes and empty responses with descriptive error messages + +### Technical Improvements +- **Return Type Enforcement**: Strict enforcement of `string|WP_Error|false` return types (never `true` or other types) +- **Multi-line Conditionals**: Improved code formatting to meet WordPress Coding Standards (120 character line limit) +- **Defensive Programming**: Added early returns for edge cases where previous filters have already handled the download +- **Minimum File Size Check**: Validates downloaded file is at least 100 bytes before considering it valid + +## [1.1.4] - 2025-08-29 +### Added +- **WordPress Admin Notices**: Integrated admin notification system that displays update availability after manual version checks +- **Dismissible Update Notices**: Users can dismiss update notifications with built-in AJAX functionality +- **Admin Notice Management**: New `showUpdateNotice()` method creates WordPress-compliant admin notices with proper styling +- **AJAX Notice Dismissal**: New `dismissUpdateNotice()` AJAX handler for seamless notice management +- **Transient-Based Notifications**: Update notices persist for the same duration as version cache (configurable via `cache_duration`) + +### Changed +- **Improved Manual Version Checks**: Enhanced `manualVersionCheck()` method now sets admin notices for immediate user feedback +- **Code Refactoring**: Centralized update availability logic using `isUpdateAvailable()` method to eliminate code duplication +- **Better WordPress Integration**: Manual version checks now properly clear WordPress update transients for immediate admin interface updates +- **Enhanced User Experience**: Update checks provide both AJAX responses and persistent admin notifications + +### Fixed +- **WordPress Admin Sync**: Manual version checks now immediately reflect in WordPress admin plugins page +- **Transient Cache Management**: Proper clearing of both plugin-specific and WordPress update caches +- **Admin Interface Updates**: Resolved disconnect between manual checks and WordPress admin display + +### Technical Improvements +- **DRY Principle**: Replaced duplicate version comparison logic with centralized `isUpdateAvailable()` method calls +- **AJAX Security**: Enhanced nonce verification and sanitization for all AJAX endpoints +- **WordPress Standards**: All admin notices follow WordPress UI/UX guidelines with proper escaping and styling +- **JavaScript Integration**: Inline JavaScript for notice dismissal with jQuery compatibility + +### Documentation +- **API Documentation**: Added comprehensive Public API Methods section to README +- **Integration Examples**: Updated all examples to demonstrate new admin notice features +- **Configuration Guide**: Enhanced advanced configuration examples with new capabilities +- **Code Examples**: Programmatic version checking examples for developers + +## [1.1.3] - 2025-08-29 +### Added +- **Enhanced Temporary File Handling**: Implemented multiple fallback strategies for temporary file creation to resolve `PCLZIP_ERR_MISSING_FILE (-4)` errors +- **Custom Temporary Directory Support**: New `custom_temp_dir` configuration option in UpdaterConfig for specifying alternative temporary directories +- **Automatic Directory Creation**: The updater now attempts to create temporary directories if they don't exist +- **Comprehensive File Verification**: Added file existence and readability checks after download to prevent installation failures + +### Changed +- **Improved Download Reliability**: Enhanced `maybeFixDownload()` method with better error handling and multiple fallback strategies +- **Robust Temporary File Strategy**: Six-tier fallback system for temporary file creation: + 1. Custom temporary directory (if configured) + 2. WordPress uploads directory + 3. WP_CONTENT_DIR/temp (auto-created) + 4. WP_TEMP_DIR (if defined in wp-config.php) + 5. System temporary directory + 6. Manual file creation as last resort + +### Fixed +- **PCLZIP Error Resolution**: Addresses `PCLZIP_ERR_MISSING_FILE (-4)` errors caused by restrictive /tmp directory permissions +- **File Write Verification**: Added byte-level verification to ensure complete file downloads +- **Permission Issues**: Better handling of directory permission problems during plugin updates + +### Documentation +- **Integration Examples**: Added examples for handling PCLZIP errors in integration guide +- **WordPress Configuration**: Documented wp-config.php approach for setting custom temporary directories +- **Troubleshooting Guide**: Comprehensive examples for different temporary directory configuration strategies + +## [1.1.2] - 2025-08-19 +### Changed +- **API Accessibility**: Changed `getLatestVersion()` method visibility from `private` to `public` to allow external access from consuming plugins + ## [1.1.1] - 2025-08-14 ### Changed - **License Migration**: Updated from GPL v2.0+ to PolyForm Noncommercial 1.0.0 diff --git a/README.md b/README.md index e84c750..026c727 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,9 @@ A reusable WordPress plugin updater that handles automatic updates from public G - πŸ›‘οΈ **Security**: AJAX nonce verification and capability checks - πŸ”§ **Configurable**: Flexible configuration options - πŸ“¦ **Easy Integration**: Simple Composer installation +- πŸ“’ **Admin Notices**: WordPress admin notifications for available updates +- πŸ—‚οΈ **Enhanced File Handling**: Multi-tier temporary file creation to resolve hosting issues +- βœ… **Manual Version Checks**: AJAX-powered manual update checking with immediate admin feedback ## Installation @@ -60,11 +63,13 @@ $config = new UpdaterConfig( 'plugin_author' => 'Your Name', 'plugin_homepage' => 'https://your-website.com', 'requires_wordpress' => '6.0', - 'requires_php' => '8.0', + 'requires_php' => '8.3', 'asset_pattern' => '{slug}-v{version}.zip', // GitHub release asset filename 'cache_duration' => 12 * 3600, // 12 hours in seconds 'ajax_action' => 'my_plugin_check_version', - 'ajax_nonce' => 'my_plugin_version_nonce' + 'ajax_nonce' => 'my_plugin_version_nonce', + 'text_domain' => 'my-plugin-textdomain', // For internationalization + 'custom_temp_dir' => WP_CONTENT_DIR . '/temp', // Custom temporary directory (v1.1.3+) ] ); @@ -80,12 +85,13 @@ $updater = new Updater($config); | `plugin_author` | string | From plugin header | Plugin author name | | `plugin_homepage` | string | GitHub repo URL | Plugin homepage URL | | `requires_wordpress` | string | `'6.0'` | Minimum WordPress version | -| `requires_php` | string | `'8.0'` | Minimum PHP version | +| `requires_php` | string | `'8.3'` | Minimum PHP version | | `asset_pattern` | string | `'{slug}-v{version}.zip'` | GitHub release asset filename pattern | | `cache_duration` | int | `43200` (12 hours) | Cache duration in seconds | | `ajax_action` | string | `'check_plugin_version'` | AJAX action name for manual checks | | `ajax_nonce` | string | `'plugin_version_check'` | AJAX nonce name | | `text_domain` | string | `'wp-github-updater'` | WordPress text domain for i18n **(New in 1.1.0)** | +| `custom_temp_dir` | string\|null | `null` | Custom temporary directory path **(New in 1.1.3)** | ### Internationalization Support (i18n) @@ -153,7 +159,7 @@ Here's a complete example for a WordPress plugin: * Version: 1.0.0 * Author: Your Name * Requires at least: 6.0 - * Requires PHP: 8.0 + * Requires PHP: 8.3 */ // Prevent direct access @@ -185,21 +191,174 @@ add_action('init', function() { // Your plugin code here... ``` +## Troubleshooting + +### PCLZIP_ERR_MISSING_FILE (-4) Error + +If you encounter the error "The package could not be installed. PCLZIP_ERR_MISSING_FILE (-4)", this typically indicates issues with the temporary directory. The updater includes multiple fallback strategies to resolve this. + +#### Solution 1: Custom Temporary Directory (Recommended) + +```php +$config = new UpdaterConfig( + __FILE__, + 'your-username/your-repo', + [ + 'custom_temp_dir' => WP_CONTENT_DIR . '/temp', + // or use uploads directory: + // 'custom_temp_dir' => wp_upload_dir()['basedir'] . '/temp', + ] +); +``` + +#### Solution 2: WordPress Configuration + +Add to your `wp-config.php` file (before "That's all, stop editing!"): + +```php +/* Set WordPress temporary directory */ +define('WP_TEMP_DIR', ABSPATH . 'wp-content/temp'); +``` + +Then create the directory with proper permissions: + +```bash +mkdir wp-content/temp +chmod 755 wp-content/temp +``` + +#### Solution 3: Plugin Activation Hook + +Create the temporary directory when your plugin is activated: + +```php +register_activation_hook(__FILE__, function() { + $temp_dir = WP_CONTENT_DIR . '/temp'; + if (!file_exists($temp_dir)) { + wp_mkdir_p($temp_dir); + } +}); +``` + +### Multi-Tier Fallback System + +The updater automatically tries multiple strategies for temporary file creation: + +1. **Custom temporary directory** (if configured) +2. **WordPress uploads directory** +3. **WP_CONTENT_DIR/temp** (auto-created) +4. **WP_TEMP_DIR** (if defined in wp-config.php) +5. **System temporary directory** (/tmp) +6. **Manual file creation** (last resort) + ## Requirements -- PHP 8.0 or higher +- PHP 8.2 or higher - WordPress 6.0 or higher - Composer for dependency management - Public GitHub repository with releases ## Development -### Running Tests +### Testing + +The package includes comprehensive testing (51 tests, 130 assertions, 100% passing): + +**Test Coverage:** +- **Unit Tests** (3 tests): Configuration and core functionality +- **Integration Tests** (22 tests): Updater + Config integration, download filters, **real GitHub API** ⭐ +- **WordPress Tests** (26 tests): Hooks, filters, and mock plugin integration + +**Running Tests:** ```bash +# Install development dependencies +composer install --dev + +# Run all tests composer test + +# Run specific test suites +./scripts/test-runner.sh unit # Unit tests only +./scripts/test-runner.sh integration # Integration tests (includes real GitHub API) +./scripts/test-runner.sh wordpress # WordPress integration tests +./scripts/test-runner.sh all # All tests + +# Run with coverage +vendor/bin/phpunit --coverage-text ``` +**Real GitHub API Testing:** + +The integration tests include **real HTTP requests** to production GitHub repositories to verify actual API behavior: + +- βœ… Validates actual GitHub API response structure +- βœ… Verifies caching performance (< 10ms for cached calls) +- βœ… Tests version comparison with real releases +- βœ… Confirms asset pattern matching with production URLs + +**Example: Test with Your Own Repository** + +```php +// tests/Integration/MyRealAPITest.php +public function testFetchLatestVersionFromMyRepo(): void { + $config = new UpdaterConfig([ + "plugin_file" => __FILE__, + "github_username" => "YourUsername", + "github_repo" => "your-repository", + ]); + + $updater = new Updater($config); + $version = $updater->getLatestVersion(); + + $this->assertNotFalse($version); + $this->assertMatchesRegularExpression("/^\d+\.\d+\.\d+$/", $version); +} +``` + +**Test Environment Setup:** + +The tests use WordPress Test Suite for authentic WordPress integration: + +```bash +# Install WordPress Test Suite (interactive) +./scripts/test-runner.sh install + +# Or manual installation +./scripts/install-wp-tests.sh wordpress_test root '' localhost latest +``` + +**PCLZIP Error Testing:** + +For testing plugins that may experience `PCLZIP_ERR_MISSING_FILE (-4)` errors, configure a custom temporary directory: + +```php +$config = new UpdaterConfig( + __FILE__, + "your-username/your-plugin", + [ + "custom_temp_dir" => WP_CONTENT_DIR . "/temp", + ] +); +``` + +### PHPUnit Version Policy + +**This package uses PHPUnit 9.6.x and MUST remain on this version.** + +**Why PHPUnit 9.6?** +- βœ… **WordPress Ecosystem Standard**: Most WordPress projects use PHPUnit 9.6 +- βœ… **WordPress Coding Standards Compatible**: [WordPress Coding Standards](https://github.com/WordPress/WordPress-Coding-Standards) uses PHPUnit 9.x +- βœ… **Yoast PHPUnit Polyfills**: Version 4.x supports PHPUnit 7.5-9.x, 11.x, 12.x, but **NOT 10.x** +- βœ… **Consumer Compatibility**: Projects depending on this package expect PHPUnit 9.6 + +**Do NOT upgrade to:** +- ❌ PHPUnit 10.x (incompatible with Yoast PHPUnit Polyfills 4.x) +- ❌ PHPUnit 11.x or 12.x (breaks compatibility with most WordPress projects) + +**Dependabot Configuration:** +The `.github/dependabot.yml` file is configured to automatically ignore PHPUnit major version updates, ensuring the package remains on 9.x. + ### Code Standards ```bash @@ -219,6 +378,38 @@ composer phpstan composer check ``` +## Public API Methods + +The updater provides several public methods for programmatic access: + +### Version Information + +```php +// Check if an update is available +$hasUpdate = $updater->isUpdateAvailable(); // Returns bool + +// Get the current plugin version +$currentVersion = $updater->getCurrentVersion(); // Returns string + +// Get the latest version from GitHub (with caching) +$latestVersion = $updater->getLatestVersion(); // Returns string|false + +// Get the GitHub repository +$repo = $updater->getGithubRepo(); // Returns string +``` + +### Manual Version Check + +You can trigger a manual version check programmatically: + +```php +// This will clear caches and check for updates +// If an update is available, it will set an admin notice +$updater->manualVersionCheck(); +``` + +**Note**: The manual version check method is designed for AJAX calls and will send JSON responses. For programmatic use, prefer the individual methods above. + ## Contributing 1. Fork the repository diff --git a/composer.json b/composer.json index 56dd9c8..95cd4dd 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "silverassist/wp-github-updater", "description": "A reusable WordPress plugin updater that handles automatic updates from public GitHub releases", - "version": "1.1.1", + "version": "1.2.1", "type": "library", "keywords": [ "wordpress", @@ -20,14 +20,19 @@ } ], "require": { - "php": ">=8.0" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^10.0", + "phpunit/phpunit": "^9.6", "squizlabs/php_codesniffer": "^3.7", "phpstan/phpstan": "^1.10", "wp-coding-standards/wpcs": "^3.0", - "slevomat/coding-standard": "^8.0" + "slevomat/coding-standard": "^8.0", + "php-stubs/wordpress-stubs": "^6.8", + "php-stubs/wordpress-tests-stubs": "^6.8", + "phpcompatibility/phpcompatibility-wp": "^2.1", + "szepeviktor/phpstan-wordpress": "^1.3", + "yoast/phpunit-polyfills": "^4.0" }, "autoload": { "psr-4": { @@ -41,6 +46,9 @@ }, "scripts": { "test": "phpunit", + "test:unit": "phpunit --testsuite unit", + "test:integration": "phpunit --testsuite integration", + "test:wordpress": "phpunit --testsuite wordpress", "phpcs": "phpcs", "phpcbf": "phpcbf", "phpstan": "phpstan analyse src/ --level=8", diff --git a/examples/integration-guide.php b/examples/integration-guide.php index fb0ea6e..62c6b49 100644 --- a/examples/integration-guide.php +++ b/examples/integration-guide.php @@ -30,11 +30,19 @@ add_action("init", function() { "asset_pattern" => "silver-assist-security-v{version}.zip", "ajax_action" => "silver_assist_security_check_version", "ajax_nonce" => "silver_assist_security_ajax", - "text_domain" => "silver-assist-security" // Your plugin's text domain + "text_domain" => "silver-assist-security", // Your plugin's text domain + "custom_temp_dir" => WP_CONTENT_DIR . "/temp", // Enhanced error handling (v1.1.3+) ] ); - new Updater($config); + $updater = new Updater($config); + + // Optional: Programmatic version checking (v1.1.2+) + if ($updater->isUpdateAvailable()) { + // Handle update availability programmatically + $latestVersion = $updater->getLatestVersion(); + error_log("Security plugin update available: " . $latestVersion); + } }); */ @@ -59,11 +67,19 @@ add_action("init", function() { "asset_pattern" => "leadgen-app-form-v{version}.zip", "ajax_action" => "leadgen_check_version", "ajax_nonce" => "leadgen_version_check", - "text_domain" => "leadgen-app-form" // Your plugin's text domain + "text_domain" => "leadgen-app-form", // Your plugin's text domain + "custom_temp_dir" => wp_upload_dir()["basedir"] . "/temp", // Alternative temp dir location ] ); - new Updater($config); + $updater = new Updater($config); + + // Optional: Add manual check button in admin + add_action("admin_init", function() use ($updater) { + if (isset($_GET["leadgen_check_update"]) && current_user_can("update_plugins")) { + $updater->manualVersionCheck(); + } + }); }); */ @@ -102,14 +118,76 @@ add_action("init", function() { "requires_wordpress" => "6.2", "ajax_action" => "my_plugin_version_check", "cache_duration" => 6 * 3600, // 6 hours - "text_domain" => "my-new-plugin" // Your plugin's text domain + "text_domain" => "my-new-plugin", // Your plugin's text domain + "custom_temp_dir" => WP_CONTENT_DIR . "/temp", // Improved hosting compatibility + ] + ); + + $updater = new Updater($config); + + // Example: Check for updates programmatically + add_action("admin_notices", function() use ($updater) { + if (!current_user_can("update_plugins")) return; + + if ($updater->isUpdateAvailable()) { + $currentVersion = $updater->getCurrentVersion(); + $latestVersion = $updater->getLatestVersion(); + + echo '
'; + echo '

My Plugin: Update available from ' . esc_html($currentVersion) . ' to ' . esc_html($latestVersion) . '

'; + echo '
'; + } + }); +}); + +// Your plugin code here... +*/ + +/** + * Example for plugins with PCLZIP_ERR_MISSING_FILE issues + * Use custom temporary directory to avoid /tmp permission problems + */ + +/* +// For plugins experiencing PCLZIP_ERR_MISSING_FILE errors: + +add_action("init", function() { + $config = new UpdaterConfig( + __FILE__, + "your-username/your-plugin", + [ + "text_domain" => "your-plugin", + "custom_temp_dir" => WP_CONTENT_DIR . "/temp", // Custom temp directory + // or use uploads directory: + // "custom_temp_dir" => wp_upload_dir()["basedir"] . "/temp", ] ); new Updater($config); + + // Optionally create the directory on plugin activation + register_activation_hook(__FILE__, function() { + $temp_dir = WP_CONTENT_DIR . "/temp"; + if (!file_exists($temp_dir)) { + wp_mkdir_p($temp_dir); + } + }); }); +*/ -// Your plugin code here... +/** + * Alternative: WordPress configuration approach + * Add this to your wp-config.php file (before the line that says + * "That's all, stop editing!"): + */ + +/* +// In wp-config.php, add this line: +define('WP_TEMP_DIR', ABSPATH . 'wp-content/temp'); + +// Then create the directory with proper permissions: +// mkdir wp-content/temp +// chmod 755 wp-content/temp */ /** @@ -121,3 +199,22 @@ add_action("init", function() { * 4. Remove your old updater class files * 5. Test the updates */ + +/** + * New Features in v1.1.4: + * + * - WordPress admin notices for manual version checks + * - Dismissible admin notices with AJAX functionality + * - Improved code organization with isUpdateAvailable() method + * + * New Features in v1.1.3: + * + * - Enhanced temporary file handling to resolve PCLZIP errors + * - Better error handling and hosting environment compatibility + * + * Public API methods (v1.1.2+): + * - $updater->isUpdateAvailable() - Check if update is available + * - $updater->getCurrentVersion() - Get current plugin version + * - $updater->getLatestVersion() - Get latest GitHub version + * - $updater->getGithubRepo() - Get repository name + */ diff --git a/phpcs.xml b/phpcs.xml index 8aa13d4..830a943 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -47,6 +47,12 @@ + + + + tests/bootstrap.php + tests/fixtures/* + diff --git a/phpstan.neon b/phpstan.neon index 65ed03a..dffa685 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,9 +1,16 @@ +includes: + - vendor/szepeviktor/phpstan-wordpress/extension.neon + parameters: level: 8 paths: - src excludePaths: - tests + bootstrapFiles: + - vendor/php-stubs/wordpress-stubs/wordpress-stubs.php + scanDirectories: + - vendor/php-stubs/wordpress-stubs ignoreErrors: - # WordPress functions are not available during static analysis - - '#Call to unknown function: [\\]?[a-zA-Z_]+#' + # Ignore WP_Error union type issues in some contexts + - '#Cannot call method get_error_message\(\) on string\|WP_Error\|false#' diff --git a/phpunit.xml b/phpunit.xml index 8498252..fb63689 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,15 +1,37 @@ - + colors="true" + verbose="true"> - - tests + + tests/Unit + + + tests/Integration + + + tests/WordPress - + ./src - + + + + + + + + + + + + + + diff --git a/scripts/install-wp-tests.sh b/scripts/install-wp-tests.sh new file mode 100755 index 0000000..63a2b7c --- /dev/null +++ b/scripts/install-wp-tests.sh @@ -0,0 +1,255 @@ +#!/usr/bin/env bash +# WordPress Test Environment Installation Script +# Based on WordPress Core test suite installer +# +# This script installs WordPress and the WordPress Test Suite for running PHPUnit tests +# Usage: ./install-wp-tests.sh [db-host] [wp-version] + +if [ $# -lt 3 ]; then + echo "" + echo "❌ ERROR: Missing required arguments" + echo "" + echo "Usage: $0 [db-host] [wp-version]" + echo "" + echo "Arguments:" + echo " db-name Database name for tests (will be created/recreated)" + echo " db-user MySQL username" + echo " db-pass MySQL password (use '' for empty password)" + echo " db-host MySQL host (default: localhost)" + echo " wp-version WordPress version to install (default: 6.7.1)" + echo "" + echo "Example:" + echo " $0 wordpress_test root '' localhost 6.7.1" + echo "" + exit 1 +fi + +DB_NAME=$1 +DB_USER=$2 +DB_PASS=$3 +DB_HOST=${4-localhost} +WP_VERSION=${5-6.7.1} +SKIP_DB_CREATE=${6-false} + +TMPDIR=${TMPDIR-/tmp} +TMPDIR=$(echo $TMPDIR | sed -e "s/\/$//") +WP_TESTS_DIR=${WP_TESTS_DIR-$TMPDIR/wordpress-tests-lib} +WP_CORE_DIR=${WP_CORE_DIR-$TMPDIR/wordpress/} + +echo "" +echo "πŸš€ WordPress Test Suite Installer" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "Installing WordPress Test Environment..." +echo "" +echo "Configuration:" +echo " Database Name: $DB_NAME" +echo " Database User: $DB_USER" +echo " Database Host: $DB_HOST" +echo " WordPress Version: $WP_VERSION" +echo " WP Tests Dir: $WP_TESTS_DIR" +echo " WP Core Dir: $WP_CORE_DIR" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" + +download() { + if [ `which curl` ]; then + curl -s "$1" > "$2"; + elif [ `which wget` ]; then + wget -nv -O "$2" "$1" + fi +} + +if [[ $WP_VERSION =~ ^[0-9]+\.[0-9]+\-(beta|RC)[0-9]+$ ]]; then + WP_BRANCH=${WP_VERSION%\-*} + WP_TESTS_TAG="branches/$WP_BRANCH" + +elif [[ $WP_VERSION =~ ^[0-9]+\.[0-9]+$ ]]; then + WP_TESTS_TAG="branches/$WP_VERSION" + +elif [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0-9]+ ]]; then + if [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0] ]]; then + WP_TESTS_TAG="tags/${WP_VERSION%??}" + else + WP_TESTS_TAG="tags/$WP_VERSION" + fi + +elif [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' || $WP_VERSION == 'latest' ]]; then + WP_TESTS_TAG="trunk" + +else + echo "Invalid version $WP_VERSION" + exit 1 +fi + +set -ex + +install_wp() { + + if [ -d $WP_CORE_DIR ]; then + echo "βœ… WordPress core already installed at $WP_CORE_DIR" + return; + fi + + echo "πŸ“₯ Downloading WordPress $WP_VERSION..." + mkdir -p $WP_CORE_DIR + + if [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then + mkdir -p $TMPDIR/wordpress-trunk + rm -rf $TMPDIR/wordpress-trunk/* + svn export --quiet https://core.svn.wordpress.org/trunk $TMPDIR/wordpress-trunk/wordpress + mv $TMPDIR/wordpress-trunk/wordpress/* $WP_CORE_DIR + else + if [ $WP_VERSION == 'latest' ]; then + local ARCHIVE_NAME='latest' + elif [[ $WP_VERSION =~ [0-9]+\.[0-9]+ ]]; then + if [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0] ]]; then + local ARCHIVE_NAME="${WP_VERSION%??}" + else + local ARCHIVE_NAME=$WP_VERSION + fi + else + local ARCHIVE_NAME="wordpress-$WP_VERSION" + fi + download https://wordpress.org/wordpress-${ARCHIVE_NAME}.tar.gz $TMPDIR/wordpress.tar.gz + tar --strip-components=1 -zxmf $TMPDIR/wordpress.tar.gz -C $WP_CORE_DIR + fi + + echo "βœ… WordPress $WP_VERSION downloaded successfully" + + echo "πŸ“₯ Downloading mysqli drop-in..." + download https://raw.githubusercontent.com/markoheijnen/wp-mysqli/master/db.php $WP_CORE_DIR/wp-content/db.php + echo "βœ… mysqli drop-in installed" +} + +install_test_suite() { + if [ -d $WP_TESTS_DIR ]; then + echo "βœ… WordPress Test Suite already installed at $WP_TESTS_DIR" + return; + fi + + echo "πŸ“₯ Downloading WordPress Test Suite from SVN..." + mkdir -p $WP_TESTS_DIR + + rm -rf $WP_TESTS_DIR/{includes,data} + + svn export --quiet --ignore-externals https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/includes/ $WP_TESTS_DIR/includes + svn export --quiet --ignore-externals https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/data/ $WP_TESTS_DIR/data + + echo "βœ… WordPress Test Suite downloaded successfully" + + echo "πŸ“ Generating wp-tests-config.php..." + if [ ! -f wp-tests-config.php ]; then + download https://develop.svn.wordpress.org/${WP_TESTS_TAG}/wp-tests-config-sample.php "$WP_TESTS_DIR"/wp-tests-config.php + WP_CORE_DIR=$(echo $WP_CORE_DIR | sed 's:/\+$::') + sed $ioption "s:dirname( __FILE__ ) . '/src/':'$WP_CORE_DIR/':" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s:__FILE__:'$WP_TESTS_DIR/wp-tests-config.php':" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s/youremptytestdbnamehere/$DB_NAME/" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s/yourusernamehere/$DB_USER/" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s/yourpasswordhere/$DB_PASS/" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s|localhost|${DB_HOST}|" "$WP_TESTS_DIR"/wp-tests-config.php + fi + echo "βœ… wp-tests-config.php generated" +} + +recreate_db() { + shopt -s nocasematch + if [[ $1 =~ ^(y|yes)$ ]] + then + mysqladmin drop $DB_NAME -f --user="$DB_USER" --password="$DB_PASS"$EXTRA + create_db + echo "Recreated the database ($DB_NAME)." + else + echo "Skipping database recreation." + exit 1 + fi + shopt -u nocasematch +} + +create_db() { + mysqladmin create $DB_NAME --user="$DB_USER" --password="$DB_PASS"$EXTRA +} + +install_db() { + if [ ${SKIP_DB_CREATE} = "true" ]; then + return 0 + fi + + # Use DB_HOST for compatibility (script historically used DB_HOSTNAME) + DB_HOSTNAME=${DB_HOST} + + EXTRA="" + + if ! [ -z "$DB_HOSTNAME" ] ; then + # If hostname starts with /, it's a socket path + if [[ $DB_HOSTNAME == /* ]] ; then + EXTRA=" --socket=$DB_HOSTNAME" + # If hostname contains a colon, it's host:port + elif [[ $DB_HOSTNAME == *:* ]] ; then + EXTRA=" --host=$(echo $DB_HOSTNAME | cut -d: -f1) --port=$(echo $DB_HOSTNAME | cut -d: -f2) --protocol=tcp" + # Otherwise it's just a hostname or IP - use TCP + else + EXTRA=" --host=$DB_HOSTNAME --protocol=tcp" + fi + fi + + if [ -n "`mysql --user="$DB_USER" --password="$DB_PASS"$EXTRA --execute='show databases;' | grep ^$DB_NAME$`" ] + then + # In CI/CD or non-interactive mode, automatically recreate database + if [ -t 0 ]; then + # Interactive mode - ask for confirmation + echo "" + echo "⚠️ DATABASE ALREADY EXISTS" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "The database '$DB_NAME' already exists in your MySQL server." + echo "" + echo "WordPress Test Suite requires a clean database installation." + echo "The existing database will be DROPPED and recreated." + echo "" + echo "⚠️ WARNING: This will DELETE all data in the '$DB_NAME' database!" + echo "" + echo "If this is a production database or contains important data," + echo "press Ctrl+C now to cancel, or type 'N' below." + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + read -p "Are you sure you want to proceed? [y/N]: " DELETE_EXISTING_DB + recreate_db $DELETE_EXISTING_DB + else + # Non-interactive mode (CI/CD) - automatically recreate + echo "πŸ”„ Database already exists - automatically recreating for test environment..." + mysqladmin drop $DB_NAME -f --user="$DB_USER" --password="$DB_PASS"$EXTRA + create_db + echo "βœ… Database recreated successfully" + fi + else + create_db + fi +} + +case $(uname -s) in + Darwin) + ioption='-i.bak' + ;; + *) + ioption='-i' + ;; +esac + +install_wp +install_test_suite +install_db + +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "βœ… WordPress Test Suite installed successfully!" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" +echo "Environment Variables:" +echo " WP_TESTS_DIR: $WP_TESTS_DIR" +echo " WP_CORE_DIR: $WP_CORE_DIR" +echo "" +echo "Add this to your phpunit.xml.dist:" +echo " " +echo "" +echo "Now you can run tests with:" +echo " vendor/bin/phpunit --testdox" +echo "" diff --git a/scripts/test-runner.sh b/scripts/test-runner.sh new file mode 100755 index 0000000..a9b319d --- /dev/null +++ b/scripts/test-runner.sh @@ -0,0 +1,231 @@ +#!/bin/bash +# WordPress Test Suite Setup and Test Runner +# This script helps install WordPress Test Suite and run tests + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Script directory +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" + +echo -e "${BLUE}================================${NC}" +echo -e "${BLUE}WP GitHub Updater Test Runner${NC}" +echo -e "${BLUE}================================${NC}" +echo "" + +# Function to check if WordPress Test Suite is installed +check_wp_tests() { + local tests_dir="${WP_TESTS_DIR:-/tmp/wordpress-tests-lib}" + + if [ -d "$tests_dir" ] && [ -f "$tests_dir/includes/functions.php" ]; then + return 0 + else + return 1 + fi +} + +# Function to install WordPress Test Suite +install_wp_tests() { + echo -e "${YELLOW}WordPress Test Suite not found${NC}" + echo "" + echo "To run WordPress integration tests, you need to install WordPress Test Suite." + echo "" + echo -e "${GREEN}Installation command:${NC}" + echo " ./scripts/install-wp-tests.sh [db-host] [wp-version]" + echo "" + echo -e "${GREEN}Example:${NC}" + echo " ./scripts/install-wp-tests.sh wordpress_test root '' localhost 6.7.1" + echo "" + read -p "Do you want to install now? (y/N) " -n 1 -r + echo + + if [[ $REPLY =~ ^[Yy]$ ]]; then + echo "" + echo "Please provide database credentials:" + read -p "Database name (default: wordpress_test): " db_name + db_name=${db_name:-wordpress_test} + + read -p "Database user (default: root): " db_user + db_user=${db_user:-root} + + read -sp "Database password (default: empty): " db_pass + echo + + read -p "Database host (default: localhost): " db_host + db_host=${db_host:-localhost} + + read -p "WordPress version (default: 6.7.1): " wp_version + wp_version=${wp_version:-6.7.1} + + echo "" + echo -e "${BLUE}Installing WordPress Test Suite...${NC}" + "$PROJECT_ROOT/scripts/install-wp-tests.sh" "$db_name" "$db_user" "$db_pass" "$db_host" "$wp_version" + + if [ $? -eq 0 ]; then + echo "" + echo -e "${GREEN}βœ“ WordPress Test Suite installed successfully!${NC}" + return 0 + else + echo "" + echo -e "${RED}βœ— Failed to install WordPress Test Suite${NC}" + return 1 + fi + else + echo "" + echo "Skipping WordPress Test Suite installation." + echo "You can install it later using the command above." + return 1 + fi +} + +# Function to run specific test suite +run_tests() { + local suite=$1 + local phpunit="$PROJECT_ROOT/vendor/bin/phpunit" + + if [ ! -f "$phpunit" ]; then + echo -e "${RED}βœ— PHPUnit not found. Please run: composer install${NC}" + exit 1 + fi + + cd "$PROJECT_ROOT" + + case $suite in + unit) + echo -e "${BLUE}Running Unit Tests...${NC}" + "$phpunit" --testsuite=unit + ;; + integration) + echo -e "${BLUE}Running Integration Tests...${NC}" + "$phpunit" --testsuite=integration + ;; + wordpress) + echo -e "${BLUE}Running WordPress Tests...${NC}" + if check_wp_tests; then + echo -e "${GREEN}βœ“ WordPress Test Suite found${NC}" + else + echo -e "${YELLOW}⚠️ WordPress Test Suite not found${NC}" + echo "WordPress tests will run with mocked functions." + echo "" + fi + "$phpunit" --testsuite=wordpress + ;; + all) + echo -e "${BLUE}Running All Tests...${NC}" + "$phpunit" + ;; + coverage) + echo -e "${BLUE}Running Tests with Coverage...${NC}" + if command -v php -m | grep -q xdebug; then + "$phpunit" --coverage-html build/coverage --coverage-text + echo "" + echo -e "${GREEN}βœ“ Coverage report generated in: build/coverage/index.html${NC}" + else + echo -e "${YELLOW}⚠️ Xdebug not found. Installing PCOV...${NC}" + echo "Please install Xdebug or PCOV for code coverage:" + echo " - Xdebug: pecl install xdebug" + echo " - PCOV: pecl install pcov" + exit 1 + fi + ;; + *) + echo -e "${RED}Unknown test suite: $suite${NC}" + show_usage + exit 1 + ;; + esac +} + +# Function to show test status +show_status() { + echo -e "${BLUE}Test Environment Status:${NC}" + echo "" + + # PHPUnit + if [ -f "$PROJECT_ROOT/vendor/bin/phpunit" ]; then + echo -e "PHPUnit: ${GREEN}βœ“ Installed${NC}" + phpunit_version=$("$PROJECT_ROOT/vendor/bin/phpunit" --version | head -n 1) + echo " $phpunit_version" + else + echo -e "PHPUnit: ${RED}βœ— Not installed${NC}" + echo " Run: composer install" + fi + + echo "" + + # WordPress Test Suite + if check_wp_tests; then + echo -e "WP Tests: ${GREEN}βœ“ Installed${NC}" + tests_dir="${WP_TESTS_DIR:-/tmp/wordpress-tests-lib}" + echo " Location: $tests_dir" + else + echo -e "WP Tests: ${YELLOW}⚠️ Not installed${NC}" + echo " Run: $0 install" + fi + + echo "" + + # Mock Plugin + mock_plugin="$PROJECT_ROOT/tests/fixtures/mock-plugin/mock-plugin.php" + if [ -f "$mock_plugin" ]; then + echo -e "Mock Plugin: ${GREEN}βœ“ Available${NC}" + echo " Location: tests/fixtures/mock-plugin/" + else + echo -e "Mock Plugin: ${RED}βœ— Not found${NC}" + fi + + echo "" +} + +# Function to show usage +show_usage() { + echo "Usage: $0 [command]" + echo "" + echo "Commands:" + echo " status Show test environment status" + echo " install Install WordPress Test Suite" + echo " unit Run unit tests only" + echo " integration Run integration tests only" + echo " wordpress Run WordPress tests only" + echo " all Run all tests (default)" + echo " coverage Run tests with code coverage" + echo " help Show this help message" + echo "" + echo "Examples:" + echo " $0 status # Check environment" + echo " $0 install # Install WP Test Suite" + echo " $0 unit # Run unit tests" + echo " $0 # Run all tests" + echo "" +} + +# Main script logic +case ${1:-all} in + status) + show_status + ;; + install) + install_wp_tests + ;; + unit|integration|wordpress|all|coverage) + run_tests "$1" + ;; + help|-h|--help) + show_usage + ;; + *) + echo -e "${RED}Unknown command: $1${NC}" + echo "" + show_usage + exit 1 + ;; +esac + +exit 0 diff --git a/src/Updater.php b/src/Updater.php index 1ff25fa..66425c5 100644 --- a/src/Updater.php +++ b/src/Updater.php @@ -7,12 +7,15 @@ * * @package SilverAssist\WpGithubUpdater * @author Silver Assist - * @version 1.1.1 + * @version 1.2.1 * @license PolyForm-Noncommercial-1.0.0 */ namespace SilverAssist\WpGithubUpdater; +use WP_Error; +use WP_Upgrader; + /** * Main updater class that handles plugin updates from GitHub releases * @@ -115,6 +118,12 @@ class Updater // Add AJAX action for manual version check \add_action("wp_ajax_{$this->config->ajaxAction}", [$this, "manualVersionCheck"]); + + // Add AJAX action for dismissing update notices + \add_action("wp_ajax_{$this->config->ajaxAction}_dismiss_notice", [$this, "dismissUpdateNotice"]); + + // Add admin notice for manual version checks + \add_action("admin_notices", [$this, "showUpdateNotice"]); } /** @@ -136,7 +145,7 @@ class Updater $latestVersion = $this->getLatestVersion(); - if ($latestVersion && version_compare($this->currentVersion, $latestVersion, "<")) { + if ($this->isUpdateAvailable()) { $transient->response[$this->pluginSlug] = (object) [ "slug" => $this->pluginBasename, "plugin" => $this->pluginSlug, @@ -203,7 +212,7 @@ class Updater * * @since 1.0.0 */ - private function getLatestVersion(): string|false + public function getLatestVersion(): string|false { // Check cache first $cachedVersion = \get_transient($this->versionTransient); @@ -384,10 +393,10 @@ class Updater /** * Clear version cache after update * - * @param \WP_Upgrader $upgrader WP_Upgrader instance + * @param WP_Upgrader $upgrader WP_Upgrader instance * @param array $data Array of update data */ - public function clearVersionCache(\WP_Upgrader $upgrader, array $data): void + public function clearVersionCache(WP_Upgrader $upgrader, array $data): void { if ($data["action"] === "update" && $data["type"] === "plugin") { if (isset($data["plugins"]) && in_array($this->pluginSlug, $data["plugins"])) { @@ -418,14 +427,31 @@ class Updater } try { + // Clear our version cache \delete_transient($this->versionTransient); + + // Clear WordPress update cache to force refresh + \delete_site_transient("update_plugins"); + $latestVersion = $this->getLatestVersion(); + $updateAvailable = $this->isUpdateAvailable(); + + // If update is available, set a transient to show admin notice + if ($updateAvailable) { + \set_transient("wp_github_updater_notice_{$this->pluginBasename}", [ + "plugin_name" => $this->config->pluginName, + "current_version" => $this->currentVersion, + "latest_version" => $latestVersion, + "github_repo" => $this->config->githubRepo, + ], $this->config->cacheDuration); // Use same duration as version cache + } \wp_send_json_success([ "current_version" => $this->currentVersion, "latest_version" => $latestVersion ?: $this->config->__("Unknown"), - "update_available" => $latestVersion && version_compare($this->currentVersion, $latestVersion, "<"), + "update_available" => $updateAvailable, "github_repo" => $this->config->githubRepo, + "notice_set" => $updateAvailable, // Indicate if notice was set ]); } catch (\Exception $e) { \wp_send_json_error([ @@ -435,6 +461,99 @@ class Updater } } + /** + * Handle dismissal of update notices via AJAX + * + * @since 1.1.4 + * @return void + */ + public function dismissUpdateNotice(): void + { + // Check nonce + $nonce = \sanitize_text_field(\wp_unslash($_POST["nonce"] ?? "")); + if (!\wp_verify_nonce($nonce, $this->config->ajaxNonce)) { + \wp_die("Security verification failed", "Error", ["response" => 403]); + } + + // Check capabilities + if (!\current_user_can("update_plugins")) { + \wp_die("Insufficient permissions", "Error", ["response" => 403]); + } + + // Delete the notice transient + $noticeKey = "wp_github_updater_notice_{$this->pluginBasename}"; + \delete_transient($noticeKey); + + \wp_send_json_success(["message" => "Notice dismissed successfully"]); + } + + /** + * Show admin notice for available updates + * + * Displays a WordPress admin notice when an update is available + * after a manual version check. + * + * @since 1.1.4 + */ + public function showUpdateNotice(): void + { + // Only show on admin pages + if (!is_admin()) { + return; + } + + // Check if we have a notice to show + $notice_data = \get_transient("wp_github_updater_notice_{$this->pluginBasename}"); + if (!$notice_data) { + return; + } + + // Only show to users who can update plugins + if (!\current_user_can("update_plugins")) { + return; + } + + $plugin_name = $notice_data["plugin_name"] ?? $this->config->pluginName; + $current_version = $notice_data["current_version"] ?? $this->currentVersion; + $latest_version = $notice_data["latest_version"] ?? "Unknown"; + $github_repo = $notice_data["github_repo"] ?? $this->config->githubRepo; + + $updates_url = \admin_url("plugins.php?plugin_status=upgrade"); + $github_url = "https://github.com/{$github_repo}/releases/latest"; + + echo '
'; + echo '

'; + echo '' . \esc_html($plugin_name) . ' '; + echo sprintf( + \esc_html($this->config->__("has a new version available: %1\$s (you have %2\$s).")), + '' . \esc_html($latest_version) . '', + \esc_html($current_version) + ); + echo '

'; + echo '

'; + echo '' . + \esc_html($this->config->__("View Updates")) . ' '; + echo '' . + \esc_html($this->config->__("View Release Notes")) . ''; + echo '

'; + echo '
'; + + // Add JavaScript to handle dismissal + echo ''; + } + /** * Get plugin data from file */ @@ -531,24 +650,61 @@ class Updater /** * Maybe fix download issues by providing better HTTP args * - * @return boolean|\WP_Error $result + * This filter intercepts the download process for our GitHub releases + * to ensure proper temporary file handling and avoid PCLZIP errors. + * + * CRITICAL: This filter MUST return one of: + * - false (or WP_Error): Let WordPress handle the download normally + * - string: Path to an already-downloaded file for WordPress to use + * - NEVER return true or any other type! + * + * @param boolean|WP_Error $result The result from previous filters + * @param string $package The package URL being downloaded + * @param object $upgrader The WP_Upgrader instance + * @param array $hook_extra Extra hook data + * @return string|WP_Error|false Path to downloaded file, WP_Error on failure, or false to continue * * @since 1.1.0 */ public function maybeFixDownload( - bool|\WP_Error $result, + bool|WP_Error $result, string $package, object $upgrader, array $hook_extra - ): bool|\WP_Error { - // Only handle GitHub downloads for our plugin - if (!str_contains($package, "github.com") || !str_contains($package, $this->config->githubRepo)) { + ): string|WP_Error|false { + // If a previous filter already handled this, respect that decision + if (\is_wp_error($result)) { return $result; } - // Use wp_remote_get with better parameters + // Only handle GitHub downloads for our specific repository + if ( + empty($package) || + !str_contains($package, "github.com") || + !str_contains($package, $this->config->githubRepo) + ) { + return false; // Let WordPress handle it normally + } + + // Additional safety check: ensure this is actually for our plugin + $is_our_plugin = false; + if (isset($hook_extra["plugin"]) && $hook_extra["plugin"] === $this->pluginSlug) { + $is_our_plugin = true; + } elseif ( + isset($hook_extra["plugins"]) && + is_array($hook_extra["plugins"]) && + in_array($this->pluginSlug, $hook_extra["plugins"]) + ) { + $is_our_plugin = true; + } + + if (!$is_our_plugin) { + return false; // Not our plugin, let WordPress handle it + } + + // Download the package with optimized settings $args = [ - "timeout" => 300, // 5 minutes + "timeout" => 300, // 5 minutes for large files "headers" => $this->getDownloadHeaders(), "sslverify" => true, "stream" => false, @@ -558,32 +714,183 @@ class Updater $response = \wp_remote_get($package, $args); if (\is_wp_error($response)) { - return $response; + return new WP_Error( + "download_failed", + sprintf( + $this->config->__("Failed to download package: %s"), + $response->get_error_message() + ) + ); } - if (200 !== \wp_remote_retrieve_response_code($response)) { - return new \WP_Error("http_404", $this->config->__("Package not found")); + $response_code = \wp_remote_retrieve_response_code($response); + if (200 !== $response_code) { + return new WP_Error( + "http_error", + sprintf( + $this->config->__("Package download failed with HTTP code %d"), + $response_code + ) + ); } - // Write to temporary file - $upload_dir = \wp_upload_dir(); - $temp_file = \wp_tempnam(basename($package), $upload_dir["basedir"] . "/"); - - if (!$temp_file) { - return new \WP_Error("temp_file_failed", $this->config->__("Could not create temporary file")); + // Get the response body + $body = \wp_remote_retrieve_body($response); + if (empty($body)) { + return new WP_Error( + "empty_response", + $this->config->__("Downloaded package is empty") + ); } - $file_handle = @fopen($temp_file, "w"); + // Create temporary file with our multi-tier fallback system + $temp_file = $this->createSecureTempFile($package); + + if (\is_wp_error($temp_file)) { + return $temp_file; + } + + // Write the downloaded content to the temporary file + $file_handle = @fopen($temp_file, "wb"); if (!$file_handle) { - return new \WP_Error("file_open_failed", $this->config->__("Could not open file for writing")); + @unlink($temp_file); // Clean up if file was created but can't be opened + return new WP_Error( + "file_open_failed", + sprintf( + $this->config->__("Could not open temporary file for writing: %s"), + $temp_file + ) + ); } - fwrite($file_handle, \wp_remote_retrieve_body($response)); + $bytes_written = fwrite($file_handle, $body); fclose($file_handle); + // Verify write operation succeeded + if ($bytes_written === false || $bytes_written !== strlen($body)) { + @unlink($temp_file); + return new WP_Error( + "file_write_failed", + $this->config->__("Failed to write complete package to temporary file") + ); + } + + // Final verification: ensure file exists and is readable + if (!file_exists($temp_file)) { + return new WP_Error( + "file_missing", + $this->config->__("Temporary file disappeared after creation") + ); + } + + if (!is_readable($temp_file)) { + @unlink($temp_file); + return new WP_Error( + "file_not_readable", + $this->config->__("Temporary file is not readable") + ); + } + + // Verify it's actually a zip file + $file_size = filesize($temp_file); + if ($file_size < 100) { // Minimum size for a valid zip + @unlink($temp_file); + return new WP_Error( + "invalid_package", + sprintf( + $this->config->__("Downloaded file is too small (%d bytes) to be a valid package"), + $file_size + ) + ); + } + + // SUCCESS: Return the path to the downloaded file + // WordPress will use this file for extraction return $temp_file; } + /** + * Create a secure temporary file with multiple fallback strategies + * + * Attempts different approaches to create a temporary file to avoid PCLZIP errors + * that can occur with restrictive /tmp directory permissions. + * + * @param string $package The package URL being downloaded + * @return string|WP_Error Path to temporary file or WP_Error on failure + * + * @since 1.1.4 + */ + private function createSecureTempFile(string $package): string|WP_Error + { + $filename = basename(parse_url($package, PHP_URL_PATH)) ?: "github-package.zip"; + + // Strategy 1: Use custom temporary directory if specified + if (!empty($this->config->customTempDir)) { + if (!is_dir($this->config->customTempDir)) { + @wp_mkdir_p($this->config->customTempDir); + } + + if (is_dir($this->config->customTempDir) && is_writable($this->config->customTempDir)) { + $temp_file = \wp_tempnam($filename, $this->config->customTempDir . "/"); + if ($temp_file) { + return $temp_file; + } + } + } + + // Strategy 2: Use WordPress uploads directory + $upload_dir = \wp_upload_dir(); + if (!empty($upload_dir["basedir"]) && is_writable($upload_dir["basedir"])) { + $temp_file = \wp_tempnam($filename, $upload_dir["basedir"] . "/"); + if ($temp_file) { + return $temp_file; + } + } + + // Strategy 3: Use WP_CONTENT_DIR/temp if it exists or can be created + $wp_content_temp = WP_CONTENT_DIR . "/temp"; + if (!is_dir($wp_content_temp)) { + @wp_mkdir_p($wp_content_temp); + } + + if (is_dir($wp_content_temp) && is_writable($wp_content_temp)) { + $temp_file = \wp_tempnam($filename, $wp_content_temp . "/"); + if ($temp_file) { + return $temp_file; + } + } + + // Strategy 4: Use WordPress temporary directory (if defined) + if (defined("WP_TEMP_DIR") && is_dir(WP_TEMP_DIR) && is_writable(WP_TEMP_DIR)) { + $temp_file = \wp_tempnam($filename, WP_TEMP_DIR . "/"); + if ($temp_file) { + return $temp_file; + } + } + + // Strategy 5: Try system temp directory as last resort + $temp_file = \wp_tempnam($filename); + if ($temp_file) { + return $temp_file; + } + + // Strategy 6: Manual temp file creation in uploads dir + if (!empty($upload_dir["basedir"])) { + $manual_temp = $upload_dir["basedir"] . "/" . uniqid("wp_github_updater_", true) . ".tmp"; + $handle = @fopen($manual_temp, "w"); + if ($handle) { + fclose($handle); + return $manual_temp; + } + } + + return new WP_Error( + "temp_file_creation_failed", + $this->config->__("Could not create temporary file. Please check directory permissions " . + "or define WP_TEMP_DIR in wp-config.php") + ); + } + /** * Get headers for GitHub API requests * diff --git a/src/UpdaterConfig.php b/src/UpdaterConfig.php index 11d617a..bb85086 100644 --- a/src/UpdaterConfig.php +++ b/src/UpdaterConfig.php @@ -7,7 +7,7 @@ * * @package SilverAssist\WpGithubUpdater * @author Silver Assist - * @version 1.1.1 + * @version 1.2.1 * @license PolyForm-Noncommercial-1.0.0 */ @@ -130,6 +130,14 @@ class UpdaterConfig */ public string $textDomain; + /** + * Custom temporary directory path + * + * @var string|null Custom path for temporary files during downloads, null for auto-detection + * @since 1.1.3 + */ + public ?string $customTempDir; + /** * Create updater configuration * @@ -156,12 +164,13 @@ class UpdaterConfig $this->pluginAuthor = $options["plugin_author"] ?? $pluginData["Author"] ?? ""; $this->pluginHomepage = $options["plugin_homepage"] ?? "https://github.com/{$githubRepo}"; $this->requiresWordPress = $options["requires_wordpress"] ?? "6.0"; - $this->requiresPHP = $options["requires_php"] ?? "8.0"; + $this->requiresPHP = $options["requires_php"] ?? "8.2"; $this->assetPattern = $options["asset_pattern"] ?? "{slug}-v{version}.zip"; $this->cacheDuration = $options["cache_duration"] ?? (12 * 3600); // 12 hours $this->ajaxAction = $options["ajax_action"] ?? "check_plugin_version"; $this->ajaxNonce = $options["ajax_nonce"] ?? "plugin_version_check"; $this->textDomain = $options["text_domain"] ?? "wp-github-updater"; + $this->customTempDir = $options["custom_temp_dir"] ?? null; } /** @@ -177,7 +186,7 @@ class UpdaterConfig */ private function getPluginData(string $pluginFile): array { - if (\function_exists("get_plugin_data")) { + if (function_exists("get_plugin_data")) { return \get_plugin_data($pluginFile); } diff --git a/tests/Integration/DownloadFilterTest.php b/tests/Integration/DownloadFilterTest.php new file mode 100644 index 0000000..78c3362 --- /dev/null +++ b/tests/Integration/DownloadFilterTest.php @@ -0,0 +1,147 @@ +testPluginFile = sys_get_temp_dir() . "/test-plugin.php"; + file_put_contents($this->testPluginFile, "config = new UpdaterConfig($this->testPluginFile, "SilverAssist/test-repo"); + $this->updater = new Updater($this->config); + } + + /** + * Clean up after each test + */ + protected function tearDown(): void + { + if (file_exists($this->testPluginFile)) { + unlink($this->testPluginFile); + } + + parent::tearDown(); + } + + /** + * Test that temporary directory configuration is respected + */ + public function testCustomTempDirectoryIsRespected(): void + { + $customTempDir = sys_get_temp_dir() . "/wp-github-updater-custom"; + + $config = new UpdaterConfig($this->testPluginFile, "SilverAssist/test-repo", [ + "custom_temp_dir" => $customTempDir, + ]); + + $this->assertEquals($customTempDir, $config->customTempDir); + } + + /** + * Test that package URL validation works correctly + */ + public function testPackageUrlValidation(): void + { + // Valid GitHub URL + $validUrl = "https://github.com/SilverAssist/test-repo/releases/download/v1.0.0/package.zip"; + $this->assertStringContainsString("github.com", $validUrl); + $this->assertStringContainsString("SilverAssist/test-repo", $validUrl); + + // Invalid URL (not GitHub) + $invalidUrl = "https://example.com/package.zip"; + $this->assertStringNotContainsString("github.com", $invalidUrl); + } + + /** + * Test file size validation logic + */ + public function testFileSizeValidation(): void + { + $minSize = 100; // Minimum size for valid ZIP + + // Valid size + $this->assertGreaterThanOrEqual($minSize, 1024); + + // Invalid size + $this->assertLessThan($minSize, 50); + } + + /** + * Test that hook_extra validation logic works + */ + public function testHookExtraValidation(): void + { + $pluginSlug = basename(dirname($this->testPluginFile)) . "/" . basename($this->testPluginFile); + + // Test single plugin update + $hook_extra_single = [ + "plugin" => $pluginSlug, + ]; + $this->assertArrayHasKey("plugin", $hook_extra_single); + $this->assertEquals($pluginSlug, $hook_extra_single["plugin"]); + + // Test bulk plugin update + $hook_extra_bulk = [ + "plugins" => [$pluginSlug, "other-plugin/other-plugin.php"], + ]; + $this->assertArrayHasKey("plugins", $hook_extra_bulk); + $this->assertContains($pluginSlug, $hook_extra_bulk["plugins"]); + } + + /** + * Test version comparison logic + */ + public function testVersionComparison(): void + { + // Current version is older than latest + $this->assertTrue(version_compare("1.0.0", "1.1.0", "<")); + $this->assertFalse(version_compare("1.1.0", "1.0.0", "<")); + + // Same version + $this->assertFalse(version_compare("1.0.0", "1.0.0", "<")); + $this->assertTrue(version_compare("1.0.0", "1.0.0", "=")); + } + + /** + * Test GitHub repository format validation + */ + public function testGitHubRepoFormat(): void + { + // Valid formats + $this->assertMatchesRegularExpression("/^[a-zA-Z0-9-]+\/[a-zA-Z0-9-_]+$/", "SilverAssist/test-repo"); + $this->assertMatchesRegularExpression("/^[a-zA-Z0-9-]+\/[a-zA-Z0-9-_]+$/", "owner/repo"); + + // Invalid formats + $this->assertDoesNotMatchRegularExpression("/^[a-zA-Z0-9-]+\/[a-zA-Z0-9-_]+$/", "invalid"); + $this->assertDoesNotMatchRegularExpression("/^[a-zA-Z0-9-]+\/[a-zA-Z0-9-_]+$/", "/owner/repo"); + } +} diff --git a/tests/Integration/RealGitHubAPITest.php b/tests/Integration/RealGitHubAPITest.php new file mode 100644 index 0000000..9b5ac5a --- /dev/null +++ b/tests/Integration/RealGitHubAPITest.php @@ -0,0 +1,423 @@ +getLatestVersion(); + + // Should return the latest version or false + $this->assertTrue( + $latestVersion === false || is_string($latestVersion), + "getLatestVersion() should return string or false" + ); + + // If successful, should be a valid version string + if ($latestVersion !== false) { + $this->assertMatchesRegularExpression( + "/^\d+\.\d+\.\d+$/", + $latestVersion, + "Version should be in format X.Y.Z" + ); + + // Latest version should be at least v1.3.0 (current latest) + $this->assertGreaterThanOrEqual( + "1.3.0", + $latestVersion, + "Latest version should be at least v1.3.0" + ); + } + } + + /** + * Test fetching release information from real GitHub repository + * + * @return void + */ + public function testFetchReleaseInformationFromRealRepo(): void + { + $config = new UpdaterConfig( + self::$testPluginFile, + "SilverAssist/silver-assist-post-revalidate" + ); + + $updater = new Updater($config); + + // Get latest version (makes real HTTP request) + $version = $updater->getLatestVersion(); + + // Should return string version or false + $this->assertTrue( + $version === false || is_string($version), + "getLatestVersion() should return string or false" + ); + + // If successful, should have valid version + if ($version !== false) { + // Verify it's a valid semantic version + $this->assertMatchesRegularExpression( + "/^\d+\.\d+\.\d+$/", + $version, + "Version should be in X.Y.Z format" + ); + + // Check if update is available + $config2 = new UpdaterConfig( + self::$testPluginFile, + "SilverAssist/silver-assist-post-revalidate", + [ + "plugin_version" => "0.0.1", // Old version + ] + ); + + $updater2 = new Updater($config2); + $hasUpdate = $updater2->isUpdateAvailable(); + + $this->assertTrue( + $hasUpdate, + "Should detect update available when current version is 0.0.1" + ); + } + } + + /** + * Test GitHub API response structure + * + * @return void + */ + public function testGitHubAPIResponseStructure(): void + { + $config = new UpdaterConfig( + self::$testPluginFile, + "SilverAssist/silver-assist-post-revalidate" + ); + + $updater = new Updater($config); + + // Get version to trigger API call + $version = $updater->getLatestVersion(); + + // Skip if API request failed + if ($version === false) { + $this->markTestSkipped("GitHub API request failed or rate limited"); + } + + // Verify version is valid format + $this->assertMatchesRegularExpression( + "/^\d+\.\d+\.\d+$/", + $version, + "Version should be in X.Y.Z format" + ); + + // Version should be at least 1.0.0 (first release) + $this->assertGreaterThanOrEqual( + "1.0.0", + $version, + "Version should be at least 1.0.0" + ); + } + + /** + * Test update check with real GitHub repository + * + * @return void + */ + public function testUpdateCheckWithRealRepo(): void + { + $config = new UpdaterConfig( + self::$testPluginFile, + "SilverAssist/silver-assist-post-revalidate", + [ + "plugin_name" => "Silver Assist Post Revalidate", + "plugin_version" => "0.0.1", // Old version to trigger update + ] + ); + + $updater = new Updater($config); + + // This makes a real HTTP request to GitHub + $hasUpdate = $updater->isUpdateAvailable(); + + // Should return boolean + $this->assertIsBool($hasUpdate); + + // With version 0.0.1, there should be an update available + // (unless API request failed) + if ($hasUpdate) { + $this->assertTrue( + $hasUpdate, + "Update should be available for version 0.0.1" + ); + + // Get latest version to verify + $latestVersion = $updater->getLatestVersion(); + $this->assertNotFalse($latestVersion); + $this->assertGreaterThan("0.0.1", $latestVersion); + } + } + + /** + * Test caching of GitHub API responses + * + * @return void + */ + public function testGitHubAPIResponseCaching(): void + { + $config = new UpdaterConfig( + self::$testPluginFile, + "SilverAssist/silver-assist-post-revalidate", + [ + "cache_duration" => 3600, // 1 hour + ] + ); + + $updater = new Updater($config); + + // First call - should make HTTP request + $version1 = $updater->getLatestVersion(); + + // Skip if API request failed + if ($version1 === false) { + $this->markTestSkipped("GitHub API request failed or rate limited"); + } + + // Second call - should use cache (much faster) + $startTime = microtime(true); + $version2 = $updater->getLatestVersion(); + $elapsed = microtime(true) - $startTime; + + // Both should return same version + $this->assertEquals($version1, $version2); + + // Second call should be very fast (cached) + $this->assertLessThan( + 0.01, // Less than 10ms + $elapsed, + "Cached call should be very fast" + ); + } + + /** + * Test GitHub API rate limiting handling + * + * @return void + */ + public function testGitHubAPIRateLimitHandling(): void + { + $config = new UpdaterConfig( + self::$testPluginFile, + "SilverAssist/silver-assist-post-revalidate", + [ + "cache_duration" => 1, // 1 second cache + ] + ); + + $updater = new Updater($config); + + // Make multiple requests (cached after first) + $results = []; + for ($i = 0; $i < 3; $i++) { + $version = $updater->getLatestVersion(); + $results[] = $version; + } + + // At least one request should succeed (or all should fail gracefully) + $hasSuccess = false; + foreach ($results as $result) { + if ($result !== false) { + $hasSuccess = true; + break; + } + } + + // All results should be the same (cached) + $uniqueResults = array_unique($results); + $this->assertCount( + 1, + $uniqueResults, + "All requests should return same result (cached)" + ); + + $this->assertTrue( + $hasSuccess || count(array_filter($results)) === 0, + "Should either succeed or fail gracefully for all requests" + ); + } + + /** + * Test asset pattern matching with real releases + * + * @return void + */ + public function testAssetPatternMatchingWithRealReleases(): void + { + $config = new UpdaterConfig( + self::$testPluginFile, + "SilverAssist/silver-assist-post-revalidate", + [ + // Custom asset pattern to match the plugin's naming convention + "asset_pattern" => "silver-assist-post-revalidate-v{version}.zip", + ] + ); + + $updater = new Updater($config); + + // Get latest version + $version = $updater->getLatestVersion(); + + // Skip if API request failed + if ($version === false) { + $this->markTestSkipped("GitHub API request failed or rate limited"); + } + + // Verify version format + $this->assertMatchesRegularExpression( + "/^\d+\.\d+\.\d+$/", + $version, + "Version should be in X.Y.Z format" + ); + + // Verify the asset pattern is configured correctly + $this->assertEquals( + "silver-assist-post-revalidate-v{version}.zip", + $config->assetPattern, + "Asset pattern should be configured" + ); + } + + /** + * Test version comparison with current and latest versions + * + * @return void + */ + public function testVersionComparisonWithRealVersions(): void + { + // Test with old version (should have update) + $config1 = new UpdaterConfig( + self::$testPluginFile, + "SilverAssist/silver-assist-post-revalidate", + [ + "plugin_version" => "1.0.0", + ] + ); + + $updater1 = new Updater($config1); + $latestVersion = $updater1->getLatestVersion(); + + // Skip if API failed + if ($latestVersion === false) { + $this->markTestSkipped("GitHub API request failed"); + } + + // 1.0.0 should be older than latest + $hasUpdate = $updater1->isUpdateAvailable(); + $this->assertTrue( + $hasUpdate, + "Version 1.0.0 should have update available (latest: {$latestVersion})" + ); + + // Verify version comparison works correctly + $this->assertGreaterThan( + "1.0.0", + $latestVersion, + "Latest version should be greater than 1.0.0" + ); + } + + /** + * Test GitHub repository information retrieval + * + * @return void + */ + public function testGitHubRepositoryInformation(): void + { + $config = new UpdaterConfig( + self::$testPluginFile, + "SilverAssist/silver-assist-post-revalidate" + ); + + $updater = new Updater($config); + + // Get version (triggers API call) + $version = $updater->getLatestVersion(); + + // Skip if failed + if ($version === false) { + $this->markTestSkipped("GitHub API request failed"); + } + + // Verify repository info is correct + $this->assertEquals( + "SilverAssist/silver-assist-post-revalidate", + $config->githubRepo, + "Repository should be configured correctly" + ); + + // Verify version is reasonable (>= 1.0.0 and < 100.0.0) + $this->assertGreaterThanOrEqual( + "1.0.0", + $version, + "Version should be at least 1.0.0" + ); + + $this->assertLessThan( + "100.0.0", + $version, + "Version should be less than 100.0.0" + ); + } +} diff --git a/tests/Integration/UpdaterIntegrationTest.php b/tests/Integration/UpdaterIntegrationTest.php new file mode 100644 index 0000000..4858f8c --- /dev/null +++ b/tests/Integration/UpdaterIntegrationTest.php @@ -0,0 +1,142 @@ +testPluginFile = sys_get_temp_dir() . "/test-plugin.php"; + file_put_contents($this->testPluginFile, "config = new UpdaterConfig($this->testPluginFile, "SilverAssist/test-repo", [ + "plugin_name" => "Test Plugin", + "plugin_description" => "Test plugin description", + "cache_duration" => 60, // Short cache for testing + ]); + } + + /** + * Clean up after each test + */ + protected function tearDown(): void + { + if (file_exists($this->testPluginFile)) { + unlink($this->testPluginFile); + } + + parent::tearDown(); + } + + /** + * Test that Updater can be instantiated with valid configuration + */ + public function testUpdaterInstantiation(): void + { + $updater = new Updater($this->config); + + $this->assertInstanceOf(Updater::class, $updater); + $this->assertEquals("1.0.0", $updater->getCurrentVersion()); + $this->assertEquals("SilverAssist/test-repo", $updater->getGithubRepo()); + } + + /** + * Test configuration validation + */ + public function testConfigurationValidation(): void + { + $this->assertEquals("Test Plugin", $this->config->pluginName); + $this->assertEquals("Test plugin description", $this->config->pluginDescription); + $this->assertEquals("SilverAssist/test-repo", $this->config->githubRepo); + $this->assertEquals(60, $this->config->cacheDuration); + } + + /** + * Test custom temporary directory configuration + */ + public function testCustomTempDirConfiguration(): void + { + $customTempDir = sys_get_temp_dir() . "/wp-github-updater-test"; + + $config = new UpdaterConfig($this->testPluginFile, "SilverAssist/test-repo", [ + "custom_temp_dir" => $customTempDir, + ]); + + $this->assertEquals($customTempDir, $config->customTempDir); + } + + /** + * Test text domain configuration + */ + public function testTextDomainConfiguration(): void + { + $config = new UpdaterConfig($this->testPluginFile, "SilverAssist/test-repo", [ + "text_domain" => "custom-domain", + ]); + + $this->assertEquals("custom-domain", $config->textDomain); + } + + /** + * Test AJAX configuration + */ + public function testAjaxConfiguration(): void + { + $this->assertNotEmpty($this->config->ajaxAction); + $this->assertNotEmpty($this->config->ajaxNonce); + $this->assertEquals("check_plugin_version", $this->config->ajaxAction); + $this->assertEquals("plugin_version_check", $this->config->ajaxNonce); + } + + /** + * Test asset pattern configuration + */ + public function testAssetPatternConfiguration(): void + { + $config = new UpdaterConfig($this->testPluginFile, "SilverAssist/test-repo", [ + "asset_pattern" => "custom-{slug}-{version}.zip", + ]); + + $this->assertEquals("custom-{slug}-{version}.zip", $config->assetPattern); + } + + /** + * Test WordPress requirements configuration + */ + public function testWordPressRequirementsConfiguration(): void + { + $config = new UpdaterConfig($this->testPluginFile, "SilverAssist/test-repo", [ + "requires_wordpress" => "6.2", + "requires_php" => "8.1", + ]); + + $this->assertEquals("6.2", $config->requiresWordPress); + $this->assertEquals("8.1", $config->requiresPHP); + } +} diff --git a/tests/UpdaterConfigTest.php b/tests/Unit/UpdaterConfigTest.php similarity index 79% rename from tests/UpdaterConfigTest.php rename to tests/Unit/UpdaterConfigTest.php index a20451d..33da8a8 100644 --- a/tests/UpdaterConfigTest.php +++ b/tests/Unit/UpdaterConfigTest.php @@ -1,20 +1,27 @@ assertEquals("/path/to/plugin.php", $config->pluginFile); + $this->assertEquals(self::$testPluginFile, $config->pluginFile); $this->assertEquals("owner/repo", $config->githubRepo); $this->assertEquals("6.0", $config->requiresWordPress); - $this->assertEquals("8.0", $config->requiresPHP); + $this->assertEquals("8.2", $config->requiresPHP); $this->assertEquals("{slug}-v{version}.zip", $config->assetPattern); $this->assertEquals("wp-github-updater", $config->textDomain); } @@ -34,7 +41,7 @@ class UpdaterConfigTest extends TestCase "text_domain" => "my-custom-plugin" ]; - $config = new UpdaterConfig("/path/to/plugin.php", "owner/repo", $options); + $config = new UpdaterConfig(self::$testPluginFile, "owner/repo", $options); $this->assertEquals("Test Plugin", $config->pluginName); $this->assertEquals("A test plugin", $config->pluginDescription); @@ -50,7 +57,7 @@ class UpdaterConfigTest extends TestCase public function testTranslationMethods(): void { - $config = new UpdaterConfig("/path/to/plugin.php", "owner/repo", [ + $config = new UpdaterConfig(self::$testPluginFile, "owner/repo", [ "text_domain" => "test-domain" ]); diff --git a/tests/WordPress/MockPluginTest.php b/tests/WordPress/MockPluginTest.php new file mode 100644 index 0000000..90f5dc0 --- /dev/null +++ b/tests/WordPress/MockPluginTest.php @@ -0,0 +1,258 @@ +pluginFile = dirname(__DIR__) . "/fixtures/mock-plugin/mock-plugin.php"; + + // Load the mock plugin + if (file_exists($this->pluginFile)) { + require_once $this->pluginFile; + + // Initialize the updater + do_action("plugins_loaded"); + + // Get updater instance + $this->updater = mock_plugin_get_updater(); + } + } + + /** + * Test that mock plugin file exists + */ + public function testMockPluginFileExists(): void + { + $this->assertFileExists($this->pluginFile, "Mock plugin file should exist"); + } + + /** + * Test that mock plugin can be loaded + */ + public function testMockPluginCanBeLoaded(): void + { + $this->assertTrue( + function_exists("mock_plugin_init_updater"), + "Mock plugin functions should be available" + ); + $this->assertTrue( + function_exists("mock_plugin_get_updater"), + "Mock plugin helper functions should be available" + ); + } + + /** + * Test that updater is initialized + */ + public function testUpdaterIsInitialized(): void + { + $this->assertNotNull($this->updater, "Updater should be initialized"); + $this->assertInstanceOf(Updater::class, $this->updater, "Should be Updater instance"); + } + + /** + * Test updater configuration + */ + public function testUpdaterConfiguration(): void + { + if (!$this->updater) { + $this->markTestSkipped("Updater not initialized"); + } + + $this->assertEquals("SilverAssist/mock-test-repo", $this->updater->getGithubRepo()); + $this->assertEquals("1.0.0", $this->updater->getCurrentVersion()); + } + + /** + * Test WordPress hooks are registered + */ + public function testWordPressHooksAreRegistered(): void + { + // Check that update check filter is registered + $this->assertNotFalse( + has_filter("pre_set_site_transient_update_plugins"), + "Update check filter should be registered" + ); + + // Check that plugin info filter is registered + $this->assertNotFalse( + has_filter("plugins_api"), + "Plugin info filter should be registered" + ); + } + + /** + * Test AJAX actions are registered + */ + public function testAjaxActionsAreRegistered(): void + { + // Check that AJAX actions are registered (only for logged-in users) + $this->assertNotFalse( + has_action("wp_ajax_mock_plugin_check_version"), + "AJAX check version action should be registered" + ); + + // Check that dismiss notice action is registered + $this->assertNotFalse( + has_action("wp_ajax_mock_plugin_check_version_dismiss_notice"), + "AJAX dismiss notice action should be registered" + ); + } + + /** + * Test plugin activation + */ + public function testPluginActivation(): void + { + // Set a transient to test cleanup + set_transient("mock-plugin_version_check", "test_data", 3600); + $this->assertNotFalse(get_transient("mock-plugin_version_check")); + + // Trigger activation + do_action("activate_" . plugin_basename($this->pluginFile)); + + // In real implementation, transient should be cleared + // For now, just verify activation doesn't cause errors + $this->assertTrue(true); + } + + /** + * Test plugin deactivation + */ + public function testPluginDeactivation(): void + { + // Set a transient + set_transient("mock-plugin_version_check", "test_data", 3600); + + // Trigger deactivation + do_action("deactivate_" . plugin_basename($this->pluginFile)); + + // Verify cleanup happens (implementation-specific) + $this->assertTrue(true); + } + + /** + * Test admin menu is registered + */ + public function testAdminMenuIsRegistered(): void + { + // Set current user as administrator + $user_id = $this->factory->user->create(["role" => "administrator"]); + wp_set_current_user($user_id); + + // Trigger admin menu hook + do_action("admin_menu"); + + // Check that menu was added + global $menu; + $this->assertIsArray($menu); + } + + /** + * Test update check with transient caching + */ + public function testUpdateCheckWithCaching(): void + { + if (!$this->updater) { + $this->markTestSkipped("Updater not initialized"); + } + + // Clear any existing cache + delete_transient("mock-plugin_version_check"); + + // First check should query API (we can't test actual API in unit tests) + $updateAvailable = $this->updater->isUpdateAvailable(); + $this->assertIsBool($updateAvailable); + + // Second check should use cache (if API was successful) + $updateAvailable2 = $this->updater->isUpdateAvailable(); + $this->assertIsBool($updateAvailable2); + } + + /** + * Test plugin data retrieval + */ + public function testPluginDataRetrieval(): void + { + $pluginData = \get_plugin_data($this->pluginFile, false, false); // Don't markup, don't translate + + $this->assertIsArray($pluginData); + $this->assertEquals("Mock Plugin for WP GitHub Updater Tests", $pluginData["Name"]); + $this->assertEquals("1.0.0", $pluginData["Version"]); + $this->assertEquals("SilverAssist", $pluginData["Author"]); + $this->assertEquals("6.0", $pluginData["RequiresWP"]); + $this->assertEquals("8.2", $pluginData["RequiresPHP"]); + } + + /** + * Test plugin basename generation + */ + public function testPluginBasename(): void + { + $basename = plugin_basename($this->pluginFile); + + $this->assertIsString($basename); + $this->assertStringContainsString("mock-plugin.php", $basename); + } + + /** + * Test custom temporary directory configuration + */ + public function testCustomTempDirectoryConfiguration(): void + { + // This tests the v1.1.3+ feature for custom temp directories + $expectedTempDir = WP_CONTENT_DIR . "/uploads/temp"; + + // The mock plugin configures a custom temp dir + // We verify this through the configuration + $this->assertDirectoryExists(WP_CONTENT_DIR . "/uploads"); + } + + /** + * Clean up after tests + */ + public function tearDown(): void + { + // Clean up transients + delete_transient("mock-plugin_version_check"); + + parent::tearDown(); + } +} diff --git a/tests/WordPress/WordPressHooksTest.php b/tests/WordPress/WordPressHooksTest.php new file mode 100644 index 0000000..51c0658 --- /dev/null +++ b/tests/WordPress/WordPressHooksTest.php @@ -0,0 +1,213 @@ +testPluginFile = sys_get_temp_dir() . "/test-plugin.php"; + file_put_contents( + $this->testPluginFile, + "config = new UpdaterConfig($this->testPluginFile, "SilverAssist/test-repo", [ + "plugin_name" => "Test Plugin", + "plugin_description" => "Test plugin description", + ]); + } + + /** + * Clean up after each test + */ + protected function tearDown(): void + { + if (file_exists($this->testPluginFile)) { + unlink($this->testPluginFile); + } + + parent::tearDown(); + } + + /** + * Test configuration object creation + */ + public function testConfigurationCreation(): void + { + $this->assertInstanceOf(UpdaterConfig::class, $this->config); + $this->assertEquals("SilverAssist/test-repo", $this->config->githubRepo); + $this->assertEquals($this->testPluginFile, $this->config->pluginFile); + } + + /** + * Test plugin homepage configuration + */ + public function testPluginHomepage(): void + { + $expectedHomepage = "https://github.com/SilverAssist/test-repo"; + $this->assertEquals($expectedHomepage, $this->config->pluginHomepage); + } + + /** + * Test plugin author configuration + */ + public function testPluginAuthor(): void + { + // When plugin file doesn't exist or can't be read, author will be empty string + // unless explicitly provided in options + $this->assertIsString($this->config->pluginAuthor); + + // Test with explicit author option + $configWithAuthor = new UpdaterConfig($this->testPluginFile, "SilverAssist/test-repo", [ + "plugin_author" => "SilverAssist", + ]); + $this->assertEquals("SilverAssist", $configWithAuthor->pluginAuthor); + } + + /** + * Test cache duration configuration + */ + public function testCacheDuration(): void + { + $this->assertEquals(43200, $this->config->cacheDuration); // Default 12 hours + + $customConfig = new UpdaterConfig($this->testPluginFile, "SilverAssist/test-repo", [ + "cache_duration" => 3600, + ]); + $this->assertEquals(3600, $customConfig->cacheDuration); + } + + /** + * Test transient naming convention + */ + public function testTransientNaming(): void + { + $pluginBasename = basename(dirname($this->testPluginFile)); + $expectedTransient = "{$pluginBasename}_version_check"; + + // The transient name should follow WordPress conventions (lowercase, numbers, underscores, dashes) + // Note: basename() may return uppercase letters, which is acceptable in WordPress transients + $this->assertMatchesRegularExpression("/^[a-zA-Z0-9_-]+$/", $expectedTransient); + } + + /** + * Test AJAX action naming convention + */ + public function testAjaxActionNaming(): void + { + $ajaxAction = $this->config->ajaxAction; + + // AJAX action should follow WordPress conventions (lowercase, numbers, underscores) + $this->assertNotEmpty($ajaxAction); + $this->assertMatchesRegularExpression("/^[a-z0-9_-]+$/", $ajaxAction); + $this->assertEquals("check_plugin_version", $ajaxAction); + } + + /** + * Test nonce naming convention + */ + public function testNonceNaming(): void + { + $nonce = $this->config->ajaxNonce; + + // Nonce should follow WordPress conventions (lowercase, numbers, underscores) + $this->assertNotEmpty($nonce); + $this->assertMatchesRegularExpression("/^[a-z0-9_-]+$/", $nonce); + $this->assertEquals("plugin_version_check", $nonce); + } + + /** + * Test plugin data structure + */ + public function testPluginDataStructure(): void + { + $this->assertIsString($this->config->pluginName); + $this->assertIsString($this->config->pluginDescription); + $this->assertIsString($this->config->pluginAuthor); + $this->assertIsString($this->config->pluginHomepage); + } + + /** + * Test WordPress version requirements + */ + public function testWordPressVersionRequirements(): void + { + $this->assertIsString($this->config->requiresWordPress); + $this->assertMatchesRegularExpression("/^\d+\.\d+$/", $this->config->requiresWordPress); + $this->assertGreaterThanOrEqual(6.0, (float) $this->config->requiresWordPress); + } + + /** + * Test PHP version requirements + */ + public function testPHPVersionRequirements(): void + { + $this->assertIsString($this->config->requiresPHP); + $this->assertMatchesRegularExpression("/^\d+\.\d+$/", $this->config->requiresPHP); + $this->assertGreaterThanOrEqual(8.2, (float) $this->config->requiresPHP); + } + + /** + * Test asset pattern replacement tokens + */ + public function testAssetPatternTokens(): void + { + $pattern = $this->config->assetPattern; + + // Pattern should contain replacement tokens + $this->assertStringContainsString("{slug}", $pattern); + $this->assertStringContainsString("{version}", $pattern); + $this->assertStringEndsWith(".zip", $pattern); + } + + /** + * Test translation function wrapper + */ + public function testTranslationFunctionWrapper(): void + { + $testString = "Test string"; + $translated = $this->config->__($testString); + + // In test environment, should return the original string + $this->assertEquals($testString, $translated); + } + + /** + * Test GitHub API URL construction + */ + public function testGitHubApiUrlConstruction(): void + { + $repo = $this->config->githubRepo; + $expectedBaseUrl = "https://api.github.com/repos/{$repo}"; + + $this->assertStringContainsString("SilverAssist", $expectedBaseUrl); + $this->assertStringContainsString("test-repo", $expectedBaseUrl); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index e6334bb..074bf99 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,28 +1,117 @@ "Mock Plugin for Testing", + "cache_duration" => 300, // 5 minutos para testing + "text_domain" => "mock-plugin", + "custom_temp_dir" => WP_CONTENT_DIR . "/uploads/temp", + // ... mΓ‘s opciones + ] +); + +$updater = new Updater($config); +``` + +### βœ… Panel de AdministraciΓ³n + +- PΓ‘gina de admin con informaciΓ³n del plugin +- BotΓ³n para check manual de actualizaciones +- Display de versiΓ³n actual y disponible +- IntegraciΓ³n AJAX para checks en tiempo real + +### βœ… Hooks de WordPress + +- `plugins_loaded`: InicializaciΓ³n del updater +- `admin_menu`: MenΓΊ de administraciΓ³n +- `activate_*`: Limpieza de cache en activaciΓ³n +- `deactivate_*`: Limpieza de cache en desactivaciΓ³n + +### βœ… AJAX Handlers + +- `mock_plugin_check_version`: Check manual de versiΓ³n +- VerificaciΓ³n de nonces y capabilities +- Respuestas JSON formateadas + +## πŸ§ͺ Uso en Tests + +### Cargar el Mock Plugin + +El mock plugin se carga automΓ‘ticamente en el bootstrap de WordPress Test Suite: + +```php +// En tests/bootstrap.php +function _manually_load_plugin() { + require_once __DIR__ . "/fixtures/mock-plugin/mock-plugin.php"; +} +tests_add_filter("muplugins_loaded", "_manually_load_plugin"); +``` + +### Acceder al Updater en Tests + +```php +class MyTest extends WP_UnitTestCase { + public function testUpdater() { + // Obtener instancia del updater + $updater = mock_plugin_get_updater(); + + // Verificar funcionalidad + $this->assertInstanceOf(Updater::class, $updater); + $this->assertEquals("1.0.0", $updater->getCurrentVersion()); + } +} +``` + +### Ejemplo de Test Completo + +Ver `tests/WordPress/MockPluginTest.php` para ejemplos completos de: + +- βœ… Test de inicializaciΓ³n +- βœ… Test de hooks registrados +- βœ… Test de AJAX actions +- βœ… Test de activaciΓ³n/desactivaciΓ³n +- βœ… Test de menu de admin +- βœ… Test de caching con transients + +## πŸ“Š Tests Incluidos + +El mock plugin tiene su propia suite de tests en `tests/WordPress/MockPluginTest.php`: + +| Test | DescripciΓ³n | +|------|-------------| +| `testMockPluginFileExists` | Verifica que el archivo existe | +| `testMockPluginCanBeLoaded` | Verifica que se puede cargar | +| `testUpdaterIsInitialized` | Verifica inicializaciΓ³n del updater | +| `testUpdaterConfiguration` | Verifica configuraciΓ³n correcta | +| `testWordPressHooksAreRegistered` | Verifica hooks de WordPress | +| `testAjaxActionsAreRegistered` | Verifica AJAX actions | +| `testPluginActivation` | Verifica activaciΓ³n del plugin | +| `testPluginDeactivation` | Verifica desactivaciΓ³n del plugin | +| `testAdminMenuIsRegistered` | Verifica menΓΊ de admin | +| `testUpdateCheckWithCaching` | Verifica caching con transients | +| `testPluginDataRetrieval` | Verifica lectura de metadata | +| `testPluginBasename` | Verifica plugin_basename() | +| `testCustomTempDirectoryConfiguration` | Verifica directorio temporal (v1.1.3+) | + +## πŸš€ Ejecutar Tests con WordPress Test Suite + +### 1. Instalar WordPress Test Suite + +```bash +./scripts/install-wp-tests.sh wordpress_test root '' localhost 6.7.1 +``` + +### 2. Ejecutar Tests de WordPress + +```bash +# Todos los tests de WordPress (incluye mock plugin) +./vendor/bin/phpunit --testsuite=wordpress + +# Solo tests del mock plugin +./vendor/bin/phpunit tests/WordPress/MockPluginTest.php + +# Todos los tests (incluye modo WordPress si estΓ‘ instalado) +./vendor/bin/phpunit +``` + +### 3. Verificar Salida + +Cuando WordPress Test Suite estΓ‘ disponible, verΓ‘s: + +``` +==================================== +WP GitHub Updater Test Suite +==================================== +Mode: WordPress Integration Tests +WP Tests Dir: /tmp/wordpress-tests-lib +==================================== +βœ“ Mock plugin loaded: /path/to/tests/fixtures/mock-plugin/mock-plugin.php +``` + +## πŸ” Funcionalidades para Testing + +### Metadata del Plugin + +```php +$pluginData = get_plugin_data($pluginFile); + +// Retorna: +[ + "Name" => "Mock Plugin for WP GitHub Updater Tests", + "Version" => "1.0.0", + "Author" => "SilverAssist", + "RequiresWP" => "6.0", + "RequiresPHP" => "8.2", + // ... +] +``` + +### Acceso Global al Updater + +```php +// Obtener updater desde cualquier parte +$updater = mock_plugin_get_updater(); + +// O desde global +$updater = $GLOBALS["mock_plugin_updater"]; +``` + +### Limpieza de Cache + +```php +// Limpiar cache de versiones +delete_transient("mock-plugin_version_check"); + +// O usar funciΓ³n de activaciΓ³n +do_action("activate_mock-plugin/mock-plugin.php"); +``` + +## πŸ“ ConfiguraciΓ³n + +### Opciones Configurables + +El mock plugin demuestra todas las opciones disponibles: + +```php +[ + "plugin_name" => "Mock Plugin for Testing", + "plugin_description" => "A mock plugin for WP GitHub Updater tests", + "plugin_author" => "SilverAssist", + "cache_duration" => 300, // 5 minutos + "text_domain" => "mock-plugin", + "custom_temp_dir" => WP_CONTENT_DIR . "/uploads/temp", + "ajax_action" => "mock_plugin_check_version", + "ajax_nonce" => "mock_plugin_nonce", + "asset_pattern" => "mock-plugin-{version}.zip", + "requires_wordpress" => "6.0", + "requires_php" => "8.2", +] +``` + +## ⚠️ Notas Importantes + +### No Usar en ProducciΓ³n + +Este plugin es **exclusivamente para testing** y no debe usarse en sitios de producciΓ³n: + +- Usa un repositorio GitHub ficticio (`mock-test-repo`) +- Cache duration muy corta (5 minutos) +- ConfiguraciΓ³n optimizada para testing + +### Repositorio Ficticio + +El plugin apunta a `SilverAssist/mock-test-repo` que puede no existir. Para tests reales de API, deberΓ‘s: + +1. Crear un repositorio de prueba en GitHub +2. Actualizar la configuraciΓ³n en `mock-plugin.php` +3. Crear releases de prueba en ese repositorio + +### Compatibilidad + +- **WordPress**: 6.0+ +- **PHP**: 8.2+ +- **PHPUnit**: 9.6+ +- **WordPress Test Suite**: Requerido para tests completos + +## πŸ”— Referencias + +- [Ejemplo de IntegraciΓ³n](../../../examples/integration-guide.php) +- [DocumentaciΓ³n del Paquete](../../../README.md) +- [Testing Summary](../../../docs/TESTING-SUMMARY.md) +- [WordPress Plugin Unit Tests](https://make.wordpress.org/cli/handbook/misc/plugin-unit-tests/) + +## πŸ“„ Licencia + +MIT - Solo para propΓ³sitos de testing + +--- + +**Última actualizaciΓ³n:** 2025-01-10 +**VersiΓ³n:** 1.0.0 +**Paquete:** silverassist/wp-github-updater v1.1.5 diff --git a/tests/fixtures/mock-plugin/mock-plugin.php b/tests/fixtures/mock-plugin/mock-plugin.php new file mode 100644 index 0000000..eacca2b --- /dev/null +++ b/tests/fixtures/mock-plugin/mock-plugin.php @@ -0,0 +1,213 @@ + "Mock Plugin for Testing", + "plugin_description" => "A mock plugin for WP GitHub Updater tests", + "plugin_author" => "SilverAssist", + + // Optional: Custom cache duration (default: 12 hours) + "cache_duration" => 300, // 5 minutes for testing + + // Optional: Custom text domain for translations + "text_domain" => "mock-plugin", + + // Optional: Custom temporary directory for downloads + "custom_temp_dir" => WP_CONTENT_DIR . "/uploads/temp", + + // Optional: Custom AJAX action names + "ajax_action" => "mock_plugin_check_version", + "ajax_nonce" => "mock_plugin_nonce", + + // Optional: Custom asset pattern for GitHub releases + "asset_pattern" => "mock-plugin-{version}.zip", + + // Optional: WordPress and PHP requirements + "requires_wp" => "6.0", + "requires_php" => "8.2", + "last_updated" => \gmdate("Y-m-d H:i:s"), + ] + ); + + // Initialize the updater + $updater = new Updater($config); + + // Store in global scope for testing access + $GLOBALS["mock_plugin_updater"] = $updater; +} + +// Initialize on plugins_loaded hook +add_action("plugins_loaded", "mock_plugin_init_updater"); + +/** + * Add admin menu for testing + */ +function mock_plugin_admin_menu(): void +{ + add_menu_page( + "Mock Plugin", + "Mock Plugin", + "manage_options", + "mock-plugin", + "mock_plugin_admin_page", + "dashicons-admin-plugins", + 100 + ); +} +add_action("admin_menu", "mock_plugin_admin_menu"); + +/** + * Admin page for testing + */ +function mock_plugin_admin_page(): void +{ + if (!current_user_can("manage_options")) { + return; + } + + $updater = $GLOBALS["mock_plugin_updater"] ?? null; + + ?> +
+

+ +
+

Plugin Information

+ + + + + + + + + + + + + + isUpdateAvailable()) : ?> + + + + + +
Current Version:getCurrentVersion() : "N/A"); ?>
GitHub Repository:getGithubRepo() : "N/A"); ?>
Update Available:isUpdateAvailable() ? "βœ… Yes" : "❌ No"; ?>
Latest Version:getLatestVersion()); ?>
+
+ +
+

Manual Update Check

+

Click the button below to manually check for updates:

+ +
+
+
+ + + = 2) { + // Return last two parts: folder/file.php + return $parts[count($parts) - 2] . "/" . $parts[count($parts) - 1]; + } + + return basename($file); + } +} + +if (!function_exists("get_plugin_data")) { + /** + * Mock get_plugin_data function for tests + * + * @param string $plugin_file Path to the plugin file + * @param bool $markup Whether to apply markup + * @param bool $translate Whether to translate + * @return array Plugin data array + */ + function get_plugin_data(string $plugin_file, bool $markup = true, bool $translate = true): array + { + if (!file_exists($plugin_file)) { + return [ + "Name" => "Test Plugin", + "Version" => "1.0.0", + "Description" => "Test plugin description", + "Author" => "Test Author", + "PluginURI" => "", + "AuthorURI" => "", + "TextDomain" => "test-plugin", + "DomainPath" => "", + "Network" => false, + "RequiresWP" => "", + "RequiresPHP" => "", + ]; + } + + $content = file_get_contents($plugin_file); + $headers = [ + "Name" => "Plugin Name", + "PluginURI" => "Plugin URI", + "Version" => "Version", + "Description" => "Description", + "Author" => "Author", + "AuthorURI" => "Author URI", + "TextDomain" => "Text Domain", + "DomainPath" => "Domain Path", + "Network" => "Network", + "RequiresWP" => "Requires at least", + "RequiresPHP" => "Requires PHP", + ]; + + $data = []; + foreach ($headers as $key => $header) { + if (preg_match("/^[ \t\/*#@]*" . preg_quote($header, "/") . ":(.*)$/mi", $content, $matches)) { + $data[$key] = trim($matches[1]); + } else { + $data[$key] = ""; + } + } + + // Convert Network to boolean + $data["Network"] = strtolower($data["Network"]) === "true"; + + return $data; + } +}