diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml new file mode 100644 index 00000000..c0152389 --- /dev/null +++ b/.github/workflows/performance.yml @@ -0,0 +1,142 @@ +name: Performance Tests + +on: + pull_request: + branches: [main] + paths-ignore: + - '**.md' + - 'docs/**' + push: + branches: [main] + paths-ignore: + - '**.md' + - 'docs/**' + +jobs: + performance: + name: Performance Benchmark + runs-on: ubuntu-latest + + steps: + - name: Checkout PR Branch + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Checkout Base Branch + uses: actions/checkout@v4 + with: + ref: ${{ github.base_ref }} + path: baseline + fetch-depth: 0 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + + - name: Run Baseline Performance Tests + run: | + # Copy the benchmark script from the PR branch into the baseline checkout + # (simple-performance-test.php is new in this PR and doesn't exist on main yet) + cp tests/simple-performance-test.php baseline/tests/simple-performance-test.php + cd baseline + php tests/simple-performance-test.php > ../baseline-results.json + echo "Baseline results:" + cat ../baseline-results.json | jq '.' + + - name: Run Current Branch Performance Tests + run: | + php tests/simple-performance-test.php > current-results.json + echo "Current results:" + cat current-results.json | jq '.' + + - name: Compare Performance Results + run: | + php tests/performance-comparator.php baseline-results.json current-results.json > performance-report.md + echo "Performance comparison:" + cat performance-report.md + + - name: Upload Performance Results + uses: actions/upload-artifact@v4 + if: always() + with: + name: performance-results-${{ github.run_number }} + path: | + baseline-results.json + current-results.json + performance-report.md + retention-days: 30 + + - name: Comment PR with Performance Results + uses: actions/github-script@v7 + if: github.event_name == 'pull_request' + with: + script: | + const fs = require('fs'); + + try { + const report = fs.readFileSync('performance-report.md', 'utf8'); + + // Find previous performance comment + const { data: comments } = await github.rest.issues.listComments({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + }); + + const existingComment = comments.find(comment => + comment.user.type === 'Bot' && + comment.body.includes('Performance Test Results') + ); + + if (existingComment) { + // Update existing comment + await github.rest.issues.updateComment({ + comment_id: existingComment.id, + owner: context.repo.owner, + repo: context.repo.repo, + body: report + }); + } else { + // Create new comment + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: report + }); + } + } catch (error) { + console.log('Error posting performance comment:', error); + } + + - name: Check for Performance Regressions + run: | + # Check if performance report contains critical regressions + if grep -q "Critical Regressions.*[1-9]" performance-report.md; then + echo "🚨 Critical performance regressions detected!" + echo "Please review the performance report and optimize before merging." + exit 1 + fi + + # Check if performance report contains any regressions + if grep -q "Regressions.*[1-9]" performance-report.md; then + echo "⚠️ Performance regressions detected." + echo "Consider optimizing before merging, but not blocking." + fi + + echo "✅ Performance tests passed!" + + - name: Performance Summary + if: always() + run: | + echo "## Performance Test Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + if [ -f "performance-report.md" ]; then + # Extract summary from performance report + sed -n '/## Summary/,/## Detailed Results/p' performance-report.md >> $GITHUB_STEP_SUMMARY + else + echo "Performance report not available." >> $GITHUB_STEP_SUMMARY + fi \ No newline at end of file diff --git a/.gitignore b/.gitignore index 72e80465..52a10360 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,12 @@ coverage.xml coverage-html/ # Local test scripts with secrets -run-tests.sh \ No newline at end of file +run-tests.sh + +# Performance benchmark result artifacts (generated by CI) +baseline-results.json +current-results.json +baseline-test.json +current-test.json +performance-report.md +tests/simple-performance-results-*.json \ No newline at end of file diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md new file mode 100644 index 00000000..d257649e --- /dev/null +++ b/docs/PERFORMANCE.md @@ -0,0 +1,224 @@ +# Performance Testing Guide + +## Overview + +This repository includes automated performance testing that runs on every pull request to detect performance regressions in the Ultimate Multisite plugin. + +## How It Works + +### Performance Benchmarks + +The performance tests measure critical plugin operations: + +1. **Dashboard Loading** - Time to load admin dashboard data +2. **Checkout Process** - Performance of checkout initialization and form preparation +3. **Site Creation Validation** - Speed of site creation data validation +4. **Membership Operations** - Performance of membership queries and status calculations +5. **API Endpoints** - Speed of API data preparation and endpoint registration +6. **Database Queries** - Performance of common database operations + +### Metrics Tracked + +- **Execution Time** (milliseconds) - How long operations take +- **Memory Usage** (MB) - Memory consumed during operations +- **Database Queries** - Number of database queries performed +- **Peak Memory** (MB) - Maximum memory usage + +### Regression Detection + +The system compares performance between the base branch and PR branch: + +- **Warning Threshold**: 15% increase in execution time, 20% increase in memory, 10% increase in queries +- **Critical Threshold**: 30% increase in execution time, 40% increase in memory, 25% increase in queries +- **Build Failure**: Critical regressions will block PR merging +- **PR Comments**: Automated performance reports posted to pull requests + +## Running Tests Locally + +### Prerequisites + +```bash +# Install dependencies +composer install + +# Setup WordPress test environment +bash bin/install-wp-tests.sh wordpress_test root root mysql latest +``` + +### Run Performance Tests + +```bash +# Run all benchmarks +php tests/performance-benchmark.php + +# Save results to specific file +php tests/performance-benchmark.php > my-results.json +``` + +### Compare Results + +```bash +# Compare two performance result files +php tests/performance-comparator.php baseline.json current.json +``` + +## Understanding Results + +### Performance Report Format + +``` +# Performance Test Results + +## Summary + +| Metric | Count | +|--------|-------| +| Total Tests | 6 | +| Critical Regressions | 0 | +| Regressions | 1 | +| Improvements | 2 | +| No Change | 3 | + +## Detailed Results + +### dashboard_loading ⚠️ + +**Issues:** +- Warning: execution_time_ms increased by 18.5% (threshold: 15%) + +| Metric | Baseline | Current | Change | +|--------|----------|---------|--------| +| execution_time_ms | 45.2 | 53.6 | +18.5% | +| memory_usage_mb | 2.1 | 2.3 | +9.5% | +``` + +### Status Indicators + +- 🚨 **Critical Regression** - Performance degradation exceeding critical thresholds +- ⚠️ **Regression** - Performance degradation exceeding warning thresholds +- ✨ **Improvement** - Performance improvement of 5% or more +- ✅ **No Change** - Performance within acceptable range + +## Performance Best Practices + +### Code Optimization + +1. **Database Queries** + - Use `wu_get_*()` functions with proper caching + - Avoid N+1 query problems + - Use WordPress caching mechanisms + +2. **Memory Management** + - Free large objects when no longer needed + - Use generators for large datasets + - Monitor memory usage in loops + +3. **Execution Time** + - Cache expensive computations + - Use efficient algorithms + - Minimize external API calls + +### Testing Guidelines + +1. **Before Submitting PR** + ```bash + # Run performance tests locally + php tests/performance-benchmark.php > current.json + + # Compare with main branch + git checkout main + php tests/performance-benchmark.php > baseline.json + git checkout - + + # Analyze results + php tests/performance-comparator.php baseline.json current.json + ``` + +2. **When Performance Regressions Occur** + - Review the specific operation showing regression + - Check for new database queries or loops + - Consider caching strategies + - Profile with Xdebug or Blackfire if needed + +## Configuration + +### Threshold Adjustment + +Edit `tests/performance-comparator.php` to modify thresholds: + +```php +private $thresholds = [ + 'execution_time_ms' => 15, // Adjust warning threshold + 'memory_usage_mb' => 20, // Adjust warning threshold + 'database_queries' => 10, // Adjust warning threshold +]; + +private $critical_thresholds = [ + 'execution_time_ms' => 30, // Adjust critical threshold + 'memory_usage_mb' => 40, // Adjust critical threshold + 'database_queries' => 25, // Adjust critical threshold +]; +``` + +### Adding New Benchmarks + +1. Add benchmark method to `tests/performance-benchmark.php`: + +```php +public function benchmark_new_feature() { + $this->start_measurement(); + + // Your performance test code here + $this->do_something_expensive(); + + $this->end_measurement('new_feature'); +} +``` + +2. Add to `run_all_benchmarks()` method: + +```php +public function run_all_benchmarks() { + // ... existing benchmarks ... + + $this->benchmark_new_feature(); + echo "✓ New feature benchmark completed\n"; +} +``` + +3. Update benchmark list in `tests/performance-comparator.php`: + +```php +$benchmarks = [ + 'dashboard_loading', + 'checkout_process', + // ... existing benchmarks ... + 'new_feature', // Add your new benchmark +]; +``` + +## Troubleshooting + +### Common Issues + +1. **"WordPress test environment not found"** + - Ensure WordPress test environment is properly installed + - Run `bash bin/install-wp-tests.sh wordpress_test root root mysql latest` + +2. **"Database connection failed"** + - Check MySQL service is running + - Verify database credentials in test configuration + +3. **"Memory limit exceeded"** + - Increase PHP memory limit in `php.ini` + - Check for memory leaks in benchmark code + +### Debug Mode + +Enable debug output by setting environment variable: + +```bash +DEBUG=1 php tests/performance-benchmark.php +``` + +This will provide additional information about each benchmark step. \ No newline at end of file diff --git a/tests/WP_Ultimo/Limits/Customer_User_Role_Limits_Test.php b/tests/WP_Ultimo/Limits/Customer_User_Role_Limits_Test.php index 2d604084..0ab8edde 100644 --- a/tests/WP_Ultimo/Limits/Customer_User_Role_Limits_Test.php +++ b/tests/WP_Ultimo/Limits/Customer_User_Role_Limits_Test.php @@ -104,6 +104,12 @@ public function test_filter_editable_roles_removes_role_when_over_limit_in_admin $admin_id = self::factory()->user->create(['role' => 'administrator']); wp_set_current_user($admin_id); if (function_exists('revoke_super_admin')) { + // Ensure wp_users.can is an array before calling revoke_super_admin + // (required on some WP versions to avoid a fatal error) + wp_cache_flush(); + if (!is_array(get_option('wp_users.can'))) { + update_option('wp_users.can', ['list_users' => true, 'promote_users' => true, 'remove_users' => true, 'edit_users' => true]); + } revoke_super_admin($admin_id); } if (function_exists('set_current_screen')) { diff --git a/tests/WP_Ultimo/Models/Customer_Test.php b/tests/WP_Ultimo/Models/Customer_Test.php index a0322528..f1e9801d 100644 --- a/tests/WP_Ultimo/Models/Customer_Test.php +++ b/tests/WP_Ultimo/Models/Customer_Test.php @@ -387,1894 +387,20 @@ public function test_to_search_results(): void { $this->assertEquals('search@example.com', $search_results['user_email']); } - // --------------------------------------------------------------- - // CRUD via helper functions - // --------------------------------------------------------------- - - /** - * Helper: create a WP user and return its ID. - * - * @param string $login User login. - * @param string $email User email. - * @return int - */ - private function make_user(string $login = '', string $email = ''): int { - - $login = $login ?: 'user_' . wp_generate_uuid4(); - $email = $email ?: $login . '@example.com'; - - return self::factory()->user->create( - [ - 'user_login' => $login, - 'user_email' => $email, - ] - ); - } - - /** - * Helper: create a saved customer via wu_create_customer. - * - * @param array $overrides Overrides to pass. - * @return Customer - */ - private function make_customer(array $overrides = []): Customer { - - $user_id = $this->make_user(); - - $defaults = [ - 'user_id' => $user_id, - 'email_verification' => 'none', - ]; - - $customer = wu_create_customer(array_merge($defaults, $overrides)); - - $this->assertNotWPError($customer); - $this->assertInstanceOf(Customer::class, $customer); - - return $customer; - } - - /** - * Test wu_create_customer creates and persists a customer. - */ - public function test_wu_create_customer_persists(): void { - - $customer = $this->make_customer(); - - $this->assertGreaterThan(0, $customer->get_id()); - $this->assertEquals('customer', $customer->get_type()); - } - - /** - * Test wu_get_customer retrieves a persisted customer by ID. - */ - public function test_wu_get_customer_by_id(): void { - - $customer = $this->make_customer(); - - $fetched = wu_get_customer($customer->get_id()); - - $this->assertInstanceOf(Customer::class, $fetched); - $this->assertEquals($customer->get_id(), $fetched->get_id()); - $this->assertEquals($customer->get_user_id(), $fetched->get_user_id()); - } - - /** - * Test wu_get_customer returns false for non-existent ID. - */ - public function test_wu_get_customer_returns_false_for_missing(): void { - - $this->assertFalse(wu_get_customer(999999)); - } - - /** - * Test wu_get_customer_by_user_id retrieves customer by WP user ID. - */ - public function test_wu_get_customer_by_user_id(): void { - - $customer = $this->make_customer(); - - $fetched = wu_get_customer_by_user_id($customer->get_user_id()); - - $this->assertInstanceOf(Customer::class, $fetched); - $this->assertEquals($customer->get_id(), $fetched->get_id()); - } - - /** - * Test wu_get_customer_by_user_id returns false for unknown user. - */ - public function test_wu_get_customer_by_user_id_returns_false_for_unknown(): void { - - $this->assertFalse(wu_get_customer_by_user_id(999999)); - } - - /** - * Test customer deletion. - */ - public function test_customer_delete(): void { - - $customer = $this->make_customer(); - $id = $customer->get_id(); - - $this->assertInstanceOf(Customer::class, wu_get_customer($id)); - - $result = $customer->delete(); - - $this->assertNotEmpty($result); - $this->assertFalse(wu_get_customer($id)); - } - - /** - * Test customer update after save. - */ - public function test_customer_update(): void { - - $customer = $this->make_customer(['email_verification' => 'none']); - - $customer->set_email_verification('verified'); - $customer->save(); - - $fetched = wu_get_customer($customer->get_id()); - - $this->assertEquals('verified', $fetched->get_email_verification()); - } - - /** - * Test creating customer with existing email reuses WP user. - */ - public function test_wu_create_customer_with_existing_user(): void { - - $user_id = $this->make_user('existinguser', 'existing@example.com'); - - $customer = wu_create_customer( - [ - 'user_id' => $user_id, - 'email_verification' => 'none', - ] - ); - - $this->assertNotWPError($customer); - $this->assertEquals($user_id, $customer->get_user_id()); - } - - // --------------------------------------------------------------- - // Getter / Setter pairs - // --------------------------------------------------------------- - - /** - * Test user_id getter returns absint. - */ - public function test_get_user_id_returns_absint(): void { - - $customer = new Customer(); - $customer->set_user_id(-5); - - $this->assertEquals(5, $customer->get_user_id()); - } - - /** - * Test user_id getter for zero when unset. - */ - public function test_get_user_id_defaults_to_zero(): void { - - $customer = new Customer(); - - $this->assertEquals(0, $customer->get_user_id()); - } - - /** - * Test date_registered getter and setter. - */ - public function test_date_registered_getter_setter(): void { - - $customer = new Customer(); - $date = '2024-06-15 12:00:00'; - - $customer->set_date_registered($date); - - $this->assertEquals($date, $customer->get_date_registered()); - } - - /** - * Test last_login getter and setter with various dates. - */ - public function test_last_login_various_dates(): void { - - $customer = new Customer(); - - $customer->set_last_login('2024-12-31 23:59:59'); - $this->assertEquals('2024-12-31 23:59:59', $customer->get_last_login()); - - $customer->set_last_login('0000-00-00 00:00:00'); - $this->assertEquals('0000-00-00 00:00:00', $customer->get_last_login()); - } - - /** - * Test type getter and setter. - */ - public function test_type_getter_setter(): void { - - $customer = new Customer(); - - $customer->set_type('customer'); - $this->assertEquals('customer', $customer->get_type()); - } - - /** - * Test VIP getter returns boolean. - */ - public function test_vip_returns_boolean_true(): void { - - $customer = new Customer(); - $customer->set_vip(1); - - $this->assertTrue($customer->is_vip()); - $this->assertIsBool($customer->is_vip()); - } - - /** - * Test VIP setter with falsy value. - */ - public function test_vip_with_falsy_value(): void { - - $customer = new Customer(); - $customer->set_vip(0); - - $this->assertFalse($customer->is_vip()); - } - - /** - * Test signup form default value. - */ - public function test_signup_form_default(): void { - - $customer = new Customer(); - - $this->assertEquals('by-admin', $customer->get_signup_form()); - } - - /** - * Test signup form setter and getter. - */ - public function test_signup_form_setter_getter(): void { - - $customer = new Customer(); - $customer->set_signup_form('my-custom-form'); - - $this->assertEquals('my-custom-form', $customer->get_signup_form()); - } - - /** - * Test network_id getter and setter. - */ - public function test_network_id_getter_setter(): void { - - $customer = new Customer(); - - $this->assertNull($customer->get_network_id()); - - $customer->set_network_id(5); - $this->assertEquals(5, $customer->get_network_id()); - - $customer->set_network_id(null); - $this->assertNull($customer->get_network_id()); - } - - /** - * Test network_id getter returns absint for positive values. - */ - public function test_network_id_returns_absint(): void { - - $customer = new Customer(); - $customer->set_network_id(42); - - $this->assertSame(42, $customer->get_network_id()); - } - - /** - * Test network_id zero is treated as null. - */ - public function test_network_id_zero_is_null(): void { - - $customer = new Customer(); - $customer->set_network_id(0); - - $this->assertNull($customer->get_network_id()); - } - - // --------------------------------------------------------------- - // IP address edge cases - // --------------------------------------------------------------- - - /** - * Test set_ips with serialized string. - */ - public function test_set_ips_with_serialized_string(): void { - - $customer = new Customer(); - $ips = ['1.1.1.1', '2.2.2.2']; - - $customer->set_ips(maybe_serialize($ips)); - - $this->assertEquals($ips, $customer->get_ips()); - } - - /** - * Test add_ip sanitizes input. - */ - public function test_add_ip_sanitizes_input(): void { - - $customer = new Customer(); - $customer->set_ips([]); - $customer->add_ip(''); - - $ips = $customer->get_ips(); - - $this->assertCount(1, $ips); - $this->assertStringNotContainsString('