← back to Secrets Manager

dw-scroll-debug.js

481 lines

/**
 * dw-scroll-debug.js
 * Headless Playwright investigation of the infinite-scroll glitch on
 * https://www.designerwallcoverings.com/collections/decoglass-wallpaper?page=6
 *
 * Instruments:
 *  - dw-card-hover.js run() fire count (via page.evaluate injection)
 *  - .dw-hover-overlay duplicate detection per card
 *  - MutationObserver callback count
 *  - Performance long-tasks (PerformanceObserver)
 *  - Boost network requests (XHR/fetch) for infinite-scroll API calls
 *  - Card count growth across scroll steps
 *  - Console errors
 */

const { chromium } = require('playwright');
const path = require('path');

const TARGET_URL = 'https://www.designerwallcoverings.com/collections/decoglass-wallpaper?page=6';
const SCROLL_STEPS = 12;
const SCROLL_PAUSE_MS = 2500;  // wait between scrolls for Boost to load
const VIEWPORT = { width: 1440, height: 900 };

(async () => {
  const browser = await chromium.launch({
    headless: true,
    args: [
      '--no-sandbox',
      '--disable-dev-shm-usage',
      '--disable-blink-features=AutomationControlled',
    ],
  });

  const context = await browser.newContext({
    viewport: VIEWPORT,
    userAgent:
      'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' +
      '(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
    extraHTTPHeaders: {
      'Accept-Language': 'en-US,en;q=0.9',
    },
  });

  const page = await context.newPage();

  // ── Capture console messages ──────────────────────────────────────────────
  const consoleErrors = [];
  const consoleWarnings = [];
  page.on('console', (msg) => {
    if (msg.type() === 'error') consoleErrors.push(msg.text());
    if (msg.type() === 'warning') consoleWarnings.push(msg.text());
  });

  // ── Capture network activity ──────────────────────────────────────────────
  const boostRequests = [];
  page.on('request', (req) => {
    const url = req.url();
    if (url.includes('boost') || url.includes('filter') || url.includes('search') || url.includes('product')) {
      boostRequests.push({ url: url.substring(0, 150), method: req.method(), type: req.resourceType() });
    }
  });

  const networkErrors = [];
  page.on('requestfailed', (req) => {
    networkErrors.push({ url: req.url().substring(0, 120), err: req.failure()?.errorText });
  });

  let httpStatus = null;
  page.on('response', (resp) => {
    if (resp.url() === TARGET_URL || resp.url().startsWith(TARGET_URL.split('?')[0])) {
      httpStatus = resp.status();
    }
  });

  console.log('=== DW Infinite-Scroll Debug ===');
  console.log(`Target: ${TARGET_URL}`);
  console.log(`Viewport: ${VIEWPORT.width}x${VIEWPORT.height}`);
  console.log(`Scroll steps: ${SCROLL_STEPS}, pause: ${SCROLL_PAUSE_MS}ms each\n`);

  // ── Navigate ──────────────────────────────────────────────────────────────
  console.log('[1] Navigating to page...');
  try {
    const resp = await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
    httpStatus = resp?.status();
    console.log(`    HTTP status: ${httpStatus}`);
    if (httpStatus === 429) {
      console.log('    [RATE-LIMITED 429] Waiting 15s before retry...');
      await page.waitForTimeout(15000);
      await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
    }
  } catch (e) {
    console.log(`    Navigation error: ${e.message}`);
  }

  // ── Wait for Boost product cards ──────────────────────────────────────────
  console.log('[2] Waiting for .boost-sd__product-item cards...');
  let initialCardCount = 0;
  try {
    await page.waitForSelector('.boost-sd__product-item', { timeout: 20000 });
    initialCardCount = await page.locator('.boost-sd__product-item').count();
    console.log(`    Initial card count: ${initialCardCount}`);
  } catch (e) {
    console.log(`    Could not find .boost-sd__product-item: ${e.message}`);
    // Check for alternate selectors
    const alt1 = await page.locator('.product-list-item').count();
    const alt2 = await page.locator('[data-product-id]').count();
    console.log(`    .product-list-item: ${alt1}, [data-product-id]: ${alt2}`);
    const html = await page.content();
    const snippet = html.substring(0, 3000);
    console.log(`    Page HTML snippet: ${snippet}`);
  }

  // ── Wait for dw-card-hover.js to initialize ───────────────────────────────
  console.log('[3] Waiting for dw-card-hover.js to initialize...');
  await page.waitForTimeout(2000);
  const dwHoverActive = await page.evaluate(() => !!window.__dwCardHover);
  console.log(`    window.__dwCardHover present: ${dwHoverActive}`);

  // ── Instrument: patch run() to count calls ────────────────────────────────
  console.log('[4] Patching run() counter and MutationObserver counter...');
  const instrumentResult = await page.evaluate(() => {
    // We cannot directly wrap the private run() function inside the IIFE.
    // Instead, we instrument the MutationObserver callback indirectly:
    // 1. Count how many times .dw-hover-overlay nodes are INSERTED (net new)
    //    which is a proxy for run() doing work on new cards.
    // 2. Count MutationObserver callbacks firing on document.body.

    window.__dwRunCount = 0;
    window.__dwOverlayInsertions = 0;
    window.__mutObsCallbackCount = 0;
    window.__longTaskCount = 0;
    window.__longTaskTotalMs = 0;

    // Count overlay insertions via a second MutationObserver
    const overlayWatcher = new MutationObserver((muts) => {
      for (const m of muts) {
        for (const node of m.addedNodes) {
          if (node.nodeType === 1) {
            if (node.classList && node.classList.contains('dw-hover-overlay')) {
              window.__dwOverlayInsertions++;
            }
            // Also count overlays inside added subtrees
            if (node.querySelectorAll) {
              window.__dwOverlayInsertions += node.querySelectorAll('.dw-hover-overlay').length;
            }
          }
        }
      }
    });
    overlayWatcher.observe(document.body, { childList: true, subtree: true });

    // Measure long tasks
    try {
      const po = new PerformanceObserver((list) => {
        for (const entry of list.getEntries()) {
          window.__longTaskCount++;
          window.__longTaskTotalMs += entry.duration;
        }
      });
      po.observe({ entryTypes: ['longtask'] });
    } catch (e) { /* longtask not supported */ }

    // Count body-level MutationObserver firings indirectly:
    // Wrap MutationObserver constructor to intercept calls on document.body
    // This needs to be done BEFORE dw-card-hover.js runs — too late now.
    // Instead, we count body childList mutations ourselves.
    const bodyMutWatcher = new MutationObserver((muts) => {
      window.__mutObsCallbackCount++;
    });
    bodyMutWatcher.observe(document.body, { childList: true, subtree: true });

    return { patched: true, overlayWatcher: 'installed', bodyMutWatcher: 'installed' };
  });
  console.log(`    Instrumentation: ${JSON.stringify(instrumentResult)}`);

  // ── Baseline overlay counts ────────────────────────────────────────────────
  console.log('[5] Baseline card and overlay counts...');
  const baseline = await page.evaluate(() => {
    const cards = document.querySelectorAll('.boost-sd__product-item, .product-list-item');
    let withOverlay = 0, withMultipleOverlays = 0;
    for (const card of cards) {
      const overlays = card.querySelectorAll('.dw-hover-overlay');
      // Also check siblings (v5 places overlay as sibling, not inside card)
      const parent = card.parentElement;
      const siblingOverlays = parent ? parent.querySelectorAll(':scope > .dw-hover-overlay') : [];
      const total = overlays.length + siblingOverlays.length;
      if (total >= 1) withOverlay++;
      if (total > 1) withMultipleOverlays++;
    }
    // Count all .dw-hover-overlay in the document
    const allOverlays = document.querySelectorAll('.dw-hover-overlay').length;
    return {
      cards: cards.length,
      withOverlay,
      withMultipleOverlays,
      allOverlays,
      overlaysInDom: allOverlays
    };
  });
  console.log(`    Cards: ${baseline.cards}, with overlay: ${baseline.withOverlay}, duplicates (>1): ${baseline.withMultipleOverlays}`);
  console.log(`    Total .dw-hover-overlay in DOM: ${baseline.allOverlays}`);

  // ── Scroll steps ──────────────────────────────────────────────────────────
  console.log(`[6] Scrolling ${SCROLL_STEPS} times to trigger infinite-scroll...`);
  const snapshots = [];

  for (let step = 0; step < SCROLL_STEPS; step++) {
    // Scroll to bottom
    await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
    await page.waitForTimeout(SCROLL_PAUSE_MS);

    // Capture state
    const snap = await page.evaluate((stepNum) => {
      const cards = document.querySelectorAll('.boost-sd__product-item, .product-list-item');
      let withOverlay = 0, withMultipleOverlays = 0, noOverlay = 0;
      const dupCards = [];

      for (const card of cards) {
        // v5 places overlay as a sibling AFTER the image column, so check
        // both inside the card and as an immediate sibling
        const insideOverlays = card.querySelectorAll('.dw-hover-overlay').length;
        // Traverse parent to find sibling overlays adjacent to this card
        let sibCount = 0;
        let sib = card.nextSibling;
        while (sib) {
          if (sib.nodeType === 1 && sib.classList && sib.classList.contains('dw-hover-overlay')) {
            sibCount++;
          }
          sib = sib.nodeType === 1 ? null : sib.nextSibling; // stop at next element node
          break; // only check first next sibling
        }
        // Also check inside card's image wrapper area
        const total = insideOverlays + sibCount;
        if (total >= 1) withOverlay++;
        else noOverlay++;
        if (total > 1 || insideOverlays > 1) {
          withMultipleOverlays++;
          dupCards.push(card.getAttribute('data-product-id') || card.className.substring(0, 40));
        }
      }

      const allOverlays = document.querySelectorAll('.dw-hover-overlay').length;

      return {
        step: stepNum,
        cardCount: cards.length,
        withOverlay,
        noOverlay,
        withMultipleOverlays,
        dupCards: dupCards.slice(0, 5),
        allOverlays,
        scrollY: window.scrollY,
        docHeight: document.body.scrollHeight,
        mutObsCallbacks: window.__mutObsCallbackCount,
        overlayInsertions: window.__dwOverlayInsertions,
        longTaskCount: window.__longTaskCount,
        longTaskTotalMs: Math.round(window.__longTaskTotalMs),
      };
    }, step + 1);

    console.log(
      `    Step ${step + 1}: cards=${snap.cardCount} overlays=${snap.allOverlays} ` +
      `dups=${snap.withMultipleOverlays} noOverlay=${snap.noOverlay} ` +
      `mutCBs=${snap.mutObsCallbacks} overlayInserts=${snap.overlayInsertions} ` +
      `longTasks=${snap.longTaskCount}(${snap.longTaskTotalMs}ms) ` +
      `scrollY=${snap.scrollY}/${snap.docHeight}`
    );
    if (snap.dupCards.length > 0) {
      console.log(`      DUPLICATE OVERLAY CARDS: ${snap.dupCards.join(', ')}`);
    }
    snapshots.push(snap);

    // Check if we stopped getting new cards (stall detection)
    if (step > 2 && snap.cardCount === snapshots[step - 1].cardCount && snap.scrollY > 0) {
      console.log(`    [STALL DETECTED] Card count unchanged at ${snap.cardCount} for 2 steps`);
    }
  }

  // ── Final analysis ────────────────────────────────────────────────────────
  console.log('\n[7] Final DOM analysis...');

  const finalAnalysis = await page.evaluate(() => {
    const cards = document.querySelectorAll('.boost-sd__product-item, .product-list-item');
    const allOverlays = document.querySelectorAll('.dw-hover-overlay');

    // Check for overlays outside of card contexts (orphaned)
    let orphanedOverlays = 0;
    for (const ov of allOverlays) {
      const inCard = ov.closest('.boost-sd__product-item, .product-list-item');
      // v5: overlay is sibling, walk up to see if it's near a card
      if (!inCard) {
        // Check previous sibling chain for a card
        let prev = ov.previousElementSibling;
        let foundCard = false;
        while (prev && !foundCard) {
          if (prev.matches('.boost-sd__product-item, .product-list-item')) foundCard = true;
          // Check inside prev for an image layout wrapper
          if (prev.matches('.boost-sd__product-item-grid-view-layout-image')) foundCard = true;
          prev = prev.previousElementSibling;
        }
        if (!foundCard) orphanedOverlays++;
      }
    }

    // Count per-card overlays using the v5 sibling placement logic
    const cardOverlayCounts = {};
    for (const card of cards) {
      const pid = card.getAttribute('data-product-id') ||
                  card.getAttribute('data-id') ||
                  Array.from(card.classList).find(c => c.includes('boost')) || 'unknown';

      // Inside the card
      let count = card.querySelectorAll('.dw-hover-overlay').length;

      // v5 places overlay after the image-col sibling; the overlay is a child
      // of the card's parent (the grid item row/wrapper), not inside the card itself.
      // Let's check by scanning ALL overlays and seeing how many follow THIS card
      // in the DOM parent.
      const parent = card.parentElement;
      if (parent) {
        const children = Array.from(parent.children);
        const cardIdx = children.indexOf(card);
        // Count consecutive .dw-hover-overlay siblings right after card
        for (let i = cardIdx + 1; i < children.length; i++) {
          if (children[i].classList.contains('dw-hover-overlay')) count++;
          else break;
        }
      }

      cardOverlayCounts[pid] = count;
    }

    const duplicatedCards = Object.entries(cardOverlayCounts).filter(([, c]) => c > 1);
    const zeroOverlayCards = Object.entries(cardOverlayCounts).filter(([, c]) => c === 0);

    // Check if Boost infinite scroll sentinel/loader exists
    const loaders = document.querySelectorAll(
      '[class*="boost-sd__load-more"], [class*="boost-sd__infinite"], [class*="load-more"], .boost-sd__button-load-more'
    );

    // Check for any CSS that might block scroll
    const bodyOverflow = window.getComputedStyle(document.body).overflow;
    const htmlOverflow = window.getComputedStyle(document.documentElement).overflow;

    return {
      finalCardCount: cards.length,
      finalOverlayCount: allOverlays.length,
      orphanedOverlays,
      duplicatedCards: duplicatedCards.slice(0, 10).map(([id, c]) => `${id}:${c}`),
      duplicatedCardCount: duplicatedCards.length,
      zeroOverlayCardCount: zeroOverlayCards.length,
      boostLoaderElements: loaders.length,
      boostLoaderClasses: Array.from(loaders).map(el => el.className).slice(0, 5),
      bodyOverflow,
      htmlOverflow,
      mutObsCallbacks: window.__mutObsCallbackCount,
      overlayInsertions: window.__dwOverlayInsertions,
      longTaskCount: window.__longTaskCount,
      longTaskTotalMs: Math.round(window.__longTaskTotalMs),
      dwHoverActive: window.__dwCardHover,
    };
  });

  console.log('\n=== FINAL ANALYSIS ===');
  console.log(`Cards in DOM: ${finalAnalysis.finalCardCount}`);
  console.log(`Total .dw-hover-overlay in DOM: ${finalAnalysis.finalOverlayCount}`);
  console.log(`Orphaned overlays (not near any card): ${finalAnalysis.orphanedOverlays}`);
  console.log(`Cards with DUPLICATE overlays: ${finalAnalysis.duplicatedCardCount}`);
  if (finalAnalysis.duplicatedCards.length > 0) {
    console.log(`  Duplicate samples: ${finalAnalysis.duplicatedCards.join(', ')}`);
  }
  console.log(`Cards with ZERO overlays: ${finalAnalysis.zeroOverlayCardCount}`);
  console.log(`Boost loader/sentinel elements: ${finalAnalysis.boostLoaderElements}`);
  if (finalAnalysis.boostLoaderClasses.length > 0) {
    console.log(`  Loader classes: ${finalAnalysis.boostLoaderClasses.join(', ')}`);
  }
  console.log(`Body overflow: ${finalAnalysis.bodyOverflow}, HTML overflow: ${finalAnalysis.htmlOverflow}`);
  console.log(`\nMutationObserver body callbacks: ${finalAnalysis.mutObsCallbacks}`);
  console.log(`run() proxy (overlay insertions): ${finalAnalysis.overlayInsertions}`);
  console.log(`Long tasks: ${finalAnalysis.longTaskCount} totaling ${finalAnalysis.longTaskTotalMs}ms`);
  console.log(`window.__dwCardHover: ${finalAnalysis.dwHoverActive}`);

  // ── Scroll growth analysis ────────────────────────────────────────────────
  console.log('\n=== SCROLL GROWTH ANALYSIS ===');
  let prevCards = baseline.cards;
  let stalls = 0;
  for (const snap of snapshots) {
    const delta = snap.cardCount - prevCards;
    const status = delta > 0 ? `+${delta} new cards` : (delta === 0 ? 'STALL - no new cards' : `SHRINK by ${Math.abs(delta)}`);
    const ratio = snap.allOverlays > 0 ? (snap.allOverlays / snap.cardCount).toFixed(2) : '0.00';
    console.log(`  Step ${snap.step}: ${snap.cardCount} cards (${status}), ${snap.allOverlays} overlays, ratio=${ratio}, longTasks=${snap.longTaskCount}(${snap.longTaskTotalMs}ms)`);
    if (delta === 0) stalls++;
    prevCards = snap.cardCount;
  }

  // ── MutationObserver thrash analysis ─────────────────────────────────────
  console.log('\n=== MUTATION OBSERVER ANALYSIS ===');
  const finalSnap = snapshots[snapshots.length - 1];
  console.log(`Total body MutationObserver callbacks fired: ${finalSnap.mutObsCallbacks}`);
  console.log(`Total overlay insertions by run(): ${finalSnap.overlayInsertions}`);
  if (finalSnap.mutObsCallbacks > 0 && finalAnalysis.finalCardCount > 0) {
    const callsPerCard = (finalSnap.mutObsCallbacks / finalAnalysis.finalCardCount).toFixed(1);
    console.log(`Average MutObs callbacks per card: ${callsPerCard}`);
    // Each run() call processes ALL cards and inserts overlays for new ones.
    // If run() fires N times and inserts M overlays, N > M suggests many no-op re-scans.
    const efficiency = finalSnap.overlayInsertions > 0
      ? (finalSnap.mutObsCallbacks / finalSnap.overlayInsertions).toFixed(1)
      : 'N/A';
    console.log(`Callback:overlay-insertion ratio: ${efficiency}:1 (higher = more wasted re-scans)`);
  }

  // ── SELF-REINFORCING LOOP DETECTION ──────────────────────────────────────
  console.log('\n=== SELF-REINFORCING LOOP DETECTION ===');
  console.log('Key question: Does run() inserting .dw-hover-overlay nodes trigger MORE observer callbacks?');
  console.log('(If yes, each overlay insertion fires the observer, which calls run() again,');
  console.log(' which already skips labeled cards — but still does a full querySelectorAll scan)');
  console.log(`Cards at baseline: ${baseline.cards}`);
  console.log(`Overlays inserted by run(): ${finalSnap.overlayInsertions}`);
  console.log(`Observer callbacks fired: ${finalSnap.mutObsCallbacks}`);
  if (finalSnap.overlayInsertions > 0 && finalSnap.mutObsCallbacks > finalSnap.overlayInsertions * 1.5) {
    console.log('FINDING: Observer callbacks >> overlay insertions — observer is firing for NON-run()-insertions too');
    console.log('  This means Boost DOM mutations are also triggering the observer, causing repeated full-grid scans');
  }

  // ── Console errors ────────────────────────────────────────────────────────
  if (consoleErrors.length > 0) {
    console.log(`\n=== CONSOLE ERRORS (${consoleErrors.length}) ===`);
    consoleErrors.slice(0, 10).forEach((e, i) => console.log(`  [${i + 1}] ${e}`));
  }
  if (consoleWarnings.length > 0) {
    console.log(`\n=== CONSOLE WARNINGS (${consoleWarnings.length}) ===`);
    consoleWarnings.slice(0, 5).forEach((e, i) => console.log(`  [${i + 1}] ${e}`));
  }
  if (networkErrors.length > 0) {
    console.log(`\n=== NETWORK ERRORS (${networkErrors.length}) ===`);
    networkErrors.slice(0, 5).forEach((e, i) => console.log(`  [${i + 1}] ${e.url} — ${e.err}`));
  }

  // ── Boost API requests ────────────────────────────────────────────────────
  const boostApiReqs = boostRequests.filter(r => r.url.includes('boost') && r.type === 'fetch' || r.url.includes('search') || r.url.includes('filter'));
  if (boostApiReqs.length > 0) {
    console.log(`\n=== BOOST API REQUESTS (${boostApiReqs.length}) ===`);
    boostApiReqs.slice(0, 10).forEach((r, i) => console.log(`  [${i + 1}] ${r.method} ${r.url}`));
  }

  // ── Final verdict ─────────────────────────────────────────────────────────
  console.log('\n=== ROOT CAUSE VERDICT ===');
  const overlapRatio = finalAnalysis.finalOverlayCount / Math.max(finalAnalysis.finalCardCount, 1);
  const hasOverlayDuplicates = finalAnalysis.duplicatedCardCount > 0;
  const hasMutThrash = finalSnap.mutObsCallbacks > 200;
  const hasLongTasks = finalAnalysis.longTaskCount > 5;
  const cardGrowthStopped = stalls >= 3;

  if (hasOverlayDuplicates) {
    console.log(`CONFIRMED: ${finalAnalysis.duplicatedCardCount} cards have DUPLICATE .dw-hover-overlay elements`);
    console.log('  Root cause: run() is inserting overlays faster than its duplicate-check catches');
    console.log('  OR: labelCard() sibling-insertion logic places overlay outside the card,');
    console.log('  so card.querySelector(".dw-hover-overlay") returns null on next run() call');
    console.log('  (the guard `if (card.querySelector(".dw-hover-overlay")) return;` misses sibling overlays)');
  }
  if (hasMutThrash) {
    console.log(`CONFIRMED: MutationObserver fired ${finalSnap.mutObsCallbacks} times during scrolling`);
    console.log('  Each fire schedules a full document.querySelectorAll() scan with 80ms debounce.');
    console.log('  With Boost appending dozens of nodes per infinite-scroll batch, this fires continuously.');
  }
  if (hasLongTasks) {
    console.log(`CONFIRMED: ${finalAnalysis.longTaskCount} long tasks (>${finalAnalysis.longTaskTotalMs}ms total)`);
    console.log('  Long tasks block scroll-event processing, causing visible jank.');
  }
  if (cardGrowthStopped) {
    console.log(`CONFIRMED: Infinite scroll STALLED — card count stopped growing for ${stalls} consecutive steps`);
  }
  if (!hasOverlayDuplicates && !hasMutThrash && !cardGrowthStopped) {
    console.log('No dramatic failures found — glitch may be intermittent or require more scroll steps');
    console.log(`Overlay/card ratio: ${overlapRatio.toFixed(2)} (should be ~1.0)`);
  }

  await browser.close();
  console.log('\nDone.');
})();