html-report-template.test.js
1 import { describe, test } from 'node:test'; 2 import assert from 'node:assert/strict'; 3 import { generateReportHTML } from '../../src/reports/html-report-template.js'; 4 5 // Mock score JSON matching the Opus premium prompt output 6 const mockScoreJson = { 7 website_url: 'https://example.com', 8 evaluation_date: '2026-03-01T12:00:00Z', 9 device_analysis: { 10 desktop_visible: true, 11 mobile_visible: true, 12 design_differences: 'Minor layout shift', 13 }, 14 technical_assessment: { 15 security_headers_present: ['hsts', 'x-frame-options'], 16 security_headers_missing: ['csp', 'permissions-policy'], 17 performance_indicators: ['gzip', 'cache-control'], 18 mobile_responsive: true, 19 page_load_indicators: 'No visible issues', 20 ssl_status: 'https', 21 }, 22 factor_scores: { 23 headline_quality: { 24 score: 4, 25 reasoning: 'Generic headline', 26 evidence: 'Welcome to Our Site', 27 current_text: 'Welcome to Our Site', 28 suggested_text: 'Same-Day Plumber in Sydney', 29 }, 30 value_proposition: { 31 score: 6, 32 reasoning: 'Benefits mentioned but vague', 33 evidence: 'We provide quality service', 34 }, 35 unique_selling_proposition: { score: 3, reasoning: 'No differentiation visible', evidence: '' }, 36 call_to_action: { score: 5, reasoning: 'CTA below fold', evidence: 'Contact Us' }, 37 urgency_messaging: { score: 2, reasoning: 'No urgency or scarcity', evidence: '' }, 38 hook_engagement: { 39 score: 7, 40 reasoning: 'Professional hero image', 41 evidence: 'Large hero banner', 42 }, 43 trust_signals: { score: 4, reasoning: 'No reviews or badges visible', evidence: '' }, 44 imagery_design: { score: 7, reasoning: 'Clean design', evidence: '' }, 45 offer_clarity: { score: 5, reasoning: 'Services listed but pricing unclear', evidence: '' }, 46 contextual_appropriateness: { 47 score: 8, 48 reasoning: 'Appropriate for trades', 49 industry_context: 'Local plumbing service', 50 }, 51 }, 52 overall_calculation: { 53 conversion_score: 48.5, 54 letter_grade: 'F', 55 grade_interpretation: 'This site has significant conversion barriers.', 56 country_code: 'AU', 57 city: 'Sydney', 58 state: 'NSW', 59 is_business_directory: false, 60 is_local_business: true, 61 is_error_page: false, 62 }, 63 problem_areas: [ 64 { 65 factor: 'headline_quality', 66 description: 'Generic headline does not communicate value', 67 approximate_y_position_percent: 5, 68 severity: 'high', 69 recommendation: 'Change headline to include city and service', 70 current_text: 'Welcome to Our Site', 71 suggested_text: 'Same-Day Plumber in Sydney', 72 }, 73 { 74 factor: 'urgency_messaging', 75 description: 'No urgency messaging anywhere on the page', 76 approximate_y_position_percent: 30, 77 severity: 'medium', 78 recommendation: 'Add a time-limited offer in the hero section', 79 }, 80 ], 81 key_strengths: ['Professional imagery', 'Clean design layout'], 82 critical_weaknesses: ['Generic headline', 'No trust signals above fold', 'CTA below fold'], 83 quick_improvement_opportunities: ['Move CTA above fold', 'Add Google Reviews badge'], 84 strategic_recommendations: [ 85 { 86 priority: 1, 87 category: 'quick_win', 88 title: 'Move CTA above fold', 89 description: 'Add a button below the headline', 90 expected_impact: 'high', 91 estimated_effort: '30 minutes', 92 who_should_do_it: 'Your web developer', 93 }, 94 { 95 priority: 2, 96 category: 'strategic', 97 title: 'Rewrite headline', 98 description: 'Include city and primary service', 99 expected_impact: 'high', 100 estimated_effort: '15 minutes', 101 who_should_do_it: 'You', 102 }, 103 ], 104 report_narratives: { 105 executive_summary: 'Your site looks clean but has several conversion barriers.', 106 action_plan_week: '1. Move the CTA\n2. Fix the headline\n3. Add reviews', 107 action_plan_month: '1. Redesign hero section\n2. Add testimonials page', 108 action_plan_quarter: '1. Build landing pages per service', 109 factor_narratives: { 110 headline_quality: 'Your headline says "Welcome to Our Site" which tells visitors nothing.', 111 value_proposition: "You mention quality but don't say what makes you different.", 112 }, 113 }, 114 confidence_assessment: { 115 overall_confidence: 'High', 116 reasoning: 'Full analysis', 117 limitation_notes: 'None', 118 }, 119 }; 120 121 describe('generateReportHTML', () => { 122 test('generates valid HTML document', () => { 123 const html = generateReportHTML({ 124 domain: 'example.com', 125 url: 'https://example.com', 126 scoreJson: mockScoreJson, 127 aboveFoldBuffer: null, 128 problemCrops: [], 129 reportDate: new Date('2026-03-01'), 130 }); 131 132 assert.ok(html.includes('<!DOCTYPE html>')); 133 assert.ok(html.includes('</html>')); 134 assert.ok(html.includes('example.com')); 135 }); 136 137 test('renders all 10 factor scores', () => { 138 const html = generateReportHTML({ 139 domain: 'test.com', 140 url: 'https://test.com', 141 scoreJson: mockScoreJson, 142 problemCrops: [], 143 }); 144 145 for (const label of [ 146 'Headline Quality', 147 'Value Proposition', 148 'What Makes You Different (USP)', 149 'Call to Action', 150 'Urgency', 151 'Hook', 152 'Trust', 153 'Imagery', 154 'Offer Clarity', 155 'Industry Context', 156 ]) { 157 assert.ok(html.includes(label), `Missing factor: ${label}`); 158 } 159 }); 160 161 test('renders score ring with correct score', () => { 162 const html = generateReportHTML({ 163 domain: 'test.com', 164 url: 'https://test.com', 165 scoreJson: mockScoreJson, 166 problemCrops: [], 167 }); 168 169 // Score should appear in the ring (rounded) 170 assert.ok(html.includes('>49<') || html.includes('>48<')); 171 }); 172 173 test('renders executive summary narrative', () => { 174 const html = generateReportHTML({ 175 domain: 'test.com', 176 url: 'https://test.com', 177 scoreJson: mockScoreJson, 178 problemCrops: [], 179 }); 180 181 assert.ok(html.includes('several conversion barriers')); 182 }); 183 184 test('renders action plan sections', () => { 185 const html = generateReportHTML({ 186 domain: 'test.com', 187 url: 'https://test.com', 188 scoreJson: mockScoreJson, 189 problemCrops: [], 190 }); 191 192 assert.ok(html.includes('This Week')); 193 assert.ok(html.includes('This Month')); 194 assert.ok(html.includes('Next 3 Months')); 195 }); 196 197 test('renders factor narratives in plain English', () => { 198 const html = generateReportHTML({ 199 domain: 'test.com', 200 url: 'https://test.com', 201 scoreJson: mockScoreJson, 202 problemCrops: [], 203 }); 204 205 assert.ok(html.includes('Welcome to Our Site')); 206 assert.ok(html.includes('Plain English:')); 207 }); 208 209 test('renders current_text → suggested_text copy changes', () => { 210 const html = generateReportHTML({ 211 domain: 'test.com', 212 url: 'https://test.com', 213 scoreJson: mockScoreJson, 214 problemCrops: [], 215 }); 216 217 assert.ok(html.includes('Same-Day Plumber in Sydney')); 218 }); 219 220 test('renders problem areas with severity badges', () => { 221 const html = generateReportHTML({ 222 domain: 'test.com', 223 url: 'https://test.com', 224 scoreJson: mockScoreJson, 225 problemCrops: [ 226 { 227 factor: 'headline_quality', 228 imageBuffer: Buffer.from('fake-image-data'), 229 description: 'Generic headline', 230 recommendation: 'Fix it', 231 severity: 'high', 232 }, 233 ], 234 }); 235 236 assert.ok(html.includes('HIGH')); 237 assert.ok(html.includes('Problem Areas')); 238 }); 239 240 test('renders strategic recommendations with priority badges', () => { 241 const html = generateReportHTML({ 242 domain: 'test.com', 243 url: 'https://test.com', 244 scoreJson: mockScoreJson, 245 problemCrops: [], 246 }); 247 248 assert.ok(html.includes('quick win')); 249 assert.ok(html.includes('Move CTA above fold')); 250 assert.ok(html.includes('Your web developer')); 251 }); 252 253 test('renders technical assessment with check/cross marks', () => { 254 const html = generateReportHTML({ 255 domain: 'test.com', 256 url: 'https://test.com', 257 scoreJson: mockScoreJson, 258 problemCrops: [], 259 }); 260 261 // HSTS should be checked 262 assert.ok(html.includes('HSTS')); 263 // CSP should be crossed (in missing list) 264 assert.ok(html.includes('CSP')); 265 }); 266 267 test('renders grade scale table with all grades', () => { 268 const html = generateReportHTML({ 269 domain: 'test.com', 270 url: 'https://test.com', 271 scoreJson: mockScoreJson, 272 problemCrops: [], 273 }); 274 275 for (const grade of ['A+', 'A−', 'B+', 'B−', 'C+', 'C−', 'D+', 'D−', 'F']) { 276 assert.ok(html.includes(grade), `Missing grade in scale table: ${grade}`); 277 } 278 }); 279 280 test('sample mode adds watermark', () => { 281 const html = generateReportHTML({ 282 domain: 'test.com', 283 url: 'https://test.com', 284 scoreJson: mockScoreJson, 285 problemCrops: [], 286 isSample: true, 287 }); 288 289 assert.ok(html.includes('SAMPLE REPORT')); 290 assert.ok(html.includes('watermark')); 291 }); 292 293 test('non-sample mode has no watermark', () => { 294 const html = generateReportHTML({ 295 domain: 'test.com', 296 url: 'https://test.com', 297 scoreJson: mockScoreJson, 298 problemCrops: [], 299 isSample: false, 300 }); 301 302 assert.ok(!html.includes('SAMPLE REPORT')); 303 }); 304 305 test('handles missing optional fields gracefully', () => { 306 const minimalJson = { 307 factor_scores: { 308 headline_quality: { score: 5 }, 309 value_proposition: { score: 5 }, 310 unique_selling_proposition: { score: 5 }, 311 call_to_action: { score: 5 }, 312 urgency_messaging: { score: 5 }, 313 hook_engagement: { score: 5 }, 314 trust_signals: { score: 5 }, 315 imagery_design: { score: 5 }, 316 offer_clarity: { score: 5 }, 317 contextual_appropriateness: { score: 5 }, 318 }, 319 overall_calculation: { conversion_score: 50, letter_grade: 'F' }, 320 }; 321 322 const html = generateReportHTML({ 323 domain: 'minimal.com', 324 url: 'https://minimal.com', 325 scoreJson: minimalJson, 326 problemCrops: [], 327 }); 328 329 assert.ok(html.includes('<!DOCTYPE html>')); 330 assert.ok(html.includes('minimal.com')); 331 }); 332 333 test('embeds above-fold screenshot as base64', () => { 334 const fakeImage = Buffer.from('PNG-fake-data'); 335 const html = generateReportHTML({ 336 domain: 'test.com', 337 url: 'https://test.com', 338 scoreJson: mockScoreJson, 339 aboveFoldBuffer: fakeImage, 340 problemCrops: [], 341 }); 342 343 assert.ok(html.includes('data:image/jpeg;base64,')); 344 }); 345 346 test('escapes HTML in domain and text fields', () => { 347 const html = generateReportHTML({ 348 domain: '<script>alert(1)</script>.com', 349 url: 'https://test.com', 350 scoreJson: mockScoreJson, 351 problemCrops: [], 352 }); 353 354 assert.ok(!html.includes('<script>alert(1)</script>')); 355 assert.ok(html.includes('<script>')); 356 }); 357 358 test('includes Audit&Fix branding', () => { 359 const html = generateReportHTML({ 360 domain: 'test.com', 361 url: 'https://test.com', 362 scoreJson: mockScoreJson, 363 problemCrops: [], 364 }); 365 366 assert.ok(html.includes('Audit')); 367 assert.ok(html.includes('Fix')); 368 assert.ok(html.includes('#e05d26')); // Brand orange 369 assert.ok(html.includes('#1a365d')); // Brand navy 370 }); 371 372 test('includes follow-up benchmarking CTA', () => { 373 const html = generateReportHTML({ 374 domain: 'test.com', 375 url: 'https://test.com', 376 scoreJson: mockScoreJson, 377 problemCrops: [], 378 }); 379 380 assert.ok(html.includes('follow-up benchmarking')); 381 assert.ok(html.includes('reports@auditandfix.com')); 382 }); 383 384 test('uses correct grade colors for A, B, C, D grades', () => { 385 const grades = ['A', 'A+', 'B+', 'C', 'D-']; 386 for (const grade of grades) { 387 const html = generateReportHTML({ 388 domain: 'test.com', 389 url: 'https://test.com', 390 scoreJson: { 391 ...mockScoreJson, 392 overall_calculation: { 393 ...mockScoreJson.overall_calculation, 394 letter_grade: grade, 395 conversion_score: grade.startsWith('A') 396 ? 92 397 : grade.startsWith('B') 398 ? 84 399 : grade.startsWith('C') 400 ? 74 401 : 63, 402 }, 403 }, 404 problemCrops: [], 405 }); 406 // All grades should render successfully with a non-empty HTML page 407 assert.ok(html.length > 1000, `Grade ${grade} should produce valid HTML`); 408 assert.ok(html.includes('<!DOCTYPE html>'), `Grade ${grade} HTML should have doctype`); 409 } 410 }); 411 412 test('renders problem crops with string imageBuffer (base64 prefix)', () => { 413 const html = generateReportHTML({ 414 domain: 'test.com', 415 url: 'https://test.com', 416 scoreJson: mockScoreJson, 417 problemCrops: [ 418 { 419 factor: 'call_to_action', 420 severity: 'high', 421 description: 'CTA is below fold', 422 recommendation: 'Move CTA above fold', 423 imageBuffer: 'data:image/jpeg;base64,/9j/4AAQSkZJRgAB', // already has data: prefix 424 }, 425 { 426 factor: 'trust_signals', 427 severity: 'medium', 428 description: 'No trust signals', 429 recommendation: 'Add testimonials', 430 imageBuffer: '/9j/4AAQSkZJRgAB', // raw base64, no prefix 431 }, 432 { 433 factor: 'urgency_messaging', 434 severity: 'low', 435 description: 'No urgency', 436 recommendation: 'Add limited offer', 437 imageBuffer: null, // no image 438 }, 439 ], 440 }); 441 assert.ok(html.includes('data:image/jpeg;base64,/9j/4AAQSkZJRgAB')); 442 assert.ok(html.includes('CTA is below fold')); 443 assert.ok(html.includes('HIGH')); 444 assert.ok(html.includes('MEDIUM')); 445 assert.ok(html.includes('LOW')); 446 }); 447 448 test('handles aboveFoldBuffer as string', () => { 449 const html = generateReportHTML({ 450 domain: 'test.com', 451 url: 'https://test.com', 452 scoreJson: mockScoreJson, 453 problemCrops: [], 454 aboveFoldBuffer: 'data:image/jpeg;base64,/9j/test', // already has data: prefix 455 }); 456 assert.ok(html.includes('data:image/jpeg;base64,/9j/test')); 457 }); 458 459 test('renders missing crop annotation, unknown severity, and copy-change block', () => { 460 const html = generateReportHTML({ 461 domain: 'test.com', 462 url: 'https://test.com', 463 scoreJson: mockScoreJson, 464 problemCrops: [ 465 { 466 factor: 'headline_quality', 467 severity: 'unknown_severity', // triggers getSeverityColor default case → #718096 468 description: 'Missing headline', 469 recommendation: 'Add a compelling headline', 470 missing: true, // triggers the "This is where we recommend adding it" annotation 471 current_text: 'Welcome to Our Site', 472 suggested_text: 'Fast Sydney Plumber', 473 }, 474 ], 475 }); 476 assert.ok(html.includes('#718096')); // default getSeverityColor for unknown severity 477 assert.ok(html.includes('Welcome to Our Site')); // current_text 478 assert.ok(html.includes('Fast Sydney Plumber')); // suggested_text 479 }); 480 });