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 '';
+
+ // 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;
+
+ ?>
+
+
+
+
+
+
+
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;
+ }
+}