/** * Property-Based Tests for ui-feedback.js * * Feature: frontend-cleanup, Property 4: Error toast display * Validates: Requirements 3.4, 4.4, 7.3 * * Property 4: Error toast display * For any failed API call, the UIFeedback.fetchJSON function should display * an error toast with the error message */ import fc from 'fast-check'; import { JSDOM } from 'jsdom'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // Load ui-feedback.js content const uiFeedbackPath = path.join(__dirname, '..', 'static', 'js', 'ui-feedback.js'); const uiFeedbackCode = fs.readFileSync(uiFeedbackPath, 'utf-8'); // Helper to create a fresh DOM environment for each test async function createTestEnvironment() { const html = `
`; const dom = new JSDOM(html, { url: 'http://localhost', runScripts: 'dangerously', resources: 'usable' }); const { window } = dom; const { document } = window; // Wait for scripts to execute and DOMContentLoaded to fire await new Promise(resolve => { if (document.readyState === 'complete') { resolve(); } else { window.addEventListener('load', resolve); } }); // Give a bit more time for the toast stack to be appended await new Promise(resolve => setTimeout(resolve, 50)); return { window, document }; } // Mock fetch to simulate API failures function createMockFetch(shouldFail, statusCode, errorMessage) { return async (url, options) => { if (shouldFail) { if (statusCode) { // HTTP error response return { ok: false, status: statusCode, statusText: errorMessage || 'Error', text: async () => errorMessage || 'Request failed', json: async () => { throw new Error('Invalid JSON'); } }; } else { // Network error throw new Error(errorMessage || 'Network error'); } } // Success case return { ok: true, status: 200, json: async () => ({ data: 'success' }) }; }; } console.log('Running Property-Based Tests for ui-feedback.js...\n'); async function runTests() { console.log('Property 4.1: fetchJSON should display error toast on HTTP errors'); // Test that HTTP errors (4xx, 5xx) trigger error toasts await fc.assert( fc.asyncProperty( fc.integer({ min: 400, max: 599 }), // HTTP error status codes fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0), // Non-empty error message async (statusCode, errorMessage) => { const { window, document } = await createTestEnvironment(); // Mock fetch to return HTTP error window.fetch = createMockFetch(true, statusCode, errorMessage); // Track toast creation let toastCreated = false; let toastType = null; let toastContent = null; // Check if UIFeedback is defined if (!window.UIFeedback) { throw new Error('UIFeedback not defined on window'); } // Override toast creation to capture calls const originalToast = window.UIFeedback.toast; window.UIFeedback.toast = (type, title, message) => { toastCreated = true; toastType = type; toastContent = { title, message }; // Still create the actual toast originalToast(type, title, message); }; // Call fetchJSON and expect it to throw let errorThrown = false; try { await window.UIFeedback.fetchJSON('/api/test', {}, 'Test Context'); } catch (err) { errorThrown = true; } // Verify error toast was created if (!toastCreated) { throw new Error(`No toast created for HTTP ${statusCode} error`); } if (toastType !== 'error') { throw new Error(`Expected error toast, got ${toastType}`); } if (!errorThrown) { throw new Error('fetchJSON should throw error on HTTP failure'); } // Verify toast is in the DOM const toastStack = document.querySelector('.toast-stack'); if (!toastStack) { throw new Error('Toast stack not found in DOM'); } const errorToasts = toastStack.querySelectorAll('.toast.error'); if (errorToasts.length === 0) { throw new Error('No error toast found in toast stack'); } return true; } ), { numRuns: 50, verbose: true } ); console.log('✓ Property 4.1 passed: HTTP errors trigger error toasts\n'); console.log('Property 4.2: fetchJSON should display error toast on network errors'); // Test that network errors trigger error toasts await fc.assert( fc.asyncProperty( fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0), // Non-empty error message async (errorMessage) => { const { window, document } = await createTestEnvironment(); // Mock fetch to throw network error window.fetch = createMockFetch(true, null, errorMessage); // Track toast creation let toastCreated = false; let toastType = null; // Override toast creation to capture calls const originalToast = window.UIFeedback.toast; window.UIFeedback.toast = (type, title, message) => { toastCreated = true; toastType = type; originalToast(type, title, message); }; // Call fetchJSON and expect it to throw let errorThrown = false; try { await window.UIFeedback.fetchJSON('/api/test', {}, 'Test Context'); } catch (err) { errorThrown = true; } // Verify error toast was created if (!toastCreated) { throw new Error('No toast created for network error'); } if (toastType !== 'error') { throw new Error(`Expected error toast, got ${toastType}`); } if (!errorThrown) { throw new Error('fetchJSON should throw error on network failure'); } return true; } ), { numRuns: 50, verbose: true } ); console.log('✓ Property 4.2 passed: Network errors trigger error toasts\n'); console.log('Property 4.3: fetchJSON should return data on success'); // Test that successful requests don't create error toasts await fc.assert( fc.asyncProperty( fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), // URL path async (urlPath) => { const { window } = await createTestEnvironment(); // Mock fetch to return success const mockData = { result: 'success', path: urlPath }; window.fetch = async () => ({ ok: true, status: 200, json: async () => mockData }); // Track toast creation let errorToastCreated = false; // Override toast creation to capture calls const originalToast = window.UIFeedback.toast; window.UIFeedback.toast = (type, title, message) => { if (type === 'error') { errorToastCreated = true; } originalToast(type, title, message); }; // Call fetchJSON const result = await window.UIFeedback.fetchJSON(`/api/${urlPath}`, {}, 'Test'); // Verify no error toast was created if (errorToastCreated) { throw new Error('Error toast created for successful request'); } // Verify data was returned if (JSON.stringify(result) !== JSON.stringify(mockData)) { throw new Error('fetchJSON did not return correct data'); } return true; } ), { numRuns: 50, verbose: true } ); console.log('✓ Property 4.3 passed: Successful requests return data without error toasts\n'); console.log('Property 4.4: toast function should create visible toast elements'); // Test that toast function creates DOM elements await fc.assert( fc.asyncProperty( fc.constantFrom('success', 'error', 'warning', 'info'), // Toast types fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), // Title fc.option(fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0), { nil: null }), // Optional message async (type, title, message) => { const { window, document } = await createTestEnvironment(); // Create toast window.UIFeedback.toast(type, title, message); // Verify toast was added to DOM const toastStack = document.querySelector('.toast-stack'); if (!toastStack) { throw new Error('Toast stack not found'); } const toasts = toastStack.querySelectorAll(`.toast.${type}`); if (toasts.length === 0) { throw new Error(`No ${type} toast found in stack`); } const lastToast = toasts[toasts.length - 1]; const toastHTML = lastToast.innerHTML; // Verify title is in toast if (!toastHTML.includes(title)) { throw new Error(`Toast does not contain title: ${title}`); } // Verify message is in toast if provided if (message && !toastHTML.includes(message)) { throw new Error(`Toast does not contain message: ${message}`); } return true; } ), { numRuns: 50, verbose: true } ); console.log('✓ Property 4.4 passed: Toast function creates visible elements\n'); console.log('Property 4.5: setBadge should update element class and text'); // Test that setBadge updates badge elements correctly await fc.assert( fc.asyncProperty( fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0), // Badge text fc.constantFrom('info', 'success', 'warning', 'danger'), // Badge tone async (text, tone) => { const { window, document } = await createTestEnvironment(); // Create a badge element const badge = document.createElement('span'); badge.className = 'badge'; document.body.appendChild(badge); // Update badge window.UIFeedback.setBadge(badge, text, tone); // Verify text was set if (badge.textContent !== text) { throw new Error(`Badge text not set correctly. Expected: ${text}, Got: ${badge.textContent}`); } // Verify class was set if (!badge.classList.contains('badge')) { throw new Error('Badge should have "badge" class'); } if (!badge.classList.contains(tone)) { throw new Error(`Badge should have "${tone}" class`); } return true; } ), { numRuns: 50, verbose: true } ); console.log('✓ Property 4.5 passed: setBadge updates element correctly\n'); console.log('Property 4.6: showLoading should display loading indicator'); // Test that showLoading creates loading indicators await fc.assert( fc.asyncProperty( fc.option(fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), { nil: undefined }), // Optional message async (message) => { const { window, document } = await createTestEnvironment(); // Create a container const container = document.createElement('div'); container.id = 'test-container'; document.body.appendChild(container); // Show loading window.UIFeedback.showLoading(container, message); // Verify loading indicator was added const loadingIndicator = container.querySelector('.loading-indicator'); if (!loadingIndicator) { throw new Error('Loading indicator not found'); } // Verify message is displayed const expectedMessage = message || 'Loading data...'; if (!loadingIndicator.textContent.includes(expectedMessage)) { throw new Error(`Loading indicator does not contain expected message: ${expectedMessage}`); } return true; } ), { numRuns: 50, verbose: true } ); console.log('✓ Property 4.6 passed: showLoading displays loading indicator\n'); console.log('Property 4.7: fadeReplace should update container content'); // Test that fadeReplace updates content await fc.assert( fc.asyncProperty( fc.string({ minLength: 1, maxLength: 200 }).filter(s => s.trim().length > 0), // HTML content async (html) => { const { window, document } = await createTestEnvironment(); // Create a container const container = document.createElement('div'); container.id = 'test-container'; container.innerHTML = 'Old content
'; document.body.appendChild(container); // Replace content window.UIFeedback.fadeReplace(container, html); // Verify content was replaced if (container.innerHTML !== html) { throw new Error('Container content not replaced'); } // Verify fade-in class was added (may be removed by timeout) // We just check that the content was updated return true; } ), { numRuns: 50, verbose: true } ); console.log('✓ Property 4.7 passed: fadeReplace updates container content\n'); console.log('\n✓ All property-based tests for ui-feedback.js passed!'); console.log('✓ Property 4: Error toast display validated successfully'); } runTests().catch(err => { console.error('Test failed:', err); process.exit(1); });