/ src / reports / mockup-generator.js
mockup-generator.js
  1  /**
  2   * Mockup Generator
  3   *
  4   * Creates before/after screenshot pairs for each problem area by:
  5   *   1. Opening the live page in Playwright
  6   *   2. Screenshotting the element targeted by `selector` (the "before")
  7   *   3. Applying `mockup_changes` via page.evaluate() DOM manipulation
  8   *   4. Re-screenshotting the same region (the "after")
  9   *
 10   * The "after" shows the business owner exactly what their site could look like
 11   * with the recommended fix applied — not a vague description of empty space.
 12   *
 13   * Falls back to y% crop from fullPageBuffer if Playwright can't find the selector.
 14   */
 15  
 16  import sharp from 'sharp';
 17  import { launchStealthBrowser, createStealthContext } from '../utils/stealth-browser.js';
 18  import Logger from '../utils/logger.js';
 19  
 20  const logger = new Logger('MockupGenerator');
 21  
 22  const VIEWPORT_WIDTH = 1440;
 23  const VIEWPORT_HEIGHT = 900;
 24  const PADDING_X = 40; // px horizontal padding around element crops
 25  const PADDING_Y = 80; // px vertical padding — enough to show surrounding context
 26  const MAX_CROP_HEIGHT = 700; // don't let a single crop exceed this
 27  const MAX_CROP_WIDTH = 900; // natural width cap — no stretching
 28  
 29  /**
 30   * Apply mockup changes to the page via DOM manipulation.
 31   * Passes serialisable changes array to page.evaluate().
 32   */
 33  async function applyMockupChanges(page, changes) {
 34    /* eslint-disable no-undef */
 35    await page.evaluate(changeList => {
 36      for (const change of changeList) {
 37        try {
 38          const el = document.querySelector(change.selector);
 39          if (!el) continue;
 40  
 41          switch (change.action) {
 42            case 'replace_text':
 43              el.textContent = change.value;
 44              break;
 45            case 'set_style':
 46              Object.assign(el.style, change.value);
 47              break;
 48            case 'insert_before':
 49              el.insertAdjacentHTML('beforebegin', change.html);
 50              break;
 51            case 'insert_after':
 52              el.insertAdjacentHTML('afterend', change.html);
 53              break;
 54            case 'set_attribute':
 55              el.setAttribute(change.attr, change.value);
 56              break;
 57            case 'remove':
 58              el.remove();
 59              break;
 60          }
 61        } catch {
 62          // Best-effort — some changes may fail if selector doesn't match
 63        }
 64      }
 65    }, changes);
 66    /* eslint-enable no-undef */
 67  }
 68  
 69  /**
 70   * Take a padded screenshot of an element by selector.
 71   * Returns { buffer, box } or null if not found.
 72   */
 73  /**
 74   * @param {Object} [opts]
 75   * @param {number} [opts.extraPadY] - Extra vertical padding (for after-shots that include injected siblings)
 76   */
 77  async function screenshotSelector(page, selector, opts = {}) {
 78    const selectors = selector.split(',').map(s => s.trim());
 79    const extraY = opts.extraPadY || 0;
 80  
 81    for (const sel of selectors) {
 82      try {
 83        const locator = page.locator(sel).first();
 84        const box = await locator.boundingBox({ timeout: 3000 });
 85        if (!box) continue;
 86  
 87        await locator.scrollIntoViewIfNeeded();
 88        await page.waitForTimeout(200);
 89  
 90        const box2 = await locator.boundingBox({ timeout: 3000 });
 91        if (!box2) continue;
 92  
 93        // Get page dimensions to clamp against
 94        /* eslint-disable no-undef */
 95        const pageHeight = await page.evaluate(() => document.documentElement.scrollHeight);
 96        /* eslint-enable no-undef */
 97  
 98        // Generous padding to show surrounding context (not just the element in isolation)
 99        const padY = PADDING_Y + extraY;
100        const clip = {
101          x: Math.max(0, box2.x - PADDING_X),
102          y: Math.max(0, box2.y - padY),
103          width: Math.min(VIEWPORT_WIDTH, Math.max(400, box2.width + PADDING_X * 2)),
104          height: Math.min(MAX_CROP_HEIGHT, Math.max(200, box2.height + padY * 2)),
105        };
106  
107        // Clamp to page bounds
108        if (clip.y + clip.height > pageHeight) {
109          clip.height = pageHeight - clip.y;
110        }
111        // Ensure minimum dimensions for Playwright
112        if (clip.width < 1 || clip.height < 1) continue;
113  
114        const buffer = await page.screenshot({ clip, type: 'png' });
115        return { buffer, box: clip };
116      } catch {
117        continue;
118      }
119    }
120    return null;
121  }
122  
123  /**
124   * Take a region screenshot by y% from the full page.
125   * Fallback when selector doesn't match.
126   */
127  async function screenshotYPercent(page, yPercent) {
128    /* eslint-disable no-undef */
129    const pageHeight = await page.evaluate(() => document.documentElement.scrollHeight);
130    const yCenter = Math.round((yPercent / 100) * pageHeight);
131    const cropHeight = Math.min(400, pageHeight);
132    const cropTop = Math.max(0, yCenter - Math.round(cropHeight / 2));
133  
134    // Scroll to position
135    await page.evaluate(y => window.scrollTo(0, y), cropTop);
136    /* eslint-enable no-undef */
137    await page.waitForTimeout(300);
138  
139    const clip = {
140      x: 0,
141      y: cropTop,
142      width: VIEWPORT_WIDTH,
143      height: Math.min(cropHeight, pageHeight - cropTop),
144    };
145  
146    const buffer = await page.screenshot({ clip, type: 'png' });
147    return { buffer, box: clip };
148  }
149  
150  /**
151   * Normalise a screenshot buffer: cap width, convert to JPEG.
152   */
153  async function normaliseBuffer(buffer) {
154    const meta = await sharp(buffer).metadata();
155  
156    let pipeline = sharp(buffer);
157  
158    // Cap width if wider than MAX_CROP_WIDTH (don't upscale, only downscale)
159    if (meta.width > MAX_CROP_WIDTH) {
160      pipeline = pipeline.resize(MAX_CROP_WIDTH, null, {
161        fit: 'inside',
162        withoutEnlargement: true,
163      });
164    }
165  
166    return pipeline.jpeg({ quality: 92 }).toBuffer();
167  }
168  
169  /**
170   * Generate before/after mockup pairs for all problem areas.
171   *
172   * @param {string} url - Page URL to open
173   * @param {Array} problemAreas - From score_json.problem_areas
174   * @param {Buffer} _fullPageBuffer - Reserved for future fallback use
175   * @returns {Array<Object>} Array of { factor, beforeBuffer, afterBuffer, description, recommendation, severity, current_text, suggested_text, usedSelector }
176   */
177  export async function generateMockups(url, problemAreas, _fullPageBuffer) {
178    if (!problemAreas || problemAreas.length === 0) {
179      logger.info('No problem areas for mockup generation');
180      return [];
181    }
182  
183    logger.info(`Generating before/after mockups for ${problemAreas.length} problem areas`);
184  
185    let browser;
186    try {
187      browser = await launchStealthBrowser({ stealthLevel: 'minimal' });
188      const context = await createStealthContext(browser, {
189        viewport: { width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT },
190      });
191      const page = await context.newPage();
192  
193      await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
194      await page.waitForTimeout(2000);
195  
196      // Close cookie/popup overlays
197      try {
198        /* eslint-disable no-undef */
199        await page.evaluate(() => {
200          const sels = [
201            '[class*="cookie"] button',
202            '[class*="Cookie"] button',
203            '[id*="cookie"] button',
204            '[class*="consent"] button',
205            '[class*="popup"] [class*="close"]',
206            '[class*="modal"] [class*="close"]',
207            '[aria-label="Close"]',
208            'button[class*="dismiss"]',
209          ];
210          for (const sel of sels) {
211            const el = document.querySelector(sel);
212            if (el) el.click();
213          }
214        });
215        /* eslint-enable no-undef */
216        await page.waitForTimeout(500);
217      } catch {
218        /* best effort */
219      }
220  
221      const mockups = [];
222  
223      for (const area of problemAreas) {
224        try {
225          const { selector } = area;
226          const changes = area.mockup_changes;
227          let usedSelector = false;
228  
229          // --- BEFORE screenshot ---
230          let beforeResult = null;
231          // For top-of-page issues, use above-fold screenshot — selector often
232          // matches a zero-height element like <script> or <style>.
233          // Detect via: y% <= 5, or selector targeting body's first child, or
234          // mockup_changes with insert_before on body > *:first-child
235          const hasTopInsert = (changes || []).some(
236            c =>
237              c.action === 'insert_before' &&
238              /body\s*>|:first-child|^header$|^nav$/i.test(c.selector || '')
239          );
240          const isTopOfPage = (area.approximate_y_position_percent || 50) <= 5 || hasTopInsert;
241          if (isTopOfPage) {
242            // Above-fold: top 900px of page at full viewport width
243            /* eslint-disable no-undef */
244            await page.evaluate(() => window.scrollTo(0, 0));
245            /* eslint-enable no-undef */
246            await page.waitForTimeout(200);
247            const clip = { x: 0, y: 0, width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT };
248            beforeResult = {
249              buffer: await page.screenshot({ clip, type: 'png' }),
250              box: clip,
251            };
252            usedSelector = true; // treat as "found" so the after path runs
253          } else if (selector) {
254            beforeResult = await screenshotSelector(page, selector);
255            if (beforeResult) usedSelector = true;
256          }
257          if (!beforeResult) {
258            beforeResult = await screenshotYPercent(page, area.approximate_y_position_percent || 50);
259          }
260  
261          const beforeBuffer = await normaliseBuffer(beforeResult.buffer);
262  
263          // --- AFTER screenshot ---
264          let afterBuffer = null;
265          if (changes && changes.length > 0) {
266            // Check if any change inserts new elements (insert_before/insert_after)
267            // — these add siblings that won't be in the original selector's bounding box
268            const hasInjection = changes.some(
269              c => c.action === 'insert_before' || c.action === 'insert_after'
270            );
271            const hasInsertBefore = changes.some(c => c.action === 'insert_before');
272  
273            // Apply DOM changes
274            await applyMockupChanges(page, changes);
275            await page.waitForTimeout(300);
276  
277            // Re-screenshot — strategy depends on what changed
278            let afterResult = null;
279  
280            if (isTopOfPage) {
281              // Top-of-page: just re-screenshot the above-fold area (banner is now visible at top)
282              /* eslint-disable no-undef */
283              await page.evaluate(() => window.scrollTo(0, 0));
284              /* eslint-enable no-undef */
285              await page.waitForTimeout(200);
286              const clip = { x: 0, y: 0, width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT };
287              afterResult = {
288                buffer: await page.screenshot({ clip, type: 'png' }),
289                box: clip,
290              };
291            } else if (hasInjection && usedSelector && selector) {
292              // Injected elements are siblings — the original selector's box won't include them.
293              // Use extra vertical padding to capture the injected content above/below.
294              const extraPad = hasInsertBefore ? 120 : 80;
295              afterResult = await screenshotSelector(page, selector, { extraPadY: extraPad });
296            } else if (usedSelector && selector) {
297              // No injection — same element, just restyled/rewritten text
298              afterResult = await screenshotSelector(page, selector);
299            }
300  
301            if (!afterResult) {
302              // Fallback: use the before clip region, expanded to catch injected content
303              const expandedClip = { ...beforeResult.box };
304              if (hasInsertBefore) {
305                // insert_before pushes content down — expand upward from y=0
306                const extraH = expandedClip.y;
307                expandedClip.y = 0;
308                expandedClip.height = Math.min(MAX_CROP_HEIGHT, expandedClip.height + extraH + 120);
309              } else if (hasInjection) {
310                expandedClip.height = Math.min(MAX_CROP_HEIGHT, expandedClip.height + 120);
311              }
312              /* eslint-disable no-undef */
313              await page.evaluate(y => window.scrollTo(0, y), expandedClip.y);
314              const pageH = await page.evaluate(() => document.documentElement.scrollHeight);
315              /* eslint-enable no-undef */
316              await page.waitForTimeout(200);
317              // Clamp to page bounds
318              if (expandedClip.y + expandedClip.height > pageH) {
319                expandedClip.height = pageH - expandedClip.y;
320              }
321              afterResult = {
322                buffer: await page.screenshot({ clip: expandedClip, type: 'png' }),
323                box: expandedClip,
324              };
325            }
326  
327            afterBuffer = await normaliseBuffer(afterResult.buffer);
328  
329            // Revert DOM changes by reloading page (cleanest approach)
330            await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
331            await page.waitForTimeout(1500);
332  
333            // Re-close popups
334            try {
335              /* eslint-disable no-undef */
336              await page.evaluate(() => {
337                const sels = [
338                  '[class*="cookie"] button',
339                  '[class*="Cookie"] button',
340                  '[id*="cookie"] button',
341                  '[class*="consent"] button',
342                  '[class*="popup"] [class*="close"]',
343                  '[class*="modal"] [class*="close"]',
344                  '[aria-label="Close"]',
345                  'button[class*="dismiss"]',
346                ];
347                for (const sel of sels) {
348                  const el = document.querySelector(sel);
349                  if (el) el.click();
350                }
351              });
352              /* eslint-enable no-undef */
353              await page.waitForTimeout(500);
354            } catch {
355              /* best effort */
356            }
357          }
358  
359          mockups.push({
360            factor: area.factor,
361            beforeBuffer,
362            afterBuffer,
363            description: area.description,
364            recommendation: area.recommendation,
365            severity: area.severity || 'medium',
366            current_text: area.current_text,
367            suggested_text: area.suggested_text,
368            usedSelector,
369          });
370  
371          logger.debug(
372            `[${area.factor}] ${usedSelector ? 'selector' : 'y% fallback'}, ` +
373              `${afterBuffer ? 'with' : 'no'} after mockup`
374          );
375        } catch (err) {
376          logger.warn(`Failed mockup for ${area.factor}: ${err.message}`);
377        }
378      }
379  
380      await context.close();
381  
382      const selectorCount = mockups.filter(m => m.usedSelector).length;
383      const afterCount = mockups.filter(m => m.afterBuffer).length;
384      logger.success(
385        `Generated ${mockups.length} mockups (${selectorCount} via selector, ` +
386          `${afterCount} with before/after pairs)`
387      );
388  
389      return mockups;
390    } finally {
391      if (browser) await browser.close();
392    }
393  }