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('