Compare commits

...

51 commits
v1.0.0 ... main

Author SHA1 Message Date
Miguel Colmenares
2710d1cc57
Merge pull request #6 from SilverAssist/dependabot/github_actions/github-actions-updates-78f35ca48a
chore(deps): bump actions/checkout from 5.0.0 to 5.0.1 in the github-actions-updates group
2025-11-19 23:18:00 -05:00
Miguel Colmenares
b1fc7d3aa1
Merge pull request #7 from SilverAssist/dependabot/github_actions/softprops/action-gh-release-2
chore(deps): bump softprops/action-gh-release from 1 to 2
2025-11-19 23:17:47 -05:00
dependabot[bot]
613543cc3f
chore(deps): bump softprops/action-gh-release from 1 to 2
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 1 to 2.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](de2c0eb89a...5be0e66d93)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: '2'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-20 04:17:26 +00:00
dependabot[bot]
3b60209444
chore(deps): bump actions/checkout in the github-actions-updates group
Bumps the github-actions-updates group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 5.0.0 to 5.0.1
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](08c6903cd8...93cb6efe18)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 5.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-20 04:17:23 +00:00
Miguel Colmenares
0aed1b7a05
Merge pull request #5 from SilverAssist/copilot/update-phpunit-version
Upgrade PHPUnit to 10.x/11.x and pin phpunit-polyfills to 4.x
2025-11-19 23:16:47 -05:00
copilot-swe-agent[bot]
c6d55a2231 Add PHPUnit version policy documentation across config files
Co-authored-by: miguelcolmenares <1714344+miguelcolmenares@users.noreply.github.com>
2025-11-20 04:15:10 +00:00
copilot-swe-agent[bot]
5ec7742b8d Pin yoast/phpunit-polyfills to ^4.0 for WordPress compatibility
Co-authored-by: miguelcolmenares <1714344+miguelcolmenares@users.noreply.github.com>
2025-11-20 04:11:52 +00:00
copilot-swe-agent[bot]
d6823737da Upgrade PHPUnit to 10.x/11.x and pin phpunit-polyfills to 4.x
Co-authored-by: miguelcolmenares <1714344+miguelcolmenares@users.noreply.github.com>
2025-11-20 04:04:31 +00:00
copilot-swe-agent[bot]
5e06cd37c0 Initial plan 2025-11-20 03:57:58 +00:00
Miguel Colmenares
df2d661825
Merge pull request #4 from SilverAssist/dependabot/composer/yoast/phpunit-polyfills-tw-2.0or-tw-4.0
chore(deps-dev): update yoast/phpunit-polyfills requirement from ^2.0 to ^2.0 || ^4.0
2025-11-14 12:35:59 -05:00
Miguel Colmenares
6ba7cdea18
Merge pull request #3 from SilverAssist/dependabot/github_actions/softprops/action-gh-release-de2c0eb89ae2a093876385947365aca7b0e5f844
chore(deps): bump softprops/action-gh-release from 26994186c0ac3ef5cae75ac16aa32e8153525f77 to de2c0eb89ae2a093876385947365aca7b0e5f844
2025-11-14 12:35:27 -05:00
dependabot[bot]
d3a75e9dd5
chore(deps-dev): update yoast/phpunit-polyfills requirement || ^4.0
Updates the requirements on [yoast/phpunit-polyfills](https://github.com/Yoast/PHPUnit-Polyfills) to permit the latest version.
- [Release notes](https://github.com/Yoast/PHPUnit-Polyfills/releases)
- [Changelog](https://github.com/Yoast/PHPUnit-Polyfills/blob/4.x/CHANGELOG.md)
- [Commits](https://github.com/Yoast/PHPUnit-Polyfills/compare/2.0.0...4.0.0)

---
updated-dependencies:
- dependency-name: yoast/phpunit-polyfills
  dependency-version: 4.0.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-14 16:07:34 +00:00
dependabot[bot]
0dd55d3b64
chore(deps): bump softprops/action-gh-release
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 26994186c0ac3ef5cae75ac16aa32e8153525f77 to de2c0eb89ae2a093876385947365aca7b0e5f844.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](26994186c0...de2c0eb89a)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: de2c0eb89ae2a093876385947365aca7b0e5f844
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-14 16:06:59 +00:00
Miguel Colmenares
012ee32202
Merge pull request #2 from SilverAssist/copilot/pin-github-actions-shas
Pin GitHub Actions to commit SHAs with Dependabot automation
2025-11-14 11:06:14 -05:00
copilot-swe-agent[bot]
7c5ac1516f Update shivammathur/setup-php to valid commit SHA v2.35.5
Replace invalid tag object SHA with actual commit SHA:
- Old: c1ab9f1d9659ca19e46e951b81596e83154238ad (tag object)
- New: bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f (v2.35.5)

This SHA corresponds to setup-php v2.35.5 (September 2025), which is the actual commit that the v2 tag points to. The previous SHA was the annotated tag object itself, not the commit it points to.

Co-authored-by: miguelcolmenares <1714344+miguelcolmenares@users.noreply.github.com>
2025-11-14 16:02:20 +00:00
copilot-swe-agent[bot]
5f86517b71 🔒 Pin all GitHub Actions to specific commit SHAs
Co-authored-by: miguelcolmenares <1714344+miguelcolmenares@users.noreply.github.com>
2025-11-14 05:40:24 +00:00
copilot-swe-agent[bot]
a6ec354462 Initial plan 2025-11-14 05:34:22 +00:00
Miguel Colmenares
b3e84d6999
Merge pull request #1 from SilverAssist/copilot/add-explicit-permissions-workflows
Verify GitHub Actions workflow permissions compliance
2025-11-14 00:33:22 -05:00
copilot-swe-agent[bot]
85338a97e6 Initial plan 2025-11-14 05:22:39 +00:00
Miguel Colmenares
811e8a6ea3
Remove CodeQL workflow to recreate via GitHub UI advanced setup 2025-11-14 00:13:30 -05:00
Miguel Colmenares
b6e0c10b4c
Update CodeQL workflow for analysis configuration 2025-11-13 23:58:51 -05:00
Miguel Colmenares
4e064581ff
Potential fix for code scanning alert no. 1: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-11-11 22:23:49 -05:00
Miguel Colmenares
7faeb7cd2f fix: Exclude test files from WordPress.Security checks
- Test and bootstrap files don't need output escaping
- Focus security checks on production code only
2025-10-11 23:12:01 -05:00
Miguel Colmenares
532b4ff589 style: Fix coding standards violations
- Remove trailing whitespace
- Fix file header spacing
- Fix control structure spacing
- Auto-fixed with phpcbf
2025-10-11 23:09:36 -05:00
Miguel Colmenares
0067b4d9f5 fix: Install Subversion for WordPress Test Suite
- Add subversion package installation step
- Required for downloading WordPress Test Suite from SVN
2025-10-11 23:05:22 -05:00
Miguel Colmenares
8c1c129c9e feat: Add WordPress Test Suite to CI workflow
- Install MySQL database for WordPress tests
- Configure WordPress Test Suite before running tests
- Add mysqli extension to PHP setup
- Enable full test suite including WordPress integration tests
- Ensure critical real-scenario tests run in CI
2025-10-11 23:03:11 -05:00
Miguel Colmenares
42c9116ccb fix: Update GitHub Actions workflow to use PHP 8.2
- Change php-version from 8.1 to 8.2 to match new minimum requirement
- Fixes workflow failures due to PHP version mismatch
2025-10-11 22:55:37 -05:00
Miguel Colmenares
6dc9280a9e chore: Bump version to 1.2.1
- Lower minimum PHP version requirement from 8.3 to 8.2
- Update composer.json version to 1.2.1
- Update @version tags in PHP source files
- Update CHANGELOG.md with v1.2.1 release notes
2025-10-11 22:51:44 -05:00
Miguel Colmenares
f9809f3d5a chore: Change PHP version requirement from 8.3 to 8.2
- Update composer.json to require PHP >=8.2
- Update all documentation (README.md, copilot-instructions.md)
- Update GitHub Actions workflow (create-release.yml)
- Update source code default value in UpdaterConfig.php
- Update all test fixtures and test expectations
- All 51 tests passing successfully
2025-10-11 22:50:38 -05:00
Miguel Colmenares
9eb29aa047 chore: bump version to 1.2.0
Release v1.2.0 includes:

- Complete test suite: 51 tests, 130 assertions, 100% pass rate
- Real GitHub API integration tests (9 tests)
- WordPress Test Suite integration with mock plugin
- Documentation consolidation (3 core files only)
- Test runner script with interactive installation
- Performance verification (< 10ms cached API calls)

Test Coverage:
- Unit Tests (3): Configuration and core functionality
- Integration Tests (22): Real GitHub API, download filters
- WordPress Tests (26): Hooks, filters, plugin integration

Breaking: Requires PHP 8.0+, WordPress 6.0+, PHPUnit 9.6.29
2025-10-10 17:03:52 -05:00
Miguel Colmenares
8934970acd refactor: move scripts from bin/ to scripts/ directory
- Move install-wp-tests.sh from bin/ to scripts/
- Move test-runner.sh from bin/ to scripts/
- Update all references in README.md
- Update all references in test-runner.sh
- Update all references in tests/fixtures/mock-plugin/README.md
- Remove bin/ directory

This aligns with the existing scripts directory structure that already
contains update-version-simple.sh.
2025-10-10 17:02:47 -05:00
Miguel Colmenares
1caafb51df feat: Complete test suite implementation with real GitHub API testing
- Add comprehensive test suite: 51 tests, 130 assertions, 100% pass rate
- Implement real GitHub API integration tests (9 tests)
- Add WordPress Test Suite integration with mock plugin
- Create test runner script with interactive installation
- Consolidate documentation into README, CHANGELOG, and copilot-instructions

Test Coverage:
- Unit Tests (3): Configuration and core functionality
- Integration Tests (22): Real GitHub API, download filters, config integration
- WordPress Tests (26): Hooks, filters, and plugin integration

Testing Infrastructure:
- WordPress Test Suite bootstrap with automatic detection
- Mock plugin for realistic WordPress environment testing
- Real HTTP requests to production GitHub repositories
- Performance verification (< 10ms cached API calls)

Documentation:
- Centralized all docs into 3 files (README, CHANGELOG, copilot-instructions)
- Removed docs/ directory (7 MD files consolidated)
- Added testing guide to README Development section
- Updated copilot-instructions with documentation policy

Real GitHub API Testing:
- Repository: SilverAssist/silver-assist-post-revalidate
- Tests version fetching, caching, update detection
- Validates asset patterns and release metadata
- Confirms production repository compatibility

Breaking: Requires PHP 8.0+, WordPress 6.0+, PHPUnit 9.6.29
2025-10-10 17:00:18 -05:00
Miguel Colmenares
1fb5c2cdab fix: resolve PCLZIP_ERR_MISSING_FILE (-4) error with complete download filter rewrite
- Complete rewrite of upgrader_pre_download filter to properly handle all download scenarios
- Fixed critical issue where filter could return invalid types causing WordPress PCLZIP errors
- Added robust plugin detection to ensure filter only intercepts downloads for correct plugin
- Enhanced error handling with comprehensive error messages for all failure points
- Added multiple validation checks (file size, readability, existence) before returning file

Changes:
- Stricter filter logic: maybeFixDownload() now returns false for non-target downloads
- Safety checks: Added verification of hook_extra data for specific plugin processing
- Return type enforcement: Strict string|WP_Error|false (never true or other types)
- Multi-line conditionals: Improved code formatting for WordPress Coding Standards
- Defensive programming: Added early returns for edge cases
- Minimum file size check: Validates downloaded file is at least 100 bytes

Technical improvements:
- Enhanced PHPDoc comments explaining critical return value requirements
- Better HTTP response code handling with descriptive error messages
- Improved download process with optimized settings
- Added use WP_Error statement for proper type hinting

Version: 1.1.5
2025-10-10 12:46:25 -05:00
Miguel Colmenares
8c7402be31 Bump version to 1.1.4
- Add WordPress admin notices for manual version checks
- Add dismissible update notifications with AJAX functionality
- Improve manual version check workflow with admin integration
- Refactor code to use centralized isUpdateAvailable() method
- Fix transient cache consistency for notices and version cache
- Enhance user experience with immediate admin feedback
- Update documentation with new API methods and examples
2025-08-30 00:04:30 -05:00
Miguel Colmenares
fd5a2b905f Update README.md with comprehensive testing and troubleshooting documentation
### Documentation Enhancements
- **Testing Configuration**: Added detailed test environment setup and PCLZIP error testing scenarios
- **Troubleshooting Section**: Complete guide for resolving PCLZIP_ERR_MISSING_FILE (-4) errors
- **Configuration Options**: Added custom_temp_dir option (v1.1.3) to configuration table
- **Multi-Tier Fallback System**: Documented the 6-tier temporary file creation strategy

### New Sections Added
- **Testing PCLZIP Error Scenarios**: How to configure and test custom temporary directories
- **WordPress Configuration for Testing**: wp-config.php setup for test environments
- **PCLZIP Error Solutions**: Three different approaches to resolve temporary file issues
- **Fallback System Documentation**: Detailed explanation of automatic fallback strategies

### Enhanced Developer Experience
- Step-by-step troubleshooting guides
- Code examples for different configuration scenarios
- Clear explanations of v1.1.3 temporary file improvements
- Better integration examples for plugins experiencing PCLZIP errors

This update provides comprehensive documentation for developers integrating the package
and troubleshooting common issues, especially the PCLZIP_ERR_MISSING_FILE error.
2025-08-29 23:25:39 -05:00
Miguel Colmenares
88d5c01009 Fix PHPCS line length violation in Updater.php
- Split long error message across multiple lines to comply with 120 character limit
- Maintains readability while following WordPress coding standards
- Resolves CI/CD pipeline PHPCS failure
2025-08-29 11:29:25 -05:00
Miguel Colmenares
d7bf5d9124 Bump version to 1.1.3 - Enhanced Temporary File Handling and PCLZIP Error Resolution
### 🔧 Major Improvements
- **PCLZIP Error Resolution**: Implemented multi-tier fallback system for temporary file creation to resolve PCLZIP_ERR_MISSING_FILE (-4) errors
- **Custom Temporary Directory Support**: Added custom_temp_dir configuration option for specifying alternative temporary directories
- **Enhanced Download Reliability**: Improved maybeFixDownload() method with comprehensive error handling and file verification

### 🚀 New Features
- **6-Tier Fallback Strategy**: Custom dir → Uploads dir → WP_CONTENT_DIR/temp → WP_TEMP_DIR → System temp → Manual creation
- **Automatic Directory Creation**: The updater now creates temporary directories if they don't exist
- **File Verification**: Added byte-level verification and readability checks after download
- **UpdaterConfig Enhancement**: New customTempDir property for flexible temporary file management

### 📖 Documentation Updates
- **Copilot Instructions**: Updated with new temporary file management strategy and configuration options
- **Integration Examples**: Added comprehensive examples for handling PCLZIP errors
- **WordPress Configuration**: Documented wp-config.php approach for custom temporary directories
- **CHANGELOG**: Detailed documentation of all enhancements and troubleshooting strategies

### 🛠️ Technical Details
- Enhanced createSecureTempFile() method with multiple fallback strategies
- Improved error messages and user guidance for temporary directory issues
- Better handling of directory permission problems during plugin updates
- Comprehensive file write verification to ensure complete downloads

This release addresses the persistent PCLZIP_ERR_MISSING_FILE (-4) error that occurs
when the system /tmp directory has restrictive permissions or is inaccessible.
2025-08-29 11:24:45 -05:00
Miguel Colmenares
8501855e8e Bump version to 1.1.2 - Make getLatestVersion() method public
- **API Accessibility**: Changed getLatestVersion() method visibility from private to public
- **External Integration**: Enables access from consuming plugins like Silver Assist Security Essentials
- **Version Update**: Bump version from 1.1.1 to 1.1.2 across all files
- **Backward Compatibility**: No breaking changes, only improved API accessibility
- **Documentation**: Updated CHANGELOG.md with method visibility change details

This change allows external classes to access the getLatestVersion() method,
resolving integration issues with consuming plugins that need to check
for available updates programmatically.
2025-08-19 18:04:22 -05:00
Miguel Colmenares
e0a60e545e Bump version to 1.1.1 and migrate to PolyForm Noncommercial License
- **Version Update**: Bump version from 1.1.0 to 1.1.1 across all files
- **License Migration**: Updated from GPL v2.0+ to PolyForm Noncommercial 1.0.0
- **Updated Files**:
  - composer.json: version and license identifier
  - LICENSE.md: complete license text replacement
  - README.md: license badge and documentation updates
  - src/Updater.php & src/UpdaterConfig.php: @license and @version tags
  - GitHub Actions workflow: license references
  - Copilot instructions: license information
- **Added Tools**:
  - scripts/update-version-simple.sh: automated version management script
  - .gitattributes: exclude development files from Composer distribution
- **CHANGELOG**: Convert [Unreleased] section to [1.1.1] with license changes
2025-08-14 21:25:53 -05:00
Miguel Colmenares
54c2cf2d2f Fix PHPCS violations in Updater.php
- Add proper sanitization for $_POST['nonce'] with sanitize_text_field() and wp_unslash()
- Fix line length violations by breaking long lines appropriately
- Improve method signature formatting for better readability
- Ensure WordPress security standards compliance
2025-08-12 23:12:28 -05:00
Miguel Colmenares
f31cc17129 Fix GitHub Actions workflow: include phpunit.xml and correct PHPUnit syntax
- Remove phpunit.xml from .gitignore to make it available in CI
- Add phpunit.xml to repository for consistent test configuration
- Fix PHPUnit command syntax (remove --verbose, use --configuration=)
- Improve debugging output in workflow
- Ensure test configuration is available during CI execution
2025-08-12 23:08:34 -05:00
Miguel Colmenares
8ba1d84b49 Integrate GitHub Actions workflow into v1.1.0 release
- Add automated release creation workflow
- Include PHPUnit and PHPCS validation in CI
- Add automated release notes generation
- Revert version back to 1.1.0 (consolidating features)
- Update CHANGELOG to include workflow features in v1.1.0
- Improve PHPUnit execution with explicit configuration
2025-08-12 23:05:41 -05:00
Miguel Colmenares
524e246be8 Fix GitHub Actions workflow - include dev dependencies for testing
- Remove --no-dev flag from composer install to include PHPUnit and PHPCS
- Update version to 1.1.1 in composer.json
- Add changelog entry for v1.1.1 with GitHub Actions workflow
- Fix: ./vendor/bin/phpunit: No such file or directory error
2025-08-12 23:01:24 -05:00
Miguel Colmenares
6bd8a57cc1 Add GitHub Actions workflow for automated releases
- Automated release creation when tags are pushed
- Manual workflow dispatch option for flexible releases
- Package validation and structure checks
- Automated test execution with PHPUnit
- Code quality validation with PHPCS
- Dynamic release notes generation from CHANGELOG.md
- Comprehensive release documentation
- GitHub Actions summary with package info
- No ZIP generation (Composer package only)
- Supports v1.1.0+ with i18n features
2025-08-12 22:58:49 -05:00
Miguel Colmenares
a6320c9862 Release v1.1.0: Add configurable i18n support and comprehensive improvements
- Add configurable text domain support for internationalization flexibility
- Implement translation wrapper methods (__() and esc_html__())
- Make all user-facing messages translatable including changelog and errors
- Add centralized HTTP header management with getApiHeaders() and getDownloadHeaders()
- Implement comprehensive PHP coding standards with phpcs.xml
- Standardize all strings to use double quotes consistently
- Add full PHP 8+ type hint coverage with union types
- Enhance PHPDoc documentation throughout codebase
- Maintain backward compatibility with automatic wp-github-updater fallback
- Add WordPress and PSR-12 coding standards enforcement
- Update User-Agent headers to version 1.1.0
- Add comprehensive test coverage for new features
- Improve security compliance and input validation
2025-08-12 22:49:47 -05:00
Miguel Colmenares
71f35e156d v1.0.2: Centralized HTTP header management and enhanced documentation
- Added centralized getApiHeaders() and getDownloadHeaders() methods
- Refactored HTTP request configurations to eliminate code duplication
- Enhanced PHPDoc documentation with comprehensive descriptions
- Improved GitHub API request reliability through consistent headers
- Updated User-Agent headers to include version information
2025-08-12 21:42:37 -05:00
Miguel Colmenares
7b55426090 Fix PCLZIP_ERR_MISSING_FILE (-4) error with improved download handling and GitHub API asset URL resolution 2025-08-12 21:33:41 -05:00
Miguel Colmenares
fb9146fb10 Update security reporting instructions and add a separator before the footer 2025-08-12 10:44:58 -05:00
Miguel Colmenares
a447ece4f1 Remove unnecessary whitespace in UpdaterConfigTest.php 2025-08-07 19:18:15 -05:00
Miguel Colmenares
67d0a21470 Complete PHPDoc documentation with @since annotations for all class properties and methods 2025-08-07 19:17:46 -05:00
Miguel Colmenares
fddc78b6d3 Add .gitattributes to exclude development files from Packagist archives 2025-08-07 16:15:34 -05:00
31 changed files with 5074 additions and 277 deletions

11
.gitattributes vendored Normal file
View file

@ -0,0 +1,11 @@
# Files and directories to exclude from Packagist archives
/.git export-ignore
/.github export-ignore
/.gitattributes export-ignore
/.gitignore export-ignore
/phpunit.xml export-ignore
/phpcs.xml export-ignore
/phpstan.neon export-ignore
/tests export-ignore
/scripts export-ignore
/composer.lock export-ignore

View file

@ -1,30 +1,431 @@
<!-- Copilot Instructions for GitHub Updater Composer Package -->
# Copilot Instructions for WP GitHub Updater Package

- [x] Verify that the copilot-instructions.md file in the .github directory is created.
## 📋 Documentation Policy

- [x] Clarify Project Requirements
<!-- Creating a Composer package for WordPress plugin GitHub updater functionality -->
**CRITICAL: ALL documentation must be consolidated in these three files ONLY:**

- [x] Scaffold the Project
<!-- ✅ Created Composer package structure with PSR-4 autoloading, main classes, tests, and configuration files -->
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

- [x] Customize the Project
<!-- ✅ Implemented reusable UpdaterConfig and Updater classes with full WordPress integration -->
**🚫 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

- [x] Install Required Extensions
<!-- ✅ No specific extensions needed for this PHP Composer package -->
**✅ 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

- [x] Compile the Project
<!-- ⚠️ Composer dependencies install partially succeeded - network/API rate limit issues with GitHub -->
**Rationale:** Centralized documentation is easier to maintain, search, and keep in sync with code changes.

- [x] Create and Run Task
<!-- ✅ No tasks needed for this library package -->
---

- [x] Launch the Project
<!-- ✅ No launch needed for library package -->
## Architecture Overview

- [x] Ensure Documentation is Complete
<!-- ✅ Completed comprehensive README.md, created integration examples, and documented the package -->
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.

## Project: WordPress GitHub Updater Composer Package
✅ **COMPLETED** - A reusable Composer package that handles automatic updates from public GitHub releases for WordPress plugins.
## Core Technologies

- **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

```
wp-github-updater/
├── .github/
│ └── copilot-instructions.md
├── src/
│ ├── Updater.php # Main updater class
│ └── UpdaterConfig.php # Configuration management
├── tests/
│ └── UpdaterConfigTest.php # Unit tests
├── examples/
│ └── integration-guide.php # Integration examples
├── composer.json # Package definition
├── README.md # Documentation
├── CHANGELOG.md # Version history
├── LICENSE.md # PolyForm Noncommercial 1.0.0 license
├── phpcs.xml # Code standards
├── phpstan.neon # Static analysis
└── phpunit.xml # Test configuration
```

## Component Architecture

### UpdaterConfig Class
**Purpose**: Configuration management for the updater functionality
**Key Responsibilities**:
- Store plugin metadata (name, version, file path, etc.)
- GitHub repository configuration
- 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
**Key Responsibilities**:
- WordPress update system hooks integration
- GitHub API communication with caching
- Plugin update checking and information display
- Download error handling and recovery
- 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
- **Capability checks** for update operations
- **Input sanitization** using WordPress functions
- **Output escaping** for all displayed content
- **WordPress transients** for secure caching

## Configuration & Settings

### Internationalization Strategy
Since this is a **reusable Composer package**, internationalization should be handled by the **consuming plugin**, not the package itself. The package should:

1. **NO hardcoded text domain** - Allow consuming plugin to pass text domain
2. **Accept text domain parameter** in UpdaterConfig constructor
3. **Pass text domain to all i18n functions** from consuming plugin
4. **Provide translation function wrappers** that use the passed text domain

```php
// ✅ CORRECT - Package approach
class UpdaterConfig {
private string $textDomain;
public function __construct(array $options) {
$this->textDomain = $options["text_domain"] ?? "wp-github-updater";
}
private function translate(string $text): string {
return \__($text, $this->textDomain);
}
}

// ❌ INCORRECT - Fixed text domain in package
\__("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
- **PSR-4 Autoloading**: Proper namespace organization (`SilverAssist\WpGithubUpdater`)
- **Type Declarations**: Use PHP 8+ strict typing everywhere
- **Error Handling**: Comprehensive error handling with WordPress WP_Error
- **Security First**: Nonce verification and capability checks

### WordPress Integration

- **Hooks & Filters**: Use appropriate WordPress hooks for update functionality
- **Transient Caching**: Leverage WordPress transients for GitHub API caching
- **Plugin API**: Integrate with WordPress plugin update system
- **HTTP API**: Use wp_remote_get() for GitHub API communication
- **File System**: Use WordPress file system functions for downloads

### Security Best Practices

- **Input Validation**: Sanitize all user inputs using WordPress functions
- **Output Escaping**: Escape all outputs using appropriate WordPress functions
- **Nonce Verification**: Use WordPress nonces for AJAX security
- **Capability Checks**: Verify user permissions before update operations
- **HTTP Security**: Proper User-Agent headers and timeout handling

### Release Process

- **Version Bumping**: Update version in composer.json and CHANGELOG.md
- **Git Tagging**: Create semantic version tags (v1.0.0, v1.1.0, etc.)
- **Documentation**: Update README and integration examples
- **Packagist**: Automatic distribution via Packagist on tag push

### Core Structure

```php
namespace SilverAssist\WpGithubUpdater;

class Updater {
private UpdaterConfig $config;
private string $pluginSlug;
private string $currentVersion;
// ... other properties

public function __construct(UpdaterConfig $config) {
// Initialize updater with configuration
}

public function checkForUpdate($transient) {
// 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
}
```

## 🚨 CRITICAL CODING STANDARDS - MANDATORY COMPLIANCE

### String Quotation Standards
- **MANDATORY**: ALL strings in PHP MUST use double quotes: `"string"`
- **i18n Functions**: ALL WordPress i18n functions MUST use double quotes: `__("Text", $textDomain)`
- **FORBIDDEN**: Single quotes for strings: `'string'` or `__('text', 'domain')`
- **Exception**: Only use single quotes inside double-quoted strings when necessary
- **SQL Queries**: Use double quotes for string literals in SQL: `WHERE option_value = "1"`

### Documentation Requirements
- **PHP**: Complete PHPDoc documentation for ALL classes, methods, and properties
- **@since tags**: Required for all public APIs with version numbers
- **English only**: All documentation must be in English for international collaboration
- **Package context**: Document as library package, not standalone plugin

### WordPress i18n Standards for Packages
- **NO fixed text domain**: Accept text domain from consuming plugin
- **Translation wrapper methods**: Provide internal translation methods
- **Consuming plugin responsibility**: Let the consuming plugin handle actual translations
- **Flexible configuration**: Allow text domain to be passed in configuration

#### Text Domain Configuration Example
```php
class UpdaterConfig {
private string $textDomain;
public function __construct(array $options) {
$this->textDomain = $options["text_domain"] ?? "wp-github-updater";
}
private function __($text): string {
return \__($text, $this->textDomain);
}
private function esc_html__($text): string {
return \esc_html__($text, $this->textDomain);
}
}
```

## Modern PHP 8+ Conventions

### Type Declarations
- **Strict typing**: All methods use parameter and return type declarations
- **Nullable types**: Use `?Type` for optional returns (e.g., `?string`)
- **Property types**: All class properties have explicit types
- **Union types**: Use `string|false` for methods that can return string or false

### PHP Coding Standards
- **Double quotes for all strings**: `"string"` not `'string'` - MANDATORY
- **String interpolation**: Use `"prefix_{$variable}"` instead of concatenation
- **Short array syntax**: `[]` not `array()`
- **Namespaces**: Use descriptive namespace `SilverAssist\WpGithubUpdater`
- **WordPress hooks**: `\add_action("init", [$this, "method"])` with array callbacks
- **PHP 8+ Features**: Match expressions, array spread operator, typed properties

### Function Prefix Usage - MANDATORY COMPLIANCE

**🚨 CRITICAL RULE: Use `\` prefix for ALL WordPress functions in namespaced context, but NOT for PHP native functions**

```php
// ✅ CORRECT - WordPress functions REQUIRE \ prefix in namespaced context
\add_action("init", [$this, "method"]);
\add_filter("pre_set_site_transient_update_plugins", [$this, "checkForUpdate"]);
\get_option("option_name", "default");
\wp_remote_get($url, $args);
\get_transient($key);
\set_transient($key, $value, $expiration);
\plugin_basename($file);
\get_plugin_data($file);

// ✅ CORRECT - WordPress i18n functions REQUIRE \ prefix
\__("Text to translate", $this->textDomain);
\esc_html__("Text to translate", $this->textDomain);
\wp_kses_post($content);

// ✅ CORRECT - PHP native functions do NOT need \ prefix
array_key_exists($key, $array);
json_decode($response, true);
version_compare($version1, $version2, "<");
preg_replace("/pattern/", "replacement", $string);
basename($path);
fwrite($handle, $data);

// ❌ INCORRECT - Missing \ prefix for WordPress functions
add_action("init", [$this, "method"]);
get_transient($key);
wp_remote_get($url);

// ❌ INCORRECT - Don't use \ with PHP native functions
\json_decode($response);
\version_compare($v1, $v2);
\basename($path);
```

### Package-Specific Function Categories

#### **WordPress Functions (ALL need `\` prefix):**
- **Update System**: `\add_filter("pre_set_site_transient_update_plugins")`, `\add_filter("plugins_api")`
- **Plugin Functions**: `\plugin_basename()`, `\get_plugin_data()`, `\plugin_dir_path()`
- **HTTP API**: `\wp_remote_get()`, `\wp_remote_retrieve_body()`, `\is_wp_error()`
- **Transients**: `\get_transient()`, `\set_transient()`, `\delete_transient()`
- **Security**: `\wp_verify_nonce()`, `\current_user_can()`, `\wp_kses_post()`
- **File System**: `\wp_upload_dir()`, `\wp_tempnam()`, `\wp_filesystem()`

#### **PHP Native Functions (NO `\` prefix needed):**
- **Array Functions**: `array_key_exists()`, `array_merge()`, `count()`
- **String Functions**: `basename()`, `dirname()`, `pathinfo()`, `trim()`
- **JSON Functions**: `json_decode()`, `json_encode()`
- **Version Functions**: `version_compare()`
- **File Functions**: `fopen()`, `fwrite()`, `fclose()`
- **Regex Functions**: `preg_replace()`, `preg_match()`

## Development Workflow

### Integration Pattern
The package is designed to be integrated into WordPress plugins like this:

```php
// In the consuming plugin
use SilverAssist\WpGithubUpdater\UpdaterConfig;
use SilverAssist\WpGithubUpdater\Updater;

$config = new UpdaterConfig([
"plugin_file" => __FILE__,
"plugin_slug" => "my-plugin/my-plugin.php",
"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
]);

$updater = new Updater($config);
```

### Error Handling Strategy
- Use WordPress `WP_Error` class for error management
- 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)
- Update composer.json version field
- Create git tags for each release
- Update CHANGELOG.md with detailed release notes
- Maintain backward compatibility within major versions

## Translation Support Strategy

Since this is a **reusable package**, translation should be handled by the **consuming plugin**:

1. **Package provides**: English text strings and translation function wrappers
2. **Consuming plugin provides**: Text domain and handles actual translation loading
3. **Configuration based**: Text domain passed via UpdaterConfig constructor
4. **Fallback domain**: Default to "wp-github-updater" if no text domain provided

This approach ensures the package can be integrated into any plugin while respecting the consuming plugin's translation strategy and text domain conventions.

50
.github/dependabot.yml vendored Normal file
View file

@ -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"

314
.github/workflows/create-release.yml vendored Normal file
View file

@ -0,0 +1,314 @@
name: Create Release

on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
version:
description: "Version number (e.g., 1.1.0)"
required: true
default: "1.1.0"

jobs:
build-and-release:
permissions:
contents: write
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1

- name: Setup PHP
uses: shivammathur/setup-php@bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f # v2.35.5
with:
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: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="${{ github.event.inputs.version }}"
else
VERSION=${GITHUB_REF#refs/tags/v}
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "tag=v$VERSION" >> $GITHUB_OUTPUT

- name: Validate package structure
run: |
VERSION="${{ steps.version.outputs.version }}"
echo "🔍 Validating WP GitHub Updater package structure..."
# Check core files exist
if [ ! -f "src/Updater.php" ]; then
echo "❌ Missing core file: src/Updater.php"
exit 1
fi
if [ ! -f "src/UpdaterConfig.php" ]; then
echo "❌ Missing core file: src/UpdaterConfig.php"
exit 1
fi
if [ ! -f "composer.json" ]; then
echo "❌ Missing composer.json"
exit 1
fi
# Verify version consistency in composer.json
COMPOSER_VERSION=$(grep '"version"' composer.json | head -n1 | sed 's/.*"version": *"\([^"]*\)".*/\1/')
if [ "$COMPOSER_VERSION" != "$VERSION" ]; then
echo "⚠️ Warning: Version in composer.json ($COMPOSER_VERSION) doesn't match tag ($VERSION)"
else
echo "✅ Version consistency verified: $VERSION"
fi
echo "✅ Package structure validation completed"

- name: Run tests
run: |
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 (including WordPress integration tests)"

- name: Run code quality checks
run: |
echo "🔍 Running PHP CodeSniffer..."
./vendor/bin/phpcs --standard=phpcs.xml src/ tests/
echo "✅ Code quality checks passed"

- name: Generate release notes from CHANGELOG
id: changelog
run: |
VERSION="${{ steps.version.outputs.version }}"
echo "📝 Generating release notes from CHANGELOG.md..."

# Extract current version changes from CHANGELOG.md
if [ -f "CHANGELOG.md" ]; then
echo "Looking for version $VERSION in CHANGELOG.md"
# Method 1: Using sed with escaped brackets (most reliable)
CHANGES=$(sed -n "/## \\[${VERSION}\\]/,/## \\[/p" CHANGELOG.md | sed '$d' | tail -n +2)
if [ -n "$CHANGES" ]; then
echo "Found changes with sed method"
echo "## What's New in v$VERSION" > release_notes.md
echo "$CHANGES" >> release_notes.md
else
echo "Sed method failed, trying awk method"
# Method 2: Using awk (more reliable across different systems)
CHANGES_AWK=$(awk "/^## \\[${VERSION}\\]/{flag=1; next} /^## \\[/{flag=0} flag" CHANGELOG.md)
if [ -n "$CHANGES_AWK" ]; then
echo "Found changes with awk method"
echo "## What's New in v$VERSION" > release_notes.md
echo "$CHANGES_AWK" >> release_notes.md
else
echo "Using fallback release notes"
echo "## What's New in v$VERSION" > release_notes.md
echo "See [CHANGELOG.md](CHANGELOG.md) for detailed changes." >> release_notes.md
fi
fi
else
echo "CHANGELOG.md not found, creating default release notes"
echo "## Release v$VERSION" > release_notes.md
echo "New release of WP GitHub Updater package" >> release_notes.md
fi

# Add package information to release notes
cat >> release_notes.md << EOF

## 📦 Package Information
- **Package Name**: wp-github-updater
- **Version**: $VERSION
- **Namespace**: SilverAssist\\WpGithubUpdater
- **License**: PolyForm Noncommercial 1.0.0
- **PHP Version**: 8.2+
- **WordPress Version**: 6.0+

## 🚀 Installation via Composer
\`\`\`bash
composer require silverassist/wp-github-updater:^$VERSION
\`\`\`

## 📋 Basic Usage
\`\`\`php
use SilverAssist\\WpGithubUpdater\\UpdaterConfig;
use SilverAssist\\WpGithubUpdater\\Updater;

// Configure the updater
\$config = new UpdaterConfig(__FILE__, 'owner/repository', [
'text_domain' => 'my-plugin-textdomain', // v1.1.0+ feature
]);

// Initialize the updater
\$updater = new Updater(\$config);
\`\`\`

## ✨ Key Features
- 🔄 Automatic WordPress plugin updates from GitHub releases
- 🌍 Configurable internationalization (i18n) support
- 📋 WordPress admin integration with update notifications
- 🔒 Secure GitHub API communication with proper error handling
- ⚡ Efficient caching system for version checks
- 🧪 Comprehensive PHPUnit test suite
- 📝 PSR-12 + WordPress coding standards compliance
- 🎯 Modern PHP 8+ architecture with strict typing

## 📚 Documentation
- **README**: [Installation and usage guide](README.md)
- **CHANGELOG**: [Complete version history](CHANGELOG.md)
- **Examples**: [Integration examples](examples/)
- **API Docs**: Comprehensive PHPDoc documentation

## 🔧 Requirements
- PHP 8.2 or higher
- WordPress 6.0 or higher
- Composer for package management
- GitHub repository with releases for updates

## 🐛 Issues & Support
Found a bug or need help? Please [open an issue](https://github.com/SilverAssist/wp-github-updater/issues) on GitHub.
EOF

- name: Update RELEASE-NOTES.md
run: |
VERSION="${{ steps.version.outputs.version }}"
DATE=$(date +"%B %d, %Y")

# Create updated RELEASE-NOTES.md
cat > RELEASE-NOTES.md << EOF
# WP GitHub Updater - Release v$VERSION

## Package Information
- **Package Name**: wp-github-updater
- **Version**: $VERSION
- **Release Date**: $DATE
- **License**: PolyForm Noncommercial 1.0.0
- **Repository**: https://github.com/SilverAssist/wp-github-updater

## Package Contents
- Core updater classes (\`src/Updater.php\`, \`src/UpdaterConfig.php\`)
- Comprehensive test suite (\`tests/\`)
- Integration examples (\`examples/\`)
- Documentation ([README.md](README.md), [CHANGELOG.md](CHANGELOG.md))
- Coding standards configuration (\`phpcs.xml\`)
- PHPUnit configuration (\`phpunit.xml\`)
- Composer package definition (\`composer.json\`)

## Installation via Composer
\`\`\`bash
composer require silverassist/wp-github-updater:^$VERSION
\`\`\`

## Requirements
- PHP 8.2+
- WordPress 6.0+
- Composer
- GitHub repository with releases

## Key Features
- Automatic WordPress plugin updates from GitHub releases
- Configurable internationalization (i18n) support
- WordPress admin dashboard integration
- Secure GitHub API communication
- Efficient version caching system
- Modern PHP 8+ architecture with strict typing
- Comprehensive test coverage
- PSR-12 + WordPress coding standards

## Usage Example
\`\`\`php
use SilverAssist\\WpGithubUpdater\\UpdaterConfig;
use SilverAssist\\WpGithubUpdater\\Updater;

// Configure the updater
\$config = new UpdaterConfig(__FILE__, 'owner/repository', [
'text_domain' => 'my-plugin-textdomain',
]);

// Initialize the updater
\$updater = new Updater(\$config);
\`\`\`

## Support & Documentation
- **Installation Guide**: [README.md](README.md)
- **Change History**: [CHANGELOG.md](CHANGELOG.md)
- **Integration Examples**: [examples/](examples/)
- **Issues**: [GitHub Issues](https://github.com/SilverAssist/wp-github-updater/issues)

## Distribution
- **Packagist**: https://packagist.org/packages/silverassist/wp-github-updater
- **GitHub Releases**: https://github.com/SilverAssist/wp-github-updater/releases
EOF

- name: Create GitHub Release
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 }}"
body_path: release_notes.md
draft: false
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Summary
run: |
echo "## 🚀 Release Summary" >> $GITHUB_STEP_SUMMARY
echo "- **Package**: WP GitHub Updater" >> $GITHUB_STEP_SUMMARY
echo "- **Version**: ${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY
echo "- **Composer**: \`silverassist/wp-github-updater:^${{ steps.version.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY
echo "- **Release**: [View Release](https://github.com/${{ github.repository }}/releases/tag/${{ steps.version.outputs.tag }})" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### ✨ Package Features" >> $GITHUB_STEP_SUMMARY
echo "- 🔄 Automatic WordPress plugin updates from GitHub" >> $GITHUB_STEP_SUMMARY
echo "- 🌍 Configurable internationalization support" >> $GITHUB_STEP_SUMMARY
echo "- 📋 WordPress admin dashboard integration" >> $GITHUB_STEP_SUMMARY
echo "- 🔒 Secure GitHub API communication" >> $GITHUB_STEP_SUMMARY
echo "- ⚡ Efficient caching system" >> $GITHUB_STEP_SUMMARY
echo "- 🎯 Modern PHP 8+ architecture" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 📦 Installation" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY
echo "composer require silverassist/wp-github-updater:^${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY

2
.gitignore vendored
View file

@ -3,7 +3,7 @@
composer.lock

# PHPUnit
/phpunit.xml
# /phpunit.xml # Keep phpunit.xml for CI/CD workflow
/.phpunit.result.cache

# Code coverage

View file

@ -1,5 +1,164 @@
# 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
- **License References**: Updated all references in composer.json, README.md, source files, and GitHub Actions workflow
- **License Documentation**: Updated license badges and documentation to reflect noncommercial licensing

## [1.1.0] - 2025-08-12
### Added
- **Configurable text domain support**: New `text_domain` option in `UpdaterConfig` constructor for internationalization flexibility
- **Translation wrapper methods**: Added `__()` and `esc_html__()` methods for package-aware translations
- **Translatable user-facing messages**: All changelog and error messages now support internationalization
- **Centralized HTTP header management**: Added `getApiHeaders()` and `getDownloadHeaders()` methods for consistent API communication
- **Comprehensive PHP coding standards**: Implemented phpcs.xml with WordPress and PSR-12 standards enforcement
- **String quotation standardization**: All strings now consistently use double quotes as per project standards
- **Enhanced type declarations**: Full PHP 8+ type hint coverage with union types and nullable parameters
- **Enhanced PHPDoc documentation**: Comprehensive descriptions and `@since` annotations throughout codebase
- **GitHub Actions workflow**: Automated release creation when tags are pushed
- **Automated testing in CI**: PHPUnit and PHPCS validation in release pipeline
- **Release documentation**: Automated generation of release notes from CHANGELOG.md
- **Package validation**: Automated structure validation and version consistency checks

### Changed
- **Improved internationalization architecture**: Text domain now configurable per consuming plugin instead of hardcoded
- **Centralized translation system**: All user-facing strings now use configurable text domain with fallback support
- **Refactored HTTP request configurations**: Eliminated code duplication through centralized header management patterns
- **Code quality enforcement**: Added automated coding standards checking with phpcs and WordPress security rules
- **Documentation standards**: Enhanced PHPDoc blocks with complete parameter and return type documentation
- **Updated User-Agent headers**: Now include version information (WP-GitHub-Updater/1.1.0)

### Fixed
- **Backward compatibility**: Existing code without `text_domain` specification continues working with `wp-github-updater` fallback
- **String consistency**: Eliminated mixed quote usage throughout codebase for improved maintainability
- **Security compliance**: Enhanced input sanitization and output escaping validation
- **GitHub API request reliability**: Improved through consistent header usage
- **Download stability**: Optimized headers for GitHub asset downloads

### Technical Improvements
- Updated User-Agent headers to version 1.1.0
- Added Composer dev dependencies: `wp-coding-standards/wpcs` and `slevomat/coding-standard`
- Implemented comprehensive test coverage for new translation features
- Enhanced error handling with proper WordPress i18n integration
- Improved code maintainability through centralized header management patterns

## [1.0.1] - 2025-08-07
### Added
- Markdown to HTML parser for changelog display
- Support for headers (#, ##, ###, ####), bold text (**text**), italic text (*text*), inline code (`code`), lists (- item), and links ([text](url))
- Improved changelog readability in WordPress plugin update modal

### Changed
- Enhanced changelog formatting from raw markdown to formatted HTML

## [1.0.0] - 2025-08-07
### Added


View file

@ -1,61 +1,25 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Polyform Noncommercial License 1.0.0

Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Acceptance
In order to receive this license, you must agree to its rules. The rules of this license are both obligations under that agreement and conditions to your license. You must not do anything with the licensed material that triggers a rule you cannot or will not follow.

For details, see https://www.gnu.org/licenses/old-licenses/gpl-2.0.html
Copyright License
The licensor grants you a copyright license for the licensed material to do everything you might do with the licensed material that would otherwise infringe the licensors copyright in it, for any noncommercial purpose, for the duration of the license, and in all territories. This license does not grant you rights to use the licensed material for commercial purposes.

This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
Commercial Purposes
Commercial purposes means use of the licensed material for a purpose intended for or directed toward commercial advantage or monetary compensation.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
Distribution and Modification
You may distribute copies of the licensed material, in whole or in part, in any medium, and you may create and distribute modified versions of the licensed material, provided that you (a) do not use the licensed material for commercial purposes, and (b) include a complete copy of this license with your distribution.

You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
Patent License
The licensor grants you a patent license for the licensed material to make, have made, use, sell, offer for sale, and import the licensed material for any noncommercial purpose, for the duration of the license, and in all territories. This license does not grant you rights to use the licensed material for commercial purposes.

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
No Other Rights
These terms do not grant you any other rights in the licensed material, and all other rights are reserved by the licensor.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Termination
If you use the licensed material in violation of this license, such use will automatically terminate your rights under this license. However, if you comply with this license after such violation, your rights will be reinstated unless the licensor has terminated this license by giving you written notice.

Copyright (c) 2025 Silver Assist

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
No Liability
As far as the law allows, the licensed material comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages related to this license or use of the licensed material, under any kind of legal claim.

227
README.md
View file

@ -1,7 +1,7 @@
# WordPress GitHub Updater

[![Latest Version on Packagist](https://img.shields.io/packagist/v/silverassist/wp-github-updater.svg?style=flat-square)](https://packagist.org/packages/silverassist/wp-github-updater)
[![Software License](https://img.shields.io/badge/license-GPLv2-blue.svg?style=flat-square)](LICENSE.md)
[![Software License](https://img.shields.io/badge/license-PolyForm--Noncommercial--1.0.0-blue.svg?style=flat-square)](LICENSE.md)
[![Total Downloads](https://img.shields.io/packagist/dt/silverassist/wp-github-updater.svg?style=flat-square)](https://packagist.org/packages/silverassist/wp-github-updater)

A reusable WordPress plugin updater that handles automatic updates from public GitHub releases. Perfect for WordPress plugins distributed outside the official repository.
@ -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,11 +85,25 @@ $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)

Starting with version 1.1.0, you can specify a custom text domain for your plugin's translations:

```php
$config = new UpdaterConfig(__FILE__, "username/repository", [
"text_domain" => "my-plugin-textdomain" // Use your plugin's text domain
]);
```

**Backward Compatibility**: Existing code without the `text_domain` option will continue to work using the default `wp-github-updater` text domain.

## GitHub Release Requirements

@ -140,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
@ -172,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
@ -206,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
@ -216,7 +420,7 @@ composer check

## License

This package is licensed under the GNU General Public License v2.0 or later (GPL-2.0-or-later). Please see [License File](LICENSE.md) for more information.
This package is licensed under the Polyform Noncommercial License 1.0.0 (PolyForm-Noncommercial-1.0.0). This license allows for non-commercial use, modification, and distribution. Please see [License File](LICENSE.md) for more information.

## Credits

@ -229,10 +433,13 @@ Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed re

## Security

If you discover any security related issues, please email security@silverassist.com instead of using the issue tracker.
If you discover any security related issues, please report them via [GitHub Issues](https://github.com/SilverAssist/wp-github-updater/issues) with the "security" label.

## Support

- **Documentation**: This README and code comments
- **Issues**: [GitHub Issues](https://github.com/SilverAssist/wp-github-updater/issues)
- **Discussions**: [GitHub Discussions](https://github.com/SilverAssist/wp-github-updater/discussions)

---

**Made with ❤️ by [Silver Assist](https://silverassist.com)**

View file

@ -1,6 +1,7 @@
{
"name": "silverassist/wp-github-updater",
"description": "A reusable WordPress plugin updater that handles automatic updates from public GitHub releases",
"version": "1.2.1",
"type": "library",
"keywords": [
"wordpress",
@ -10,7 +11,7 @@
"auto-update"
],
"homepage": "https://github.com/SilverAssist/wp-github-updater",
"license": "GPL-2.0-or-later",
"license": "PolyForm-Noncommercial-1.0.0",
"authors": [
{
"name": "Silver Assist",
@ -19,12 +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"
"phpstan/phpstan": "^1.10",
"wp-coding-standards/wpcs": "^3.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": {
@ -38,8 +46,11 @@
},
"scripts": {
"test": "phpunit",
"phpcs": "phpcs --standard=PSR12 src/ tests/",
"phpcbf": "phpcbf --standard=PSR12 src/ tests/",
"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",
"check": [
"@phpcs",
@ -49,7 +60,10 @@
},
"config": {
"sort-packages": true,
"optimize-autoloader": true
"optimize-autoloader": true,
"allow-plugins": {
"dealerdirect/phpcodesniffer-composer-installer": true
}
},
"minimum-stability": "stable",
"prefer-stable": true

View file

@ -22,18 +22,27 @@ use SilverAssist\WpGithubUpdater\Updater;
use SilverAssist\WpGithubUpdater\UpdaterConfig;

// Initialize updater (replaces your existing Updater class instantiation)
add_action('init', function() {
add_action("init", function() {
$config = new UpdaterConfig(
__FILE__, // Path to your main plugin file
'SilverAssist/silver-assist-security', // Your GitHub repo
"SilverAssist/silver-assist-security", // Your GitHub repo
[
'asset_pattern' => 'silver-assist-security-v{version}.zip',
'ajax_action' => 'silver_assist_security_check_version',
'ajax_nonce' => 'silver_assist_security_ajax'
"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
"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);
}
});
*/

@ -50,18 +59,27 @@ require_once __DIR__ . '/vendor/autoload.php';
use SilverAssist\WpGithubUpdater\Updater;
use SilverAssist\WpGithubUpdater\UpdaterConfig;

add_action('init', function() {
add_action("init", function() {
$config = new UpdaterConfig(
__FILE__,
'your-username/leadgen-app-form', // Your GitHub repo
"your-username/leadgen-app-form", // Your GitHub repo
[
'asset_pattern' => 'leadgen-app-form-v{version}.zip',
'ajax_action' => 'leadgen_check_version',
'ajax_nonce' => 'leadgen_version_check'
"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
"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();
}
});
});
*/

@ -89,24 +107,87 @@ use SilverAssist\WpGithubUpdater\Updater;
use SilverAssist\WpGithubUpdater\UpdaterConfig;

// Initialize the updater
add_action('init', function() {
add_action("init", function() {
$config = new UpdaterConfig(
__FILE__,
'your-username/my-new-plugin', // GitHub repository
"your-username/my-new-plugin", // GitHub repository
[
// Optional customizations
'asset_pattern' => 'my-plugin-{version}.zip',
'requires_php' => '8.1',
'requires_wordpress' => '6.2',
'ajax_action' => 'my_plugin_version_check',
'cache_duration' => 6 * 3600 // 6 hours
"asset_pattern" => "my-plugin-{version}.zip",
"requires_php" => "8.1",
"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
"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 '<div class="notice notice-info">';
echo '<p>My Plugin: Update available from ' . esc_html($currentVersion) . ' to ' . esc_html($latestVersion) . '</p>';
echo '</div>';
}
});
});

// 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
*/

/**
@ -118,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
*/

View file

@ -1,5 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpcs>
<!--
WP GitHub Updater - PHP Coding Standards Configuration
This configuration enforces the coding standards defined in our
project instructions, specifically:
🚨 MANDATORY STANDARDS:
- ALL strings MUST use double quotes: "string" not 'string'
- WordPress security best practices (escaping, sanitization)
- Text domain validation for i18n functions
- PSR-12 compliance for modern PHP 8+ conventions
- Proper documentation standards for public APIs
Package-specific allowances:
- PSR-4 naming conventions (not WordPress file naming)
- Namespace usage instead of global prefixes
- String interpolation with variables in double quotes
- Reasonable line length limits (120/160 characters)
-->
<arg name="basepath" value="."/>
<arg name="cache" value=".phpcs-cache"/>
<arg name="colors"/>
@ -14,6 +33,74 @@
<file>src</file>
<file>tests</file>

<!-- Include the whole PSR12 standard -->
<!-- Base PSR12 standard -->
<rule ref="PSR12"/>

<!-- 🚨 CRITICAL RULE: Enforce double quotes for strings -->
<!-- This is our MANDATORY string quotation standard -->
<rule ref="Squiz.Strings.DoubleQuoteUsage">
<exclude name="Squiz.Strings.DoubleQuoteUsage.NotRequired"/>
<!-- Allow variables in double quotes (string interpolation) -->
<exclude name="Squiz.Strings.DoubleQuoteUsage.ContainsVar"/>
</rule>

<!-- WordPress Security (Critical for WordPress integration) -->
<rule ref="WordPress.Security.EscapeOutput"/>
<rule ref="WordPress.Security.ValidatedSanitizedInput"/>
<!-- Exclude WordPress.Security checks from test files -->
<rule ref="WordPress.Security">
<exclude-pattern>tests/bootstrap.php</exclude-pattern>
<exclude-pattern>tests/fixtures/*</exclude-pattern>
</rule>

<!-- WordPress i18n (Text domain validation) -->
<rule ref="WordPress.WP.I18n">
<properties>
<property name="text_domain" type="array">
<element value="wp-github-updater"/>
</property>
</properties>
<!-- Allow dynamic text domains from configuration -->
<exclude name="WordPress.WP.I18n.NonSingularStringLiteralText"/>
<exclude name="WordPress.WP.I18n.NonSingularStringLiteralDomain"/>
</rule>

<!-- Basic Documentation (without overly strict requirements) -->
<rule ref="Squiz.Commenting.ClassComment">
<exclude name="Squiz.Commenting.ClassComment.TagNotAllowed"/>
<!-- Allow test classes without doc comments -->
<exclude-pattern>tests/*</exclude-pattern>
</rule>
<!-- Allow WordPress-style method naming for i18n wrappers -->
<rule ref="PSR1.Methods.CamelCapsMethodName">
<exclude name="PSR1.Methods.CamelCapsMethodName.NotCamelCaps"/>
<type>warning</type> <!-- Make this a warning instead of error -->
</rule>
<!-- Allow flexibility for package-specific conventions -->
<rule ref="WordPress.Files.FileName">
<severity>0</severity> <!-- PSR-4 naming -->
</rule>
<rule ref="WordPress.NamingConventions.PrefixAllGlobals">
<severity>0</severity> <!-- Namespaced package -->
</rule>

<rule ref="PSR1.Files.SideEffects">
<severity>0</severity> <!-- Test files can have side effects -->
</rule>

<!-- Allow reasonable line lengths for readability -->
<rule ref="Generic.Files.LineLength">
<properties>
<property name="lineLimit" value="120"/>
<property name="absoluteLineLimit" value="160"/>
</properties>
</rule>

<!-- Exclude external dependencies -->
<exclude-pattern>vendor/*</exclude-pattern>
<exclude-pattern>*.min.*</exclude-pattern>
</phpcs>

View file

@ -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#'

37
phpunit.xml Normal file
View file

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.6/phpunit.xsd"
bootstrap="tests/bootstrap.php"
backupGlobals="false"
colors="true"
verbose="true">
<testsuites>
<testsuite name="unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="integration">
<directory>tests/Integration</directory>
</testsuite>
<testsuite name="wordpress">
<directory>tests/WordPress</directory>
</testsuite>
</testsuites>
<coverage>
<include>
<directory suffix=".php">./src</directory>
</include>
<report>
<html outputDirectory="build/coverage"/>
<text outputFile="php://stdout" showUncoveredFiles="false"/>
</report>
</coverage>
<php>
<!-- Environment variable for WordPress Test Suite directory -->
<!-- Set this to enable WordPress integration tests -->
<!-- Example: export WP_TESTS_DIR=/tmp/wordpress-tests-lib -->
<env name="WP_TESTS_DIR" value="" force="false"/>
<!-- WordPress Core directory (auto-detected if not set) -->
<env name="WP_CORE_DIR" value="" force="false"/>
</php>
</phpunit>

255
scripts/install-wp-tests.sh Executable file
View file

@ -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-name> <db-user> <db-pass> [db-host] [wp-version]

if [ $# -lt 3 ]; then
echo ""
echo "❌ ERROR: Missing required arguments"
echo ""
echo "Usage: $0 <db-name> <db-user> <db-pass> [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 " <const name=\"WP_TESTS_DIR\" value=\"$WP_TESTS_DIR\"/>"
echo ""
echo "Now you can run tests with:"
echo " vendor/bin/phpunit --testdox"
echo ""

231
scripts/test-runner.sh Executable file
View file

@ -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-name> <db-user> <db-pass> [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

298
scripts/update-version-simple.sh Executable file
View file

@ -0,0 +1,298 @@
#!/bin/bash

# WP GitHub Updater - Version Update Script
# Updates version numbers across all project files consistently

set -e # Exit on any error

# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color

# Configuration
PROJECT_NAME="WP GitHub Updater"
COMPOSER_FILE="composer.json"
README_FILE="README.md"
CHANGELOG_FILE="CHANGELOG.md"
PHP_SOURCE_DIR="src"

# Function to display usage
usage() {
echo -e "${BLUE}Usage: $0 <version> [--no-confirm]${NC}"
echo ""
echo "Examples:"
echo " $0 1.1.1 # Update to version 1.1.1 with confirmation"
echo " $0 1.2.0 --no-confirm # Update to version 1.2.0 without confirmation (for CI)"
echo ""
echo "This script will update version numbers in:"
echo " - composer.json"
echo " - README.md (if version references exist)"
echo " - CHANGELOG.md (if unreleased section exists)"
exit 1
}

# Function to validate version format
validate_version() {
local version=$1
if [[ ! $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo -e "${RED}Error: Version must be in format X.Y.Z (e.g., 1.2.3)${NC}"
exit 1
fi
}

# Function to get current version from composer.json
get_current_version() {
if [[ -f "$COMPOSER_FILE" ]]; then
grep '"version"' "$COMPOSER_FILE" | head -n1 | sed 's/.*"version": *"\([^"]*\)".*/\1/'
else
echo "unknown"
fi
}

# Function to update composer.json
update_composer_version() {
local new_version=$1
if [[ -f "$COMPOSER_FILE" ]]; then
echo -e "${YELLOW}Updating $COMPOSER_FILE...${NC}"
# Create backup
cp "$COMPOSER_FILE" "${COMPOSER_FILE}.backup"
# Update version using sed
sed -i.tmp "s/\"version\": *\"[^\"]*\"/\"version\": \"$new_version\"/" "$COMPOSER_FILE"
rm "${COMPOSER_FILE}.tmp"
echo -e "${GREEN}✅ Updated composer.json version to $new_version${NC}"
else
echo -e "${RED}❌ composer.json not found${NC}"
exit 1
fi
}

# Function to update CHANGELOG.md if it has unreleased section
update_changelog_if_unreleased() {
local new_version=$1
local current_date=$(date +"%Y-%m-%d")
if [[ -f "$CHANGELOG_FILE" ]]; then
# Check if there's an [Unreleased] section
if grep -q "## \[Unreleased\]" "$CHANGELOG_FILE"; then
echo -e "${YELLOW}Updating CHANGELOG.md [Unreleased] section...${NC}"
# Create backup
cp "$CHANGELOG_FILE" "${CHANGELOG_FILE}.backup"
# Replace [Unreleased] with the new version and date
sed -i.tmp "s/## \[Unreleased\]/## [$new_version] - $current_date/" "$CHANGELOG_FILE"
rm "${CHANGELOG_FILE}.tmp"
echo -e "${GREEN}✅ Updated CHANGELOG.md [Unreleased] to [$new_version] - $current_date${NC}"
else
echo -e "${BLUE} No [Unreleased] section found in CHANGELOG.md, skipping${NC}"
fi
else
echo -e "${BLUE} CHANGELOG.md not found, skipping${NC}"
fi
}

# Function to update PHP source files
update_php_files() {
local new_version=$1
if [[ -d "$PHP_SOURCE_DIR" ]]; then
echo -e "${YELLOW}Updating PHP source files in $PHP_SOURCE_DIR/...${NC}"
# Find all PHP files in the source directory
local php_files
php_files=$(find "$PHP_SOURCE_DIR" -name "*.php" -type f)
if [[ -n "$php_files" ]]; then
while IFS= read -r file; do
if [[ -f "$file" ]]; then
# Create backup
cp "$file" "${file}.backup"
# Update @version tags in PHP files
if grep -q "@version" "$file"; then
sed -i.tmp "s/@version [0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*/@version $new_version/" "$file"
rm "${file}.tmp"
echo -e "${GREEN} ✅ Updated @version in $(basename "$file")${NC}"
fi
# Update @since tags for the current version (if they exist)
if grep -q "@since $new_version" "$file"; then
echo -e "${BLUE} @since $new_version already present in $(basename "$file")${NC}"
fi
# Update version constants (if they exist)
if grep -q "VERSION.*=" "$file"; then
sed -i.tmp "s/VERSION.*=.*['\"][0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*['\"];/VERSION = \"$new_version\";/" "$file"
rm "${file}.tmp"
echo -e "${GREEN} ✅ Updated VERSION constant in $(basename "$file")${NC}"
fi
fi
done <<< "$php_files"
else
echo -e "${BLUE} No PHP files found in $PHP_SOURCE_DIR${NC}"
fi
else
echo -e "${BLUE} Directory $PHP_SOURCE_DIR not found, skipping PHP files update${NC}"
fi
}

# Function to show what will be updated
show_changes_preview() {
local current_version=$1
local new_version=$2
echo -e "${BLUE}========================================${NC}"
echo -e "${BLUE}$PROJECT_NAME - Version Update Preview${NC}"
echo -e "${BLUE}========================================${NC}"
echo ""
echo -e "Current version: ${YELLOW}$current_version${NC}"
echo -e "New version: ${GREEN}$new_version${NC}"
echo ""
echo "Files that will be updated:"
if [[ -f "$COMPOSER_FILE" ]]; then
echo -e " ${GREEN}✓${NC} $COMPOSER_FILE"
fi
if [[ -f "$CHANGELOG_FILE" ]] && grep -q "## \[Unreleased\]" "$CHANGELOG_FILE"; then
echo -e " ${GREEN}✓${NC} $CHANGELOG_FILE (convert [Unreleased] to [$new_version])"
else
echo -e " ${YELLOW}⚠${NC} $CHANGELOG_FILE (no [Unreleased] section found)"
fi
# Show PHP files that will be updated
if [[ -d "$PHP_SOURCE_DIR" ]]; then
local php_files
php_files=$(find "$PHP_SOURCE_DIR" -name "*.php" -type f)
if [[ -n "$php_files" ]]; then
echo -e " ${GREEN}✓${NC} PHP files in $PHP_SOURCE_DIR/:"
while IFS= read -r file; do
echo -e " - $(basename "$file")"
done <<< "$php_files"
fi
fi
echo ""
}

# Function to confirm changes
confirm_changes() {
local skip_confirm=$1
if [[ "$skip_confirm" != "true" ]]; then
echo -e "${YELLOW}Do you want to proceed with these changes? (y/N): ${NC}"
read -r response
if [[ ! "$response" =~ ^[Yy]$ ]]; then
echo -e "${RED}Update cancelled.${NC}"
exit 0
fi
fi
}

# Function to restore backups on error
cleanup_on_error() {
echo -e "${RED}An error occurred. Restoring backups...${NC}"
if [[ -f "${COMPOSER_FILE}.backup" ]]; then
mv "${COMPOSER_FILE}.backup" "$COMPOSER_FILE"
echo -e "${GREEN}Restored composer.json${NC}"
fi
if [[ -f "${CHANGELOG_FILE}.backup" ]]; then
mv "${CHANGELOG_FILE}.backup" "$CHANGELOG_FILE"
echo -e "${GREEN}Restored CHANGELOG.md${NC}"
fi
# Restore PHP file backups
if [[ -d "$PHP_SOURCE_DIR" ]]; then
local php_backups
php_backups=$(find "$PHP_SOURCE_DIR" -name "*.php.backup" -type f 2>/dev/null || true)
if [[ -n "$php_backups" ]]; then
while IFS= read -r backup_file; do
if [[ -f "$backup_file" ]]; then
local original_file="${backup_file%.backup}"
mv "$backup_file" "$original_file"
echo -e "${GREEN}Restored $(basename "$original_file")${NC}"
fi
done <<< "$php_backups"
fi
fi
exit 1
}

# Function to clean up successful backups
cleanup_backups() {
rm -f "${COMPOSER_FILE}.backup"
rm -f "${CHANGELOG_FILE}.backup"
# Clean up PHP file backups
if [[ -d "$PHP_SOURCE_DIR" ]]; then
find "$PHP_SOURCE_DIR" -name "*.php.backup" -type f -delete 2>/dev/null || true
fi
}

# Main script
main() {
local new_version=$1
local no_confirm=$2
# Check if version is provided
if [[ -z "$new_version" ]]; then
usage
fi
# Validate version format
validate_version "$new_version"
# Get current version
local current_version
current_version=$(get_current_version)
# Set up error handler
trap cleanup_on_error ERR
# Show preview
show_changes_preview "$current_version" "$new_version"
# Confirm changes
confirm_changes "$no_confirm"
# Update files
echo -e "${BLUE}Updating version to $new_version...${NC}"
echo ""
update_composer_version "$new_version"
update_changelog_if_unreleased "$new_version"
update_php_files "$new_version"
# Clean up backups on success
cleanup_backups
echo ""
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN}✅ Version update completed successfully!${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
echo -e "${BLUE}Next steps:${NC}"
echo "1. Review the changes: git diff"
echo "2. Commit the changes: git add . && git commit -m \"Bump version to $new_version\""
echo "3. Create a tag: git tag -a v$new_version -m \"Release v$new_version\""
echo "4. Push changes: git push origin main --tags"
echo ""
}

# Parse arguments
if [[ "$2" == "--no-confirm" ]]; then
main "$1" "true"
else
main "$1" "false"
fi

View file

@ -2,55 +2,88 @@

/**
* WordPress GitHub Updater
*
*
* A reusable WordPress plugin updater that handles automatic updates from public GitHub releases.
*
* @package SilverAssist\WpGithubUpdater
* @author Silver Assist
* @license GPL-2.0-or-later
* @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
*
* This class integrates with WordPress update system to provide automatic
* updates from GitHub releases. It handles version checking, plugin information
* display, and the actual update process.
*
* @package SilverAssist\WpGithubUpdater
* @since 1.0.0
*/
class Updater
{
/**
* Updater configuration
*
* @var UpdaterConfig Configuration object with all updater settings
* @since 1.0.0
*/
private UpdaterConfig $config;

/**
* Plugin slug (folder/file.php)
*
* @var string WordPress plugin slug identifier
* @since 1.0.0
*/
private string $pluginSlug;

/**
* Plugin basename (folder name only)
*
* @var string Plugin directory name without file extension
* @since 1.0.0
*/
private string $pluginBasename;

/**
* Current plugin version
*
* @var string Current version of the plugin being updated
* @since 1.0.0
*/
private string $currentVersion;

/**
* Plugin data from header
*
* @var array Plugin metadata extracted from plugin file header
* @since 1.0.0
*/
private array $pluginData;

/**
* Transient name for version cache
*
* @var string WordPress transient key for caching version information
* @since 1.0.0
*/
private string $versionTransient;

/**
* Initialize the updater
*
* @param UpdaterConfig $config Updater configuration
* Sets up plugin identification, version information and WordPress hooks.
*
* @param UpdaterConfig $config Updater configuration object
*
* @since 1.0.0
*/
public function __construct(UpdaterConfig $config)
{
@ -61,29 +94,48 @@ class Updater

// Get plugin data
$this->pluginData = $this->getPluginData();
$this->currentVersion = $this->pluginData['Version'] ?? '1.0.0';
$this->currentVersion = $this->pluginData["Version"] ?? "1.0.0";

$this->initHooks();
}

/**
* Initialize WordPress hooks
*
* Sets up filters and actions needed for WordPress update system integration.
*
*
* @since 1.0.0
*/
private function initHooks(): void
{
\add_filter('pre_set_site_transient_update_plugins', [$this, 'checkForUpdate']);
\add_filter('plugins_api', [$this, 'pluginInfo'], 20, 3);
\add_action('upgrader_process_complete', [$this, 'clearVersionCache'], 10, 2);
\add_filter("pre_set_site_transient_update_plugins", [$this, "checkForUpdate"]);
\add_filter("plugins_api", [$this, "pluginInfo"], 20, 3);
\add_action("upgrader_process_complete", [$this, "clearVersionCache"], 10, 2);

// Improve download reliability
\add_filter("upgrader_pre_download", [$this, "maybeFixDownload"], 10, 4);

// Add AJAX action for manual version check
\add_action("wp_ajax_{$this->config->ajaxAction}", [$this, 'manualVersionCheck']);
\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"]);
}

/**
* Check for plugin updates
*
* @param mixed $transient The update_plugins transient
* @return mixed
* Compares the current plugin version with the latest GitHub release
* and adds update information to the WordPress update transient if needed.
*
* @param mixed $transient The update_plugins transient containing current plugin versions
* @return mixed The modified transient with update information added if available
*
* @since 1.0.0
*/
public function checkForUpdate($transient)
{
@ -93,16 +145,16 @@ 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,
'new_version' => $latestVersion,
'url' => $this->config->pluginHomepage,
'package' => $this->getDownloadUrl($latestVersion),
'tested' => \get_bloginfo('version'),
'requires_php' => $this->config->requiresPHP,
'compatibility' => new \stdClass(),
"slug" => $this->pluginBasename,
"plugin" => $this->pluginSlug,
"new_version" => $latestVersion,
"url" => $this->config->pluginHomepage,
"package" => $this->getDownloadUrl($latestVersion),
"tested" => \get_bloginfo("version"),
"requires_php" => $this->config->requiresPHP,
"compatibility" => new \stdClass(),
];
}

@ -112,14 +164,19 @@ class Updater
/**
* Get plugin information for the update API
*
* Provides detailed plugin information when WordPress requests it,
* including version, changelog, and download information.
*
* @param false|object|array $result The result object or array
* @param string $action The type of information being requested
* @param object $args Plugin API arguments
* @return false|object|array
* @param string $action The type of information being requested
* @param object $args Plugin API arguments
* @return false|object|array Plugin information object or original result
*
* @since 1.0.0
*/
public function pluginInfo($result, string $action, object $args)
public function pluginInfo(false|object|array $result, string $action, object $args): false|object|array
{
if ($action !== 'plugin_information' || $args->slug !== $this->pluginBasename) {
if ($action !== "plugin_information" || $args->slug !== $this->pluginBasename) {
return $result;
}

@ -127,31 +184,35 @@ class Updater
$changelog = $this->getChangelog();

return (object) [
'slug' => $this->pluginBasename,
'plugin' => $this->pluginSlug,
'version' => $latestVersion ?: $this->currentVersion,
'author' => $this->config->pluginAuthor,
'author_profile' => $this->config->pluginHomepage,
'requires' => $this->config->requiresWordPress,
'tested' => \get_bloginfo('version'),
'requires_php' => $this->config->requiresPHP,
'name' => $this->config->pluginName,
'homepage' => $this->config->pluginHomepage,
'sections' => [
'description' => $this->config->pluginDescription,
'changelog' => $changelog,
"slug" => $this->pluginBasename,
"plugin" => $this->pluginSlug,
"version" => $latestVersion ?: $this->currentVersion,
"author" => $this->config->pluginAuthor,
"author_profile" => $this->config->pluginHomepage,
"requires" => $this->config->requiresWordPress,
"tested" => \get_bloginfo("version"),
"requires_php" => $this->config->requiresPHP,
"name" => $this->config->pluginName,
"homepage" => $this->config->pluginHomepage,
"sections" => [
"description" => $this->config->pluginDescription,
"changelog" => $changelog,
],
'download_link' => $this->getDownloadUrl($latestVersion),
'last_updated' => $this->getLastUpdated(),
"download_link" => $this->getDownloadUrl($latestVersion),
"last_updated" => $this->getLastUpdated(),
];
}

/**
* Get the latest version from GitHub releases
* Get latest version from GitHub
*
* @return string|false
* Fetches the latest release version from GitHub API with caching support.
*
* @return string|false Latest version string or false if failed
*
* @since 1.0.0
*/
public function getLatestVersion()
public function getLatestVersion(): string|false
{
// Check cache first
$cachedVersion = \get_transient($this->versionTransient);
@ -161,11 +222,8 @@ class Updater

$apiUrl = "https://api.github.com/repos/{$this->config->githubRepo}/releases/latest";
$response = \wp_remote_get($apiUrl, [
'timeout' => 15,
'headers' => [
'Accept' => 'application/vnd.github.v3+json',
'User-Agent' => 'WordPress/' . \get_bloginfo('version'),
],
"timeout" => 15,
"headers" => $this->getApiHeaders(),
]);

if (\is_wp_error($response) || 200 !== \wp_remote_retrieve_response_code($response)) {
@ -176,11 +234,11 @@ class Updater
$body = \wp_remote_retrieve_body($response);
$data = json_decode($body, true);

if (!isset($data['tag_name'])) {
if (!isset($data["tag_name"])) {
return false;
}

$version = ltrim($data['tag_name'], 'v');
$version = ltrim($data["tag_name"], "v");

// Cache the version
\set_transient($this->versionTransient, $version, $this->config->cacheDuration);
@ -192,13 +250,21 @@ class Updater
* Get download URL for a specific version
*
* @param string $version The version to download
* @return string
*
* @since 1.0.0
*/
private function getDownloadUrl(string $version): string
{
// First try to get the actual download URL from the release assets
$downloadUrl = $this->getAssetDownloadUrl($version);
if ($downloadUrl) {
return $downloadUrl;
}

// Fallback to constructed URL
$pattern = $this->config->assetPattern;
$filename = str_replace(
['{slug}', '{version}'],
["{slug}", "{version}"],
[$this->pluginBasename, $version],
$pattern
);
@ -206,86 +272,134 @@ class Updater
return "https://github.com/{$this->config->githubRepo}/releases/download/v{$version}/{$filename}";
}

/**
* Get actual asset download URL from GitHub API
*
* @param string $version The version to get asset URL for
* @return string|null Asset download URL or null if not found
*
* @since 1.1.0
*/
private function getAssetDownloadUrl(string $version): ?string
{
$apiUrl = "https://api.github.com/repos/{$this->config->githubRepo}/releases/tags/v{$version}";

$response = \wp_remote_get($apiUrl, [
"timeout" => 10,
"headers" => $this->getApiHeaders(),
]);

if (\is_wp_error($response) || 200 !== \wp_remote_retrieve_response_code($response)) {
return null;
}

$body = \wp_remote_retrieve_body($response);
$data = json_decode($body, true);

if (!isset($data["assets"]) || empty($data["assets"])) {
return null;
}

// Look for the ZIP asset
foreach ($data["assets"] as $asset) {
if (str_ends_with($asset["name"], ".zip")) {
return $asset["browser_download_url"];
}
}

return null;
}

/**
* Get changelog from GitHub releases
*
* @return string
* Fetches release notes from GitHub API and formats them as HTML.
*
* @return string Formatted changelog HTML
*
* @since 1.0.0
*/
private function getChangelog(): string
{
$apiUrl = "https://api.github.com/repos/{$this->config->githubRepo}/releases";
$response = \wp_remote_get($apiUrl, [
'timeout' => 15,
'headers' => [
'Accept' => 'application/vnd.github.v3+json',
'User-Agent' => 'WordPress/' . \get_bloginfo('version'),
],
"timeout" => 15,
"headers" => $this->getApiHeaders(),
]);

if (\is_wp_error($response) || 200 !== \wp_remote_retrieve_response_code($response)) {
return "Unable to fetch changelog. Visit the <a href=\"https://github.com/{$this->config->githubRepo}/releases\">GitHub releases page</a> for updates.";
$github_link = "<a href=\"https://github.com/{$this->config->githubRepo}/releases\">"
. $this->config->__("GitHub releases page") . "</a>";
return sprintf(
$this->config->__("Unable to fetch changelog. Visit the %s for updates."),
$github_link
);
}

$body = \wp_remote_retrieve_body($response);
$releases = json_decode($body, true);

if (!is_array($releases)) {
return 'Unable to parse changelog.';
return $this->config->__("Unable to parse changelog.");
}

$changelog = '';
$changelog = "";
foreach (array_slice($releases, 0, 5) as $release) { // Show last 5 releases
$version = ltrim($release['tag_name'], 'v');
$date = date('Y-m-d', strtotime($release['published_at']));
$body = $release['body'] ?: 'No release notes provided.';
$version = ltrim($release["tag_name"], "v");
$date = date("Y-m-d", strtotime($release["published_at"]));
$body = $release["body"] ?: $this->config->__("No release notes provided.");

$changelog .= "<h4>Version {$version} ({$date})</h4>\n";
$changelog .= "<div>" . \wp_kses_post($body) . "</div>\n\n";
$changelog .= sprintf(
"<h4>%s</h4>\n",
sprintf($this->config->__("Version %1\$s (%2\$s)"), $version, $date)
);
$changelog .= "<div>" . \wp_kses_post($this->parseMarkdownToHtml($body)) . "</div>\n\n";
}

return $changelog ?: 'No changelog available.';
return $changelog ?: $this->config->__("No changelog available.");
}

/**
* Get last updated date
*
* @return string
* Fetches the publication date of the latest release from GitHub API.
*
* @return string Last updated date in Y-m-d format
*
* @since 1.0.0
*/
private function getLastUpdated(): string
{
$apiUrl = "https://api.github.com/repos/{$this->config->githubRepo}/releases/latest";
$response = \wp_remote_get($apiUrl, [
'timeout' => 15,
'headers' => [
'Accept' => 'application/vnd.github.v3+json',
'User-Agent' => 'WordPress/' . \get_bloginfo('version'),
],
"timeout" => 15,
"headers" => $this->getApiHeaders(),
]);

if (\is_wp_error($response) || 200 !== \wp_remote_retrieve_response_code($response)) {
return date('Y-m-d');
return date("Y-m-d");
}

$body = \wp_remote_retrieve_body($response);
$data = json_decode($body, true);

if (!isset($data['published_at'])) {
return date('Y-m-d');
if (!isset($data["published_at"])) {
return date("Y-m-d");
}

return date('Y-m-d', strtotime($data['published_at']));
return date("Y-m-d", strtotime($data["published_at"]));
}

/**
* Clear version cache after update
*
* @param \WP_Upgrader $upgrader WP_Upgrader instance
* @param array $data Array of update data
* @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'])) {
if ($data["action"] === "update" && $data["type"] === "plugin") {
if (isset($data["plugins"]) && in_array($this->pluginSlug, $data["plugins"])) {
\delete_transient($this->versionTransient);
}
}
@ -297,54 +411,164 @@ class Updater
public function manualVersionCheck(): void
{
// Verify nonce
if (!\wp_verify_nonce($_POST['nonce'] ?? '', $this->config->ajaxNonce)) {
$nonce = \sanitize_text_field(\wp_unslash($_POST["nonce"] ?? ""));
if (!\wp_verify_nonce($nonce, $this->config->ajaxNonce)) {
\wp_send_json_error([
'message' => 'Security check failed',
'code' => 'invalid_nonce'
"message" => $this->config->__("Security check failed"),
"code" => "invalid_nonce"
]);
}
if (!\current_user_can('update_plugins')) {

if (!\current_user_can("update_plugins")) {
\wp_send_json_error([
'message' => 'Insufficient permissions',
'code' => 'insufficient_permissions'
"message" => $this->config->__("Insufficient permissions"),
"code" => "insufficient_permissions"
]);
}

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 ?: 'Unknown',
'update_available' => $latestVersion && version_compare($this->currentVersion, $latestVersion, '<'),
'github_repo' => $this->config->githubRepo,
"current_version" => $this->currentVersion,
"latest_version" => $latestVersion ?: $this->config->__("Unknown"),
"update_available" => $updateAvailable,
"github_repo" => $this->config->githubRepo,
"notice_set" => $updateAvailable, // Indicate if notice was set
]);
} catch (\Exception $e) {
\wp_send_json_error([
'message' => "Error checking for updates: {$e->getMessage()}",
'code' => 'version_check_failed'
"message" => sprintf($this->config->__("Error checking for updates: %s"), $e->getMessage()),
"code" => "version_check_failed"
]);
}
}

/**
* 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 '<div class="notice notice-warning is-dismissible" data-notice="wp-github-updater-' .
\esc_attr($this->pluginBasename) . '">';
echo '<p>';
echo '<strong>' . \esc_html($plugin_name) . '</strong> ';
echo sprintf(
\esc_html($this->config->__("has a new version available: %1\$s (you have %2\$s).")),
'<strong>' . \esc_html($latest_version) . '</strong>',
\esc_html($current_version)
);
echo '</p>';
echo '<p>';
echo '<a href="' . \esc_url($updates_url) . '" class="button button-primary">' .
\esc_html($this->config->__("View Updates")) . '</a> ';
echo '<a href="' . \esc_url($github_url) . '" class="button" target="_blank">' .
\esc_html($this->config->__("View Release Notes")) . '</a>';
echo '</p>';
echo '</div>';

// Add JavaScript to handle dismissal
echo '<script>
jQuery(document).ready(function($) {
$(document).on("click", "[data-notice=\"wp-github-updater-' .
\esc_js($this->pluginBasename) . '\"] .notice-dismiss", function() {
$.post(ajaxurl, {
action: "' . \esc_js($this->config->ajaxAction) . '_dismiss_notice",
nonce: "' . \esc_js(\wp_create_nonce($this->config->ajaxNonce)) . '",
plugin: "' . \esc_js($this->pluginBasename) . '"
});
});
});
</script>';
}

/**
* Get plugin data from file
*/
private function getPluginData(): array
{
if (!function_exists('get_plugin_data')) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
if (!\function_exists("get_plugin_data")) {
require_once ABSPATH . "wp-admin/includes/plugin.php";
}

return \get_plugin_data($this->config->pluginFile);
}

/**
* Get current version
*
* @return string
*/
public function getCurrentVersion(): string
{
@ -354,7 +578,6 @@ class Updater
/**
* Get GitHub repository
*
* @return string
*/
public function getGithubRepo(): string
{
@ -364,11 +587,344 @@ class Updater
/**
* Check if update is available
*
* @return bool
*/
public function isUpdateAvailable(): bool
{
$latestVersion = $this->getLatestVersion();
return $latestVersion && version_compare($this->currentVersion, $latestVersion, '<');
return $latestVersion && version_compare($this->currentVersion, $latestVersion, "<");
}

/**
* Parse Markdown to HTML
*
* Converts basic Markdown syntax to HTML for better changelog display.
* Supports headers, bold text, italic text, inline code, lists, and links.
*
* @param string $markdown Markdown content to convert
* @return string HTML formatted content
*
* @since 1.0.1
*/
private function parseMarkdownToHtml(string $markdown): string
{
// Basic markdown to HTML conversion
$html = $markdown;

// Headers (# -> h2, ## -> h3, ### -> h4, #### -> h5)
$html = preg_replace("/^#### (.*$)/m", "<h5>$1</h5>", $html);
$html = preg_replace("/^### (.*$)/m", "<h4>$1</h4>", $html);
$html = preg_replace("/^## (.*$)/m", "<h3>$1</h3>", $html);
$html = preg_replace("/^# (.*$)/m", "<h2>$1</h2>", $html);

// Bold text (**text** -> <strong>text</strong>)
$html = preg_replace("/\*\*(.*?)\*\*/", "<strong>$1</strong>", $html);

// Italic text (*text* -> <em>text</em>)
$html = preg_replace("/(?<!\*)\*([^*]+)\*(?!\*)/", "<em>$1</em>", $html);

// Code blocks (`code` -> <code>code</code>)
$html = preg_replace("/`([^`]+)`/", "<code>$1</code>", $html);

// Unordered lists (- item -> <ul><li>item</li></ul>)
$html = preg_replace_callback("/(?:^- (.+)(?:\n|$))+/m", function ($matches) {
$items = preg_split("/\n- /", trim($matches[0]));
$items[0] = ltrim($items[0], "- ");
$liItems = array_map(fn($item) => "<li>" . trim($item) . "</li>", array_filter($items));
return "<ul>" . implode("", $liItems) . "</ul>";
}, $html);

// Links ([text](url) -> <a href="url">text</a>)
$html = preg_replace("/\[([^\]]+)\]\(([^)]+)\)/", "<a href=\"$2\">$1</a>", $html);

// Line breaks (double newline -> <br>)
$html = preg_replace("/\n\s*\n/", "<br>", $html);
$html = preg_replace("/\n/", "<br>", $html);

// Clean up extra line breaks and spaces
$html = preg_replace("/(<br>\s*){3,}/", "<br>", $html);
$html = trim($html);

return $html;
}

/**
* Maybe fix download issues by providing better HTTP args
*
* 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,
string $package,
object $upgrader,
array $hook_extra
): string|WP_Error|false {
// If a previous filter already handled this, respect that decision
if (\is_wp_error($result)) {
return $result;
}

// 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 for large files
"headers" => $this->getDownloadHeaders(),
"sslverify" => true,
"stream" => false,
"filename" => null,
];

$response = \wp_remote_get($package, $args);

if (\is_wp_error($response)) {
return new WP_Error(
"download_failed",
sprintf(
$this->config->__("Failed to download package: %s"),
$response->get_error_message()
)
);
}

$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
)
);
}

// 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")
);
}

// 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) {
@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
)
);
}

$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
*
* Returns standard headers for GitHub API communication including
* User-Agent and Accept headers for optimal API interaction.
*
* @return array<string, string> Array of HTTP headers
*
* @since 1.1.0
*/
private function getApiHeaders(): array
{
return [
"User-Agent" => "WP-GitHub-Updater/{$this->currentVersion}",
"Accept" => "application/vnd.github.v3+json",
];
}

/**
* Get headers for GitHub asset downloads
*
* Returns headers optimized for downloading GitHub release assets
* including compression support and extended timeouts.
*
* @return array<string, string> Array of HTTP headers
*
* @since 1.1.0
*/
private function getDownloadHeaders(): array
{
return [
"User-Agent" => "WP-GitHub-Updater/{$this->currentVersion}",
"Accept" => "application/octet-stream",
"Accept-Encoding" => "gzip, deflate",
];
}
}

View file

@ -2,88 +2,153 @@

/**
* WordPress GitHub Updater
*
*
* A reusable WordPress plugin updater that handles automatic updates from public GitHub releases.
*
* @package SilverAssist\WpGithubUpdater
* @author Silver Assist
* @license GPL-2.0-or-later
* @version 1.2.1
* @license PolyForm-Noncommercial-1.0.0
*/

namespace SilverAssist\WpGithubUpdater;

/**
* Configuration class for the GitHub updater
*
* This class holds all the configuration settings needed for the updater to work.
* It provides a structured way to pass plugin information and updater settings
* to the main Updater class.
*
* @package SilverAssist\WpGithubUpdater
* @since 1.0.0
*/
class UpdaterConfig
{
/**
* Plugin file path
*
* @var string Path to the main plugin file
* @since 1.0.0
*/
public string $pluginFile;

/**
* GitHub repository (owner/repo)
*
* @var string GitHub repository in the format "owner/repo"
* @since 1.0.0
*/
public string $githubRepo;

/**
* Plugin name
*
* @var string Display name of the plugin
* @since 1.0.0
*/
public string $pluginName;

/**
* Plugin description
*
* @var string Brief description of the plugin
* @since 1.0.0
*/
public string $pluginDescription;

/**
* Plugin author
*
* @var string Author name or company
* @since 1.0.0
*/
public string $pluginAuthor;

/**
* Plugin homepage
*
* @var string URL to the plugin's homepage
* @since 1.0.0
*/
public string $pluginHomepage;

/**
* Minimum WordPress version
*
* @var string Minimum required WordPress version
* @since 1.0.0
*/
public string $requiresWordPress;

/**
* Minimum PHP version
*
* @var string Minimum required PHP version
* @since 1.0.0
*/
public string $requiresPHP;

/**
* Release asset filename pattern
* Use {version} as placeholder for version number
*
* @var string Pattern for GitHub release asset filename
* @since 1.0.0
*/
public string $assetPattern;

/**
* Cache duration in seconds
*
* @var int How long to cache version information
* @since 1.0.0
*/
public int $cacheDuration;

/**
* AJAX action name for manual version check
*
* @var string WordPress AJAX action name
* @since 1.0.0
*/
public string $ajaxAction;

/**
* AJAX nonce name
*
* @var string WordPress nonce name for AJAX requests
* @since 1.0.0
*/
public string $ajaxNonce;

/**
* Text domain for translations
*
* @var string WordPress text domain for i18n functions
* @since 1.1.0
*/
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
*
* Initializes the updater configuration with plugin metadata and settings.
* Accepts text domain from the consuming plugin for proper i18n support.
*
* @param string $pluginFile Main plugin file path
* @param string $githubRepo GitHub repository (owner/repo)
* @param array $options Additional configuration options
* @param array $options Additional configuration options including text_domain
*
* @since 1.0.0
*/
public function __construct(string $pluginFile, string $githubRepo, array $options = [])
{
@ -94,28 +159,64 @@ class UpdaterConfig
$pluginData = $this->getPluginData($pluginFile);

// Set defaults from plugin data or options
$this->pluginName = $options['plugin_name'] ?? $pluginData['Name'] ?? '';
$this->pluginDescription = $options['plugin_description'] ?? $pluginData['Description'] ?? '';
$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->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->pluginName = $options["plugin_name"] ?? $pluginData["Name"] ?? "";
$this->pluginDescription = $options["plugin_description"] ?? $pluginData["Description"] ?? "";
$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.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;
}

/**
* Get plugin data from file
*
* Retrieves plugin metadata from the plugin file header.
* Falls back to empty array when WordPress functions aren't available.
*
* @param string $pluginFile Path to the plugin file
* @return array Plugin data array
*
* @since 1.0.0
*/
private function getPluginData(string $pluginFile): array
{
if (function_exists('get_plugin_data')) {
return get_plugin_data($pluginFile);
if (function_exists("get_plugin_data")) {
return \get_plugin_data($pluginFile);
}

// Fallback for when WordPress functions aren't available
return [];
}

/**
* Translation wrapper for the package
*
* @param string $text Text to translate
* @return string Translated text
*
* @since 1.1.0
*/
public function __(string $text): string
{
return \__($text, $this->textDomain);
}

/**
* Escaped translation wrapper for the package
*
* @param string $text Text to translate and escape
* @return string Translated and escaped text
*
* @since 1.1.0
*/
public function esc_html__(string $text): string
{
return \esc_html__($text, $this->textDomain);
}
}

View file

@ -0,0 +1,147 @@
<?php

/**
* Integration tests for download filter functionality
*
* @package SilverAssist\WpGithubUpdater\Tests\Integration
*/

namespace SilverAssist\WpGithubUpdater\Tests\Integration;

use PHPUnit\Framework\TestCase;
use SilverAssist\WpGithubUpdater\Updater;
use SilverAssist\WpGithubUpdater\UpdaterConfig;

/**
* Test download filter functionality
*
* These tests verify the download filter behavior including
* temporary file creation and validation.
*/
class DownloadFilterTest extends TestCase
{
private UpdaterConfig $config;
private Updater $updater;
private string $testPluginFile;

/**
* Set up test environment before each test
*/
protected function setUp(): void
{
parent::setUp();

// Create a temporary plugin file for testing
$this->testPluginFile = sys_get_temp_dir() . "/test-plugin.php";
file_put_contents($this->testPluginFile, "<?php\n/*\nPlugin Name: Test Plugin\nVersion: 1.0.0\n*/");

// Create test configuration
$this->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");
}
}

View file

@ -0,0 +1,423 @@
<?php

/**
* Real GitHub API Integration Tests
*
* Tests using real GitHub API with silver-assist-post-revalidate repository.
* These tests make actual HTTP requests to GitHub's API.
*
* @package SilverAssist\WpGithubUpdater
* @since 1.1.6
*/

namespace SilverAssist\WpGithubUpdater\Tests\Integration;

use PHPUnit\Framework\TestCase;
use SilverAssist\WpGithubUpdater\UpdaterConfig;
use SilverAssist\WpGithubUpdater\Updater;

/**
* Real GitHub API Integration Test Class
*
* Tests actual communication with GitHub's API using a real repository.
*
* @since 1.1.6
*/
class RealGitHubAPITest extends TestCase
{
/**
* Test plugin file path
*
* @var string
*/
private static string $testPluginFile;

/**
* Set up before class
*
* @return void
*/
public static function setUpBeforeClass(): void
{
parent::setUpBeforeClass();
self::$testPluginFile = dirname(__DIR__) . "/fixtures/test-plugin.php";
}

/**
* Test fetching latest version from real GitHub repository
*
* @return void
*/
public function testFetchLatestVersionFromRealRepo(): void
{
$config = new UpdaterConfig(
self::$testPluginFile,
"SilverAssist/silver-assist-post-revalidate"
);

$updater = new Updater($config);

// This makes a real HTTP request to GitHub
$latestVersion = $updater->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"
);
}
}

View file

@ -0,0 +1,142 @@
<?php

/**
* Integration tests for Updater class
*
* @package SilverAssist\WpGithubUpdater\Tests\Integration
*/

namespace SilverAssist\WpGithubUpdater\Tests\Integration;

use PHPUnit\Framework\TestCase;
use SilverAssist\WpGithubUpdater\Updater;
use SilverAssist\WpGithubUpdater\UpdaterConfig;

/**
* Test Updater integration with GitHub API
*
* These tests verify the integration between the Updater class
* and external dependencies like GitHub API (mocked).
*/
class UpdaterIntegrationTest extends TestCase
{
private UpdaterConfig $config;
private string $testPluginFile;

/**
* Set up test environment before each test
*/
protected function setUp(): void
{
parent::setUp();

// Create a temporary plugin file for testing
$this->testPluginFile = sys_get_temp_dir() . "/test-plugin.php";
file_put_contents($this->testPluginFile, "<?php\n/*\nPlugin Name: Test Plugin\nVersion: 1.0.0\n*/");

// Create test configuration
$this->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);
}
}

View file

@ -0,0 +1,69 @@
<?php

namespace SilverAssist\WpGithubUpdater\Tests\Unit;

use PHPUnit\Framework\TestCase;
use SilverAssist\WpGithubUpdater\UpdaterConfig;

class UpdaterConfigTest extends TestCase
{
private static string $testPluginFile;

public static function setUpBeforeClass(): void
{
self::$testPluginFile = dirname(__DIR__) . "/fixtures/test-plugin.php";
}

public function testBasicConfiguration(): void
{
$config = new UpdaterConfig(self::$testPluginFile, "owner/repo");

$this->assertEquals(self::$testPluginFile, $config->pluginFile);
$this->assertEquals("owner/repo", $config->githubRepo);
$this->assertEquals("6.0", $config->requiresWordPress);
$this->assertEquals("8.2", $config->requiresPHP);
$this->assertEquals("{slug}-v{version}.zip", $config->assetPattern);
$this->assertEquals("wp-github-updater", $config->textDomain);
}

public function testCustomConfiguration(): void
{
$options = [
"plugin_name" => "Test Plugin",
"plugin_description" => "A test plugin",
"plugin_author" => "Test Author",
"requires_wordpress" => "6.2",
"requires_php" => "8.1",
"asset_pattern" => "custom-{version}.zip",
"cache_duration" => 3600,
"ajax_action" => "custom_check",
"ajax_nonce" => "custom_nonce",
"text_domain" => "my-custom-plugin"
];

$config = new UpdaterConfig(self::$testPluginFile, "owner/repo", $options);

$this->assertEquals("Test Plugin", $config->pluginName);
$this->assertEquals("A test plugin", $config->pluginDescription);
$this->assertEquals("Test Author", $config->pluginAuthor);
$this->assertEquals("6.2", $config->requiresWordPress);
$this->assertEquals("8.1", $config->requiresPHP);
$this->assertEquals("custom-{version}.zip", $config->assetPattern);
$this->assertEquals(3600, $config->cacheDuration);
$this->assertEquals("custom_check", $config->ajaxAction);
$this->assertEquals("custom_nonce", $config->ajaxNonce);
$this->assertEquals("my-custom-plugin", $config->textDomain);
}

public function testTranslationMethods(): void
{
$config = new UpdaterConfig(self::$testPluginFile, "owner/repo", [
"text_domain" => "test-domain"
]);

// These methods would normally call WordPress i18n functions
// We"re just testing they exist and return strings for now
$this->assertIsString($config->__("Test string"));
$this->assertIsString($config->esc_html__("Test string"));
}
}

View file

@ -1,47 +0,0 @@
<?php

namespace SilverAssist\WpGithubUpdater\Tests;

use PHPUnit\Framework\TestCase;
use SilverAssist\WpGithubUpdater\UpdaterConfig;

class UpdaterConfigTest extends TestCase
{
public function testBasicConfiguration()
{
$config = new UpdaterConfig('/path/to/plugin.php', 'owner/repo');
$this->assertEquals('/path/to/plugin.php', $config->pluginFile);
$this->assertEquals('owner/repo', $config->githubRepo);
$this->assertEquals('6.0', $config->requiresWordPress);
$this->assertEquals('8.0', $config->requiresPHP);
$this->assertEquals('{slug}-v{version}.zip', $config->assetPattern);
}
public function testCustomConfiguration()
{
$options = [
'plugin_name' => 'Test Plugin',
'plugin_description' => 'A test plugin',
'plugin_author' => 'Test Author',
'requires_wordpress' => '6.2',
'requires_php' => '8.1',
'asset_pattern' => 'custom-{version}.zip',
'cache_duration' => 3600,
'ajax_action' => 'custom_check',
'ajax_nonce' => 'custom_nonce'
];
$config = new UpdaterConfig('/path/to/plugin.php', 'owner/repo', $options);
$this->assertEquals('Test Plugin', $config->pluginName);
$this->assertEquals('A test plugin', $config->pluginDescription);
$this->assertEquals('Test Author', $config->pluginAuthor);
$this->assertEquals('6.2', $config->requiresWordPress);
$this->assertEquals('8.1', $config->requiresPHP);
$this->assertEquals('custom-{version}.zip', $config->assetPattern);
$this->assertEquals(3600, $config->cacheDuration);
$this->assertEquals('custom_check', $config->ajaxAction);
$this->assertEquals('custom_nonce', $config->ajaxNonce);
}
}

View file

@ -0,0 +1,258 @@
<?php

/**
* Tests for Mock Plugin with WordPress Test Suite
*
* These tests use the WordPress Test Suite to test the updater
* in a real WordPress environment with the mock plugin.
*
* NOTE: These tests are ONLY loaded when WordPress Test Suite is available.
* They will be skipped automatically if WP_UnitTestCase is not defined.
*
* @package SilverAssist\WpGithubUpdater\Tests\WordPress
*/

namespace SilverAssist\WpGithubUpdater\Tests\WordPress;

// Only load tests if WordPress Test Suite is available
if (!class_exists("WP_UnitTestCase")) {
return;
}

use WP_UnitTestCase;
use SilverAssist\WpGithubUpdater\Updater;

/**
* Mock Plugin Integration Tests
*
* These tests require WordPress Test Suite to be installed.
* Run: ./bin/install-wp-tests.sh wordpress_test root '' localhost 6.7.1
*/
class MockPluginTest extends WP_UnitTestCase
{
private string $pluginFile;
private ?Updater $updater = null;

/**
* Set up test environment
*/
public function setUp(): void
{
parent::setUp();

// Define plugin file path
$this->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();
}
}

View file

@ -0,0 +1,213 @@
<?php

/**
* WordPress-specific integration tests
*
* @package SilverAssist\WpGithubUpdater\Tests\WordPress
*/

namespace SilverAssist\WpGithubUpdater\Tests\WordPress;

use PHPUnit\Framework\TestCase;
use SilverAssist\WpGithubUpdater\Updater;
use SilverAssist\WpGithubUpdater\UpdaterConfig;

/**
* Test WordPress-specific functionality
*
* These tests verify WordPress hooks, filters, and integration points.
* Note: These tests use mocked WordPress functions.
*/
class WordPressHooksTest extends TestCase
{
private UpdaterConfig $config;
private string $testPluginFile;

/**
* Set up test environment before each test
*/
protected function setUp(): void
{
parent::setUp();

// Create a temporary plugin file for testing
$this->testPluginFile = sys_get_temp_dir() . "/test-plugin.php";
file_put_contents(
$this->testPluginFile,
"<?php\n/*\nPlugin Name: Test Plugin\nVersion: 1.0.0\nDescription: Test plugin\n*/"
);

// Create test configuration
$this->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);
}
}

117
tests/bootstrap.php Normal file
View file

@ -0,0 +1,117 @@
<?php

/**
* PHPUnit bootstrap file
*
* This bootstrap automatically detects the test environment:
* - If WordPress Test Suite is available, loads it for WordPress integration tests
* - Otherwise, uses mocked WordPress functions for unit/integration tests
*
* @package SilverAssist\WpGithubUpdater\Tests
*/

// Determine if we should load WordPress Test Suite
$_tests_dir = getenv("WP_TESTS_DIR");
$_skip_wp_tests = filter_var(getenv("SKIP_WP_TESTS_IF_MISSING"), FILTER_VALIDATE_BOOLEAN);

// If not set via environment, try common locations
if (!$_tests_dir) {
$_tests_dir = rtrim(sys_get_temp_dir(), "/\\") . "/wordpress-tests-lib";
}

// Check if WordPress Test Suite is available
$_wp_tests_available = file_exists($_tests_dir . "/includes/functions.php");

// Load Composer autoloader
require_once __DIR__ . "/../vendor/autoload.php";

// Load Yoast PHPUnit Polyfills for PHPUnit 9.x compatibility
if (file_exists(__DIR__ . "/../vendor/yoast/phpunit-polyfills/phpunitpolyfills-autoload.php")) {
require_once __DIR__ . "/../vendor/yoast/phpunit-polyfills/phpunitpolyfills-autoload.php";
}

// Decide which environment to load
if ($_wp_tests_available) {
// WordPress Test Suite is available - load it
echo "\n";
echo "====================================\n";
echo "WP GitHub Updater Test Suite\n";
echo "====================================\n";
echo "Mode: WordPress Integration Tests\n";
echo "WP Tests Dir: $_tests_dir\n";
echo "====================================\n\n";

// Load WordPress test suite
require_once $_tests_dir . "/includes/functions.php";

/**
* Manually load the mock plugin for testing
*
* This loads our mock plugin that integrates the WP GitHub Updater package
* into a WordPress environment for real integration testing.
*/
function _manually_load_plugin()
{
// Load the mock plugin that uses the WP GitHub Updater package
$mock_plugin_file = __DIR__ . "/fixtures/mock-plugin/mock-plugin.php";

if (file_exists($mock_plugin_file)) {
require_once $mock_plugin_file;
echo "✓ Mock plugin loaded: {$mock_plugin_file}\n";
} else {
echo "⚠️ Mock plugin not found at: {$mock_plugin_file}\n";
}
}

tests_add_filter("muplugins_loaded", "_manually_load_plugin");

// Start up the WP testing environment
require $_tests_dir . "/includes/bootstrap.php";
} else {
// WordPress Test Suite not available - use mocks

// Define WordPress constants for mock environment
if (!defined("ABSPATH")) {
define("ABSPATH", __DIR__ . "/../");
}

if (!defined("WP_CONTENT_DIR")) {
define("WP_CONTENT_DIR", ABSPATH . "wp-content");
}

if (!defined("WP_PLUGIN_DIR")) {
define("WP_PLUGIN_DIR", WP_CONTENT_DIR . "/plugins");
}

// Load WordPress function mocks for non-WP-Test-Suite environment
require_once __DIR__ . "/wordpress-mocks.php";

if (!$_skip_wp_tests) {
echo "\n";
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n";
echo "⚠️ WordPress Test Suite not found\n";
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n";
echo "\n";
echo "Running tests with mocked WordPress functions.\n";
echo "For full WordPress integration tests, install WordPress Test Suite:\n";
echo " ./bin/install-wp-tests.sh wordpress_test root '' localhost 6.7.1\n";
echo "\n";
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n";
echo "\n";
}

// Note: We don't load wordpress-stubs here because they conflict with our mocks
// wordpress-stubs are only used for static analysis (PHPStan)
// Our WordPress function mocks are loaded via Composer's autoload-dev files
// See composer.json autoload-dev.files section

// Display test suite information
echo "\n";
echo "====================================\n";
echo "WP GitHub Updater Test Suite\n";
echo "====================================\n";
echo "Mode: Unit/Integration Tests (Mocked)\n";
echo "PHP Version: " . PHP_VERSION . "\n";
echo "PHPUnit Version: " . PHPUnit\Runner\Version::id() . "\n";
echo "====================================\n\n";
}

260
tests/fixtures/mock-plugin/README.md vendored Normal file
View file

@ -0,0 +1,260 @@
# Mock Plugin for WP GitHub Updater Tests

## 📋 Descripción

Este es un **plugin de prueba (fixture)** que demuestra la integración correcta del paquete `silverassist/wp-github-updater` en un entorno real de WordPress.

## 🎯 Propósito

El mock plugin sirve para:

1. **Testing Real de WordPress**: Permite probar el updater en un entorno WordPress completo con WordPress Test Suite
2. **Ejemplo de Integración**: Demuestra el patrón recomendado de integración del paquete
3. **Documentación Viva**: Sirve como referencia para desarrolladores que integren el paquete

## 📁 Estructura

```
tests/fixtures/mock-plugin/
├── mock-plugin.php # Plugin principal con integración completa
├── readme.txt # WordPress plugin readme
└── README.md # Esta documentación
```

## 🔧 Características Implementadas

### ✅ Integración Completa del Updater

```php
// Configuración completa con todas las opciones
$config = new UpdaterConfig(
__FILE__,
"SilverAssist/mock-test-repo",
[
"plugin_name" => "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

View file

@ -0,0 +1,213 @@
<?php

/**
* Plugin Name: Mock Plugin for WP GitHub Updater Tests
* Plugin URI: https://github.com/SilverAssist/wp-github-updater
* Description: A mock WordPress plugin used for testing the WP GitHub Updater package
* Version: 1.0.0
* Author: SilverAssist
* Author URI: https://github.com/SilverAssist
* License: MIT
* Text Domain: mock-plugin
* Requires at least: 6.0
* Requires PHP: 8.2
*
* This is a test fixture plugin that demonstrates integration with
* the SilverAssist/WpGithubUpdater package for automated testing.
*/

// Prevent direct access
if (!defined("ABSPATH")) {
exit;
}

// Autoload Composer dependencies
if (file_exists(__DIR__ . "/../../../vendor/autoload.php")) {
require_once __DIR__ . "/../../../vendor/autoload.php";
}

use SilverAssist\WpGithubUpdater\UpdaterConfig;
use SilverAssist\WpGithubUpdater\Updater;

/**
* Initialize the GitHub Updater for this mock plugin
*
* This function demonstrates the recommended integration pattern
* for the WP GitHub Updater package.
*/
function mock_plugin_init_updater(): void
{
// Create updater configuration
$config = new UpdaterConfig(
__FILE__, // Plugin main file
"SilverAssist/mock-test-repo", // GitHub repository
[
// Optional: Override plugin metadata
"plugin_name" => "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;

?>
<div class="wrap">
<h1><?php echo esc_html(get_admin_page_title()); ?></h1>
<div class="card">
<h2>Plugin Information</h2>
<table class="form-table">
<tr>
<th>Current Version:</th>
<td><?php echo esc_html($updater ? $updater->getCurrentVersion() : "N/A"); ?></td>
</tr>
<tr>
<th>GitHub Repository:</th>
<td><?php echo esc_html($updater ? $updater->getGithubRepo() : "N/A"); ?></td>
</tr>
<tr>
<th>Update Available:</th>
<td><?php echo $updater && $updater->isUpdateAvailable() ? "✅ Yes" : "❌ No"; ?></td>
</tr>
<?php if ($updater && $updater->isUpdateAvailable()) : ?>
<tr>
<th>Latest Version:</th>
<td><?php echo esc_html($updater->getLatestVersion()); ?></td>
</tr>
<?php endif; ?>
</table>
</div>
<div class="card">
<h2>Manual Update Check</h2>
<p>Click the button below to manually check for updates:</p>
<button type="button" class="button button-primary" id="mock-plugin-check-update">
Check for Updates
</button>
<div id="mock-plugin-result" style="margin-top: 10px;"></div>
</div>
</div>
<script>
jQuery(document).ready(function($) {
$("#mock-plugin-check-update").on("click", function() {
var button = $(this);
var result = $("#mock-plugin-result");
button.prop("disabled", true).text("Checking...");
result.html("");
$.ajax({
url: ajaxurl,
type: "POST",
data: {
action: "mock_plugin_check_version",
nonce: "<?php echo wp_create_nonce("mock_plugin_nonce"); ?>"
},
success: function(response) {
if (response.success) {
result.html('<div class="notice notice-success"><p>' + response.data.message + '</p></div>');
} else {
result.html('<div class="notice notice-error"><p>' + response.data + '</p></div>');
}
},
error: function() {
result.html('<div class="notice notice-error"><p>Error checking for updates</p></div>');
},
complete: function() {
button.prop("disabled", false).text("Check for Updates");
}
});
});
});
</script>
<?php
}

/**
* Activation hook
*/
function mock_plugin_activate(): void
{
// Clear any existing update caches
delete_transient(basename(dirname(__FILE__)) . "_version_check");
}
register_activation_hook(__FILE__, "mock_plugin_activate");

/**
* Deactivation hook
*/
function mock_plugin_deactivate(): void
{
// Clean up transients
delete_transient(basename(dirname(__FILE__)) . "_version_check");
}
register_deactivation_hook(__FILE__, "mock_plugin_deactivate");

/**
* Helper function to get the updater instance (for testing)
*
* @return Updater|null
*/
function mock_plugin_get_updater(): ?Updater
{
return $GLOBALS["mock_plugin_updater"] ?? null;
}

33
tests/fixtures/mock-plugin/readme.txt vendored Normal file
View file

@ -0,0 +1,33 @@
=== Mock Plugin for WP GitHub Updater Tests ===
Contributors: silverassist
Tags: testing, github, updater
Requires at least: 6.0
Tested up to: 6.7
Requires PHP: 8.2
Stable tag: 1.0.0
License: MIT
License URI: https://opensource.org/licenses/MIT

A mock WordPress plugin used for testing the WP GitHub Updater package.

== Description ==

This is a test fixture plugin that demonstrates integration with the SilverAssist/WpGithubUpdater package.

It is used exclusively for automated testing and should not be used in production environments.

== Installation ==

This plugin is for testing purposes only and should not be installed on production sites.

For testing:
1. Install WordPress Test Suite using the provided install-wp-tests.sh script
2. The plugin will be automatically loaded in the test environment
3. Run PHPUnit tests with the WordPress test suite

== Changelog ==

= 1.0.0 =
* Initial release for testing purposes
* Demonstrates WP GitHub Updater integration
* Includes admin interface for manual testing

14
tests/fixtures/test-plugin.php vendored Normal file
View file

@ -0,0 +1,14 @@
<?php

/**
* Plugin Name: Test Plugin for Unit Tests
* Description: A minimal test plugin for UpdaterConfig tests
* Version: 1.0.0
* Author: Test Author
* Author URI: https://example.com
* Requires at least: 6.0
* Requires PHP: 8.2
* Text Domain: test-plugin
*/

// This file only exists for testing purposes

163
tests/wordpress-mocks.php Normal file
View file

@ -0,0 +1,163 @@
<?php

/**
* WordPress function mocks for testing
*
* This file provides implementations for WordPress functions that are only
* defined (but not implemented) in wordpress-stubs.
*
* @package SilverAssist\WpGithubUpdater\Tests
*/

// Translation functions
if (!function_exists("__")) {
/**
* Mock __ function for tests
*
* @param string $text Text to translate
* @param string $domain Text domain
* @return string Translated text (returns original in tests)
*/
function __(string $text, string $domain = "default"): string
{
return $text;
}
}

if (!function_exists("esc_html__")) {
/**
* Mock esc_html__ function for tests
*
* @param string $text Text to translate
* @param string $domain Text domain
* @return string Escaped and translated text
*/
function esc_html__(string $text, string $domain = "default"): string
{
return htmlspecialchars($text, ENT_QUOTES, "UTF-8");
}
}

// WordPress hooks and filters
if (!function_exists("add_filter")) {
/**
* Mock add_filter function for tests
*
* @param string $hook_name Hook name
* @param callable $callback Callback function
* @param int $priority Priority
* @param int $accepted_args Accepted arguments
* @return bool Always returns true
*/
function add_filter(string $hook_name, callable $callback, int $priority = 10, int $accepted_args = 1): bool
{
return true;
}
}

if (!function_exists("add_action")) {
/**
* Mock add_action function for tests
*
* @param string $hook_name Hook name
* @param callable $callback Callback function
* @param int $priority Priority
* @param int $accepted_args Accepted arguments
* @return bool Always returns true
*/
function add_action(string $hook_name, callable $callback, int $priority = 10, int $accepted_args = 1): bool
{
return true;
}
}

// Plugin functions
if (!function_exists("plugin_basename")) {
/**
* Mock plugin_basename function for tests
*
* @param string $file Plugin file path
* @return string Plugin basename
*/
function plugin_basename(string $file): string
{
$file = str_replace("\\", "/", $file);
$file = preg_replace("|/+|", "/", $file);

// If WP_PLUGIN_DIR is defined, make path relative to it
if (defined("WP_PLUGIN_DIR")) {
$plugin_dir = str_replace("\\", "/", WP_PLUGIN_DIR);
$plugin_dir = preg_replace("|/+|", "/", $plugin_dir);
$file = preg_replace("#^" . preg_quote($plugin_dir, "#") . "/#", "", $file);
}

// Otherwise just return folder/file.php format
$file = trim($file, "/");
$parts = explode("/", $file);

if (count($parts) >= 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;
}
}