/ tests / reports / html-report-template.test.js
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('&lt;script&gt;'));
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  });